Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9f935222
|
||
|
|
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,11 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
@@ -69,6 +74,11 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
|
||||
@@ -22,37 +22,25 @@ 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 mirror
|
||||
run: |
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: 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,24 +80,12 @@ 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 mirror
|
||||
run: |
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -207,24 +195,12 @@ 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 mirror
|
||||
run: |
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -290,7 +266,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,63 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
|
||||
|
||||
### Changed
|
||||
|
||||
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
|
||||
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
|
||||
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
|
||||
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
|
||||
- При определении языка Steam_EMU теперь учитывается переменная `LANGUAGE`
|
||||
- При возврате из карточки игры библиотека сохраняет прокрутку и фокус на этой карточке
|
||||
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
|
||||
- После добавления или удаления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог
|
||||
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
|
||||
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
|
||||
- Настройки exe и `user.conf` применяются автоматически при изменении
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
|
||||
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
|
||||
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
|
||||
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
|
||||
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Release" alt="Releases"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-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,11 +73,19 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
'--silent[Launch .exe file without GUI]'
|
||||
'--log[Enable PortProton logging]'
|
||||
'--silent[Launch Windows file or native game in tray]'
|
||||
'--log[Enable game logging]'
|
||||
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
|
||||
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
@@ -94,18 +102,26 @@ 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"
|
||||
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
|
||||
complete -c portprotonqt -l log -d "Enable PortProton logging"
|
||||
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
|
||||
complete -c portprotonqt -l log -d "Enable game logging"
|
||||
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
|
||||
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
|
||||
complete -c portprotonqt -s h -l help -d "Show help message"
|
||||
|
||||
@@ -4,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()
|
||||
|
||||
+155
-129
@@ -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,21 +11,21 @@ from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
display_config,
|
||||
find_game_by_exe,
|
||||
get_portproton_start_command,
|
||||
get_portproton_location,
|
||||
save_portdata_path_to_config,
|
||||
@@ -34,6 +34,7 @@ from portprotonqt.config import (
|
||||
window_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
|
||||
from portprotonqt.cli import (
|
||||
parse_args,
|
||||
is_portproton_url,
|
||||
@@ -66,6 +67,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 +85,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 +142,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":
|
||||
@@ -335,6 +273,17 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parsed_args
|
||||
native_launch_uri = None
|
||||
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
|
||||
native_launch_uri = args.file_or_url
|
||||
elif args.file_or_url and not is_launch_file(args.file_or_url):
|
||||
target = normalize_launch_path(args.file_or_url)
|
||||
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
|
||||
entry = find_game_by_exe(target)
|
||||
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
|
||||
result = create_native_desktop_file(target, name)
|
||||
if result and os.path.isfile(result[1]):
|
||||
native_launch_uri = NATIVE_URI_PREFIX + result[1]
|
||||
setup_logger(args.debug_level)
|
||||
logger = get_logger(__name__)
|
||||
if consume_download_counter_skip():
|
||||
@@ -366,6 +315,9 @@ def main():
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif native_launch_uri:
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
@@ -393,7 +345,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 +371,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 +387,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()
|
||||
@@ -456,7 +415,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -541,22 +500,19 @@ def main():
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri
|
||||
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
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}"
|
||||
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
@@ -570,17 +526,44 @@ def main():
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16],
|
||||
"protondb_appid": game[16] if len(game) > 16 else "",
|
||||
})
|
||||
|
||||
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 target.startswith(NATIVE_URI_PREFIX):
|
||||
if silent and not log_mode:
|
||||
window.toggleGame(target)
|
||||
else:
|
||||
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
|
||||
return
|
||||
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
|
||||
@@ -591,7 +574,11 @@ def main():
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if exe_path:
|
||||
if native_launch_uri:
|
||||
def handle_native_launch() -> None:
|
||||
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_native_launch)
|
||||
elif exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
@@ -665,15 +652,15 @@ 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())
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
|
||||
handle_game_request(unquote(launch_path), False, log_mode)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -807,13 +794,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 +888,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 +911,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()
|
||||
+291
-3
@@ -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",
|
||||
@@ -67,12 +89,12 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--silent",
|
||||
action="store_true",
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
help="Launch supported Windows file or native game in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
help="Enable logging for the launched Windows file or native game"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
@@ -89,7 +111,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
'file_or_url',
|
||||
nargs='?',
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
|
||||
)
|
||||
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
|
||||
separator_index = sys.argv.index("--")
|
||||
@@ -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)
|
||||
|
||||
@@ -2,13 +2,16 @@ import os
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
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,
|
||||
get_portproton_location,
|
||||
extract_exec_target_path,
|
||||
favorites_config,
|
||||
favorites_folders_config,
|
||||
@@ -29,6 +32,7 @@ from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
@@ -68,6 +72,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(
|
||||
@@ -119,6 +131,8 @@ class ContextMenuManager:
|
||||
Returns:
|
||||
bool: True if the game is running, False otherwise.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
|
||||
if game_card.game_source == "steam":
|
||||
return False
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -355,8 +369,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 +398,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 +463,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 +562,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 +580,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
|
||||
|
||||
@@ -626,6 +737,9 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "native":
|
||||
self._add_native_actions(menu, game_card)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -698,16 +812,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()
|
||||
@@ -716,6 +838,96 @@ class ContextMenuManager:
|
||||
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
|
||||
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
for directory, add_text, remove_text, icon in (
|
||||
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
|
||||
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
|
||||
):
|
||||
if not directory:
|
||||
continue
|
||||
exists = (Path(directory) / source.name).is_file()
|
||||
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
|
||||
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(card.name)
|
||||
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
|
||||
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
|
||||
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
|
||||
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
action.triggered.connect(lambda: self._edit_native_shortcut(card))
|
||||
if managed:
|
||||
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
|
||||
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
|
||||
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
|
||||
|
||||
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
target = Path(directory) / source.name
|
||||
try:
|
||||
if target.is_file():
|
||||
if target == source:
|
||||
retained = Path(get_portproton_location() or source.parent) / source.name
|
||||
retained.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, retained)
|
||||
target.unlink()
|
||||
else:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(source.read_text(encoding="utf-8"))
|
||||
command = quote_native_arguments([exec_line])
|
||||
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
|
||||
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
|
||||
section = f"Desktop Action {action}"
|
||||
if parser.has_section(section):
|
||||
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open("w", encoding="utf-8") as output:
|
||||
parser.write(output, space_around_delimiters=False)
|
||||
target.chmod(0o755)
|
||||
if directory == self._get_applications_dir():
|
||||
self._update_desktop_database()
|
||||
self.parent.refreshGames()
|
||||
except Exception as error:
|
||||
logger.warning("Cannot update native shortcut: %s", error)
|
||||
self.signals.show_warning_dialog.emit(_("Error"), str(error))
|
||||
|
||||
def _edit_native_shortcut(self, card: "GameCard") -> None:
|
||||
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=card.name, exe_path=source, cover_path=card.cover_path)
|
||||
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
name = dialog.nameEdit.text().strip()
|
||||
if not name:
|
||||
return
|
||||
cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(cover) and dialog.last_cover_path:
|
||||
cover = dialog.last_cover_path
|
||||
try:
|
||||
self._save_local_shortcut_name(source, name)
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
|
||||
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
|
||||
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
|
||||
if os.path.abspath(cover) != str(target):
|
||||
self._remove_old_cover_files(directory)
|
||||
shutil.copyfile(cover, target)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(card.name):
|
||||
self.add_to_steam(name, card.exec_line, cover)
|
||||
elif is_game_in_steam(card.name):
|
||||
self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
self.parent.refreshGames()
|
||||
except OSError as error:
|
||||
logger.warning("Cannot edit native metadata: %s", error)
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
Launch or stop a game based on its current state.
|
||||
@@ -723,6 +935,9 @@ class ContextMenuManager:
|
||||
Args:
|
||||
game_card: The GameCard instance containing game data.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@@ -805,7 +1020,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
|
||||
@@ -881,6 +1098,9 @@ class ContextMenuManager:
|
||||
|
||||
def _get_exec_line(self, game_name, exec_line):
|
||||
"""Retrieve and validate exec_line from .desktop file if necessary."""
|
||||
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
return entry.get("Exec") if entry else None
|
||||
if exec_line and exec_line.strip() != "full":
|
||||
return exec_line
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1012,6 +1232,20 @@ class ContextMenuManager:
|
||||
|
||||
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
|
||||
card_exec_line = exec_line
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
|
||||
str(Path(self._get_applications_dir()) / source.name)]
|
||||
if desktop:
|
||||
paths.append(str(Path(desktop) / source.name))
|
||||
self._remove_installed_shortcuts(game_name, paths)
|
||||
self._remove_statistics_entry(str(source), game_name)
|
||||
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||
for key in ("all", "native", "favorites"):
|
||||
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1054,14 +1288,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 +1548,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 +1663,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 +1724,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 +1737,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 +1764,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 +1783,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)
|
||||
|
||||
@@ -1613,26 +1873,30 @@ class ContextMenuManager:
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
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((NATIVE_URI_PREFIX, "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,27 +1918,25 @@ 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"),
|
||||
_(message).format(game_name=game_name)
|
||||
)
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
from itertools import accumulate
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
|
||||
from PySide6.QtCore import 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:
|
||||
|
||||
@@ -6,6 +6,9 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import IO
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import get_portproton_location
|
||||
@@ -18,7 +21,7 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DebugLogManager:
|
||||
"""Manages debug log creation with game launch and Wine output capture."""
|
||||
"""Manage debug logs for Wine and native game launches."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
@@ -26,15 +29,19 @@ class DebugLogManager:
|
||||
self.start_command: list[str] | None = None
|
||||
self.wine_output: list[str] = []
|
||||
self.runtime_env: dict[str, str] | None = None
|
||||
self.native_output: IO[str] | None = None
|
||||
self.native_directory: str | None = None
|
||||
self.is_running = False
|
||||
self.output_queue = queue.Queue()
|
||||
self.output_thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, exe_path: str, start_command: list[str]) -> bool:
|
||||
"""Start game with PW_LOG=1 and capture output."""
|
||||
"""Capture game output, enabling PW_LOG for PortProton launches."""
|
||||
if self.is_running:
|
||||
return False
|
||||
if exe_path.startswith("native://"):
|
||||
return self._start_native(exe_path)
|
||||
|
||||
self.exe_path = exe_path
|
||||
self.start_command = start_command
|
||||
@@ -86,6 +93,52 @@ class DebugLogManager:
|
||||
logger.error("Failed to start debug session: %s", e)
|
||||
return False
|
||||
|
||||
def _start_native(self, uri: str) -> bool:
|
||||
from portprotonqt.native_games import get_native_launch
|
||||
|
||||
try:
|
||||
command, directory = get_native_launch(uri.removeprefix("native://"))
|
||||
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
|
||||
self.process = subprocess.Popen(
|
||||
command, cwd=directory, stdout=self.native_output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to start native debug session: %s", error)
|
||||
if self.native_output is not None:
|
||||
self.native_output.close()
|
||||
self.native_output = None
|
||||
return False
|
||||
self.exe_path = uri.removeprefix("native://")
|
||||
self.start_command = command
|
||||
self.native_directory = directory
|
||||
self.is_running = True
|
||||
return True
|
||||
|
||||
def _save_native_log(self) -> str | None:
|
||||
output = self.native_output
|
||||
if output is None:
|
||||
return None
|
||||
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
|
||||
|
||||
try:
|
||||
settings = load_native_settings(str(self.exe_path))
|
||||
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.seek(0)
|
||||
with destination.open("w", encoding="utf-8") as log:
|
||||
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
|
||||
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
|
||||
shutil.copyfileobj(output, log)
|
||||
return str(destination)
|
||||
except OSError as error:
|
||||
logger.error("Failed to save native debug log: %s", error)
|
||||
return None
|
||||
finally:
|
||||
output.close()
|
||||
self.native_output = None
|
||||
self.native_directory = None
|
||||
|
||||
def _read_output(self):
|
||||
"""Read output from subprocess in a separate thread."""
|
||||
if self.process and self.process.stdout:
|
||||
@@ -98,19 +151,20 @@ class DebugLogManager:
|
||||
logger.debug("Error reading output: %s", e)
|
||||
|
||||
def stop(self) -> str | None:
|
||||
"""Stop game and save debug log with captured Wine output."""
|
||||
self._capture_env_vars()
|
||||
"""Stop the game and save its diagnostic log."""
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
self._stop_event.set()
|
||||
|
||||
if self.is_running and self.process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.process.poll() is None:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -123,7 +177,7 @@ class DebugLogManager:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
log_file = self._save_log()
|
||||
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
|
||||
|
||||
if self.process and self.process.stdout:
|
||||
try:
|
||||
@@ -146,7 +200,8 @@ class DebugLogManager:
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
self._capture_env_vars()
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
return True
|
||||
|
||||
def _capture_env_vars(self) -> None:
|
||||
|
||||
@@ -290,7 +290,8 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
|
||||
game_dir = os.path.dirname(exe_path)
|
||||
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
|
||||
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
if portproton_path:
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
|
||||
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -313,8 +314,17 @@ def generate_system_info(
|
||||
get_d3d_extras_status,
|
||||
)
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
|
||||
|
||||
native = bool(exe_path and exe_path.startswith("native://"))
|
||||
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
|
||||
if desktop_path:
|
||||
runtime_env = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
exe_path = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
|
||||
portproton_path = get_portproton_location() or ""
|
||||
if not portproton_path and not native:
|
||||
return "Error: PortProton location not found"
|
||||
|
||||
lines = []
|
||||
@@ -344,16 +354,17 @@ def generate_system_info(
|
||||
lines.append(get_libc_version())
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
if not native:
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Date and time of start debug for PortProton:")
|
||||
lines.append(datetime.now().strftime("%c %z"))
|
||||
@@ -419,26 +430,28 @@ def generate_system_info(
|
||||
lines.append(locale_avail)
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
if not native:
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
if exe_path:
|
||||
ppdb_content = get_ppdb_content(exe_path, start_cmd)
|
||||
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
|
||||
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
|
||||
if ppdb_content:
|
||||
lines.append(f"Use {exe_path}.ppdb db file:")
|
||||
lines.append(f"Use {ppdb_path}.ppdb db file:")
|
||||
if ppdb_log_vars:
|
||||
lines.append("\n".join(ppdb_log_vars))
|
||||
lines.append(ppdb_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
user_overrides = get_user_overrides(portproton_path)
|
||||
user_overrides = "" if native else get_user_overrides(portproton_path)
|
||||
if user_overrides:
|
||||
lines.append(user_overrides)
|
||||
lines.append("-" * 61)
|
||||
|
||||
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.native_games import get_native_launch, load_native_settings
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
create_scroll_area,
|
||||
create_back_button,
|
||||
@@ -93,6 +95,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)
|
||||
@@ -652,7 +657,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
str(game_source).lower() not in {"gog", "egs", "native"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -667,7 +672,24 @@ class DetailPageManager:
|
||||
buttons_layout.addWidget(add_button)
|
||||
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if str(game_source).lower() == "portproton":
|
||||
if source == "native":
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
|
||||
buttons_layout.addWidget(settings_button)
|
||||
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
if os.path.dirname(exec_line.removeprefix("native://")) in (
|
||||
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
|
||||
):
|
||||
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
|
||||
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
source in {"gog", "egs"}
|
||||
@@ -722,6 +744,8 @@ class DetailPageManager:
|
||||
|
||||
def _get_current_exe(self, exec_line: str) -> str | None:
|
||||
"""Extract current executable from exec line."""
|
||||
if exec_line.startswith("native://"):
|
||||
return os.path.basename(exec_line.removeprefix("native://"))
|
||||
file_to_check = extract_exec_target_path(exec_line)
|
||||
return os.path.basename(file_to_check) if file_to_check else None
|
||||
|
||||
@@ -819,9 +843,26 @@ class DetailPageManager:
|
||||
|
||||
def _open_executable_folder(self, exe_path: str | None) -> None:
|
||||
"""Open the folder containing an executable."""
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
if not exe_path:
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
if exe_path.startswith("native://"):
|
||||
try:
|
||||
desktop_path = exe_path.removeprefix("native://")
|
||||
settings = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
executable = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
|
||||
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
raise ValueError("Game directory is not available")
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native game folder: %s", error)
|
||||
QMessageBox.warning(self.main_window, _("Error"), str(error))
|
||||
return
|
||||
else:
|
||||
if not os.path.exists(exe_path):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
|
||||
def _has_game_shortcut(self, game_name: str) -> bool:
|
||||
@@ -1451,14 +1492,15 @@ class DetailPageManager:
|
||||
"""Start debug logging session."""
|
||||
if exe_path is None:
|
||||
return
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
native = exe_path.startswith("native://")
|
||||
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
start_command = [] if native else get_portproton_start_command()
|
||||
if start_command is None:
|
||||
return
|
||||
|
||||
if self.debug_log_manager.start(resolved_exe_path, start_command):
|
||||
@@ -1480,7 +1522,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 +1680,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,37 @@
|
||||
"""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, QMessageBox
|
||||
)
|
||||
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.native_games import create_native_desktop_file, is_native_executable
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -28,6 +40,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 +138,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 +433,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()
|
||||
@@ -233,6 +484,9 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
|
||||
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
|
||||
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
|
||||
self.add_to_menu_checkbox.setChecked(True)
|
||||
self.add_to_desktop_checkbox.setChecked(True)
|
||||
|
||||
self.coverPreview = QLabel(self)
|
||||
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -327,17 +581,60 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
def file_filter(path: str) -> bool:
|
||||
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
|
||||
|
||||
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
|
||||
file_explorer.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 +648,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)
|
||||
@@ -481,6 +967,13 @@ class AddGameDialog(DraggableDialog):
|
||||
if not name:
|
||||
return None, None
|
||||
|
||||
if is_native_executable(exe_path):
|
||||
cover = self.last_cover_path or self.coverEdit.text().strip()
|
||||
result = create_native_desktop_file(exe_path, name, cover)
|
||||
if result is None:
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
|
||||
return result or (None, None)
|
||||
|
||||
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
|
||||
parent_widget = cast("MainWindow | None", self.parentWidget())
|
||||
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
|
||||
@@ -497,6 +990,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")),
|
||||
]
|
||||
|
||||
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
|
||||
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
thumbnail_source = self.file_path
|
||||
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
|
||||
resolved_path = self.resolve_launch_file_path(self.file_path)
|
||||
@@ -125,6 +125,14 @@ class FileExplorer(DraggableDialog):
|
||||
return
|
||||
thumbnail_source = resolved_path
|
||||
|
||||
if thumbnail_source.lower().endswith(".appimage"):
|
||||
icon_path = get_exe_icon_cache_path(thumbnail_source)
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
image = QImage()
|
||||
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
|
||||
image = QImage(icon_path)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
return
|
||||
if not thumbnail_source.lower().endswith(".exe"):
|
||||
return
|
||||
|
||||
@@ -142,6 +150,8 @@ class FileExplorer(DraggableDialog):
|
||||
os.unlink(tmp_name)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
if self.file_path.lower().endswith(".appimage"):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QImage())
|
||||
|
||||
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
|
||||
super().__init__(parent)
|
||||
@@ -214,7 +224,7 @@ class FileExplorer(DraggableDialog):
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
resolver = None
|
||||
main_window = cast("MainWindow | None", self.main_window)
|
||||
@@ -246,6 +256,8 @@ class FileExplorer(DraggableDialog):
|
||||
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
|
||||
icon = QIcon(QPixmap.fromImage(image))
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
if image.isNull():
|
||||
return
|
||||
item = self.file_items.get(file_path)
|
||||
if item:
|
||||
item.setData(THEMED_ICON_ROLE, None)
|
||||
@@ -607,7 +619,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,
|
||||
@@ -58,6 +60,7 @@ from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, Gamescop
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_TOGGLE_KEYS, NATIVE_ADVANCED_KEYS, load_native_settings, save_native_settings, get_native_ppdb_path
|
||||
from portprotonqt.preloader import Preloader
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
from portprotonqt.settings_manager import (
|
||||
@@ -164,11 +167,11 @@ class ExeSettingsDialog(
|
||||
if not self.user_conf and not self.exe_path and not self.appid:
|
||||
return
|
||||
self.portproton_path = get_portproton_location()
|
||||
if self.portproton_path is None:
|
||||
if self.portproton_path is None and self.game_source != "native":
|
||||
logger.error("PortProton location not found")
|
||||
return
|
||||
self.start_sh = get_portproton_start_command()
|
||||
if self.start_sh is None:
|
||||
if self.start_sh is None and self.game_source != "native":
|
||||
logger.error("PortProton start command not found")
|
||||
return
|
||||
|
||||
@@ -176,7 +179,7 @@ class ExeSettingsDialog(
|
||||
self.lg_dist_aliases = {}
|
||||
self.plugins_ver = ""
|
||||
self.prefix_options = []
|
||||
if self.portproton_path:
|
||||
if self.portproton_path and self.game_source != "native":
|
||||
scripts_path = get_portproton_scripts_path()
|
||||
if scripts_path:
|
||||
var_path = os.path.join(scripts_path, "var")
|
||||
@@ -198,6 +201,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 = {}
|
||||
@@ -221,6 +230,9 @@ class ExeSettingsDialog(
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
self.toggle_settings = get_toggle_settings()
|
||||
if self.game_source == "native":
|
||||
self.toggle_settings = {key: description for key, description in self.toggle_settings.items() if key in NATIVE_TOGGLE_KEYS}
|
||||
self.setWindowTitle(_("Native") + " — " + _("Settings"))
|
||||
|
||||
self.setup_ui()
|
||||
if self.user_conf:
|
||||
@@ -282,6 +294,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 +309,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 +476,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 +553,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 +560,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,9 +581,14 @@ class ExeSettingsDialog(
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load available toggles and current settings."""
|
||||
self.settings_loading = True
|
||||
self.settings_container.setCurrentIndex(0)
|
||||
self.advanced_container.setCurrentIndex(0)
|
||||
|
||||
if self.game_source == "native":
|
||||
self.on_show_ppdb_finished(0, QProcess.ExitStatus.NormalExit)
|
||||
return
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_show_ppdb_finished)
|
||||
command = ["cli", "--get-user-conf"] if self.user_conf else [
|
||||
@@ -518,7 +598,7 @@ class ExeSettingsDialog(
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
"""Handle --show-ppdb output."""
|
||||
"""Populate shared settings controls from the selected game's data."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.current_settings = {}
|
||||
self.blocked_keys = set()
|
||||
@@ -533,7 +613,15 @@ class ExeSettingsDialog(
|
||||
self.reject()
|
||||
return
|
||||
|
||||
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
self.current_settings = load_native_settings(cast(str, self.exe_path))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot load native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
self.reject()
|
||||
return
|
||||
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
|
||||
for line in output.splitlines():
|
||||
line_stripped = line.strip()
|
||||
@@ -625,6 +713,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."""
|
||||
@@ -633,6 +723,16 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
if not os.path.exists(db_path):
|
||||
save_native_settings(self.exe_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.critical(self, _("Error"), _("PPDB file does not exist at: ") + db_path)
|
||||
@@ -656,6 +756,14 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.information(self, _("Information"), _("PPDB file does not exist at: ") + db_path)
|
||||
self.load_current_settings()
|
||||
@@ -667,8 +775,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 +796,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 +819,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):
|
||||
@@ -816,6 +924,14 @@ class ExeSettingsDialog(
|
||||
dist_options=self.dist_options,
|
||||
prefix_options=self.prefix_options
|
||||
)
|
||||
if self.game_source == "native":
|
||||
advanced_settings = [setting for setting in advanced_settings if setting['key'] in NATIVE_ADVANCED_KEYS]
|
||||
for setting in advanced_settings:
|
||||
if setting["key"] == "PW_WINE_CPU_TOPOLOGY":
|
||||
setting["options"] = [disabled_text] + [str(count) for count in range(1, len(os.sched_getaffinity(0)) + 1)]
|
||||
for setting in advanced_settings:
|
||||
if setting['key'] == 'LAUNCH_PARAMETERS':
|
||||
setting['description'] = _("Launch Arguments")
|
||||
if self.user_conf:
|
||||
for setting in advanced_settings:
|
||||
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
|
||||
@@ -859,13 +975,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 +1009,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 +1062,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 +1079,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 +1233,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 +1340,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 +1351,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 +1394,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 +1410,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 +1466,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 +1474,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 +1544,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 +1628,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 +1714,53 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
values = dict(change.split('=', 1) for change in changes)
|
||||
save_native_settings(cast(str, self.exe_path), values)
|
||||
except Exception as error:
|
||||
logger.warning("Cannot save native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
self.saving = True
|
||||
self.clear_ppdb_button.setEnabled(False)
|
||||
self.saved_current_settings = self.current_settings.copy()
|
||||
self.saved_values = dict(change.split('=', 1) for change in changes)
|
||||
self.saved_display_values = {
|
||||
key: widget.currentText() if isinstance(widget, QComboBox) else widget.text().strip()
|
||||
for key, widget in self.advanced_widgets.items()
|
||||
if isinstance(widget, (QComboBox, QLineEdit))
|
||||
}
|
||||
for key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
if key in self.saved_display_values:
|
||||
self.saved_display_values[key] = re.sub(
|
||||
r"[ \t]", "_", self.saved_display_values[key].strip()
|
||||
).upper()
|
||||
if 'PW_RUN_AFTER_EXE' in self.saved_values:
|
||||
self.saved_display_values['PW_RUN_AFTER_EXE'] = self.saved_values['PW_RUN_AFTER_EXE']
|
||||
if self.user_conf:
|
||||
self.user_conf_changes = changes
|
||||
self.close_after_user_conf_changes = True
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
return
|
||||
|
||||
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 +1771,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 +1868,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 +1894,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 +1911,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 +1930,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 +2011,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 +2043,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("/"))
|
||||
|
||||
+43
-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()
|
||||
@@ -396,6 +402,7 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.native_visible = str(game_source).lower() == "native"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
@@ -509,6 +516,13 @@ class GameCard(AnimatedCard):
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
self.nativeLabel = SourceCorner(
|
||||
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
icon=portproton_icon,
|
||||
@@ -655,6 +669,7 @@ class GameCard(AnimatedCard):
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.nativeLabel, "badge_linux"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
@@ -873,10 +888,11 @@ 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.native_visible, self.nativeLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -899,6 +915,7 @@ class GameCard(AnimatedCard):
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.nativeLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1040,21 +1057,19 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.native_visible = str(self.game_source).lower() == "native"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible 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.nativeLabel.setVisible(self.native_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 +1271,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 +1306,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:
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"""Unified icon extractor for NE and PE files."""
|
||||
"""Unified icon extractor for NE, PE and type-2 AppImages."""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
import posixpath
|
||||
import glob
|
||||
import pefile
|
||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
|
||||
from PySide6.QtGui import QImage, QImageWriter
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -15,6 +23,82 @@ logger = get_logger(__name__)
|
||||
RT_ICON = 3
|
||||
RT_GROUP_ICON = 14
|
||||
THUMBNAIL_SIZE = 128
|
||||
APPIMAGE_EXTRACT_TIMEOUT = 10
|
||||
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
|
||||
|
||||
|
||||
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
|
||||
"""Read an icon and resolve archive-local links without extracting to disk."""
|
||||
filename = ".DirIcon"
|
||||
seen: set[str] = set()
|
||||
while filename not in seen:
|
||||
seen.add(filename)
|
||||
result = subprocess.run(
|
||||
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
|
||||
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
|
||||
"--skip-devices", "--skip-specials", "--log-level", "error"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
|
||||
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
|
||||
if member is None:
|
||||
return b""
|
||||
if member.issym() or member.islnk():
|
||||
target = member.linkname
|
||||
base = posixpath.dirname(filename) if member.issym() else ""
|
||||
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
|
||||
else posixpath.join(base, target))
|
||||
if filename == ".." or filename.startswith("../"):
|
||||
return b""
|
||||
continue
|
||||
if not member.isfile():
|
||||
return b""
|
||||
stream = archive.extractfile(member)
|
||||
return stream.read() if stream is not None else b""
|
||||
return b""
|
||||
|
||||
|
||||
def _get_appimage_icon(path: str) -> io.BytesIO | None:
|
||||
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
|
||||
with open(path, "rb") as source:
|
||||
header = source.read(64)
|
||||
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
|
||||
return None
|
||||
layout = ELF_SECTION_LAYOUTS.get(header[4])
|
||||
if layout is None or header[5] not in (1, 2):
|
||||
return None
|
||||
endian = "<" if header[5] == 1 else ">"
|
||||
section_offset, offset_format, size_offset = layout
|
||||
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
|
||||
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
|
||||
offset += entry_size * entry_count
|
||||
source.seek(offset)
|
||||
magic = source.read(6)
|
||||
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
|
||||
return None
|
||||
try:
|
||||
if magic == b"DWARFS":
|
||||
data = _read_dwarfs_icon(path, offset)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
data = result.stdout
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
image = QImage.fromData(data)
|
||||
if image.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
|
||||
return None
|
||||
return io.BytesIO(buffer.data().data())
|
||||
|
||||
|
||||
def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
@@ -49,7 +133,12 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
custom_icon = get_custom_icon_path(exe_path)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
if exe_path.lower().endswith(".appimage"):
|
||||
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
"images",
|
||||
@@ -60,7 +149,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
|
||||
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
|
||||
"""
|
||||
Generates a thumbnail for an .exe file.
|
||||
Generates a thumbnail for a Windows executable or type-2 AppImage.
|
||||
|
||||
inputfile: the input file path
|
||||
outfile: output filename
|
||||
@@ -113,6 +202,8 @@ class IconExtractor:
|
||||
"""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
if f.read(4) == b"\x7fELF":
|
||||
return _get_appimage_icon(self.file_path)
|
||||
if not self._parse_file(f):
|
||||
return None
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ def load_pixmap_async(
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
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-10-17 23:27+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,17 @@ msgstr ""
|
||||
"A pasta de dados do PortProton não pode ser lida nem gravada. Escolha "
|
||||
"outra pasta para os dados do PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
@@ -114,12 +122,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 +149,16 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar Tudo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
@@ -279,14 +288,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 +307,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 +319,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 +475,16 @@ msgstr "Outra operação da Epic está em andamento"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo do Wine não encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -959,6 +962,22 @@ msgstr "Atualizando AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
@@ -974,6 +993,10 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Capa Personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
|
||||
@@ -986,6 +1009,33 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Falha ao baixar a capa"
|
||||
|
||||
@@ -1003,8 +1053,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 +1083,6 @@ msgstr "Aba Anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Próxima Aba"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
@@ -1177,9 +1230,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 +1268,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 +1301,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 +1313,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 +1336,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 +2178,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 +2260,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 +2299,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 +2334,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 +2367,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,9 +2400,12 @@ 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 ALT Linux i586 dependency check"
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
|
||||
@@ -2370,7 +2413,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 +2625,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-24 09: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,10 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -985,6 +1012,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 +1055,14 @@ msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1026,9 +1085,6 @@ msgstr "Предыдущая вкладка"
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
@@ -1176,9 +1232,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 +1318,6 @@ msgstr "Не удалось открыть файл PPDB:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
|
||||
@@ -1547,7 +1597,7 @@ msgid "Failed to save custom preset."
|
||||
msgstr "Ошибка сохранения пользовательских настроек."
|
||||
|
||||
msgid "Overlay position"
|
||||
msgstr "Позиция MangoHud"
|
||||
msgstr "Положение MangoHud"
|
||||
|
||||
msgid "Device battery"
|
||||
msgstr "Батарея устройства"
|
||||
@@ -1562,7 +1612,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 +1981,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 +2256,6 @@ msgstr "Ошибка"
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2284,6 +2330,9 @@ msgstr ""
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
|
||||
@@ -2347,6 +2396,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 +2619,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 "Выбрать тему:"
|
||||
|
||||
|
||||
@@ -21,14 +21,22 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择其他文件夹。"
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "更新完成"
|
||||
|
||||
@@ -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,9 +143,16 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "全选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
@@ -273,14 +282,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 +299,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 +309,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}"
|
||||
@@ -476,6 +469,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 "未找到游戏。"
|
||||
|
||||
@@ -925,6 +928,22 @@ msgstr "正在更新 AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
|
||||
@@ -940,6 +959,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 +975,34 @@ msgstr "应用"
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "下载封面失败"
|
||||
|
||||
@@ -967,9 +1018,15 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
@@ -992,9 +1049,6 @@ msgstr "上一标签页"
|
||||
msgid "Next Tab"
|
||||
msgstr "下一标签页"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
@@ -1142,9 +1196,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 已下载完成!"
|
||||
|
||||
@@ -1234,9 +1285,6 @@ msgstr "打开 PPDB 文件失败:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
|
||||
@@ -2187,10 +2235,6 @@ msgstr "详细"
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
@@ -2273,6 +2317,9 @@ msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
@@ -2338,6 +2385,9 @@ msgstr "启用界面音效"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2560,15 +2610,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 "选择主题:"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+248
-41
@@ -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
|
||||
@@ -27,6 +29,7 @@ from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
|
||||
from portprotonqt.debug_utils import get_prefix_name
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
@@ -90,6 +93,7 @@ from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
GAME_PROCESS_CHECK_INTERVAL_MS = 500
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
@@ -115,7 +119,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:
|
||||
@@ -1034,7 +1038,7 @@ class MainWindow(
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
# Load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def check_completion():
|
||||
@@ -1064,14 +1068,24 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
self._load_native_games_async(native_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
for source in results
|
||||
}
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
@@ -1113,15 +1127,106 @@ 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)
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback, "native": native_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
getattr(self, f"_load_{source}_games_async")(callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
|
||||
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
self._native_games_callback = callback
|
||||
self.native_library_worker = NativeLibraryWorker()
|
||||
self.native_library_worker.finished.connect(self._on_native_games_loaded)
|
||||
self.native_library_worker.start()
|
||||
|
||||
def open_native_settings(self, exec_line: str) -> None:
|
||||
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
try:
|
||||
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
|
||||
except OSError as error:
|
||||
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
dialog.exec()
|
||||
dialog.deleteLater()
|
||||
|
||||
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
|
||||
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
if self.game_processes:
|
||||
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
|
||||
self.stop_running_game(button)
|
||||
else:
|
||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||
return
|
||||
try:
|
||||
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
|
||||
raise ValueError("Invalid native desktop path")
|
||||
command, directory = get_native_launch(str(desktop_path))
|
||||
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
|
||||
return
|
||||
self.native_game_running = True
|
||||
self.game_stopped_by_user = False
|
||||
self.game_processes = [process]
|
||||
self.current_running_button = button
|
||||
self.target_exe = desktop_path.name
|
||||
self.game_start_exe = str(desktop_path)
|
||||
self.game_start_exact_path = True
|
||||
self.game_start_time = datetime.now()
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_launch_started = True
|
||||
self.wine_download_seen = False
|
||||
save_last_launch(desktop_path.stem, self.game_start_time)
|
||||
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
|
||||
self.launch_output_queue = Queue()
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
SoundManager().play("game_launch")
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
|
||||
|
||||
@Slot()
|
||||
def _on_native_games_loaded(self) -> None:
|
||||
worker = self.native_library_worker
|
||||
if worker is None:
|
||||
return
|
||||
self.native_library_worker = None
|
||||
games = worker.games
|
||||
worker.deleteLater()
|
||||
if not games:
|
||||
self._native_games_callback([])
|
||||
return
|
||||
completed = []
|
||||
|
||||
def on_info(info: dict, game: tuple) -> None:
|
||||
updated = list(game)
|
||||
updated[1] = info.get("description", "") or game[1]
|
||||
updated[2] = game[2] or info.get("cover", "")
|
||||
updated[3] = info.get("appid", "")
|
||||
updated[4] = info.get("controller_support", "")
|
||||
completed.append(tuple(updated))
|
||||
if len(completed) == len(games):
|
||||
self._native_games_callback(completed)
|
||||
|
||||
for game in games:
|
||||
if ui_config.get_economy_mode():
|
||||
on_info(get_cached_steam_game_info(game[0], game[5]), game)
|
||||
else:
|
||||
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
|
||||
|
||||
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
api = getattr(self, "gog_api", None) or GOGAPI()
|
||||
installed = api.load_installed()
|
||||
@@ -1129,6 +1234,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 +1302,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 +1332,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 +1519,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):
|
||||
@@ -1420,7 +1560,7 @@ class MainWindow(
|
||||
callback: Callable[[tuple | None], None],
|
||||
):
|
||||
entry = parse_desktop_entry(file_path)
|
||||
if not entry:
|
||||
if not entry or read_native_game(Path(file_path)) is not None:
|
||||
callback(None)
|
||||
return
|
||||
desktop_name = entry.get("Name", _("Unknown Application"))
|
||||
@@ -1762,11 +1902,13 @@ class MainWindow(
|
||||
self.detail_page_manager.openGameDetailPage(game_data)
|
||||
pending_log_exe = self._pending_log_exe
|
||||
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
detail_exe = exec_line
|
||||
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
|
||||
if pending_log_exe and detail_exe:
|
||||
if os.path.abspath(detail_exe) == pending_log_exe:
|
||||
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_exe:
|
||||
self._pending_log_exe = None
|
||||
log_button = self.detail_page_manager._debug_log_button
|
||||
if log_button:
|
||||
@@ -2083,7 +2225,7 @@ class MainWindow(
|
||||
|
||||
|
||||
def checkTargetExe(self):
|
||||
"""Update launch state from the marker and PortProton process."""
|
||||
"""Update launch state and reset controls after the game exits."""
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
child_running = self._has_running_game_process()
|
||||
|
||||
@@ -2208,6 +2350,7 @@ class MainWindow(
|
||||
self.game_processes = [] if launcher_only else [
|
||||
proc for proc in self.game_processes if proc.poll() is None
|
||||
]
|
||||
self.native_game_running = False
|
||||
if not getattr(self, "_animated_covers_suspended", False):
|
||||
self.input_manager.resume_gamepad_polling()
|
||||
self._refresh_portproton_shortcuts()
|
||||
@@ -2302,6 +2445,8 @@ class MainWindow(
|
||||
|
||||
def _get_game_executable(self, exec_line: str) -> str:
|
||||
"""Resolve a library launch entry to its tracked executable."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
return exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
if exec_line.startswith("gog://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.gog_api.get_launch_target(app_id) or "")
|
||||
@@ -2311,6 +2456,19 @@ class MainWindow(
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
if getattr(self, "native_game_running", False):
|
||||
if any(process.poll() is None for process in self.game_processes):
|
||||
return True
|
||||
groups = {process.pid for process in self.game_processes}
|
||||
for process in psutil.process_iter(attrs=["pid", "status"]):
|
||||
try:
|
||||
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except PermissionError:
|
||||
continue
|
||||
return False
|
||||
if self._observe_game_processes():
|
||||
return True
|
||||
process_alive = any(proc.poll() is None for proc in self.game_processes)
|
||||
@@ -2432,6 +2590,8 @@ class MainWindow(
|
||||
self._start_launch_output_reader(game_process)
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if getattr(self, "native_game_running", False):
|
||||
return
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
logger.info("Compatibility report skipped: crash reports disabled")
|
||||
return
|
||||
@@ -2493,20 +2653,22 @@ 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:
|
||||
for proc in self.game_processes:
|
||||
if proc.poll() is not None:
|
||||
if proc.poll() is not None and not getattr(self, "native_game_running", False):
|
||||
continue
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.kill()
|
||||
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if not getattr(self, "native_game_running", False):
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as e:
|
||||
@@ -2525,11 +2687,14 @@ class MainWindow(
|
||||
return True
|
||||
|
||||
def stop_running_game(self, button=None) -> bool:
|
||||
"""Stop current game via PortProton CLI stop command."""
|
||||
"""Stop the current game's owned processes and launcher."""
|
||||
if button is not None:
|
||||
self.current_running_button = button
|
||||
self.game_stopped_by_user = True
|
||||
self.egs_launch_cancelled = True
|
||||
if getattr(self, "native_game_running", False):
|
||||
self._terminate_game_processes()
|
||||
return True
|
||||
if getattr(self, "launcher_process_only", False):
|
||||
if self._run_portproton_stop_command():
|
||||
return True
|
||||
@@ -2561,8 +2726,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 +2844,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 +2929,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 +3036,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 +3094,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 +3156,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)
|
||||
@@ -3001,6 +3202,10 @@ class MainWindow(
|
||||
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
def toggleGame(self, exec_line, button=None, game_name=None):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
self._launch_native_game(exec_line, button)
|
||||
return
|
||||
|
||||
# Handle Steam games
|
||||
if exec_line.startswith("steam://"):
|
||||
self._launch_steam_game(exec_line)
|
||||
@@ -3063,6 +3268,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:
|
||||
|
||||
@@ -22,6 +22,7 @@ install_data(
|
||||
'localization.py',
|
||||
'logger.py',
|
||||
'main_window.py',
|
||||
'native_games.py',
|
||||
'port_data_path_selector.py',
|
||||
'portproton_api.py',
|
||||
'preloader.py',
|
||||
@@ -186,6 +187,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',
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Discover installed native games from desktop application entries."""
|
||||
|
||||
import configparser
|
||||
import platform
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
from portprotonqt.time_utils import (
|
||||
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
|
||||
format_last_launch, format_playtime,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
NATIVE_URI_PREFIX = "native://"
|
||||
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
|
||||
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
|
||||
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
|
||||
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
|
||||
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
|
||||
|
||||
|
||||
def is_native_executable(path: str) -> bool:
|
||||
"""Recognize native launch files while preserving Windows and disc formats."""
|
||||
return (
|
||||
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
|
||||
and os.path.isfile(path)
|
||||
and os.access(path, os.X_OK)
|
||||
)
|
||||
|
||||
|
||||
def quote_native_arguments(arguments: list[str]) -> str:
|
||||
"""Quote arguments using desktop entry escaping, without a shell."""
|
||||
escaped = []
|
||||
for argument in arguments:
|
||||
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
|
||||
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
|
||||
return " ".join(escaped)
|
||||
|
||||
|
||||
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
|
||||
"""Use the installed application launcher for native desktop shortcuts."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID")
|
||||
appimage = os.getenv("APPIMAGE", "").strip()
|
||||
launcher = ["portprotonqt"]
|
||||
if flatpak_id:
|
||||
launcher = ["flatpak", "run", flatpak_id]
|
||||
elif appimage and os.path.isfile(appimage):
|
||||
launcher = [appimage]
|
||||
if mode:
|
||||
launcher.append(mode)
|
||||
return quote_native_arguments(launcher) + " " + command
|
||||
|
||||
|
||||
def get_native_source_command(command: str) -> str:
|
||||
"""Unwrap a PortProtonQt desktop command before applying native settings."""
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
|
||||
target = extract_exec_target_path(parts)
|
||||
if target:
|
||||
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
|
||||
return quote_native_arguments([target])
|
||||
return command
|
||||
|
||||
|
||||
def get_native_tool_paths() -> dict[str, str]:
|
||||
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
|
||||
scripts = get_portproton_scripts_path()
|
||||
location = get_portproton_location()
|
||||
if not scripts or not location:
|
||||
return {}
|
||||
values = {}
|
||||
try:
|
||||
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.removeprefix("export ").partition("=")
|
||||
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
|
||||
values[key] = value.strip().strip('"\'')
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native runtime versions: %s", error)
|
||||
return {}
|
||||
root = Path(location) / "data/tmp"
|
||||
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
|
||||
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
|
||||
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
|
||||
|
||||
|
||||
def read_native_variables(path: Path) -> dict[str, str]:
|
||||
"""Read literal var/PPDB assignments without executing shell commands."""
|
||||
values = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip().startswith(("export ", "check_variables ")):
|
||||
continue
|
||||
parts = shlex.split(line, comments=True)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0] == "check_variables" and len(parts) == 3:
|
||||
values[parts[1]] = parts[2]
|
||||
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
|
||||
key, value = parts[1].split("=", 1)
|
||||
values[key] = value.replace("\\$", "$").replace("\\`", "`")
|
||||
return values
|
||||
|
||||
|
||||
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
|
||||
"""Keep native settings and logs in a named per-game user data directory."""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
|
||||
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
|
||||
|
||||
|
||||
def get_native_defaults() -> dict[str, str]:
|
||||
"""Read supported Linux defaults from the existing PortProton var file."""
|
||||
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
|
||||
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
|
||||
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
|
||||
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
|
||||
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
|
||||
scripts = get_portproton_scripts_path()
|
||||
if scripts and (Path(scripts) / "var").is_file():
|
||||
defaults = read_native_variables(Path(scripts) / "var")
|
||||
values.update({key: value for key, value in defaults.items()
|
||||
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
|
||||
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
|
||||
if item not in {"wine", "winesync"})
|
||||
return values
|
||||
|
||||
|
||||
def load_native_settings(desktop_path: str) -> dict[str, str]:
|
||||
"""Load var defaults, existing desktop settings, and the game PPDB."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
settings = get_native_defaults()
|
||||
if parser.has_section(NATIVE_SETTINGS_SECTION):
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
|
||||
database = Path(desktop_path + ".ppdb")
|
||||
if database.is_file():
|
||||
content = database.read_text(encoding="utf-8")
|
||||
if content.lstrip().startswith("["):
|
||||
parser.read_string(content)
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
else:
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
|
||||
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
|
||||
executable = next((part.replace("%%", "%") for part in reversed(parts)
|
||||
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
|
||||
if executable is None and parts:
|
||||
executable = shutil.which(parts[0])
|
||||
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
|
||||
for legacy in legacy_paths:
|
||||
if legacy.is_file():
|
||||
settings.update(read_native_variables(legacy))
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
if database.is_file():
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
|
||||
return settings
|
||||
|
||||
|
||||
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
|
||||
paths = get_native_tool_paths()
|
||||
runtime = Path(paths.get("runtime", ""))
|
||||
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
|
||||
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
|
||||
raise ValueError("PortProton container runtime is not installed")
|
||||
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
|
||||
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
|
||||
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
|
||||
if platform.freedesktop_os_release().get("ID") == "altlinux":
|
||||
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
|
||||
if path.is_dir()]
|
||||
if drivers:
|
||||
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
|
||||
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
|
||||
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
|
||||
portable = Path(paths.get("plugins", "")) / "portable"
|
||||
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
|
||||
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
|
||||
layers = portable / "share/vulkan/implicit_layer.d"
|
||||
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
|
||||
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
|
||||
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
|
||||
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
|
||||
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
|
||||
raise ValueError("PortProton portable MangoHud is not installed")
|
||||
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
|
||||
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
|
||||
return command, environment
|
||||
|
||||
|
||||
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
|
||||
"""Compose Linux launch wrappers and environment for a desktop entry."""
|
||||
prefix = []
|
||||
environment = []
|
||||
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
|
||||
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
|
||||
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
|
||||
if settings.get(key) == "1":
|
||||
environment.append(variable)
|
||||
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
|
||||
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
|
||||
if settings.get(key) not in (None, "", "disabled"):
|
||||
environment.append(f"{variable}={settings[key]}")
|
||||
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
|
||||
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
|
||||
if settings.get("PW_GAMESCOPE") == "1":
|
||||
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
|
||||
if settings.get("PW_USE_RUNTIME") == "1":
|
||||
runtime_command, runtime_env = build_native_runtime(settings)
|
||||
prefix += runtime_command
|
||||
environment += runtime_env
|
||||
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
|
||||
if cores not in ("", "disabled"):
|
||||
available = sorted(os.sched_getaffinity(0))
|
||||
count = int(cores)
|
||||
if not 1 <= count <= len(available):
|
||||
raise ValueError("Invalid CPU cores limit")
|
||||
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
|
||||
if settings.get("PW_USE_GAMEMODE") == "1":
|
||||
prefix.append("gamemoderun")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if settings.get("PW_USE_RUNTIME") != "1":
|
||||
prefix += ["mangohud", "--dlsym"]
|
||||
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
|
||||
config = settings.get("MANGOHUD_CONFIG", "")
|
||||
if settings.get("FPS_LIMIT"):
|
||||
config += f",fps_limit={settings['FPS_LIMIT']}"
|
||||
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
|
||||
if settings.get("PW_VKBASALT") == "1":
|
||||
environment.append("ENABLE_VKBASALT=1")
|
||||
if settings.get("VKBASALT_CONFIG_FILE"):
|
||||
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
|
||||
if environment:
|
||||
prefix = ["env", *environment, *prefix]
|
||||
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
|
||||
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
|
||||
|
||||
|
||||
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
|
||||
"""Keep native vkBasalt configuration isolated from global settings."""
|
||||
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
|
||||
settings.pop("VKBASALT_CONFIG_FILE", None)
|
||||
return
|
||||
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
|
||||
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
|
||||
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
|
||||
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
|
||||
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
|
||||
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
|
||||
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
|
||||
if shader and shader != "cas":
|
||||
if Path(shader).name != shader:
|
||||
raise ValueError("Invalid vkBasalt shader name")
|
||||
shader_path = plugins / "shaders" / (shader + ".fx")
|
||||
if not shader_path.is_file():
|
||||
raise ValueError(f"vkBasalt shader is not installed: {shader}")
|
||||
lines.append(f"{shader} = {shader_path}")
|
||||
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
settings["VKBASALT_CONFIG_FILE"] = str(config)
|
||||
|
||||
|
||||
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
|
||||
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
|
||||
settings = load_native_settings(desktop_path)
|
||||
settings.update(changes)
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
command, directory = settings["Exec"], settings.get("Path", "")
|
||||
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
|
||||
raise ValueError("Invalid native launch command or directory")
|
||||
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
|
||||
raise ValueError("Working directory must be an existing absolute path")
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_native_vkbasalt_config(desktop_path, settings)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
for key, value in sorted(settings.items()):
|
||||
if key in {"Name", "Icon", "Exec", "Path"}:
|
||||
continue
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
|
||||
output.write(f'export {key}="{escaped}"\n')
|
||||
os.replace(temporary, database)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
|
||||
"""Build a native launcher through PortProtonQt with the game's directory."""
|
||||
path = Path(executable).expanduser().absolute()
|
||||
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
|
||||
logger.warning("Not an executable native game: %s", path)
|
||||
return None
|
||||
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
|
||||
logger.warning("Invalid native game name or path")
|
||||
return None
|
||||
name = " ".join(name.splitlines()).strip()
|
||||
# Desktop Exec uses double quoting and doubles literal percent signs.
|
||||
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
|
||||
directory = str(path.parent).replace("\\", "\\\\")
|
||||
icon = get_custom_icon_path(str(path)) or icon
|
||||
if not icon and path.suffix.lower() == ".appimage":
|
||||
icon = get_exe_icon_cache_path(str(path))
|
||||
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
|
||||
comment = _('Launch "{name}" with PortProton').format(name=name)
|
||||
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
|
||||
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
|
||||
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
|
||||
("RunLog", _("Run in logging mode"), "--log")):
|
||||
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
|
||||
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
logger.warning("PortProton data directory is unavailable")
|
||||
return None
|
||||
filename = name.replace("/", "_") + ".desktop"
|
||||
destination = Path(location) / filename
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot create native library directory: %s", error)
|
||||
return None
|
||||
return content, str(destination)
|
||||
|
||||
|
||||
def read_native_game(path: Path) -> tuple | None:
|
||||
"""Read a game entry without modifying the installed desktop file."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read_string(path.read_text(encoding="utf-8"))
|
||||
if not parser.has_section("Desktop Entry"):
|
||||
return None
|
||||
entry = parser["Desktop Entry"]
|
||||
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
|
||||
return None
|
||||
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
|
||||
return None
|
||||
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
|
||||
return None
|
||||
name, command = entry.get("Name", ""), entry.get("Exec", "")
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if not name or not parts:
|
||||
return None
|
||||
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
|
||||
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
|
||||
return None
|
||||
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
|
||||
return None
|
||||
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
|
||||
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
root = str(data_home / "PortProtonQt/custom_data")
|
||||
custom = Path(resolve_custom_data_dir(root, str(path)))
|
||||
if not custom.is_dir() and executable:
|
||||
custom = Path(resolve_custom_data_dir(root, executable))
|
||||
description = entry.get("Comment", "")
|
||||
metadata = custom / "metadata.txt"
|
||||
if metadata.is_file():
|
||||
translations = read_metadata_translations(str(metadata), get_metadata_language())
|
||||
name = translations.get("name") or name
|
||||
description = translations.get("description") or description
|
||||
cover = next((str(path) for path in custom.glob("cover.*") if path.is_file()), "")
|
||||
if not cover:
|
||||
from portprotonqt.steam_api.api import get_cached_sgdb_cover
|
||||
|
||||
cover = get_cached_sgdb_cover(name)
|
||||
if not cover and executable.lower().endswith(".appimage"):
|
||||
cached_icon = get_exe_icon_cache_path(executable)
|
||||
if os.path.isfile(cached_icon):
|
||||
cover = cached_icon
|
||||
return (name, description, cover,
|
||||
"", "", NATIVE_URI_PREFIX + str(path), _("Never"), "", "", "", 0, 0,
|
||||
"native", "", "", "")
|
||||
except configparser.Error as error:
|
||||
logger.warning("Invalid native desktop entry %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native desktop entry %s: %s", path, error)
|
||||
except UnicodeError as error:
|
||||
logger.warning("Invalid native desktop encoding %s: %s", path, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native desktop command %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def get_native_launch(desktop_path: str) -> tuple[list[str], str]:
|
||||
"""Resolve desktop field codes into a direct Linux process command."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
entry = parser["Desktop Entry"]
|
||||
settings = load_native_settings(desktop_path)
|
||||
entry["Exec"] = settings["Exec"]
|
||||
if get_native_ppdb_path(desktop_path, settings).is_file():
|
||||
entry["Exec"] = build_native_launch_command(settings["Exec"], settings)
|
||||
entry["Path"] = settings.get("Path", "").replace("\\", "\\\\")
|
||||
escapes = {"\\": "\\", "s": " ", "n": "\n", "t": "\t", "r": "\r"}
|
||||
command = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Exec", ""))
|
||||
arguments = []
|
||||
for part in shlex.split(command):
|
||||
part = part.replace("\\$", "$").replace("\\`", "`")
|
||||
if part == "%i":
|
||||
if entry.get("Icon"):
|
||||
arguments.extend(["--icon", entry["Icon"]])
|
||||
continue
|
||||
value = re.sub(r"%%|%[fFuUdDnNvmick]", lambda match:
|
||||
{"%%": "%", "%c": entry.get("Name", ""), "%k": desktop_path}.get(match[0], ""), part)
|
||||
if value:
|
||||
arguments.append(value)
|
||||
if not arguments:
|
||||
raise ValueError("Empty native launch command")
|
||||
directory = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Path", ""))
|
||||
if directory and not os.path.isdir(directory):
|
||||
raise ValueError(f"Working directory does not exist: {directory}")
|
||||
executable = next((part for part in reversed(arguments)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), arguments[0])
|
||||
fallback = Path(executable).parent if os.path.isabs(executable) else Path.home()
|
||||
return arguments, directory or str(fallback)
|
||||
|
||||
|
||||
def load_native_game_times(games: list[tuple]) -> list[tuple]:
|
||||
"""Read shared statistics once for the native library batch."""
|
||||
statistics, launches = {}, {}
|
||||
try:
|
||||
path = get_statistics_path()
|
||||
if os.path.isfile(path):
|
||||
statistics = parse_playtime_file(path)
|
||||
path = get_last_launch_path()
|
||||
if os.path.isfile(path):
|
||||
launches = dict(parsed for line in Path(path).read_text(encoding="utf-8").splitlines()
|
||||
if (parsed := _parse_last_launch_line(line)))
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native game statistics: %s", error)
|
||||
updated_games = []
|
||||
for game in games:
|
||||
path = Path(game[5].removeprefix(NATIVE_URI_PREFIX))
|
||||
updated = list(game)
|
||||
updated[11] = statistics.get(str(path).replace(" ", "#@_@#"), 0)
|
||||
updated[7] = format_playtime(updated[11])
|
||||
if path.stem in launches:
|
||||
try:
|
||||
launched = datetime.fromisoformat(launches[path.stem])
|
||||
updated[6], updated[10] = format_last_launch(launched), launched.timestamp()
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native last launch time for %s: %s", path, error)
|
||||
updated_games.append(tuple(updated))
|
||||
return updated_games
|
||||
|
||||
|
||||
class NativeLibraryWorker(QThread):
|
||||
"""Scan application directories outside the UI thread."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.games: list[tuple] = []
|
||||
|
||||
def run(self) -> None:
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(os.pathsep)
|
||||
roots = [data_home, *(Path(directory) for directory in data_dirs if directory),
|
||||
data_home / "flatpak/exports/share", Path("/var/lib/flatpak/exports/share")]
|
||||
directories = [root / "applications" for root in roots]
|
||||
directories.insert(0, data_home / "PortProtonQt/native_games")
|
||||
location = get_portproton_location()
|
||||
if location:
|
||||
directories.insert(0, Path(location))
|
||||
seen: set[str] = set()
|
||||
commands: set[tuple[str, str, str]] = set()
|
||||
for applications in directories:
|
||||
try:
|
||||
for path in sorted(applications.glob("*.desktop") if location and applications == Path(location)
|
||||
else applications.rglob("*.desktop")):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
desktop_id = str(path.relative_to(applications)).replace(os.sep, "-")
|
||||
if desktop_id in seen:
|
||||
continue
|
||||
seen.add(desktop_id)
|
||||
game = read_native_game(path)
|
||||
if not game:
|
||||
continue
|
||||
entry = parse_desktop_entry(str(path)) or {}
|
||||
command = get_native_source_command(entry.get("Exec", ""))
|
||||
identity = (command, entry.get("Path", ""), entry.get("Name", ""))
|
||||
if identity in commands:
|
||||
continue
|
||||
commands.add(identity)
|
||||
self.games.append(game)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot scan native games in %s: %s", applications, error)
|
||||
self.games = load_native_game_times(self.games)
|
||||
@@ -175,7 +175,7 @@ def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose:
|
||||
message_box.setText(text)
|
||||
if allow_choose:
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
message_box.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())
|
||||
+102
-16
@@ -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):
|
||||
@@ -553,6 +575,23 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
|
||||
)
|
||||
|
||||
|
||||
def get_cached_sgdb_cover(game_name: str) -> str:
|
||||
"""Read a cached SGDB grid URL without creating a download worker."""
|
||||
cache = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
key = f"sgdb_{safe_name}_grids"
|
||||
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
|
||||
return ""
|
||||
cover = cache.load_json(key)
|
||||
if not isinstance(cover, str):
|
||||
return ""
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
|
||||
return cover
|
||||
|
||||
|
||||
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
"""Return Steam metadata from local cache only."""
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
@@ -560,7 +599,8 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
game_exe = extract_exec_target_path(exec_line) or exec_line
|
||||
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
|
||||
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
|
||||
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
|
||||
candidates = remove_duplicates(filter_candidates(names))
|
||||
apps_path = CacheManager().cache_dir / "steam_apps.json"
|
||||
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
|
||||
if not steam_apps:
|
||||
@@ -623,8 +663,22 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.cache import get_exiftool_data
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
cache_manager = CacheManager()
|
||||
match_key = "steam_match_" + hashlib.sha256(
|
||||
orjson.dumps([desktop_name, exec_line])
|
||||
).hexdigest()
|
||||
cached_match = (
|
||||
cache_manager.load_json(match_key)
|
||||
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
|
||||
)
|
||||
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
|
||||
cached_match = None
|
||||
elif cached_match["appid"] <= 0:
|
||||
cached_match = None
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
is_native = exec_line.startswith("native://")
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -646,7 +700,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 or is_native:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -670,9 +724,9 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not is_store_uri:
|
||||
if exe_name and not (is_store_uri or is_native):
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not is_store_uri:
|
||||
if folder_name and not (is_store_uri or is_native):
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
@@ -685,10 +739,13 @@ 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
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -716,15 +773,20 @@ 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
|
||||
|
||||
game_name = desktop_name or exe_name.capitalize()
|
||||
|
||||
if not matching_app:
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
def on_sgdb_cover_non_steam(cover: str) -> None:
|
||||
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -749,18 +811,39 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
appid = matching_app["appid"]
|
||||
if cached_match is None:
|
||||
cache_manager.save_json(match_key, matching_app)
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if is_native:
|
||||
info = app_info or {}
|
||||
result = {"appid": appid, "name": info.get("name", game_name),
|
||||
"description": info.get("short_description", ""),
|
||||
"controller_support": info.get("controller_support", "")}
|
||||
|
||||
def on_native_cover(cover: str) -> None:
|
||||
result["cover"] = cover
|
||||
callback(result)
|
||||
|
||||
local_cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if local_cover:
|
||||
on_native_cover(local_cover)
|
||||
else:
|
||||
fetch_sgdb_cover_async(result["name"], on_native_cover)
|
||||
return
|
||||
if not app_info:
|
||||
cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if not cover and not has_custom_data:
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
def on_anticheat_info_no_info(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
appid="",
|
||||
appid=appid,
|
||||
name=decode_text(game_name),
|
||||
description="",
|
||||
cover="",
|
||||
cover=cover,
|
||||
controller_support="",
|
||||
protondb_tier="",
|
||||
steam_game="false",
|
||||
steam_game="true",
|
||||
anticheat_status=anticheat_info.get("status", ""),
|
||||
anticheat_slug=anticheat_info.get("slug", ""),
|
||||
)
|
||||
@@ -797,4 +880,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
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Steam shortcuts management for PortProtonQt."""
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import zlib
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
import vdf
|
||||
import websocket
|
||||
|
||||
from PySide6.QtGui import QIcon, QImageReader
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -33,6 +40,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__)
|
||||
@@ -167,7 +175,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
|
||||
"""Parse exec_line to extract executable path."""
|
||||
try:
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
logger.error("Failed to parse exec_line: %s", exec_line)
|
||||
return None, "Failed to parse executable command: no target path"
|
||||
@@ -220,6 +228,33 @@ export LD_PRELOAD=
|
||||
return script_path, ""
|
||||
|
||||
|
||||
def _resolve_desktop_icon(source: str) -> QIcon:
|
||||
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
|
||||
if not source:
|
||||
return QIcon()
|
||||
if os.path.isfile(source):
|
||||
return QIcon(source)
|
||||
icon = QIcon.fromTheme(source)
|
||||
if not icon.isNull() or os.path.basename(source) != source:
|
||||
return icon
|
||||
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
|
||||
filename = glob.escape(source)
|
||||
if Path(source).suffix.lstrip(".").lower() not in formats:
|
||||
filename += ".*"
|
||||
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
|
||||
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
|
||||
f"icons/{filename}", f"pixmaps/{filename}")
|
||||
for root in roots:
|
||||
for pattern in patterns:
|
||||
for path in sorted(Path(root).glob(pattern)):
|
||||
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
|
||||
continue
|
||||
icon = QIcon(str(path))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
"""Generate thumbnail icon for the game."""
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
|
||||
@@ -232,7 +267,13 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
if os.path.exists(generated_icon_path):
|
||||
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if exe_path.endswith(".desktop"):
|
||||
entry = parse_desktop_entry(exe_path) or {}
|
||||
source = entry.get("Icon", "")
|
||||
icon = _resolve_desktop_icon(source)
|
||||
success = icon.pixmap(128, 128).save(generated_icon_path)
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"Failed to generate thumbnail for {exe_path}")
|
||||
return ""
|
||||
@@ -328,11 +369,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 +391,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 +400,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 +460,9 @@ 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/"))
|
||||
local_path = exe_path.removeprefix("native://")
|
||||
if not is_store_uri and not os.path.exists(local_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -396,7 +476,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(local_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else local_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
@@ -471,10 +553,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 +569,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:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -13,6 +15,7 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -39,9 +42,11 @@ from portprotonqt.dialogs.proton_manager import (
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, save_native_settings, is_native_executable, read_native_game
|
||||
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
|
||||
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
|
||||
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
|
||||
from portprotonqt.tabs.theme_store_workers import THEME_ARCHIVE_EXTENSIONS, ThemeArchiveInstallWorker
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -95,10 +100,27 @@ 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")
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
)
|
||||
self._position_library_controls_widget()
|
||||
self._on_only_installed_changed(game_config.get_only_installed())
|
||||
@@ -150,9 +172,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)
|
||||
)
|
||||
@@ -335,22 +365,39 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"Epic Games",
|
||||
"PortProton",
|
||||
_("Native"),
|
||||
_("Favorites"),
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
display_filter,
|
||||
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"))
|
||||
@@ -363,7 +410,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
)
|
||||
|
||||
self.badge_view_keys = ["detailed", "compact", "hidden"]
|
||||
@@ -496,9 +543,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 +587,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 +655,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
|
||||
) or (os.path.isfile(url.toLocalFile()) and is_native_executable(url.toLocalFile())):
|
||||
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):
|
||||
@@ -626,19 +680,61 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.open_local_autoinstall_card(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
|
||||
self.openAddGameDialog(path)
|
||||
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()
|
||||
@@ -663,6 +759,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
name = dialog.nameEdit.text().strip()
|
||||
exe_path = dialog.exeEdit.text().strip()
|
||||
user_cover = dialog.coverEdit.text().strip()
|
||||
native = is_native_executable(exe_path)
|
||||
if native:
|
||||
user_cover = dialog.last_cover_path or user_cover
|
||||
|
||||
if not name or not exe_path:
|
||||
return
|
||||
@@ -697,14 +796,30 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
entry = parse_desktop_entry(desktop_path)
|
||||
if not entry:
|
||||
return
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
if not native:
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
elif cover_path:
|
||||
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
|
||||
self._write_desktop_file(desktop_entry, desktop_path)
|
||||
if native:
|
||||
try:
|
||||
save_native_settings(desktop_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
for source in ("all", "native", "favorites"):
|
||||
getattr(self, "_loaded_library_cache", {}).pop(source, None)
|
||||
description = entry.get("Comment", "")
|
||||
exec_line = entry.get("Exec", exe_path)
|
||||
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
|
||||
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
|
||||
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
|
||||
|
||||
# User cover fallback
|
||||
user_cover_path = cover_path # Already set if user provided
|
||||
native_game = read_native_game(Path(desktop_path)) if native else None
|
||||
if native_game:
|
||||
user_cover_path = native_game[2] or user_cover_path
|
||||
|
||||
# Statistics (playtime, last launch - defaults for new)
|
||||
playtime_seconds = 0
|
||||
@@ -753,13 +868,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"portproton",
|
||||
"native" if native else "portproton",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""),
|
||||
steam_info.get("ppdb_rating", ""),
|
||||
)
|
||||
|
||||
# Incremental add
|
||||
if native and game_config.get_display_filter() not in ("all", "native"):
|
||||
return
|
||||
self.game_library_manager.add_game_incremental(game_data)
|
||||
|
||||
# Trigger visible images load
|
||||
@@ -781,7 +898,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
cached_steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"portproton",
|
||||
"native" if native else "portproton",
|
||||
cached_steam_info.get("anticheat_slug", ""),
|
||||
cached_steam_info.get("ppdb_id", ""),
|
||||
cached_steam_info.get("ppdb_rating", ""),
|
||||
@@ -793,6 +910,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
|
||||
"""Apply shortcut options selected in add/edit game dialog."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
for directory, checkbox in (
|
||||
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
|
||||
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
|
||||
):
|
||||
if directory and checkbox.isChecked():
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
shutil.copyfile(source, Path(directory) / source.name)
|
||||
os.chmod(Path(directory) / source.name, 0o755)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(game_name):
|
||||
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
|
||||
elif is_game_in_steam(game_name):
|
||||
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
|
||||
return
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
if dialog.add_to_menu_checkbox.isChecked():
|
||||
|
||||
@@ -508,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,
|
||||
@@ -21,7 +32,9 @@ class MainWindowWorkersMixin:
|
||||
"egs_auth_worker": None,
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"native_library_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
"gog_auth_worker": None,
|
||||
"gog_account_worker": None,
|
||||
@@ -38,6 +51,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 |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0008 1a6.9996 6.9996 0 0 0-7.0008 6.9992 6.9996 6.9996 0 0 0 7.0008 7.0008 6.9996 6.9996 0 0 0 6.9992-7.0008 6.9996 6.9996 0 0 0-6.9992-6.9992zm0.03052 1.8345c0.49138 0 0.68069 0.026991 1.0522 0.151 0.77261 0.25794 1.4608 0.88663 1.857 1.698 0.36432 0.74592 0.47136 1.542 0.34538 2.5446-0.02796 0.2226-0.0498 0.49442-0.0498 0.60402v0.1992l0.24578 0.24739c0.8226 0.82687 1.5016 1.8186 1.8892 2.7614 0.0076 0.01852 0.01187 0.0377 0.01928 0.05622-0.49301 0.83904-1.1794 1.5485-2.0016 2.0691-0.04447-0.3775-0.13313-0.76334-0.26667-1.147-0.33295-0.95658-0.86947-1.775-1.5422-2.3534-0.22323-0.19199-0.53694-0.41582-0.75181-0.53655-0.0648-0.036441-0.11405-0.069295-0.11084-0.072289 0.0035-0.00285 0.10384-0.043441 0.22329-0.08996 0.47005-0.18305 0.84651-0.41233 1.1438-0.69558 0.4802-0.45754 0.7046-0.96565 0.70361-1.5984-0.0012-0.60346-0.212-1.0975-0.64418-1.5133-0.37522-0.36091-0.8574-0.54398-1.3639-0.53815-0.30388 0.003495-0.61702 0.07465-0.92048 0.21687-0.40675 0.1906-0.86555 0.57266-1.2353 1.0265-0.19966 0.24497-0.64341 0.92641-0.61687 1.0008 0.17687 0.49573 0.05014 0.89774-0.68755 1.1213-0.14863 0.00956-0.2752 0.022977-0.28112 0.028916-0.01608 0.016029 0.06854 0.1696 0.16707 0.30843 0.14523 0.20473 0.39623 0.4208 0.6763 0.57992 0.0689 0.039149 0.12571 0.076335 0.1253 0.081928 0 0.00577-0.07689 0.054243-0.16867 0.10763-0.66042 0.38426-1.248 1.1985-1.6 2.2201-0.14407 0.4181-0.23536 0.77816-0.28755 1.1454-0.55629-0.51797-1.0112-1.1354-1.3414-1.8201 0.18238-0.35341 0.3666-0.624 0.69237-1.0217 0.32937-0.40208 0.89278-0.92816 1.3494-1.2594l0.17671-0.12851v-0.12209c1e-6 -0.11617-0.0027-0.11963-0.04659-0.10602-0.13353 0.041394-0.41447 0.20677-0.53012 0.31165l-0.05783 0.053012 0.01125-0.24418c0.01809-0.40899 0.1444-0.77469 0.40482-1.1647l0.13815-0.20562-0.0498-0.1751c-0.1341-0.46267-0.11978-1.0338 0.04016-1.5133 0.15572-0.46694 0.38428-0.82639 0.76144-1.2032 0.5179-0.51738 1.0636-0.80828 1.8201-0.96867 0.24284-0.051483 0.30063-0.056286 0.71165-0.056225zm0.45783 3.1325c0.39072-0.00731 0.65853 0.36199 0.5494 0.75663-0.03707 0.13416-0.22104 0.31614-0.36466 0.35823-0.13428 0.039318-0.26887 0.039149-0.38072 0v-0.00161c-0.19556-0.069357-0.35076-0.27993-0.36787-0.5012-0.01367-0.17688 0.04474-0.32481 0.17831-0.4498 0.12845-0.12009 0.21938-0.15913 0.38554-0.16225zm-0.14297 0.33574c-0.06851 0.00128-0.10608 0.017958-0.15904 0.06747-0.05507 0.051532-0.07953 0.11181-0.0739 0.18474 0.0071 0.091227 0.07199 0.17863 0.15261 0.20723 0.04612 0.016141 0.10046 0.01621 0.15582 0 0.05921-0.017353 0.13572-0.090873 0.151-0.14618 0.045-0.1627-0.06542-0.31627-0.22651-0.31325zm-3.0185 0.55904c-0.01687-1.298e-4 -0.03412 0.00176-0.05301 0.00321-0.12511 0.00961-0.15006 0.020885-0.27149 0.12048-0.15317 0.12564-0.3168 0.33226-0.45301 0.57349-0.10345 0.18332-0.24198 0.53868-0.2008 0.51566 0.0143-0.00799 0.11568-0.087633 0.22329-0.17671 0.26056-0.21554 0.35801-0.28499 0.67148-0.47068 0.36751-0.21785 0.41804-0.28276 0.32932-0.42892-0.05724-0.094184-0.12772-0.13564-0.24578-0.13655z" fill="#fff" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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}
|
||||
|
||||
|
||||
+591
-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)
|
||||
@@ -95,6 +49,8 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
|
||||
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
|
||||
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
|
||||
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
|
||||
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
|
||||
@@ -104,19 +60,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 +88,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 +104,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,10 +231,62 @@ def test_other_launch_modes_keep_main_window(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [[], ["--silent"], ["--log"], ["--silent", "--log"]])
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_native_shortcut_launch_modes(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, flags: list[str], existing: bool,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
from PySide6.QtCore import QThread
|
||||
from portprotonqt.native_games import create_native_desktop_file
|
||||
|
||||
startup = silent_startup
|
||||
executable = startup.exe.with_name("native game")
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
result = create_native_desktop_file(str(executable), "Native")
|
||||
assert result is not None
|
||||
content, destination = result
|
||||
Path(destination).write_text(content)
|
||||
uri = "native://" + destination
|
||||
startup.socket.waitForConnected.return_value = existing
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(executable)])
|
||||
mode = "log" if "--log" in flags else "silent" if "--silent" in flags else "open"
|
||||
if existing:
|
||||
startup.app.main()
|
||||
startup.socket.write.assert_called_once_with(f"{mode}:{quote(uri, safe='')}".encode())
|
||||
startup.window.assert_not_called()
|
||||
return
|
||||
window, timers = MagicMock(), MagicMock()
|
||||
window.game_processes = []
|
||||
startup.window.side_effect, startup.window.return_value = None, window
|
||||
monkeypatch.setattr(startup.app, "QTimer", timers)
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
def event_loop() -> int:
|
||||
callback = next(call.args[1] for call in timers.singleShot.call_args_list
|
||||
if getattr(call.args[1], "__name__", "") == "handle_native_launch")
|
||||
callback()
|
||||
return 0
|
||||
startup.qt_app.exec.side_effect = event_loop
|
||||
with pytest.raises(SystemExit):
|
||||
startup.app.main()
|
||||
assert window.silent_launch_mode is (mode == "silent")
|
||||
if mode == "silent":
|
||||
window.toggleGame.assert_called_once_with(uri)
|
||||
else:
|
||||
window._load_native_games_async.assert_called_once()
|
||||
window._load_native_games_async.call_args.args[0]([
|
||||
("Native", "", "", "", "", uri, "", "", "", "", 0, 0, "native", "", "", "")])
|
||||
assert window.openGameDetailPage.call_args.args[0]["exec_line"] == uri
|
||||
if mode == "log":
|
||||
assert window._pending_log_exe == uri
|
||||
|
||||
|
||||
class TestNormalizeLaunchPath:
|
||||
def test_plain_path(self):
|
||||
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
|
||||
@@ -260,9 +372,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 +384,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 +463,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"
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from pytest import MonkeyPatch, mark
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
import portprotonqt.detail_pages.utils as detail_utils
|
||||
@@ -701,3 +701,23 @@ def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
|
||||
add_count = sum(1 for t in button_texts if "Add" in t or "Добавить" in t)
|
||||
assert edit_count == 1
|
||||
assert add_count == 0
|
||||
|
||||
|
||||
@mark.parametrize("managed", [False, True])
|
||||
def test_native_detail_folder_button_only_for_managed_games(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, managed: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
data = tmp_path / "data"
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(data))
|
||||
directory = data / "PortProtonQt/native_games" if managed else tmp_path / "usr/share/applications"
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
manager._create_game_buttons_layout({
|
||||
"game_source": "native",
|
||||
"exec_line": "native://" + str(directory / "hytale.desktop"),
|
||||
"name": "Hytale Launcher",
|
||||
})
|
||||
buttons = _get_added_buttons(manager)
|
||||
assert "Settings" in buttons
|
||||
assert "Create Log" in buttons
|
||||
assert ("Open Folder" in buttons) is managed
|
||||
@@ -12,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."""
|
||||
|
||||
@@ -95,3 +147,61 @@ def test_list_icon_uses_theme_state_colors(
|
||||
QIcon.Mode.Selected: "folder-#222222.svg",
|
||||
QIcon.Mode.Disabled: "folder-#333333.svg",
|
||||
}
|
||||
|
||||
|
||||
def test_appimage_thumbnail_worker_caches_icon_and_finishes(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
calls = []
|
||||
|
||||
def generate(path: str, output: str) -> bool:
|
||||
calls.append(path)
|
||||
image = QImage(16, 16, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("red"))
|
||||
return image.save(output)
|
||||
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", generate)
|
||||
pool = QThreadPool()
|
||||
for _attempt in range(2):
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image, received=received: received.append((path, image)))
|
||||
pool.start(loader)
|
||||
assert pool.waitForDone(5000)
|
||||
_application.processEvents()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert not received[0][1].isNull()
|
||||
assert calls == [str(source)]
|
||||
assert pool.activeThreadCount() == 0
|
||||
|
||||
|
||||
def test_appimage_thumbnail_failure_completes_without_replacing_icon(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", lambda *_args: False)
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image: received.append((path, image)))
|
||||
loader.run()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert received[0][1].isNull()
|
||||
item = MagicMock()
|
||||
explorer = SimpleNamespace(thumbnail_cache={}, file_items={str(source): item})
|
||||
FileExplorer._set_thumbnail_icon(cast(FileExplorer, explorer), str(source), QImage())
|
||||
assert str(source) in explorer.thumbnail_cache
|
||||
item.setIcon.assert_not_called()
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch, raises
|
||||
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,102 @@ 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)
|
||||
|
||||
|
||||
@mark.parametrize("elf_class,endian", [(1, "<"), (2, "<"), (2, ">")])
|
||||
@mark.parametrize("svg", [False, True])
|
||||
def test_appimage_icon_extraction(tmp_path: Path, elf_class: int, endian: str, svg: bool) -> None:
|
||||
from portprotonqt.icon_extractor import APPIMAGE_EXTRACT_TIMEOUT, ELF_SECTION_LAYOUTS
|
||||
|
||||
header = bytearray(64)
|
||||
header[:4] = b"\x7fELF"
|
||||
header[4:6] = bytes([elf_class, 1 if endian == "<" else 2])
|
||||
header[8:11] = b"AI\x02"
|
||||
offset_position, offset_format, size_position = ELF_SECTION_LAYOUTS[elf_class]
|
||||
struct.pack_into(endian + offset_format, header, offset_position, len(header))
|
||||
struct.pack_into(endian + "HH", header, size_position, 0, 0)
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
output = tmp_path / "icon.png"
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">'
|
||||
b'<rect width="16" height="16" fill="red"/></svg>') if svg else icon.getvalue()
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", return_value=SimpleNamespace(stdout=payload)) as run:
|
||||
assert generate_thumbnail(str(source), str(output))
|
||||
run.assert_called_once_with(
|
||||
["unsquashfs", "-cat", "-o", "64", str(source), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with Image.open(output) as image:
|
||||
assert image.size == (128, 128)
|
||||
assert image.convert("RGBA").getpixel((64, 64)) == (255, 0, 0, 255)
|
||||
|
||||
|
||||
def test_appimage_icon_missing_tool(tmp_path: Path) -> None:
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert not generate_thumbnail(str(source), str(tmp_path / "icon.png"))
|
||||
|
||||
|
||||
@mark.parametrize("target", ["icon.png", "../outside", ".DirIcon"])
|
||||
def test_dwarfs_icon_symlink(tmp_path: Path, target: str) -> None:
|
||||
import tarfile
|
||||
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"DWARFS")
|
||||
link_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=link_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo(".DirIcon")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = target
|
||||
archive.addfile(member)
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
icon_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=icon_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo("icon.png")
|
||||
member.size = len(icon.getvalue())
|
||||
archive.addfile(member, io.BytesIO(icon.getvalue()))
|
||||
results = [SimpleNamespace(stdout=data.getvalue()) for data in (link_archive, icon_archive)]
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=results) as run:
|
||||
assert generate_thumbnail(str(source), str(tmp_path / "icon.png")) is (target == "icon.png")
|
||||
assert run.call_count == (2 if target == "icon.png" else 1)
|
||||
assert run.call_args_list[0].args[0][0] == "dwarfsextract"
|
||||
assert "--skip-specials" in run.call_args_list[0].args[0]
|
||||
+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
|
||||
+138
-16
@@ -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
|
||||
@@ -15,7 +13,9 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
|
||||
from portprotonqt import image_utils
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
LOAD_TIMEOUT_MS = 30000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -54,6 +54,23 @@ def library_scene(
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def test_native_card_uses_linux_source_corner(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr(ThemeManager(), "current_theme_name", "standart")
|
||||
card = manager._create_game_card((
|
||||
"Native", "", "", "", "", "native:///game.desktop", "", "", "", "", 0, 0, "native",
|
||||
))
|
||||
assert card.native_visible
|
||||
assert isinstance(card.nativeLabel._icon, str)
|
||||
assert Path(card.nativeLabel._icon).name == "badge_linux.svg"
|
||||
assert Path(card.nativeLabel._icon).is_file()
|
||||
assert not card.portproton_visible
|
||||
card.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_new_horizontal_cards_fit_before_layout_insertion(
|
||||
@@ -399,7 +416,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 +424,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 +439,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 +451,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 +468,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 +477,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 +560,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()
|
||||
+429
-30
@@ -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", "native_library_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,15 +434,17 @@ 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)])
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
def test_source_ribbon_visibility(
|
||||
monkeypatch: MonkeyPatch,
|
||||
vertical_layout: bool,
|
||||
ribbon_visible: bool,
|
||||
source: str,
|
||||
) -> None:
|
||||
card = MagicMock()
|
||||
card.coverLabel = MagicMock()
|
||||
card.game_source = "portproton"
|
||||
card.game_source = source
|
||||
card.ppdb_id = "report-id"
|
||||
card.protondb_tier = "Platinum"
|
||||
card.anticheat_status = "Supported"
|
||||
@@ -329,9 +455,9 @@ def test_source_ribbon_visibility(
|
||||
card.parentWidget.return_value = None
|
||||
monkeypatch.setattr("portprotonqt.game_card.ui_config.get_economy_mode", lambda: False)
|
||||
|
||||
GameCard.update_badge_visibility(card, "portproton")
|
||||
GameCard.update_badge_visibility(card, source)
|
||||
|
||||
card.portprotonLabel.setVisible.assert_called_with(ribbon_visible)
|
||||
getattr(card, f"{source}Label").setVisible.assert_called_with(ribbon_visible)
|
||||
card.ppdbLabel.setVisible.assert_called_with(False)
|
||||
card.protondbLabel.setVisible.assert_called_with(False)
|
||||
card.anticheatLabel.setVisible.assert_called_with(False)
|
||||
@@ -667,6 +793,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 +1287,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 +1427,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
|
||||
@@ -1579,7 +1709,8 @@ def test_open_local_autoinstall_card_uses_autoinstall_page(tmp_path: Path) -> No
|
||||
assert game_data["exec_line"] == f"autoinstall:{shlex.quote(str(script_path))}"
|
||||
assert return_tab_index == 0
|
||||
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
@mark.parametrize("native", [False, True])
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.touch()
|
||||
started = []
|
||||
@@ -1604,11 +1735,12 @@ def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
window.detail_page_manager = FakeDetailPageManager()
|
||||
window.stackedWidget = FakeStackedWidget()
|
||||
window.currentDetailPage = None
|
||||
window._pending_log_exe = str(exe_path)
|
||||
target = "native://" + str(tmp_path / "Native Game.desktop") if native else str(exe_path)
|
||||
window._pending_log_exe = target
|
||||
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": str(exe_path)})
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": target})
|
||||
|
||||
assert started == [(str(exe_path), window.detail_page_manager._debug_log_button)]
|
||||
assert started == [(target, window.detail_page_manager._debug_log_button)]
|
||||
assert window._pending_log_exe is None
|
||||
|
||||
def test_launch_exe_skips_library_load_for_ppai() -> None:
|
||||
@@ -1760,7 +1892,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 +1904,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 +1973,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 +1983,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 +2001,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 +2268,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 +2289,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 +2317,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 +2620,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 +2661,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)
|
||||
@@ -2595,6 +2738,7 @@ def test_add_game_dialog_does_not_reprocess_its_shortcut(
|
||||
exeEdit=SimpleNamespace(text=lambda: "/games/praest.exe"),
|
||||
coverEdit=SimpleNamespace(text=lambda: ""),
|
||||
getDesktopEntryData=lambda: (entry, str(desktop_path)),
|
||||
native_checkbox=SimpleNamespace(isChecked=lambda: False),
|
||||
last_cover_path="",
|
||||
)
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -2749,15 +2893,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 +2930,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 +3133,256 @@ 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
|
||||
)
|
||||
|
||||
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch, source: str) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: f"gog,{source}")
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
callbacks = {}
|
||||
loaded = MagicMock()
|
||||
window = cast(Any, SimpleNamespace(
|
||||
_loading_games=False, launch_exe=None, games_loaded=SimpleNamespace(emit=loaded),
|
||||
_load_gog_games_async=lambda callback: callbacks.update(gog=callback),
|
||||
_load_portproton_games_async=MagicMock(), _load_native_games_async=MagicMock(),
|
||||
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
|
||||
))
|
||||
setattr(window, f"_load_{source}_games_async", lambda callback: callbacks.update({source: callback}))
|
||||
|
||||
MainWindow.loadGames(window, force_load=True)
|
||||
pp_game = ("PP", "", "", "", "", "pp.exe")
|
||||
gog_game = ("GOG", "", "", "", "", "gog://launch/1")
|
||||
callbacks["gog"]([gog_game])
|
||||
loaded.assert_not_called()
|
||||
callbacks[source]([pp_game])
|
||||
|
||||
loaded.assert_called_once_with([pp_game, gog_game] if source == "portproton" else [gog_game, pp_game])
|
||||
getattr(window, f"_load_{'native' if source == 'portproton' else 'portproton'}_games_async").assert_not_called()
|
||||
window._load_steam_games_async.assert_not_called()
|
||||
window._load_egs_games_async.assert_not_called()
|
||||
|
||||
|
||||
@mark.parametrize("click_target", ["text", "arrow"])
|
||||
def test_library_source_filter_opens_across_entire_field(
|
||||
click_target: str, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
test_window = cast(Any, window)
|
||||
source_style = "QListView::indicator { image: none; }"
|
||||
test_window.theme = SimpleNamespace(
|
||||
CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=source_style,
|
||||
COMBOBOX_STYLE="QComboBox { combobox-popup: 0; }", SCROLL_STYLE="",
|
||||
)
|
||||
test_window._register_gamepad_tooltip = lambda *_args: None
|
||||
test_window._delay_library_controls_hover_close = lambda: None
|
||||
test_window._position_library_controls_widget = lambda: None
|
||||
test_window._on_only_installed_changed = lambda _checked: None
|
||||
saved = MagicMock()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog")
|
||||
monkeypatch.setattr(game_config, "set_display_filter", saved)
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
controls = QWidget()
|
||||
LibraryMixin._add_library_filter_controls(window, QGridLayout(controls))
|
||||
controls.show()
|
||||
application.processEvents()
|
||||
combo = test_window.gamesDisplayCombo
|
||||
line_edit = combo.lineEdit()
|
||||
option = QStyleOptionComboBox()
|
||||
combo.initStyleOption(option)
|
||||
arrow = combo.style().subControlRect(
|
||||
QStyle.ComplexControl.CC_ComboBox, option, QStyle.SubControl.SC_ComboBoxArrow, combo
|
||||
)
|
||||
global_point = combo.mapToGlobal(
|
||||
line_edit.geometry().center() if click_target == "text" else arrow.center()
|
||||
)
|
||||
target = QApplication.widgetAt(global_point)
|
||||
assert isinstance(target, QWidget) and target is (line_edit if click_target == "text" else combo)
|
||||
assert source_style in combo.styleSheet()
|
||||
|
||||
QTest.mouseMove(target, target.mapFromGlobal(global_point))
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton, pos=target.mapFromGlobal(global_point))
|
||||
application.processEvents()
|
||||
assert combo.view().isVisible()
|
||||
index = combo.model().index(test_window.filter_keys.index("portproton"), 0)
|
||||
QTest.mouseMove(combo.view().viewport(), combo.view().visualRect(index).center())
|
||||
QTest.mouseClick(combo.view().viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=combo.view().visualRect(index).center())
|
||||
application.processEvents()
|
||||
saved.assert_called_once_with("gog,portproton")
|
||||
assert combo.currentText() == "GOG + PortProton"
|
||||
controls.close()
|
||||
|
||||
|
||||
@mark.parametrize("readonly", [True, False])
|
||||
def test_custom_combo_opens_readonly_field_on_mouse_release(readonly: bool) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
combo = CustomComboBox()
|
||||
combo.addItems(["GOG", "PortProton"])
|
||||
combo.setEditable(True)
|
||||
line_edit = combo.lineEdit()
|
||||
assert line_edit is not None
|
||||
line_edit.setReadOnly(readonly)
|
||||
line_edit.installEventFilter(combo)
|
||||
combo.show()
|
||||
application.processEvents()
|
||||
|
||||
QTest.mousePress(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
|
||||
assert not combo.view().isVisible()
|
||||
QTest.mouseRelease(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
|
||||
application.processEvents()
|
||||
assert combo.view().isVisible() is readonly
|
||||
combo.hidePopup()
|
||||
combo.close()
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
+1302
-50
File diff suppressed because it is too large.
Load diff
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user