Compare commits

..
1 Commits
Author SHA1 Message Date
Boria138 eacbbe846d feat: add initial ARM64 Wine runner support
Code check / Check code (pull_request) Successful in 2m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-10-21 11:44:33 +05:00
77 changed files with 1017 additions and 6679 deletions

No files matched your search

+4 -34
View File
@@ -31,24 +31,9 @@ jobs:
image: archlinux:base-devel
options: --privileged --device /dev/fuse
steps:
- name: Configure Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
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
@@ -89,24 +74,9 @@ jobs:
image: archlinux:base-devel
steps:
- name: Configure Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
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
+3 -18
View File
@@ -22,26 +22,11 @@ jobs:
name: Check code
runs-on: ubuntu-latest
container:
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
steps:
- name: Configure Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
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 CI dependencies
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Set up Python
uses: https://git.linux-gaming.ru/actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version-file: "pyproject.toml"
+6 -36
View File
@@ -80,27 +80,12 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
options: --privileged --device /dev/fuse
steps:
- name: Configure Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
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
@@ -210,27 +195,12 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
steps:
- name: Configure Arch mirrors
- name: Configure Arch mirror
run: |
if curl -fL --connect-timeout 10 --max-time 20 \
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
-o /tmp/rate-mirrors.tar.gz &&
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
else
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
fi
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
-1
View File
@@ -45,4 +45,3 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
.codex
CODE_QUALITY_PLAN.md
.repowise
.pz
+2 -33
View File
@@ -7,15 +7,8 @@
### Added
- Добавлено отслеживание запуска Steam-игр на странице игры
- Добавлен CLI для получения локальных данных библиотеки
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
- Добавлен массовый импорт игр из выбранной папки
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
- Добавлен режим множественного выделения карточек через контекстное меню: ЛКМ и кнопка A геймпада переключают выделение, Ctrl+клик позволяет выделять карточки напрямую
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
- Добавлено предупреждение о кириллице в путях к игре или PortProton
@@ -23,35 +16,16 @@
### Changed
- Для игр GOG и EGS папка установки выбирается до добавления в очередь
- Ускорено получение списка DLC для GOG
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
- При определении языка Steam_EMU теперь учитывается переменная `LANGUAGE`
- При возврате из карточки игры библиотека сохраняет прокрутку и фокус на этой карточке
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
- После добавления или удаления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог
- После добавления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог об успешном добавлении
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
- Настройки exe и `user.conf` применяются автоматически при изменении
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
### Fixed
- Запросы установки других игр GOG и EGS во время получения списка DLC добавляются в очередь вместо ошибки «Другая загрузка уже выполняется»
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
- Исправлено зависание при смене сетки библиотеки
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
@@ -62,15 +36,10 @@
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
- Исправлена гонка при запуске игры с `--silent`
- Исправлено обрезание значений в избранных настройках
- При выключенной опции «Fix video in game» больше не блокируется создание DXGI Device Manager в Wine/Proton
### Contributors
- @Dervart
- @adem4ik
- @rogerio-mattos
- @Rubin13
- @iodnoorov
---
+1 -1
View File
@@ -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&amp;label=Release" alt="Releases"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-17.9k-green?style=flat-square" alt="Total Downloads"></a>
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&amp;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&amp;logo=linux" alt="Platform: Linux"></a>
+1 -1
View File
@@ -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&amp;label=Релиз" alt="Релизы"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-17.9k-green?style=flat-square" alt="Всего загрузок"></a>
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&amp;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&amp;logo=linux" alt="Платформа: Linux"></a>
@@ -615,6 +615,20 @@ check_wayland_session () {
}
export -f check_wayland_session
check_arm () {
if [[ -n "$ARM_IN_USE" ]]
then [[ "$ARM_IN_USE" == 1 ]] && return 0 || return 1
fi
if [[ "$(uname -m)" =~ ^(aarch64|arm64)$ ]] ; then
export ARM_IN_USE=1
return 0
else
export ARM_IN_USE=0
return 1
fi
}
export -f check_arm
check_flatpak () {
if [[ -n "$FLATPAK_IN_USE" ]]
then [[ "$FLATPAK_IN_USE" == 1 ]] && return 0 || return 1
@@ -1140,7 +1154,12 @@ init_wine_ver () {
export WINEDIR="${WINEDIR}/files"
fi
if [[ "$PW_USE_WOW64" == "1" ]] \
if [[ -d "${WINEDIR}/bin-arm64" ]]
then
export WINE="${WINEDIR}/bin-arm64/wine"
export WINELOADER="${WINEDIR}/bin-arm64/wine"
export WINESERVER="${WINEDIR}/bin-arm64/wineserver"
elif [[ "$PW_USE_WOW64" == "1" ]] \
&& [[ -d "${WINEDIR}/bin-wow64" ]]
then
print_warning "wine wow64 mode in use!"
@@ -1186,7 +1205,9 @@ init_wine_ver () {
if ! check_flatpak ; then
WINE_LIBRARY_PATH="${WINEDIR}/lib"
if [[ -d "${WINEDIR}/lib64/gstreamer-1.0" ]]
if [[ -d "${WINEDIR}/lib/aarch64-linux-gnu" ]]
then WINE_LIBRARY_PATH+=":${WINEDIR}/lib/aarch64-linux-gnu:${WINEDIR}/lib/x86_64-linux-gnu:${WINEDIR}/lib/i386-linux-gnu"
elif [[ -d "${WINEDIR}/lib64/gstreamer-1.0" ]]
then WINE_LIBRARY_PATH+=":${WINEDIR}/lib64"
elif [[ -d "${WINEDIR}/lib/x86_64-linux-gnu" ]]
then WINE_LIBRARY_PATH+=":${WINEDIR}/lib/x86_64-linux-gnu:${WINEDIR}/lib/i386-linux-gnu"
@@ -1199,10 +1220,19 @@ init_wine_ver () {
if [[ -d "${WINEDIR}"/share/espeak-ng-data ]] ; then
export ESPEAK_DATA_PATH="${WINEDIR}"/share/
fi
if [[ -f "${WINEDIR}/share/fex-emu/Config.json" ]] \
&& [[ -f "${WINEDIR}/lib/wine/aarch64-unix/libwow64fex.so" ]]
then
export FEX_APP_CONFIG="${FEX_APP_CONFIG:-${WINEDIR}/share/fex-emu/Config.json}"
export FEX_APP_CONFIG_LOCATION="${FEX_APP_CONFIG_LOCATION:-${WINEDIR}/share/fex-emu}"
fi
return 0
}
pw_init_runtime () {
if check_arm ; then
export PW_USE_RUNTIME="0"
fi
if [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|| [[ ! -f "${PW_WINELIB}/pressure-vessel/bin/pressure-vessel-wrap" ]]
then
@@ -1211,7 +1241,11 @@ pw_init_runtime () {
if [[ "$PW_USE_RUNTIME" != "1" ]] \
|| [[ "$PW_WINE_USE" == "USE_SYSTEM_WINE" ]]
then
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
if check_arm ; then
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}"
else
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
fi
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
if [[ -z "$PW_TERM" ]] ; then
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
@@ -1244,7 +1278,13 @@ ${nvidia_lib32_path}"
PW_PV_OVERRIDES="/usr/lib/pressure-vessel/overrides/lib"
if ! check_flatpak; then
export PW_LD_LIBRARY_PATH="\
if check_arm ; then
export PW_LD_LIBRARY_PATH="\
${PW_PV_OVERRIDES}/aarch64-linux-gnu/aliases:\
/overrides/lib/aarch64-linux-gnu:\
/lib/aarch64-linux-gnu"
else
export PW_LD_LIBRARY_PATH="\
${PW_PLUGINS_PATH}/portable/lib/lib64:\
${PW_PLUGINS_PATH}/portable/lib/lib32:\
${PW_PV_OVERRIDES}/x86_64-linux-gnu/aliases:\
@@ -1253,6 +1293,7 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
/overrides/lib/i386-linux-gnu:\
/lib/x86_64-linux-gnu:\
/lib/i386-linux-gnu"
fi
fi
export PW_PATH="${PW_PLUGINS_PATH}/portable/bin${PATH:+:$PATH}"
@@ -1295,7 +1336,11 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
export PRESSURE_VESSEL_RUNTIME_BASE="${PW_WINELIB}"
export PRESSURE_VESSEL_VARIABLE_DIR="${PW_WINELIB}/var"
export PRESSURE_VESSEL_PREFIX="${PW_WINELIB}/pressure-vessel"
export PRESSURE_VESSEL_ARCHITECTURES="x86_64-linux-gnu:i386-linux-gnu"
if check_arm ; then
export PRESSURE_VESSEL_ARCHITECTURES="aarch64-linux-gnu"
else
export PRESSURE_VESSEL_ARCHITECTURES="x86_64-linux-gnu:i386-linux-gnu"
fi
export FONTCONFIG_PATH=''
unset PRESSURE_VESSEL_FILESYSTEMS_RO PRESSURE_VESSEL_FILESYSTEMS_RW
@@ -1651,13 +1696,18 @@ pw_check_and_download_runtime () {
}
pw_check_and_download_dxvk_and_vkd3d () {
if check_arm ; then
# ARM64 Wine runners provide compatible DXVK and VKD3D builds.
# Build ARM64 dxvk using cross-compiler
export PW_USE_SUPPLIED_DXVK_VKD3D="1"
return 0
fi
case "${PW_VULKAN_USE}" in
1) DXVK_VAR_VER="${DXVK_SAREK_VER}"; VKD3D_VAR_VER="${VKD3D_SAREK_VER}" ;;
2) DXVK_VAR_VER="${DXVK_OLD_VER}"; VKD3D_VAR_VER="${VKD3D_OLD_VER}" ;;
6) DXVK_VAR_VER="${DXVK_NEW_VER}"; VKD3D_VAR_VER="${VKD3D_NEW_VER}" ;;
*) DXVK_VAR_VER=""; VKD3D_VAR_VER="" ;;
esac
# Download DXVK
if [[ -n "${DXVK_VAR_VER}" ]] ; then
if [[ ! -d "${PW_VULKAN_DIR}/dxvk-${DXVK_VAR_VER}" ]] ; then
@@ -1746,6 +1796,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
}
pw_check_and_download_plugins () {
if check_arm ; then
# ARM64 plugins archive needs an architecture-specific rebuild.
return 0
fi
if [[ ! -f "${PW_PLUGINS_PATH}/portable/lib/lib64/libvkbasalt.so" ]] ; then
print_info "Download and install plugins${PW_PLUGINS_VER}..."
if try_download "github.com/Castro-Fidel/wine_builds/releases/download/plugins${PW_PLUGINS_VER}/plugins${PW_PLUGINS_VER}.tar.xz" "${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}.tar.xz" ; then
@@ -2647,7 +2701,10 @@ pw_enable_gamemode_basic () {
start_portproton () {
pw_check_and_download_plugins
if [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
if check_arm ; then
export PW_USE_RUNTIME="0"
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
elif [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
else
pw_check_and_download_runtime
@@ -2787,15 +2844,19 @@ start_portproton () {
var_winedlloverride_update "winebth.sys=d"
pw_wineboot () {
local wine_pe64_path="${WINEDIR}/lib64/wine/x86_64-windows"
if [[ -d "${WINEDIR}/lib/wine/aarch64-windows" ]]
then wine_pe64_path="${WINEDIR}/lib/wine/aarch64-windows"
fi
LIST_NATIVE_WINE_DLL_LINKS="winevulkan ir50_32 amd_ags_x64 wined3d vulkan-1"
for dll in $LIST_NATIVE_WINE_DLL_LINKS ; do
try_force_link_file "${WINEDIR}/lib64/wine/x86_64-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
try_force_link_file "${wine_pe64_path}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
done
LIST_NATIVE_WINE_DLL_COPY="atl100 ntdll shell32"
for dll in $LIST_NATIVE_WINE_DLL_COPY ; do
try_copy_file "${WINEDIR}/lib64/wine/x86_64-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
try_copy_file "${wine_pe64_path}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
try_copy_file "${WINEDIR}/lib/wine/i386-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
done
@@ -2807,7 +2868,7 @@ start_portproton () {
try_force_link_file "${WINEDIR}/lib64/icu/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
elif [[ -f "${WINEDIR}/lib/wine/icu/i386-windows/${dll}.dll" ]] ; then
try_force_link_file "${WINEDIR}/lib/wine/icu/i386-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/icu/x86_64-windows/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/icu/${wine_pe64_path##*/}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
else
try_remove_file "${WINEPREFIX}/drive_c/windows/syswow64/${dll}.dll"
try_remove_file "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
@@ -2829,7 +2890,9 @@ start_portproton () {
then
pw_clear_pfx
check_dirs_and_files_in_pfx
if [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
if check_arm ; then
pw_wineboot -i
elif [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
pw_wineboot -r
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
@@ -2999,7 +3062,7 @@ start_portproton () {
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
else unset WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
fi
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
@@ -3225,9 +3288,18 @@ start_portproton () {
export __GL_YIELD="NOTHING"
export mesa_glthread="true"
PW_SUPPLIED_PE64_ARCH="x86_64-windows"
PW_WINE_PE64_PATH="${WINEDIR}/lib64/wine/x86_64-windows"
if [[ -d "${WINEDIR}/lib/wine/aarch64-windows" ]]
then
PW_SUPPLIED_PE64_ARCH="aarch64-windows"
PW_WINE_PE64_PATH="${WINEDIR}/lib/wine/aarch64-windows"
export DXVK_ENABLE_NVAPI="0"
fi
if [[ $PW_USE_SUPPLIED_DXVK_VKD3D == "1" ]] \
&& [[ ! $PW_WINE_USE =~ (PROTON_LG|WINE_LG) ]] \
&& [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d9.dll" || -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d9.dll" ]] ; then
&& [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d9.dll" || -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d9.dll" ]] ; then
if [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d10.dll" && -f "${WINEDIR}/lib64/wine/dxvk/d3d10_1.dll" ]] ; then
CP_DXVK_FILES="d3d11 d3d10core d3d10_1 d3d10 d3d9 dxgi"
CP_WINE_FILES=""
@@ -3240,13 +3312,13 @@ start_portproton () {
try_force_link_file "${WINEDIR}/lib/wine/d8vk/d3d8.dll" "${WINEPREFIX}/drive_c/windows/syswow64/d3d8.dll"
try_force_link_file "${WINEDIR}/lib64/wine/d8vk/d3d8.dll" "${WINEPREFIX}/drive_c/windows/system32/d3d8.dll"
var_winedlloverride_update "d3d8=n"
elif [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d8.dll" || -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d8.dll" ]]
elif [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d8.dll" || -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d8.dll" ]]
then add_to_var CP_DXVK_FILES "d3d8"
else add_to_var CP_WINE_FILES "d3d8"
fi
fi
if [[ -f "${WINEDIR}/lib64/wine/vkd3d-proton/d3d12core.dll" ]] \
|| [[ -f "${WINEDIR}/lib/wine/vkd3d-proton/x86_64-windows/d3d12core.dll" ]] ; then
|| [[ -f "${WINEDIR}/lib/wine/vkd3d-proton/${PW_SUPPLIED_PE64_ARCH}/d3d12core.dll" ]] ; then
CP_VKD3D_FILES="d3d12 d3d12core"
elif [[ -f "${WINEDIR}/lib64/wine/vkd3d-proton/d3d12.dll" ]] ; then
CP_VKD3D_FILES="d3d12"
@@ -3254,7 +3326,7 @@ start_portproton () {
else
add_to_var CP_WINE_FILES "d3d12 d3d12core"
fi
[[ -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d9.dll" ]] && PW_USE_SUPPLIED_DXVK_VKD3D="2"
[[ -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d9.dll" ]] && PW_USE_SUPPLIED_DXVK_VKD3D="2"
else
PW_USE_SUPPLIED_DXVK_VKD3D="0"
fi
@@ -3492,7 +3564,7 @@ start_portproton () {
print_info "Try create symlink WINE files..."
for copy_wine_dll in $CP_WINE_FILES ; do
try_force_link_file "${WINEDIR}/lib64/wine/x86_64-windows/${copy_wine_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${copy_wine_dll}.dll"
try_force_link_file "${PW_WINE_PE64_PATH}/${copy_wine_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${copy_wine_dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/${copy_wine_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${copy_wine_dll}.dll"
done
@@ -3521,7 +3593,7 @@ start_portproton () {
case "$PW_USE_SUPPLIED_DXVK_VKD3D" in
2)
try_force_link_file "${WINEDIR}/lib/wine/dxvk/i386-windows/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${wine_dxvk_dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/dxvk/x86_64-windows/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_dxvk_dll}.dll" ;;
try_force_link_file "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_dxvk_dll}.dll" ;;
1)
try_force_link_file "${WINEDIR}/lib/wine/dxvk/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${wine_dxvk_dll}.dll"
try_force_link_file "${WINEDIR}/lib64/wine/dxvk/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_dxvk_dll}.dll" ;;
@@ -3568,7 +3640,7 @@ start_portproton () {
2)
if [[ $wine_vkd3d_dll =~ d3d12 ]] ; then
try_force_link_file "${WINEDIR}/lib/wine/vkd3d-proton/i386-windows/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${wine_vkd3d_dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/vkd3d-proton/x86_64-windows/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_vkd3d_dll}.dll"
try_force_link_file "${WINEDIR}/lib/wine/vkd3d-proton/${PW_SUPPLIED_PE64_ARCH}/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${wine_vkd3d_dll}.dll"
fi ;;
1)
if [[ $wine_vkd3d_dll =~ d3d12 ]] ; then
@@ -4012,25 +4084,6 @@ pw_launch_wrapper () {
return
fi
local -a wrapper_scripts=()
local wrapper_script
local wrapper_scripts_valid=1
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
for wrapper_script in "${wrapper_scripts[@]}" ; do
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
wrapper_scripts_valid=0
fi
done
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
for wrapper_script in "${wrapper_scripts[@]}" ; do
"${wrapper_script}"
done
"$@"
return
fi
fi
local -a wrapper_cmd
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
@@ -4212,22 +4265,9 @@ portwine_launch () {
fi
done
else
local -a run_after_exes=() run_after_environment=()
local run_after_entry run_after_index
local run_after_valid=1
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
run_after_exes=("${PW_RUN_AFTER_EXE}")
else
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
fi
for run_after_entry in "${run_after_exes[@]}" ; do
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
run_after_valid=0
fi
done
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
&& [[ "${run_after_valid}" == 1 ]] \
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
then
local wait_delay wait_delay_int run_after_bat
@@ -4251,7 +4291,6 @@ portwine_launch () {
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
fi
pw_exe_file_win="${pw_exe_file_win//\//\\}"
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
@@ -4270,30 +4309,10 @@ timeout /t ${wait_delay_int} /nobreak
start "" "%PW_RUN_AFTER_SECOND_PATH%"
EOF
local run_after_first_win="${run_after_exe_win}"
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
run_after_entry="${run_after_exes[run_after_index]}"
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
if [[ -z "${run_after_exe_win}" ]] ; then
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
else
run_after_exe_win="z:${run_after_entry//\//\\}"
fi
fi
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
done
(
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
export "${run_after_environment[@]}"
fi
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
)
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
try_remove_file "${run_after_bat}"
else
case "${PW_EXE_FILE,,}" in
@@ -4409,7 +4428,8 @@ gui_edit_db () {
DISABLE_EDIT_DB_LIST+=" PW_USE_SUPPLIED_DXVK_VKD3D"
fi
if [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
if check_arm \
|| [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|| [[ ! -f "${PW_WINELIB}/pressure-vessel/bin/pressure-vessel-wrap" ]]
then
DISABLE_EDIT_DB_LIST+=" PW_USE_RUNTIME"
+7 -23
View File
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
prev="${COMP_WORDS[COMP_CWORD-1]}"
# All available options
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
# Values for options with arguments
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
@@ -73,19 +73,11 @@ _portprotonqt() {
'--add-steam-compat-tool[Add as Steam compatibility tool]'
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
'--list-games[Print local game libraries as JSON]'
'--game-info[Print one game as JSON]:ID:'
'--edit-game[Edit game settings]:ID:'
'*--set[Set a game setting]:KEY=VALUE:'
'--all[Include uninstalled games in the library list]'
'--fields[Select JSON fields]:FIELD,...:'
'--pretty[Format JSON with indentation]'
'--raw[Print compact JSON]'
'--clear-cache[Clear PortProtonQt cache and exit]'
'--reset-settings[Reset PortProtonQt settings and exit]'
'--ppqtos[Show the system tab]'
'--silent[Launch Windows file or native game in tray]'
'--log[Enable game logging]'
'--silent[Launch .exe file without GUI]'
'--log[Enable PortProton logging]'
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
'(-h --help)'{-h,--help}'[Show help message]'
@@ -102,26 +94,18 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
# Fish completion for portprotonqt
complete -c portprotonqt -f
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
complete -c portprotonqt -l raw -d "Print compact JSON"
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
complete -c portprotonqt -l ppqtos -d "Show the system tab"
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
complete -c portprotonqt -l log -d "Enable game logging"
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
complete -c portprotonqt -l log -d "Enable PortProton logging"
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
complete -c portprotonqt -s h -l help -d "Show help message"
+120 -144
View File
@@ -2,7 +2,7 @@ import argparse
import sys
import os
import subprocess
import shlex
import signal
import threading
import urllib.error
import urllib.request
@@ -11,21 +11,21 @@ from logging import Logger
import psutil
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import (
QApplication,
QMenu,
QMessageBox,
QSystemTrayIcon,
)
from PySide6.QtGui import QIcon
from PySide6.QtGui import QAction, QIcon
from portprotonqt.config import (
consume_download_counter_skip,
display_config,
find_game_by_exe,
get_portproton_start_command,
get_portproton_location,
save_portdata_path_to_config,
@@ -34,7 +34,6 @@ from portprotonqt.config import (
window_config,
)
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
from portprotonqt.cli import (
parse_args,
is_portproton_url,
@@ -67,8 +66,6 @@ if APP_VERSION == "@APP" "_VERSION@":
__app_version__ = APP_VERSION
RESTART_STOP_TIMEOUT_SECONDS = 30
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
def get_version():
@@ -85,6 +82,103 @@ def get_version():
return __app_version__
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
try:
subprocess.run(
start_sh + ["cli", "--stop"],
capture_output=True,
text=True,
check=False,
timeout=30,
)
except (OSError, subprocess.SubprocessError) as e:
logger.warning("Failed to execute PortProton stop command: %s", e)
def get_portproton_tray_icon() -> QIcon:
from portprotonqt.theme_manager import ThemeManager
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
if isinstance(icon, QIcon):
return icon
return QIcon()
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
"""Launch a game with minimal tray game controls."""
logger = get_logger(__name__)
app.setQuitOnLastWindowClosed(False)
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
tray_icon.setToolTip(__app_name__)
from datetime import datetime
from portprotonqt.time_utils import save_last_launch, save_playtime
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
start_time = datetime.now()
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
tray_menu = QMenu()
def end_silent_run():
monitor_timer.stop()
elapsed = int((datetime.now() - start_time).total_seconds())
if elapsed > 0:
save_playtime(exe_path, elapsed)
tray_icon.hide()
app.quit()
def stop_game() -> None:
stop_portproton_game(start_sh, logger)
end_silent_run()
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
if process.poll() is not None:
end_silent_run()
def update_pause_action() -> list[psutil.Process]:
try:
children = psutil.Process(process.pid).children(recursive=True)
paused = bool(children) and all(
child.status() == psutil.STATUS_STOPPED for child in children
)
except (psutil.NoSuchProcess, psutil.AccessDenied):
children = []
paused = False
pause_action.setEnabled(bool(children))
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
return children
def toggle_pause() -> None:
children = update_pause_action()
if not children:
return
try:
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
for child in children:
try:
child.send_signal(pause_signal)
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
logger.warning("Failed to inspect game pause state: %s", error)
update_pause_action()
pause_action = QAction(_("Pause Game"), tray_menu)
pause_action.triggered.connect(toggle_pause)
tray_menu.addAction(pause_action)
tray_menu.aboutToShow.connect(update_pause_action)
stop_action = QAction(_("Stop Game"), tray_menu)
stop_action.triggered.connect(stop_game)
tray_menu.addAction(stop_action)
tray_icon.setContextMenu(tray_menu)
tray_icon.show()
monitor_timer = QTimer(app)
monitor_timer.timeout.connect(close_when_game_exits)
monitor_timer.start(1000)
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
"""Restore a PortProton prefix backup."""
logger = get_logger(__name__)
@@ -142,38 +236,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
def main():
parsed_args = parse_args()
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
import orjson
from portprotonqt.cli import edit_game, list_games
try:
if parsed_args.edit_game:
result = edit_game(parsed_args.edit_game, parsed_args.settings)
elif parsed_args.game_info:
games = list_games()
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
if result is None:
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
return 1
else:
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
if parsed_args.fields:
entries = result if isinstance(result, list) else [result]
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
result = selected if isinstance(result, list) else selected[0]
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
except OSError as error:
get_logger(__name__).error("Library command failed: %s", error)
return 1
except ValueError as error:
get_logger(__name__).error("Invalid library command: %s", error)
return 1
except RuntimeError as error:
get_logger(__name__).error("Failed to edit game settings: %s", error)
return 1
return 0
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
@@ -273,17 +335,6 @@ def main():
app.setApplicationVersion(__app_version__)
args = parsed_args
native_launch_uri = None
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
native_launch_uri = args.file_or_url
elif args.file_or_url and not is_launch_file(args.file_or_url):
target = normalize_launch_path(args.file_or_url)
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
entry = find_game_by_exe(target)
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
result = create_native_desktop_file(target, name)
if result and os.path.isfile(result[1]):
native_launch_uri = NATIVE_URI_PREFIX + result[1]
setup_logger(args.debug_level)
logger = get_logger(__name__)
if consume_download_counter_skip():
@@ -315,9 +366,6 @@ def main():
elif is_restore_prefix_request(args):
restore_prefix_path = normalize_launch_path(args.file_or_url)
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
elif native_launch_uri:
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
elif args.file_or_url and is_launch_file(args.file_or_url):
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
@@ -345,7 +393,7 @@ def main():
if theme_store_id is not None:
ipc_message = f"theme:{theme_store_id}"
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
from PySide6.QtCore import QThread, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from requests import RequestException
from portprotonqt.appimage_updater import (
@@ -371,10 +419,7 @@ def main():
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
# --- Single-instance logic ---
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
server_name = os.path.join(runtime_dir, __app_id__)
instance_lock = QLockFile(server_name + ".lock")
owns_instance = instance_lock.tryLock(0)
server_name = __app_id__
socket = QLocalSocket()
socket.connectToServer(server_name)
@@ -387,11 +432,7 @@ def main():
logger.info("Restored existing instance from tray")
return
if not owns_instance:
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
return
# Only the lock owner can remove a stale socket.
# Remove old socket if it exists
QLocalServer.removeServer(server_name)
local_server = QLocalServer()
@@ -415,7 +456,7 @@ def main():
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
# Check if we have a portproton:// URL or launch file to handle
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
if is_portproton_url(args.file_or_url):
# Parse the portproton:// URL to get the full download URL
download_url = parse_portproton_url(args.file_or_url)
@@ -500,19 +541,22 @@ def main():
launch_path = exe_path or autoinstall_path
silent_game_request = is_silent_launch and not args.log and bool(
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
exe_path or gog_launch_uri or egs_launch_uri
)
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
window.silent_launch_mode = silent_game_request
if silent_game_request:
window._initialize_tray(__app_name__)
assert window.tray_manager is not None
window.tray_manager.set_minimal_mode()
if exe_path:
window.tray_manager.tray_icon.hide()
else:
window.tray_manager.set_minimal_mode()
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
exec_line = f"{source}://launch/{app_id}"
if log_mode:
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
def open_card(games: list[tuple]) -> None:
game = next((item for item in games if item[5] == exec_line), None)
@@ -526,44 +570,17 @@ def main():
"anticheat_status": game[9], "playtime_seconds": game[11],
"game_source": game[12], "anticheat_slug": game[13],
"ppdb_id": game[14], "ppdb_rating": game[15],
"protondb_appid": game[16] if len(game) > 16 else "",
"protondb_appid": game[16],
})
loader = getattr(window, f"_load_{source}_games_async")
loader(open_card)
restart_target = None
def handle_game_request(
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
) -> None:
nonlocal restart_target
if restart_target is not None:
return
if silent and (confirm or window.game_processes):
message_box = QMessageBox(window)
message_box.setIcon(QMessageBox.Icon.Question)
message_box.setWindowTitle(__app_name__)
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
restart_target = target
app.quit()
return
if target.startswith(NATIVE_URI_PREFIX):
if silent and not log_mode:
window.toggleGame(target)
else:
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
return
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
if is_launch_file(target):
path = normalize_launch_path(target)
if silent:
window.toggleGame(shlex.quote(path))
run_silent_tray(app, start_sh, path)
return
window.handle_launch_exe(path, log_mode=log_mode)
return
@@ -574,11 +591,7 @@ def main():
open_store_game_card(source, app_id, log_mode)
# Handle launch file if provided
if native_launch_uri:
def handle_native_launch() -> None:
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
QTimer.singleShot(0, handle_native_launch)
elif exe_path:
if exe_path:
# Defer the call until after the window is shown
def handle_launch_exe() -> None:
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
@@ -652,15 +665,15 @@ def main():
if msg.startswith("silent:"):
target = unquote(msg[7:].strip())
handle_game_request(target, True, confirm=True)
handle_game_request(target, True)
elif msg.startswith("open:") or msg.startswith("log:"):
log_mode = msg.startswith("log:")
launch_path = unquote(msg.split(":", 1)[1].strip())
if launch_path and is_launch_file(launch_path):
logger.info("Opening launch file via IPC: %s", launch_path)
handle_game_request(launch_path, False, log_mode)
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
handle_game_request(unquote(launch_path), False, log_mode)
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
handle_game_request(unquote(launch_path), False, True)
else:
logger.warning("Invalid launch file via IPC: %s", launch_path)
elif msg.startswith("restore:"):
@@ -911,44 +924,7 @@ def main():
app.aboutToQuit.connect(cleanup_on_exit)
exit_code = app.exec()
if restart_target is not None:
if window.game_processes:
window._terminate_game_processes()
window.resetPlayButton()
window._stopBackgroundWorkers()
try:
children = psutil.Process().children(recursive=True)
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
continue
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
for child in alive:
try:
child.kill()
except psutil.NoSuchProcess:
continue
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
if alive:
logger.error("Cannot restart while child processes are still running")
sys.exit(1)
if start_sh:
subprocess.run(start_sh + ["cli", "--stop"], check=True,
timeout=RESTART_STOP_TIMEOUT_SECONDS)
instance_lock.unlock()
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
except OSError as error:
logger.error("Failed to restart PortProtonQt: %s", error)
exit_code = 1
except psutil.Error as error:
logger.error("Failed to stop PortProtonQt child processes: %s", error)
exit_code = 1
except subprocess.SubprocessError as error:
logger.error("Failed to stop PortProton before restart: %s", error)
exit_code = 1
sys.exit(exit_code)
sys.exit(app.exec())
if __name__ == "__main__":
main()
+3 -291
View File
@@ -2,7 +2,6 @@ import argparse
import os
import re
import shutil
import shlex
import sys
from pathlib import Path
@@ -18,27 +17,6 @@ def parse_args():
Parses command-line arguments.
"""
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
library = parser.add_mutually_exclusive_group()
library.add_argument(
"--list-games", action="store_true",
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
)
library.add_argument(
"--game-info", metavar="ID",
help="Print one game as JSON using its source-qualified ID from --list-games"
)
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
help="Set a game setting; repeat to change multiple values with --edit-game")
parser.add_argument("--all", dest="all_games", action="store_true",
help="Include uninstalled games from cached libraries in --list-games")
parser.add_argument("--fields", metavar="FIELD,...",
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
formatting = parser.add_mutually_exclusive_group()
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
help="Format JSON with indentation (default)")
formatting.add_argument("--raw", dest="pretty", action="store_false",
help="Print compact JSON without indentation")
parser.add_argument(
"--fullscreen",
action="store_true",
@@ -89,12 +67,12 @@ def parse_args():
parser.add_argument(
"--silent",
action="store_true",
help="Launch supported Windows file or native game in tray without showing the main window"
help="Launch supported Windows file in tray without showing the main window"
)
parser.add_argument(
"--log",
action="store_true",
help="Enable logging for the launched Windows file or native game"
help="Enable PortProton logging for the launched Windows file"
)
parser.add_argument(
"--restore-prefix",
@@ -111,7 +89,7 @@ def parse_args():
parser.add_argument(
'file_or_url',
nargs='?',
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
)
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
separator_index = sys.argv.index("--")
@@ -128,276 +106,10 @@ def parse_args():
args = parser.parse_args()
launch_args = []
if args.settings and not args.edit_game:
parser.error("--set requires --edit-game")
if args.edit_game and not args.settings:
parser.error("--edit-game requires at least one --set KEY=VALUE")
if args.all_games and not args.list_games:
parser.error("--all requires --list-games")
if args.fields is not None:
if not (args.list_games or args.game_info):
parser.error("--fields requires --list-games or --game-info")
args.fields = [field.strip() for field in args.fields.split(",")]
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
parser.error("--fields requires comma-separated field names without empty entries")
args.launch_args = launch_args
return args
def _read_game_settings(path: Path) -> dict[str, str]:
"""Read literal shell assignments without executing configuration code."""
from portprotonqt.logger import get_logger
settings: dict[str, str] = {}
if not path.is_file():
return settings
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
get_logger(__name__).warning("Failed to read %s: %s", path, error)
return settings
for line in content.splitlines():
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
if not match:
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
if not match:
continue
value = match[2]
if any(token in value for token in ("$", "`")):
continue
try:
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
parts = list(lexer)
except ValueError:
continue
if len(parts) <= 1:
settings[match[1]] = parts[0] if parts else ""
return settings
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
"""Read custom artwork and cached metadata without fetching remote resources."""
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.localization import get_metadata_language, read_metadata_translations
from portprotonqt.steam_api.api import get_cached_steam_game_info
from portprotonqt.time_utils import (
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
)
metadata = get_cached_steam_game_info(name, exec_line)
metadata.setdefault("name", name)
metadata.setdefault("description", "")
metadata.setdefault("cover", "")
if not executable:
return metadata
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
metadata["custom_data_directory"] = str(folder)
metadata_file = folder / "metadata.txt"
if metadata_file.is_file():
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
metadata.update({key: value for key, value in translations.items() if value})
for extension in COVER_IMAGE_EXTENSIONS:
cover = folder / f"cover{extension}"
if cover.is_file():
metadata["cover"] = str(cover)
break
metadata["custom_icon"] = get_custom_icon_path(executable)
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
return metadata
def _get_game_runtime(
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
) -> dict:
"""Export configured runtime values; never source PPDB files or launch Wine."""
user_conf = location / "data/user.conf"
ppdb = Path(f"{executable}.ppdb") if executable else None
settings = _read_game_settings(ppdb) if ppdb else {}
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
settings.update(overrides)
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
prefix = overrides.get("PW_PREFIX_NAME", prefix)
resolved_runner = runner
if runner == "PROTON_LG":
resolved_runner = variables.get("PW_PROTON_LG_VER")
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
resolved_runner = variables.get("PW_WINE_LG_VER")
runner_path = None
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
location / "data/dist" / resolved_runner.upper()
)
elif resolved_runner == "USE_SYSTEM_WINE":
wine = shutil.which("wine")
runner_path = Path(wine).parent.parent if wine else None
wine_directory = runner_path
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
wine_directory /= "files"
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
wine_directory / "bin-wow64"
).is_dir() else "bin"
prefix_path = location / "data/prefixes" / prefix if prefix else None
return {
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
"runner": resolved_runner,
"runner_path": str(runner_path) if runner_path else None,
"runner_installed": runner_path.is_dir() if runner_path else False,
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
"settings": settings, "user_config": str(user_conf),
"ppdb_exists": ppdb.is_file() if ppdb else False,
}
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
"""Export a cached GOG or Epic library using the existing store backends."""
from portprotonqt.egs_api import EGSAPI
from portprotonqt.gog_api import GOGAPI
from portprotonqt.main_window import MainWindow
api = GOGAPI() if source == "gog" else EGSAPI()
installed = api.load_installed()
games = []
for record in api.load_library():
app_id = str(record.get("app_id", ""))
if not app_id:
continue
is_installed = api.is_game_installed(app_id, installed)
target = api.get_launch_target(app_id) if is_installed else None
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
metadata = _get_game_metadata(name, target, target) if target else {}
metadata.pop("name", None)
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
games.append({
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
**metadata,
"id": f"{source}:{app_id}", "source": source, "name": name,
"description": record.get("description") or metadata.get("description", ""),
"cover": cover or record.get("cover") or metadata.get("cover", ""),
"icon": icon, "installed": is_installed,
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
"executable": target, "directory": str(Path(target).parent) if target else None,
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
})
return games
def _list_steam_games() -> list[dict]:
"""Export installed Steam games and their locally cached metadata."""
from portprotonqt.main_window import MainWindow
from portprotonqt.steam_api import get_steam_installed_games
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
games = []
for name, appid, last_played, playtime in get_steam_installed_games():
metadata = get_cached_full_steam_game_info(appid, name)
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
games.append({
**metadata,
"id": f"steam:{appid}", "source": "steam", "name": name,
"cover": cover or metadata.get("cover", ""),
"installed": True, "exec": f"steam://rungameid/{appid}",
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
})
return games
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
"""Return local PortProton shortcuts without launching games or downloading metadata."""
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
if not location:
return []
games = []
for desktop_path in sorted(Path(location).glob("*.desktop")):
entry = parse_desktop_entry(str(desktop_path))
if entry is None:
continue
exec_line = entry.get("Exec", "")
executable = extract_exec_target_path(exec_line)
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
games.append({
**metadata,
"id": f"portproton:{desktop_path.name}", "source": "portproton",
"installed": bool(executable and Path(executable).is_file()),
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
"desktop_file": str(desktop_path),
"exec": exec_line,
"executable": executable,
"directory": str(Path(executable).parent) if executable else None,
"icon": icon,
"ppdb": f"{executable}.ppdb" if executable else None,
})
return games
def list_games() -> list[dict]:
"""Return locally available library data from all supported game sources."""
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
location = get_portproton_location()
scripts = get_portproton_scripts_path()
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
games = _list_portproton_games(location, variables, overrides)
games.extend(_list_steam_games())
for source in ("gog", "egs"):
games.extend(_list_store_games(source, location, variables, overrides))
return games
def edit_game(game_id: str, changes: list[str]) -> dict:
"""Update a game's PPDB through PortProton and verify the saved settings."""
import subprocess
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
game = next((game for game in list_games() if game["id"] == game_id), None)
if game is None:
raise ValueError(f"Game not found: {game_id}")
executable = game.get("executable")
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
values: dict[str, str] = {}
for change in changes:
key, separator, value = change.partition("=")
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
raise ValueError(f"Unknown or invalid game setting: {key}")
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
raise ValueError(f"Unsupported shell syntax in setting: {key}")
if key in toggles and value not in {"0", "1"}:
raise ValueError(f"{key} must be 0 or 1")
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
values[key] = value
if not values:
raise ValueError("At least one setting is required")
command = get_portproton_start_command()
if not command:
raise ValueError("PortProton start.sh was not found")
result = subprocess.run(
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
capture_output=True, text=True, errors="replace", check=False,
)
if result.returncode:
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
saved = _read_game_settings(Path(f"{executable}.ppdb"))
if any(saved.get(key) != value for key, value in values.items()):
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
return next(game for game in list_games() if game["id"] == game_id)
def add_steam_compat_tool(force_install: bool = False) -> bool:
"""
Add PortProtonQt as a Steam compatibility tool.
-9
View File
@@ -581,14 +581,6 @@ def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
return hashed_dir
def get_custom_icon_path(exe_path: str) -> str:
"""Return the existing custom icon for an executable."""
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
path = os.path.join(folder, "icon.png")
return path if os.path.isfile(path) else ""
def create_desktop_file(
exe_path: str,
game_name: str | None = None,
@@ -614,7 +606,6 @@ def create_desktop_file(
"applications-games" if is_store_uri and not has_icon_source
else os.path.join(base_path, "img", f"{icon_name}.png")
)
icon_path = get_custom_icon_path(exe_path) or icon_path
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
if icon_path != "applications-games":
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
+46 -198
View File
@@ -2,8 +2,6 @@ import os
import glob
import shutil
import tempfile
import configparser
from pathlib import Path
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
@@ -11,7 +9,6 @@ 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,
@@ -32,7 +29,6 @@ from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
from portprotonqt.logger import get_logger
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
@@ -131,8 +127,6 @@ class ContextMenuManager:
Returns:
bool: True if the game is running, False otherwise.
"""
if game_card.game_source == "native":
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
if game_card.game_source == "steam":
return False
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
@@ -369,8 +363,8 @@ class ContextMenuManager:
)
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
try:
@@ -398,8 +392,8 @@ class ContextMenuManager:
)
msg_box.setIcon(QMessageBox.Icon.Warning)
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
try:
@@ -543,7 +537,7 @@ class ContextMenuManager:
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 = menu.addAction(_("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))
@@ -567,7 +561,7 @@ class ContextMenuManager:
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 = menu.addAction(_("Select multiple"))
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
menu.setActiveAction(selection_action)
menu.addSeparator()
@@ -737,9 +731,6 @@ class ContextMenuManager:
)
)
if game_card.game_source == "native":
self._add_native_actions(menu, game_card)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DesktopLocation
@@ -812,21 +803,16 @@ class ContextMenuManager:
if os.path.exists(menu_path)
else self.add_to_menu(game_card.name, game_card.exec_line)
)
if (
game_card.game_source in ("portproton", "gog", "egs")
and not is_uninstalled_store_game
and get_steam_home() is not None
):
is_in_steam = is_game_in_steam(game_card.name)
steam_icon_name = "delete" if is_in_steam else "menu_steam"
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
if is_in_steam
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
)
if 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
@@ -838,96 +824,6 @@ class ContextMenuManager:
menu.popup(game_card.mapToGlobal(pos))
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
for directory, add_text, remove_text, icon in (
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
):
if not directory:
continue
exists = (Path(directory) / source.name).is_file()
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
if get_steam_home() is not None:
is_in_steam = is_game_in_steam(card.name)
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
action.triggered.connect(lambda: self._edit_native_shortcut(card))
if managed:
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
target = Path(directory) / source.name
try:
if target.is_file():
if target == source:
retained = Path(get_portproton_location() or source.parent) / source.name
retained.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, retained)
target.unlink()
else:
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = lambda optionstr: optionstr
parser.read_string(source.read_text(encoding="utf-8"))
command = quote_native_arguments([exec_line])
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
section = f"Desktop Action {action}"
if parser.has_section(section):
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8") as output:
parser.write(output, space_around_delimiters=False)
target.chmod(0o755)
if directory == self._get_applications_dir():
self._update_desktop_database()
self.parent.refreshGames()
except Exception as error:
logger.warning("Cannot update native shortcut: %s", error)
self.signals.show_warning_dialog.emit(_("Error"), str(error))
def _edit_native_shortcut(self, card: "GameCard") -> None:
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=card.name, exe_path=source, cover_path=card.cover_path)
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
if dialog.exec() != QDialog.DialogCode.Accepted:
return
name = dialog.nameEdit.text().strip()
if not name:
return
cover = dialog.coverEdit.text().strip()
if not os.path.isfile(cover) and dialog.last_cover_path:
cover = dialog.last_cover_path
try:
self._save_local_shortcut_name(source, name)
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
if os.path.abspath(cover) != str(target):
self._remove_old_cover_files(directory)
shutil.copyfile(cover, target)
if dialog.add_to_steam_checkbox.isChecked():
if not is_game_in_steam(card.name):
self.add_to_steam(name, card.exec_line, cover)
elif is_game_in_steam(card.name):
self.remove_from_steam(card.name, card.exec_line, "native")
self.parent.refreshGames()
except OSError as error:
logger.warning("Cannot edit native metadata: %s", error)
def _launch_game(self, game_card):
"""
Launch or stop a game based on its current state.
@@ -935,9 +831,6 @@ class ContextMenuManager:
Args:
game_card: The GameCard instance containing game data.
"""
if game_card.game_source == "native":
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
return
if not self._check_portproton():
return
@@ -1020,9 +913,7 @@ class ContextMenuManager:
if not entry:
continue
exec_line = entry.get("Exec", entry.get("exec", ""))
exe_path = extract_exec_target_path(exec_line)
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
if script_name.lower() in exec_line.lower():
return file_path
except OSError:
continue
@@ -1098,9 +989,6 @@ class ContextMenuManager:
def _get_exec_line(self, game_name, exec_line):
"""Retrieve and validate exec_line from .desktop file if necessary."""
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
return entry.get("Exec") if entry else None
if exec_line and exec_line.strip() != "full":
return exec_line
desktop_path = self._get_desktop_path(game_name)
@@ -1232,20 +1120,6 @@ class ContextMenuManager:
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
card_exec_line = exec_line
if exec_line.startswith(NATIVE_URI_PREFIX):
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
str(Path(self._get_applications_dir()) / source.name)]
if desktop:
paths.append(str(Path(desktop) / source.name))
self._remove_installed_shortcuts(game_name, paths)
self._remove_statistics_entry(str(source), game_name)
self.game_library_manager.remove_game_incremental(game_name, exec_line)
for key in ("all", "native", "favorites"):
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
return
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
@@ -1297,8 +1171,8 @@ class ContextMenuManager:
msg_box.setText(_("Are you sure you want to delete '{game_name}'? This will remove the .desktop file and custom data.").format(game_name=game_name))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
@@ -1548,8 +1422,7 @@ class ContextMenuManager:
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
return
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=game_name, cover_path=cover_path, steam_appid=app_id,
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
new_name = dialog.nameEdit.text().strip()
@@ -1663,27 +1536,6 @@ class ContextMenuManager:
except OSError as error:
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
def _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
game_dir = resolve_custom_data_dir(custom_root, exe_path)
os.makedirs(game_dir, exist_ok=True)
metadata_path = os.path.join(game_dir, "metadata.txt")
metadata_lines = []
if os.path.isfile(metadata_path):
with open(metadata_path, encoding="utf-8") as metadata_file:
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
clean_name = " ".join(name.splitlines()).strip()
temp_path = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
temp_path = metadata_file.name
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
os.replace(temp_path, metadata_path)
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
"""
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
@@ -1724,12 +1576,7 @@ class ContextMenuManager:
_("Game name and executable path are required")
)
return
old_path = self._get_desktop_path(game_name)
old_entry = parse_desktop_entry(old_path)
original_name = old_entry.get("Name", game_name) if old_entry else game_name
dialog.nameEdit.setText(original_name)
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
dialog.nameEdit.setText(new_name)
if not desktop_entry or not new_desktop_path:
self.signals.show_warning_dialog.emit(
_("Error"),
@@ -1737,15 +1584,10 @@ class ContextMenuManager:
)
return
new_desktop_path = old_path
old_path = self._get_desktop_path(game_name)
moved_paths = []
try:
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
if exe_path == new_exe_path and os.path.isfile(old_path):
with open(old_path, encoding="utf-8") as source:
desktop_entry = source.read()
if new_name != game_name:
self._save_local_shortcut_name(new_exe_path, new_name)
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
@@ -1764,6 +1606,13 @@ class ContextMenuManager:
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
if exe_path != new_exe_path:
self._relocate_shortcut_statistics(exe_path, new_exe_path)
if game_name != new_name and os.path.exists(old_path):
self._remove_file(
old_path,
_("Failed to delete old .desktop file: {error}"),
_("Removed old .desktop file for '{game_name}'"),
game_name
)
saved_entry = parse_desktop_entry(new_desktop_path)
updated_exec_line = new_exe_path
if saved_entry:
@@ -1783,9 +1632,11 @@ class ContextMenuManager:
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = resolve_custom_data_dir(
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
new_exe_path,
custom_folder = os.path.join(
xdg_data_home,
"PortProtonQt",
"custom_data",
get_custom_data_dir_name(new_exe_path),
)
os.makedirs(custom_folder, exist_ok=True)
@@ -1873,22 +1724,15 @@ class ContextMenuManager:
"""
if not self._check_portproton():
return
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
exec_line = self._get_exec_line(game_name, exec_line)
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
if not exec_line.startswith((NATIVE_URI_PREFIX, "gog://launch/", "egs://launch/")):
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Adding game '%s' to Steam", game_name)
try:
if exec_line.startswith(("gog://launch/", "egs://launch/")):
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
else:
success, message = add_to_steam(game_name, exec_line, cover_path)
success, message = add_to_steam(game_name, exec_line, cover_path)
if success:
if self._batch_action:
return
@@ -1925,18 +1769,22 @@ class ContextMenuManager:
_("Success"),
_("'{game_name}' was removed from Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
)
return
else:
self.signals.show_info_dialog.emit(
_("Success"),
_("'{game_name}' was removed from Steam.").format(game_name=game_name)
)
else:
self.signals.show_warning_dialog.emit(
_("Error"),
_(message).format(game_name=game_name)
)
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
exec_line = self._get_exec_line(game_name, exec_line)
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
return
logger.debug("Removing game '%s' from Steam", game_name)
try:
+2 -15
View File
@@ -1,8 +1,8 @@
import os
from itertools import accumulate
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
from PySide6.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
from PySide6.QtSvg import QSvgRenderer
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config import ui_config
@@ -18,19 +18,6 @@ class CustomComboBox(QComboBox):
super().__init__(parent)
self.theme = theme
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
line_edit = self.lineEdit()
if (
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
):
if event.type() == QEvent.Type.MouseButtonPress:
return True
if event.type() == QEvent.Type.MouseButtonRelease:
self.showPopup()
return True
return super().eventFilter(watched, event)
def contextMenuEvent(self, event):
line_edit = self.lineEdit()
if line_edit is None:
+8 -63
View File
@@ -6,9 +6,6 @@ import signal
import subprocess
import threading
import time
import tempfile
import shutil
from typing import IO
from portprotonqt.logger import get_logger
from portprotonqt.config import get_portproton_location
@@ -21,7 +18,7 @@ logger = get_logger(__name__)
class DebugLogManager:
"""Manage debug logs for Wine and native game launches."""
"""Manages debug log creation with game launch and Wine output capture."""
def __init__(self):
self.process: subprocess.Popen | None = None
@@ -29,19 +26,15 @@ class DebugLogManager:
self.start_command: list[str] | None = None
self.wine_output: list[str] = []
self.runtime_env: dict[str, str] | None = None
self.native_output: IO[str] | None = None
self.native_directory: str | None = None
self.is_running = False
self.output_queue = queue.Queue()
self.output_thread = None
self._stop_event = threading.Event()
def start(self, exe_path: str, start_command: list[str]) -> bool:
"""Capture game output, enabling PW_LOG for PortProton launches."""
"""Start game with PW_LOG=1 and capture output."""
if self.is_running:
return False
if exe_path.startswith("native://"):
return self._start_native(exe_path)
self.exe_path = exe_path
self.start_command = start_command
@@ -93,52 +86,6 @@ class DebugLogManager:
logger.error("Failed to start debug session: %s", e)
return False
def _start_native(self, uri: str) -> bool:
from portprotonqt.native_games import get_native_launch
try:
command, directory = get_native_launch(uri.removeprefix("native://"))
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
self.process = subprocess.Popen(
command, cwd=directory, stdout=self.native_output,
stderr=subprocess.STDOUT, start_new_session=True,
)
except Exception as error:
logger.error("Failed to start native debug session: %s", error)
if self.native_output is not None:
self.native_output.close()
self.native_output = None
return False
self.exe_path = uri.removeprefix("native://")
self.start_command = command
self.native_directory = directory
self.is_running = True
return True
def _save_native_log(self) -> str | None:
output = self.native_output
if output is None:
return None
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
try:
settings = load_native_settings(str(self.exe_path))
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
destination.parent.mkdir(parents=True, exist_ok=True)
output.seek(0)
with destination.open("w", encoding="utf-8") as log:
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
shutil.copyfileobj(output, log)
return str(destination)
except OSError as error:
logger.error("Failed to save native debug log: %s", error)
return None
finally:
output.close()
self.native_output = None
self.native_directory = None
def _read_output(self):
"""Read output from subprocess in a separate thread."""
if self.process and self.process.stdout:
@@ -151,20 +98,19 @@ class DebugLogManager:
logger.debug("Error reading output: %s", e)
def stop(self) -> str | None:
"""Stop the game and save its diagnostic log."""
if self.native_output is None:
self._capture_env_vars()
"""Stop game and save debug log with captured Wine output."""
self._capture_env_vars()
self._stop_event.set()
if self.is_running and self.process:
try:
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
try:
time.sleep(0.1)
if self.process.poll() is None:
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
except Exception as e:
@@ -177,7 +123,7 @@ class DebugLogManager:
except queue.Empty:
pass
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
log_file = self._save_log()
if self.process and self.process.stdout:
try:
@@ -200,8 +146,7 @@ class DebugLogManager:
self.is_running = False
return False
if self.native_output is None:
self._capture_env_vars()
self._capture_env_vars()
return True
def _capture_env_vars(self) -> None:
+22 -35
View File
@@ -290,8 +290,7 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
game_dir = os.path.dirname(exe_path)
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
if portproton_path:
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
if os.path.exists(tmp_dir):
@@ -314,17 +313,8 @@ def generate_system_info(
get_d3d_extras_status,
)
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
native = bool(exe_path and exe_path.startswith("native://"))
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
if desktop_path:
runtime_env = load_native_settings(desktop_path)
command, directory = get_native_launch(desktop_path)
exe_path = next((part for part in reversed(command)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
portproton_path = get_portproton_location() or ""
if not portproton_path and not native:
portproton_path = get_portproton_location()
if not portproton_path:
return "Error: PortProton location not found"
lines = []
@@ -354,17 +344,16 @@ def generate_system_info(
lines.append(get_libc_version())
lines.append("-" * 61)
if not native:
lines.append(get_vulkan_use_info(portproton_path, exe_path))
lines.append("-" * 61)
lines.append(get_vulkan_use_info(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Version WINE in use:")
lines.append(get_wine_version(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Version WINE in use:")
lines.append(get_wine_version(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Program bit depth:")
lines.append(get_program_bit_depth(exe_path))
lines.append("-" * 61)
lines.append("Program bit depth:")
lines.append(get_program_bit_depth(exe_path))
lines.append("-" * 61)
lines.append("Date and time of start debug for PortProton:")
lines.append(datetime.now().strftime("%c %z"))
@@ -430,28 +419,26 @@ def generate_system_info(
lines.append(locale_avail)
lines.append("-" * 61)
if not native:
lines.append(get_d3d_extras_status(portproton_path, exe_path))
lines.append(get_d3d_extras_status(portproton_path, exe_path))
lines.append("-" * 61)
prefix_name = get_prefix_name(exe_path)
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
if winetricks_content:
lines.append("winetricks.log:")
lines.append(winetricks_content)
lines.append("-" * 61)
prefix_name = get_prefix_name(exe_path)
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
if winetricks_content:
lines.append("winetricks.log:")
lines.append(winetricks_content)
lines.append("-" * 61)
if exe_path:
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
ppdb_content = get_ppdb_content(exe_path, start_cmd)
if ppdb_content:
lines.append(f"Use {ppdb_path}.ppdb db file:")
lines.append(f"Use {exe_path}.ppdb db file:")
if ppdb_log_vars:
lines.append("\n".join(ppdb_log_vars))
lines.append(ppdb_content)
lines.append("-" * 61)
user_overrides = "" if native else get_user_overrides(portproton_path)
user_overrides = get_user_overrides(portproton_path)
if user_overrides:
lines.append(user_overrides)
lines.append("-" * 61)
+9 -58
View File
@@ -19,8 +19,6 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
from PySide6.QtGui import QDesktopServices
from portprotonqt.native_games import get_native_launch, load_native_settings
from portprotonqt.detail_pages.widgets import (
create_scroll_area,
create_back_button,
@@ -95,9 +93,6 @@ class DetailPageManager:
def openGameDetailPage(self, game_data: dict) -> None:
"""Open detailed game information page."""
self._current_detail_source = ("game", dict(game_data))
self._return_to_library_card_key = (
game_data.get("name", ""), game_data.get("exec_line", "")
)
detail_page = QWidget()
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
@@ -657,7 +652,7 @@ class DetailPageManager:
)
buttons_layout.addWidget(open_folder_button)
elif (
str(game_source).lower() not in {"gog", "egs", "native"}
str(game_source).lower() not in {"gog", "egs"}
and not self._has_game_shortcut(game_name)
):
add_button = self._make_action_button(
@@ -672,24 +667,7 @@ class DetailPageManager:
buttons_layout.addWidget(add_button)
# Show settings button for PortProton games or Steam games using PortProtonQt
if source == "native":
settings_button = self._make_action_button(
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
)
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
buttons_layout.addWidget(settings_button)
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
buttons_layout.addWidget(log_button)
self._debug_log_button = log_button
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
if os.path.dirname(exec_line.removeprefix("native://")) in (
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
):
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
buttons_layout.addWidget(folder_button)
elif str(game_source).lower() == "portproton":
if str(game_source).lower() == "portproton":
self._add_portproton_buttons(buttons_layout, exec_line)
elif (
source in {"gog", "egs"}
@@ -744,8 +722,6 @@ class DetailPageManager:
def _get_current_exe(self, exec_line: str) -> str | None:
"""Extract current executable from exec line."""
if exec_line.startswith("native://"):
return os.path.basename(exec_line.removeprefix("native://"))
file_to_check = extract_exec_target_path(exec_line)
return os.path.basename(file_to_check) if file_to_check else None
@@ -761,8 +737,6 @@ class DetailPageManager:
play_button = self._make_action_button(text, icon)
play_button.setProperty("sound_event", False)
play_button.clicked.connect(lambda: self.main_window.toggleGame(exec_line, play_button))
if exec_line.startswith("steam://"):
self.main_window._toggle_steam_game(exec_line, play_button, launch=False)
return play_button
def _add_portproton_buttons(self, buttons_layout: FlowLayout, exec_line: str) -> None:
@@ -845,26 +819,9 @@ class DetailPageManager:
def _open_executable_folder(self, exe_path: str | None) -> None:
"""Open the folder containing an executable."""
if not exe_path:
if not exe_path or not os.path.exists(exe_path):
return
if exe_path.startswith("native://"):
try:
desktop_path = exe_path.removeprefix("native://")
settings = load_native_settings(desktop_path)
command, directory = get_native_launch(desktop_path)
executable = next((part for part in reversed(command)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
if not folder_path or not os.path.isdir(folder_path):
raise ValueError("Game directory is not available")
except Exception as error:
logger.warning("Cannot resolve native game folder: %s", error)
QMessageBox.warning(self.main_window, _("Error"), str(error))
return
else:
if not os.path.exists(exe_path):
return
folder_path = os.path.dirname(os.path.abspath(exe_path))
folder_path = os.path.dirname(os.path.abspath(exe_path))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
def _has_game_shortcut(self, game_name: str) -> bool:
@@ -1494,15 +1451,14 @@ class DetailPageManager:
"""Start debug logging session."""
if exe_path is None:
return
native = exe_path.startswith("native://")
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
if resolved_exe_path is None:
return
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
if not self.main_window._check_alt_i586_dependencies_before_launch():
return
start_command = [] if native else get_portproton_start_command()
if start_command is None:
start_command = get_portproton_start_command()
if not start_command:
return
if self.debug_log_manager.start(resolved_exe_path, start_command):
@@ -1524,7 +1480,7 @@ class DetailPageManager:
msg_box.setWindowTitle(_("Log Saved"))
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
result = msg_box.exec()
@@ -1682,15 +1638,10 @@ class DetailPageManager:
container = getattr(self.main_window, "gamesListWidget", None)
if container is None:
return
manager = getattr(self.main_window, "game_library_manager", None)
card_key = getattr(self, "_return_to_library_card_key", None)
card = getattr(manager, "game_card_cache", {}).get(card_key)
cards = [
widget for widget in container.findChildren(QWidget)
if widget.__class__.__name__ == "GameCard"
]
if card is not None and card.isVisible():
cards = [card]
if not cards:
return
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
+2 -68
View File
@@ -10,7 +10,7 @@ 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, QProgressDialog, QScrollArea, QLineEdit, QApplication, QMessageBox
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
)
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
import psutil
@@ -30,8 +30,6 @@ from portprotonqt.config import (
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
@@ -366,8 +364,6 @@ class AddGameDialog(DraggableDialog):
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)
@@ -433,19 +429,6 @@ class AddGameDialog(DraggableDialog):
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
layout.addRow(cover_button_label, coverBrowseButton)
self.iconEdit = CustomLineEdit(self, theme=self.theme)
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
icon_label = QLabel(_("Custom Icon:"))
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
icon_row = QHBoxLayout()
icon_row.addWidget(self.iconEdit)
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
icon_browse.clicked.connect(self.browseIcon)
icon_row.addWidget(icon_browse)
layout.addRow(icon_label, icon_row)
self.add_to_steam_checkbox = QCheckBox()
self.add_to_menu_checkbox = QCheckBox()
self.add_to_desktop_checkbox = QCheckBox()
@@ -484,9 +467,6 @@ class AddGameDialog(DraggableDialog):
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
self.add_to_menu_checkbox.setChecked(True)
self.add_to_desktop_checkbox.setChecked(True)
self.coverPreview = QLabel(self)
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -581,53 +561,16 @@ class AddGameDialog(DraggableDialog):
def accept(self):
"""Handle Apply button."""
icon_path = self.iconEdit.text().strip()
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
from PySide6.QtWidgets import QMessageBox
image = QPixmap(icon_path)
if image.isNull():
QMessageBox.warning(self, _("Error"), _("Invalid image"))
return
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
temporary_path = None
try:
os.makedirs(folder, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
temporary_path = temporary.name
if not image.save(temporary_path, "PNG"):
raise OSError("Failed to save icon")
os.replace(temporary_path, os.path.join(folder, "icon.png"))
except OSError as error:
logger.warning("Failed to save custom icon: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
return
finally:
if temporary_path:
Path(temporary_path).unlink(missing_ok=True)
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().accept()
def browseIcon(self) -> None:
from portprotonqt.dialogs.file_explorer import FileExplorer
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
initial_path=os.path.dirname(self.iconEdit.text()) or None)
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
explorer.show()
def browseExe(self):
"""Open file manager to select executable or disc image."""
try:
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
def file_filter(path: str) -> bool:
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
if not self.edit_mode:
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
@@ -967,13 +910,6 @@ class AddGameDialog(DraggableDialog):
if not name:
return None, None
if is_native_executable(exe_path):
cover = self.last_cover_path or self.coverEdit.text().strip()
result = create_native_desktop_file(exe_path, name, cover)
if result is None:
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
return result or (None, None)
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
parent_widget = cast("MainWindow | None", self.parentWidget())
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
@@ -990,8 +926,6 @@ class AddGameDialog(DraggableDialog):
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
if get_custom_icon_path(exe_path):
return desktop_entry, desktop_path
if not self.last_cover_path and file_icon_path:
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
+4 -18
View File
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.localization import _
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
self.signals.thumbnail_ready.emit(self.file_path, image)
else:
logger.warning("Failed to load image: %s", self.file_path)
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
thumbnail_source = self.file_path
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
resolved_path = self.resolve_launch_file_path(self.file_path)
@@ -125,14 +125,6 @@ class FileExplorer(DraggableDialog):
return
thumbnail_source = resolved_path
if thumbnail_source.lower().endswith(".appimage"):
icon_path = get_exe_icon_cache_path(thumbnail_source)
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
image = QImage()
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
image = QImage(icon_path)
self.signals.thumbnail_ready.emit(self.file_path, image)
return
if not thumbnail_source.lower().endswith(".exe"):
return
@@ -150,8 +142,6 @@ class FileExplorer(DraggableDialog):
os.unlink(tmp_name)
except Exception as e:
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
if self.file_path.lower().endswith(".appimage"):
self.signals.thumbnail_ready.emit(self.file_path, QImage())
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent)
@@ -224,7 +214,7 @@ class FileExplorer(DraggableDialog):
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
continue
mime_type = mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
self.pending_thumbnails.add(file_path)
resolver = None
main_window = cast("MainWindow | None", self.main_window)
@@ -256,8 +246,6 @@ class FileExplorer(DraggableDialog):
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
icon = QIcon(QPixmap.fromImage(image))
self.thumbnail_cache[file_path] = icon
if image.isNull():
return
item = self.file_items.get(file_path)
if item:
item.setData(THEMED_ICON_ROLE, None)
@@ -619,9 +607,7 @@ class FileExplorer(DraggableDialog):
if not self.directory_only:
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter:
if callable(self.file_filter):
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
elif isinstance(self.file_filter, str):
if isinstance(self.file_filter, str):
files = [f for f in files if f.lower().endswith(self.file_filter)]
elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
+10 -5
View File
@@ -352,9 +352,14 @@ class ProtonManager(DraggableDialog):
logger.error(f"Wine data loading failed: {error_msg}")
if hasattr(self, 'content_stack'):
self.content_stack.setCurrentIndex(1)
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
if hasattr(self, 'tab_widget'):
self.tab_widget.clear()
self.create_installed_tab()
error_tab = QWidget()
error_layout = QVBoxLayout(error_tab)
error_layout.addWidget(error_label)
self.tab_widget.addTab(error_tab, _("Error"))
def process_metadata(self, metadata):
self.cpu_level = get_cpu_level()
@@ -674,7 +679,7 @@ class ProtonManager(DraggableDialog):
version_item = QTableWidgetItem(version_name)
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 1, version_item)
size_item = QTableWidgetItem(_("Loading…"))
size_item = QTableWidgetItem(_("Unknown"))
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row_index, 2, size_item)
for col in range(table.columnCount()):
@@ -953,8 +958,8 @@ class ProtonManager(DraggableDialog):
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
+25 -167
View File
@@ -60,7 +60,6 @@ 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 (
@@ -167,11 +166,11 @@ class ExeSettingsDialog(
if not self.user_conf and not self.exe_path and not self.appid:
return
self.portproton_path = get_portproton_location()
if self.portproton_path is None and self.game_source != "native":
if self.portproton_path is None:
logger.error("PortProton location not found")
return
self.start_sh = get_portproton_start_command()
if self.start_sh is None and self.game_source != "native":
if self.start_sh is None:
logger.error("PortProton start command not found")
return
@@ -179,7 +178,7 @@ class ExeSettingsDialog(
self.lg_dist_aliases = {}
self.plugins_ver = ""
self.prefix_options = []
if self.portproton_path and self.game_source != "native":
if self.portproton_path:
scripts_path = get_portproton_scripts_path()
if scripts_path:
var_path = os.path.join(scripts_path, "var")
@@ -230,9 +229,6 @@ class ExeSettingsDialog(
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.toggle_settings = get_toggle_settings()
if self.game_source == "native":
self.toggle_settings = {key: description for key, description in self.toggle_settings.items() if key in NATIVE_TOGGLE_KEYS}
self.setWindowTitle(_("Native") + " — " + _("Settings"))
self.setup_ui()
if self.user_conf:
@@ -294,11 +290,6 @@ class ExeSettingsDialog(
"""Resolve run-after executable relative to the main executable."""
if not exe_path:
return exe_path
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
return ",".join(
self._resolve_run_after_exe_path(path.strip())
for path in exe_path.split(",") if path.strip()
)
normalized = os.path.normpath(os.path.expanduser(exe_path))
if os.path.isabs(normalized):
return normalized
@@ -309,70 +300,6 @@ class ExeSettingsDialog(
return normalized
return os.path.normpath(os.path.join(game_dir, normalized))
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
table = self.advanced_table
row = next(index for index in range(table.rowCount())
if table.cellWidget(index, 1) is target.parentWidget())
expanded = bool(target.property("ppqt_run_after_expanded"))
target.setProperty("ppqt_run_after_expanded", not expanded)
if expanded:
while row + 1 < table.rowCount():
item = table.item(row + 1, 0)
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
break
table.removeRow(row + 1)
return
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
if not target.text():
entries = [""]
for index, path in enumerate(entries):
entry_row = row + index + 1
table.insertRow(entry_row)
item = QTableWidgetItem(path)
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
table.setItem(entry_row, 0, item)
entry_edit = QLineEdit(path)
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
entry_edit.installEventFilter(self)
entry_edit.textChanged.connect(
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
)
table.setCellWidget(entry_row, 0, entry_edit)
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.clicked.connect(
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
)
table.setCellWidget(entry_row, 1, button)
table.resizeRowsToContents()
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
entries = target.text().split(",")
if index < len(entries):
entries[index] = value
target.setText(",".join(entries))
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
entries.pop(index)
self._toggle_run_after_entries(target)
target.setText(",".join(entries))
if entries:
self._toggle_run_after_entries(target)
target.setFocus(Qt.FocusReason.OtherFocusReason)
def _clear_run_after_entries(self, target: QLineEdit) -> None:
table = self.advanced_table
for row in reversed(range(table.rowCount())):
item = table.item(row, 0)
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
table.removeRow(row)
target.setProperty("ppqt_run_after_expanded", False)
target.clear()
if target.isVisible():
target.setFocus(Qt.FocusReason.OtherFocusReason)
def _get_setting_file_selector_path(self, current_path: str) -> str:
"""Get initial path for setting file selectors."""
initial_path = os.path.expanduser("~")
@@ -476,7 +403,7 @@ class ExeSettingsDialog(
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
self.advanced_table.horizontalHeader().resizeSection(1, 230)
self.advanced_table.setWordWrap(True)
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
@@ -585,10 +512,6 @@ class ExeSettingsDialog(
self.settings_container.setCurrentIndex(0)
self.advanced_container.setCurrentIndex(0)
if self.game_source == "native":
self.on_show_ppdb_finished(0, QProcess.ExitStatus.NormalExit)
return
process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished)
command = ["cli", "--get-user-conf"] if self.user_conf else [
@@ -598,7 +521,7 @@ class ExeSettingsDialog(
process.start(args[0], args[1:])
def on_show_ppdb_finished(self, exit_code, exit_status):
"""Populate shared settings controls from the selected game's data."""
"""Handle --show-ppdb output."""
process = cast(QProcess, self.sender())
self.current_settings = {}
self.blocked_keys = set()
@@ -613,15 +536,7 @@ class ExeSettingsDialog(
self.reject()
return
if self.game_source == "native":
try:
self.current_settings = load_native_settings(cast(str, self.exe_path))
except Exception as error:
logger.warning("Cannot load native settings: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
self.reject()
return
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
for line in output.splitlines():
line_stripped = line.strip()
@@ -723,16 +638,6 @@ class ExeSettingsDialog(
return
db_path = self.exe_path + ".ppdb"
if self.game_source == "native":
try:
settings = load_native_settings(self.exe_path)
db_path = str(get_native_ppdb_path(self.exe_path, settings))
if not os.path.exists(db_path):
save_native_settings(self.exe_path, {})
except Exception as error:
logger.warning("Cannot create native PPDB: %s", error)
QMessageBox.critical(self, _("Error"), str(error))
return
if not os.path.exists(db_path):
QMessageBox.critical(self, _("Error"), _("PPDB file does not exist at: ") + db_path)
@@ -756,14 +661,6 @@ class ExeSettingsDialog(
return
db_path = self.exe_path + ".ppdb"
if self.game_source == "native":
try:
settings = load_native_settings(self.exe_path)
db_path = str(get_native_ppdb_path(self.exe_path, settings))
except Exception as error:
logger.warning("Cannot resolve native PPDB: %s", error)
QMessageBox.critical(self, _("Error"), str(error))
return
if not os.path.exists(db_path):
QMessageBox.information(self, _("Information"), _("PPDB file does not exist at: ") + db_path)
self.load_current_settings()
@@ -775,8 +672,8 @@ class ExeSettingsDialog(
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
@@ -796,8 +693,8 @@ class ExeSettingsDialog(
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
@@ -924,14 +821,6 @@ class ExeSettingsDialog(
dist_options=self.dist_options,
prefix_options=self.prefix_options
)
if self.game_source == "native":
advanced_settings = [setting for setting in advanced_settings if setting['key'] in NATIVE_ADVANCED_KEYS]
for setting in advanced_settings:
if setting["key"] == "PW_WINE_CPU_TOPOLOGY":
setting["options"] = [disabled_text] + [str(count) for count in range(1, len(os.sched_getaffinity(0)) + 1)]
for setting in advanced_settings:
if setting['key'] == 'LAUNCH_PARAMETERS':
setting['description'] = _("Launch Arguments")
if self.user_conf:
for setting in advanced_settings:
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
@@ -1062,12 +951,10 @@ class ExeSettingsDialog(
if setting['key'] == 'PW_RUN_AFTER_EXE':
text_container = QWidget()
text_container.setProperty("ppqt_run_after_exe_widget", True)
container_layout = QVBoxLayout(text_container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.addWidget(line_edit)
line_edit.hide()
text_layout = QHBoxLayout()
container_layout.addLayout(text_layout)
text_layout = QHBoxLayout(text_container)
text_layout.setContentsMargins(0, 0, 0, 0)
text_layout.setSpacing(6)
text_layout.addWidget(line_edit)
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
@@ -1079,38 +966,26 @@ class ExeSettingsDialog(
def open_run_after_exe_selector(
_checked: bool = False,
target_line_edit=line_edit,
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
file_filter="",
):
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = self._get_setting_file_selector_path(
target_line_edit.text().strip()
)
file_explorer = FileExplorer(
self,
theme=self.theme,
file_filter=file_filter,
initial_path=self._get_setting_file_selector_path(
target_line_edit.text().rsplit(",", 1)[-1].strip()
),
initial_path=initial_path,
)
file_explorer.file_signal.file_selected.connect(
lambda path, target=target_line_edit: target.setText(
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
)
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
)
file_explorer.exec()
browse_button.clicked.connect(open_run_after_exe_selector)
expand_button = AutoSizeButton("≡")
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
expand_button.clicked.connect(
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
)
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
clear_button.clicked.connect(
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
)
text_layout.addWidget(clear_button)
text_layout.addWidget(expand_button)
text_layout.addWidget(browse_button)
self.advanced_table.setCellWidget(row, 1, text_container)
else:
@@ -1233,8 +1108,6 @@ class ExeSettingsDialog(
break
source_header = source_table.horizontalHeader()
if source_header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents:
source_table.resizeColumnToContents(1)
target_header = self.favorites_table.horizontalHeader()
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
@@ -1714,17 +1587,6 @@ 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()
@@ -1868,20 +1730,15 @@ class ExeSettingsDialog(
item = table.item(row, 0)
if item is None:
continue
widget = table.cellWidget(row, 0)
if widget is not None and not isinstance(widget, QLabel):
continue
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
if text is None:
text = item.text()
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
label = cast(QLabel | None, table.cellWidget(row, 0))
if label is None:
text = item.text()
label = QLabel()
label.setTextFormat(Qt.TextFormat.RichText)
label.setWordWrap(True)
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
item.setText("")
table.setCellWidget(row, 0, label)
label.setAutoFillBackground(False)
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
@@ -1896,6 +1753,7 @@ class ExeSettingsDialog(
label.setPalette(palette)
if label.foregroundRole() != QPalette.ColorRole.Text:
label.setForegroundRole(QPalette.ColorRole.Text)
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
name, separator, description = text.partition("\n")
formatted_text = escape(name)
if separator:
+4 -10
View File
@@ -266,11 +266,9 @@ class EGSAPI:
data = self._load_json(self.config_dir / "installed.json", {})
return data if isinstance(data, dict) else {}
def is_game_installed(
self, app_id: str, installed_data: dict[str, dict] | None = None
) -> bool:
def is_game_installed(self, app_id: str) -> bool:
"""Return whether Legendary records a usable installation."""
return self.get_launch_target(app_id, installed_data) is not None
return self.get_launch_target(app_id) is not None
@staticmethod
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
@@ -287,13 +285,9 @@ class EGSAPI:
return True
return False
def get_launch_target(
self, app_id: str, installed_data: dict[str, dict] | None = None
) -> str | None:
def get_launch_target(self, app_id: str) -> str | None:
"""Return the installed game's Windows executable."""
installed = (
installed_data if installed_data is not None else self.load_installed()
).get(app_id, {})
installed = self.load_installed().get(app_id, {})
install_path = Path(str(installed.get("install_path", "")))
executable = str(installed.get("executable", "")).replace("\\", "/")
target = install_path.joinpath(*executable.split("/"))
+17 -32
View File
@@ -16,7 +16,6 @@ from PySide6.QtGui import (
QEnterEvent,
QFocusEvent,
QMouseEvent,
QKeyEvent,
QPaintEvent,
)
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
@@ -402,7 +401,6 @@ class GameCard(AnimatedCard):
self.steam_visible = str(game_source).lower() == "steam"
self.gog_visible = str(game_source).lower() == "gog"
self.egs_visible = str(game_source).lower() == "egs"
self.native_visible = str(game_source).lower() == "native"
self.portproton_visible = str(game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
@@ -516,13 +514,6 @@ class GameCard(AnimatedCard):
)
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
self.nativeLabel = SourceCorner(
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
config=corner_config,
parent=self.coverWidget,
)
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
icon=portproton_icon,
@@ -669,7 +660,6 @@ class GameCard(AnimatedCard):
(self.steamLabel, "badge_steam"),
(self.gogLabel, "badge_gog"),
(self.egsLabel, "badge_egs"),
(self.nativeLabel, "badge_linux"),
(self.portprotonLabel, "badge_portproton"),
):
icon = self.theme_manager.get_icon(
@@ -888,11 +878,10 @@ class GameCard(AnimatedCard):
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
source_ribbons = [
(self.steam_visible, self.steamLabel),
(self.gog_visible, self.gogLabel),
(self.egs_visible, self.egsLabel),
(self.native_visible, self.nativeLabel),
(self.portproton_visible, self.portprotonLabel),
(self.steam_visible and not self.vertical_layout, self.steamLabel),
(self.gog_visible and not self.vertical_layout, self.gogLabel),
(self.egs_visible and not self.vertical_layout, self.egsLabel),
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
]
for is_visible, ribbon in source_ribbons:
@@ -915,7 +904,6 @@ class GameCard(AnimatedCard):
self.portprotonLabel.raise_()
self.gogLabel.raise_()
self.egsLabel.raise_()
self.nativeLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
pass
@@ -1057,19 +1045,21 @@ class GameCard(AnimatedCard):
self.steam_visible = str(self.game_source).lower() == "steam"
self.gog_visible = str(self.game_source).lower() == "gog"
self.egs_visible = str(self.game_source).lower() == "egs"
self.native_visible = str(self.game_source).lower() == "native"
self.portproton_visible = str(self.game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
hidden_badges = self.badge_view_mode == "hidden"
show_source_ribbons = self.vertical_layout is not True
try:
self.steamLabel.setVisible(self.steam_visible)
self.gogLabel.setVisible(self.gog_visible)
self.egsLabel.setVisible(self.egs_visible)
self.nativeLabel.setVisible(self.native_visible)
self.portprotonLabel.setVisible(self.portproton_visible)
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
self.portprotonLabel.setVisible(
self.portproton_visible and show_source_ribbons
)
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
@@ -1303,19 +1293,14 @@ class GameCard(AnimatedCard):
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
self.batch_selected = not self.batch_selected
self.update()
event.accept()
return
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:
+2 -93
View File
@@ -1,21 +1,13 @@
"""Unified icon extractor for NE, PE and type-2 AppImages."""
"""Unified icon extractor for NE and PE files."""
import os
import hashlib
import re
import shlex
import struct
import io
import subprocess
import tarfile
import posixpath
import glob
import pefile
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
from PySide6.QtGui import QImage, QImageWriter
from PIL import Image
from portprotonqt.config.base import CACHE_DIR
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
@@ -23,82 +15,6 @@ logger = get_logger(__name__)
RT_ICON = 3
RT_GROUP_ICON = 14
THUMBNAIL_SIZE = 128
APPIMAGE_EXTRACT_TIMEOUT = 10
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
"""Read an icon and resolve archive-local links without extracting to disk."""
filename = ".DirIcon"
seen: set[str] = set()
while filename not in seen:
seen.add(filename)
result = subprocess.run(
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
"--skip-devices", "--skip-specials", "--log-level", "error"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
if member is None:
return b""
if member.issym() or member.islnk():
target = member.linkname
base = posixpath.dirname(filename) if member.issym() else ""
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
else posixpath.join(base, target))
if filename == ".." or filename.startswith("../"):
return b""
continue
if not member.isfile():
return b""
stream = archive.extractfile(member)
return stream.read() if stream is not None else b""
return b""
def _get_appimage_icon(path: str) -> io.BytesIO | None:
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
with open(path, "rb") as source:
header = source.read(64)
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
return None
layout = ELF_SECTION_LAYOUTS.get(header[4])
if layout is None or header[5] not in (1, 2):
return None
endian = "<" if header[5] == 1 else ">"
section_offset, offset_format, size_offset = layout
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
offset += entry_size * entry_count
source.seek(offset)
magic = source.read(6)
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
return None
try:
if magic == b"DWARFS":
data = _read_dwarfs_icon(path, offset)
else:
result = subprocess.run(
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
data = result.stdout
except OSError as error:
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
return None
except subprocess.SubprocessError as error:
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
return None
image = QImage.fromData(data)
if image.isNull():
return None
buffer = QBuffer()
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
return None
return io.BytesIO(buffer.data().data())
def _resolve_batch_icon_target(inputfile: str) -> str | None:
@@ -133,12 +49,7 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
def get_exe_icon_cache_path(exe_path: str) -> str:
"""Return the shared cache path for an executable icon."""
custom_icon = get_custom_icon_path(exe_path)
if custom_icon:
return custom_icon
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
if exe_path.lower().endswith(".appimage"):
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
return os.path.join(
CACHE_DIR,
"images",
@@ -149,7 +60,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
"""
Generates a thumbnail for a Windows executable or type-2 AppImage.
Generates a thumbnail for an .exe file.
inputfile: the input file path
outfile: output filename
@@ -202,8 +113,6 @@ class IconExtractor:
"""
try:
with open(self.file_path, "rb") as f:
if f.read(4) == b"\x7fELF":
return _get_appimage_icon(self.file_path)
if not self._parse_file(f):
return None
+1 -1
View File
@@ -498,7 +498,7 @@ def load_pixmap_async(
logger.error(f"URL processing error {cover}: {e}")
# SteamGridDB (SGDB)
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
try:
parts = cover.split("/")
filename = parts[-1] if parts else "sgdb_cover.png"
+7 -22
View File
@@ -86,8 +86,9 @@ class MouseEmulationThread(QThread):
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
}, name="Virtual DPad Mouse")
self._device = ui
self.finished.emit(True)
# Store device in thread result
self._device = ui
except PermissionError as e:
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
@@ -213,6 +214,10 @@ class InputManager(
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
# Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes
self.file_explorer = None
self.nav_timer = QTimer(self)
@@ -260,8 +265,6 @@ class InputManager(
def _on_mouse_emulation_finished(self, success: bool):
"""Handle mouse emulation thread completion."""
if not self.mouse_emulation_enabled:
return
if success and self.mouse_emulation_thread:
device = self.mouse_emulation_thread.get_device()
if device:
@@ -278,22 +281,6 @@ class InputManager(
self.emulation_active = self.mouse_emulation_enabled
if not self.emulation_active:
self.emulation_triggered = False
thread = self.mouse_emulation_thread
if thread and thread.isRunning():
return
if self.emulation_triggered:
if self.ui is None:
self._async_enable_mouse_emulation()
elif self.ui is not None:
try:
self.ui.close()
except Exception as e:
logger.error("EMUL: Error closing virtual mouse: %s", e)
self.ui = None
self.mouse_emulation_thread = None
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container."""
@@ -427,10 +414,8 @@ class InputManager(
# Stop the thread if still running
thread = self.mouse_emulation_thread
if thread:
if thread and thread.isRunning():
thread.wait()
if self.ui is None:
self.ui = thread.get_device()
self.mouse_emulation_thread = None
if self.ui:
+4 -14
View File
@@ -2,7 +2,6 @@ import time
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
@@ -135,15 +134,10 @@ class ButtonInputMixin(InputMixin):
return True
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
if isinstance(focused, QLineEdit) and focused.isReadOnly():
focused = self._find_parent_combo(focused)
if not isinstance(focused, QComboBox):
return False
if button_code in BUTTONS['confirm']:
if focused.view().isVisible():
self._activate_list_view_selection(focused.view())
else:
focused.showPopup()
focused.showPopup()
return True
if button_code in BUTTONS['back'] and focused.view().isVisible():
focused.hidePopup()
@@ -168,10 +162,7 @@ class ButtonInputMixin(InputMixin):
self, active: QWidget, focused: QWidget | None, button_code: int
) -> bool:
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
if (
button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit)
and not focused.isReadOnly()
):
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
if keyboard:
keyboard.show_for_widget(focused)
return True
@@ -182,7 +173,7 @@ class ButtonInputMixin(InputMixin):
combo = self._find_parent_combo(focused)
if combo is not None and combo.isEditable():
line_edit = combo.lineEdit()
if line_edit is not None and not line_edit.isReadOnly() and keyboard:
if line_edit is not None and keyboard:
line_edit.setFocus()
keyboard.show_for_widget(line_edit)
return True
@@ -202,7 +193,7 @@ class ButtonInputMixin(InputMixin):
parent = parent.parentWidget()
return None
def _activate_list_view_selection(self, view: QAbstractItemView) -> None:
def _activate_list_view_selection(self, view: QListView) -> None:
index = view.currentIndex()
if not index.isValid():
return
@@ -211,7 +202,6 @@ class ButtonInputMixin(InputMixin):
combo.setCurrentIndex(index.row())
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
combo.activated.emit(index.row())
return
view.activated.emit(index)
view.clicked.emit(index)
+1 -1
View File
@@ -138,7 +138,7 @@ class FileExplorerInputMixin(InputMixin):
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
self.file_explorer.show_context_menu(item_rect.center())
self.file_explorer.show_folder_context_menu(item_rect.center())
elif button_code in BUTTONS['add_game']: # X button
if self._handle_active_window_button(self.file_explorer, button_code):
+3 -5
View File
@@ -4,7 +4,7 @@ from typing import cast
from shiboken6 import isValid
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
from PySide6.QtGui import QKeyEvent, QKeySequence
from PySide6.QtGui import QKeySequence
from PySide6.QtWidgets import (
QAbstractScrollArea,
QApplication,
@@ -130,7 +130,7 @@ class KeyboardInputMixin(InputMixin):
return True
if event.type() == QEvent.Type.KeyPress:
if isinstance(event, QKeyEvent) and self._redirect_gamecard_input_to_search(event):
if self._redirect_gamecard_input_to_search(event):
return True
key = self._qt_event_to_input_key(event)
if key is not None:
@@ -235,9 +235,7 @@ class KeyboardInputMixin(InputMixin):
1: 'autoInstallSearchLineEdit',
}
def _redirect_gamecard_input_to_search(self, event: QKeyEvent) -> bool:
if event.key() == Qt.Key.Key_Space:
return False
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
focused = self._focused_widget()
if not isinstance(focused, GameCard):
return False
+9 -33
View File
@@ -476,14 +476,7 @@ class SettingsInputMixin(InputMixin):
if table != self.settings_dialog.advanced_table:
table.setFocus(Qt.FocusReason.OtherFocusReason)
return
entry_widget = table.cellWidget(row, 0)
if isinstance(entry_widget, QLineEdit):
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
cell_widget = table.cellWidget(row, 1)
if isinstance(cell_widget, QPushButton):
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
return
if isinstance(cell_widget, QComboBox):
line_edit = cell_widget.lineEdit()
if cell_widget.isEditable() and line_edit is not None:
@@ -499,33 +492,15 @@ class SettingsInputMixin(InputMixin):
and cell_widget.property("ppqt_run_after_exe_widget")
):
line_edit = cell_widget.findChild(QLineEdit)
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
if line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return
button = cell_widget.findChild(QPushButton)
if button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return
table.setFocus(Qt.FocusReason.OtherFocusReason)
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
"""Move focus between inline line edit and button in settings cell."""
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
return False
focused = QApplication.focusWidget()
for row in range(table.rowCount()):
entry_widget = table.cellWidget(row, 0)
delete_button = table.cellWidget(row, 1)
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
continue
if focused not in (entry_widget, delete_button):
continue
widget = delete_button if focused == entry_widget else entry_widget
if not widget.isEnabled():
return False
table.setCurrentCell(row, 1)
widget.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if table.currentColumn() != 1:
return False
@@ -533,14 +508,15 @@ class SettingsInputMixin(InputMixin):
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
return False
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
line_edit = cell_widget.findChild(QLineEdit)
button = cell_widget.findChild(QPushButton)
focused = QApplication.focusWidget()
if focused in widgets:
index = widgets.index(focused) + (1 if value > 0 else -1)
if 0 <= index < len(widgets):
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value > 0 and focused == line_edit and button and button.isEnabled():
button.setFocus(Qt.FocusReason.OtherFocusReason)
return True
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
return True
return False
def _focus_first_row_in_current_settings_table(self):
@@ -22,22 +22,20 @@ msgstr ""
"X-Generator: Weblate 2026.5\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr ""
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
msgid "Update complete"
msgstr ""
@@ -119,6 +117,12 @@ msgid ""
"cannot be undone."
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr ""
@@ -150,9 +154,6 @@ msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
@@ -277,6 +278,14 @@ msgstr ""
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -304,6 +313,10 @@ msgid ""
" take effect."
msgstr ""
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr ""
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr ""
@@ -768,9 +781,6 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -783,9 +793,6 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
@@ -919,9 +926,6 @@ msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Custom Icon:"
msgstr ""
msgid "Add shortcut to:"
msgstr ""
@@ -931,9 +935,6 @@ msgstr ""
msgid "Apply"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "Select folder"
msgstr ""
@@ -958,6 +959,9 @@ msgstr ""
msgid "Close"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "Failed to download cover"
msgstr ""
@@ -9,7 +9,7 @@ msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
"PO-Revision-Date: 2026-10-12 19:09+0000\n"
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/de/>\n"
@@ -21,24 +21,20 @@ msgstr ""
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr "Spiel fortsetzen"
msgid "Pause Game"
msgstr "Spiel anhalten"
msgid "Stop Game"
msgstr "Spiel beenden"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr "Kein Lese/Schreibzugriff auf den Ordner. Wähle anderen Benutzerordner."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
"PortProtonQt läuft bereits. Möchtest du es sowie alle Spiele beenden und "
"anschließend PortProtonQt neu starten?"
msgid "Yes"
msgstr "Ja"
msgid "No"
msgstr "Nein"
msgid "Update complete"
msgstr "Aktualisierung abgeschlossen"
@@ -124,6 +120,12 @@ msgstr ""
"Soll der Ordner '{folder_name}' wirklich gelöscht werden? Dieser Vorgang ist "
"unwiderruflich."
msgid "Yes"
msgstr "Ja"
msgid "No"
msgstr "Nein"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "Ordner konnte nicht gelöscht werden: {error}"
@@ -151,15 +153,13 @@ msgstr "Die Datei existiert bereits: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Datei konnte nicht umbenannt werden: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Mehrere auswählen"
msgstr "Alle auswählen"
msgid "Delete from PortProton"
msgstr "Von PortProton löschen"
msgid "Stop Game"
msgstr "Spiel beenden"
msgid "Launch Game"
msgstr "Spiel starten"
@@ -289,6 +289,14 @@ msgstr "Fehler beim Generieren der .desktop-Datei"
msgid "Failed to save .desktop file: {error}"
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
@@ -320,6 +328,10 @@ msgstr ""
"'{game_name}' wurde von Steam entfernt. Bitte starte Steam neu, damit die "
"Änderungen wirksam werden."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "'{game_name}' wurde von Steam entfernt."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "Das Spiel '{game_name}' konnte nicht von Steam entfernt werden: {error}"
@@ -483,13 +495,9 @@ msgid ""
"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"
msgstr ""
msgid "Game not found."
msgstr "Spiel nicht gefunden."
@@ -845,9 +853,6 @@ msgstr "Min."
msgid "sec."
msgstr "Sek."
msgid "Pause Game"
msgstr "Spiel anhalten"
msgid "Exit"
msgstr "Exit"
@@ -860,9 +865,6 @@ msgstr "Favoriten"
msgid "Recent Games"
msgstr "Aktuelle Spiele"
msgid "Resume Game"
msgstr "Spiel fortsetzen"
msgid "Failed to pause game"
msgstr "Spiel konnte nicht pausiert werden"
@@ -968,19 +970,19 @@ msgstr "Start {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "Scannen: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "Überprüfung vorhandener Verknüpfungen: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr "Hinzufügen: {name}"
msgstr ""
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Verknüpfung konnte nicht erstellt werden: {name}"
msgstr "Ordner konnte nicht erstellt werden: {error}"
msgid "Name:"
msgstr "Titel:"
@@ -997,10 +999,6 @@ msgstr "Eigenes Titelbild:"
msgid "Enter local path or URL for cover image"
msgstr "Lokaler Pfad oder die URL für das Titelbild"
#, fuzzy
msgid "Custom Icon:"
msgstr "Eigenes Titelbild:"
msgid "Add shortcut to:"
msgstr "Verknüpfung hinzufügen zu:"
@@ -1010,35 +1008,36 @@ msgstr "Titelbild-Vorschau:"
msgid "Apply"
msgstr "Anwenden"
msgid "Invalid image"
msgstr "Ungültiges Bild"
#, fuzzy
msgid "Select folder"
msgstr "Ordner auswählen"
msgstr "Ordner löschen"
#, fuzzy
msgid "Processing games…"
msgstr "Spiele werden verarbeitet…"
msgstr "Aktuelle Spiele"
#, 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."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Spiele auswählen"
msgstr "Aktuelle Spiele"
msgid "Close"
msgstr "Schließen"
msgid "Invalid image"
msgstr "Ungültiges Bild"
msgid "Failed to download cover"
msgstr "Herunterladen des Titelbildes fehlgeschlagen"
@@ -2407,7 +2406,7 @@ msgid "Show compatibility report after game crash"
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
msgid "Disable Cyrillic path warning"
msgstr "Warnung bei kyrillischen Pfaden deaktivieren"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
@@ -21,6 +21,15 @@ msgstr ""
"X-Generator: Weblate 2026.8.1\n"
"Generated-By: Babel 2.17.0\n"
msgid "Resume Game"
msgstr "Reanudar el juego"
msgid "Pause Game"
msgstr "Pausar el juego"
msgid "Stop Game"
msgstr "Detener Juego"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
@@ -28,17 +37,6 @@ msgstr ""
"La carpeta de datos de PortProton no tiene permisos de lectura ni escritura. "
"Elija otra carpeta para los datos de PortProton."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
msgid "Yes"
msgstr "Sí"
msgid "No"
msgstr "No"
msgid "Update complete"
msgstr "Actualización completada"
@@ -124,6 +122,12 @@ msgstr ""
"¿Estás seguro de que quieres eliminar la carpeta «{folder_name}»? Esta "
"acción no se puede deshacer."
msgid "Yes"
msgstr "Sí"
msgid "No"
msgstr "No"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "No se ha podido eliminar la carpeta: {error}"
@@ -158,9 +162,6 @@ msgstr "Seleccionar todo"
msgid "Delete from PortProton"
msgstr "Eliminar de PortProton"
msgid "Stop Game"
msgstr "Detener Juego"
msgid "Launch Game"
msgstr "Iniciar juego"
@@ -290,6 +291,14 @@ msgstr "No se han podido generar los datos del archivo .desktop"
msgid "Failed to save .desktop file: {error}"
msgstr "No se ha podido guardar el archivo .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "No se ha podido copiar la imagen de portada: {error}"
@@ -321,6 +330,10 @@ msgstr ""
"«{game_name}» se ha eliminado de Steam. Por favor, reinicia Steam para que "
"los cambios surtan efecto."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "«{game_name}» se ha eliminado de Steam."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "No se ha podido eliminar el juego «{game_name}» de Steam: {error}"
@@ -860,9 +873,6 @@ msgstr "min"
msgid "sec."
msgstr "s"
msgid "Pause Game"
msgstr "Pausar el juego"
msgid "Exit"
msgstr "Salir"
@@ -875,9 +885,6 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Juegos recientes"
msgid "Resume Game"
msgstr "Reanudar el juego"
msgid "Failed to pause game"
msgstr "Error al pausar el juego"
@@ -1011,10 +1018,6 @@ msgstr "Portada personalizada:"
msgid "Enter local path or URL for cover image"
msgstr "Ingrese la ruta local o la URL de la imagen de portada"
#, fuzzy
msgid "Custom Icon:"
msgstr "Portada personalizada:"
msgid "Add shortcut to:"
msgstr "Agregar acceso directo a:"
@@ -1024,9 +1027,6 @@ msgstr "Vista previa de la portada:"
msgid "Apply"
msgstr "Aplicar"
msgid "Invalid image"
msgstr "Imagen no válida"
#, fuzzy
msgid "Select folder"
msgstr "Borrar la carpeta"
@@ -1054,6 +1054,9 @@ msgstr "Juegos recientes"
msgid "Close"
msgstr ""
msgid "Invalid image"
msgstr "Imagen no válida"
msgid "Failed to download cover"
msgstr "No se ha podido descargar la portada"
+31 -27
View File
@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-10-24 18:13+0500\n"
"POT-Creation-Date: 2026-10-21 00:34+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,22 +18,20 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr ""
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
msgid "Update complete"
msgstr ""
@@ -115,6 +113,12 @@ msgid ""
"cannot be undone."
msgstr ""
msgid "Yes"
msgstr ""
msgid "No"
msgstr ""
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr ""
@@ -146,9 +150,6 @@ msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
@@ -273,6 +274,14 @@ msgstr ""
msgid "Failed to save .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr ""
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr ""
@@ -300,6 +309,10 @@ msgid ""
" take effect."
msgstr ""
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr ""
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr ""
@@ -764,9 +777,6 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Pause Game"
msgstr ""
msgid "Exit"
msgstr ""
@@ -779,9 +789,6 @@ msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Resume Game"
msgstr ""
msgid "Failed to pause game"
msgstr ""
@@ -915,9 +922,6 @@ msgstr ""
msgid "Enter local path or URL for cover image"
msgstr ""
msgid "Custom Icon:"
msgstr ""
msgid "Add shortcut to:"
msgstr ""
@@ -927,9 +931,6 @@ msgstr ""
msgid "Apply"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "Select folder"
msgstr ""
@@ -954,6 +955,9 @@ msgstr ""
msgid "Close"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "Failed to download cover"
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-11-01 00:08+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"
@@ -21,6 +21,15 @@ msgstr ""
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr "Retomar jogo"
msgid "Pause Game"
msgstr "Pausar jogo"
msgid "Stop Game"
msgstr "Parar o Jogo"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
@@ -28,19 +37,6 @@ msgstr ""
"A pasta de dados do PortProton não pode ser lida nem gravada. Escolha "
"outra pasta para os dados do PortProton."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
msgid "Yes"
msgstr "Sim"
msgid "No"
msgstr "Não"
msgid "Update complete"
msgstr "Atualização concluída"
@@ -124,6 +120,12 @@ msgstr ""
"Tem certeza de que deseja excluir a pasta '{folder_name}'? Esta ação não "
"pode ser desfeita."
msgid "Yes"
msgstr "Sim"
msgid "No"
msgstr "Não"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "Falha ao excluir a pasta: {error}"
@@ -151,15 +153,13 @@ 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 vários"
msgstr "Selecionar Tudo"
msgid "Delete from PortProton"
msgstr "Excluir do PortProton"
msgid "Stop Game"
msgstr "Parar o Jogo"
msgid "Launch Game"
msgstr "Iniciar Jogo"
@@ -289,6 +289,14 @@ msgstr "Falha ao gerar os dados do arquivo .desktop"
msgid "Failed to save .desktop file: {error}"
msgstr "Falha ao salvar o arquivo .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Falha ao copiar a imagem de capa: {error}"
@@ -320,6 +328,10 @@ msgstr ""
"'{game_name}' foi removido da Steam. Reinicie à Steam para que as "
"alterações tenham efeito."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "'{game_name}' foi removido da Steam."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "Falha ao remover o jogo '{game_name}' da Steam: {error}"
@@ -482,13 +494,9 @@ msgid ""
"characters:\n"
"\n"
msgstr ""
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
"caracteres cirílicos:\n"
"\n"
msgid "Don't remind again"
msgstr "Não lembrar novamente"
msgstr ""
msgid "Game not found."
msgstr "Jogo não encontrado."
@@ -847,9 +855,6 @@ msgstr "min."
msgid "sec."
msgstr "s."
msgid "Pause Game"
msgstr "Pausar jogo"
msgid "Exit"
msgstr "Sair"
@@ -862,9 +867,6 @@ msgstr "Favoritos"
msgid "Recent Games"
msgstr "Jogos Recentes"
msgid "Resume Game"
msgstr "Retomar jogo"
msgid "Failed to pause game"
msgstr "Falha ao pausar o jogo"
@@ -969,19 +971,19 @@ msgstr "Iniciando {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "Verificado: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "Verificando os atalhos existentes: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr "Adicionando: {name}"
msgstr ""
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Falha ao criar atalho: {name}"
msgstr "Falha ao criar a pasta: {error}"
msgid "Name:"
msgstr "Nome:"
@@ -998,9 +1000,6 @@ msgstr "Capa Personalizada:"
msgid "Enter local path or URL for cover image"
msgstr "Informe o caminho local ou a URL da imagem de capa"
msgid "Custom Icon:"
msgstr "Ícone personalizado:"
msgid "Add shortcut to:"
msgstr "Adicionar atalho em:"
@@ -1010,35 +1009,36 @@ msgstr "Pré-visualização da Capa:"
msgid "Apply"
msgstr "Aplicar"
msgid "Invalid image"
msgstr "Imagem inválida"
#, fuzzy
msgid "Select folder"
msgstr "Selecionar pasta"
msgstr "Excluir Pasta"
#, fuzzy
msgid "Processing games…"
msgstr "Processando jogos…"
msgstr "Jogos Recentes"
#, python-brace-format
msgid ""
"Processed {completed}/{total}\n"
"{current}"
msgstr ""
"Processados: {completed}/{total}\n"
"{current}"
msgid "Information"
msgstr "Informação"
msgid "No games found in the selected folder."
msgstr "Nenhum jogo encontrado na pasta selecionada."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Selecionar jogos"
msgstr "Jogos Recentes"
msgid "Close"
msgstr "Fechar"
msgid "Invalid image"
msgstr "Imagem inválida"
msgid "Failed to download cover"
msgstr "Falha ao baixar a capa"
@@ -2404,7 +2404,7 @@ msgid "Show compatibility report after game crash"
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
msgid "Disable Cyrillic path warning"
msgstr "Desativar aviso de caminho em cirílico"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "Desativar verificação de dependências do ALT Linux i586"
@@ -9,7 +9,7 @@ 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-26 12:27+0000\n"
"PO-Revision-Date: 2026-10-15 19:27+0000\n"
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/ru/>\n"
@@ -22,6 +22,15 @@ msgstr ""
"X-Generator: Weblate 2026.10.1\n"
"Generated-By: Babel 2.18.0\n"
msgid "Resume Game"
msgstr "Возобновить игру"
msgid "Pause Game"
msgstr "Приостановить игру"
msgid "Stop Game"
msgstr "Остановить игру"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
@@ -29,19 +38,6 @@ msgstr ""
"Папка с данными PortProton не доступна для чтения и записи. Выберите "
"другую папку для данных PortProton."
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr ""
"PortProtonQt уже запущен. Остановить его и все игры, а затем перезапустить "
"для нового запуска?"
msgid "Yes"
msgstr "Да"
msgid "No"
msgstr "Нет"
msgid "Update complete"
msgstr "Обновление завершено"
@@ -125,6 +121,12 @@ msgstr ""
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
"отменить."
msgid "Yes"
msgstr "Да"
msgid "No"
msgstr "Нет"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "Не удалось удалить папку: {error}"
@@ -151,15 +153,13 @@ msgstr "Файл уже существует: {file_name}"
msgid "Failed to rename file: {error}"
msgstr "Не удалось переименовать файл: {error}"
#, fuzzy
msgid "Select multiple"
msgstr "Выбрать несколько"
msgstr "Выбрать всё"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
msgid "Stop Game"
msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
@@ -287,6 +287,14 @@ msgstr "Не удалось сгенерировать данные файла .
msgid "Failed to save .desktop file: {error}"
msgstr "Не удалось сохранить файл .desktop: {error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "Не удалось удалить старый файл .desktop: {error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "Удалён старый файл .desktop для '{game_name}'"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "Не удалось скопировать обложку: {error}"
@@ -318,6 +326,10 @@ msgstr ""
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
" чтобы изменения вступили в силу."
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "'{game_name}' был(а) удален(а) из Steam."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "Не удалось удалить игру '{game_name}' из Steam: {error}"
@@ -480,12 +492,9 @@ msgid ""
"characters:\n"
"\n"
msgstr ""
"В следующих путях обнаружены символы кириллицы. Wine может работать "
"неправильно. Переместите игру или PortProton в путь без символов кириллицы:\n"
"\n"
msgid "Don't remind again"
msgstr "Больше не напоминать"
msgstr ""
msgid "Game not found."
msgstr "Игра не найдена."
@@ -845,9 +854,6 @@ msgstr "мин."
msgid "sec."
msgstr "сек."
msgid "Pause Game"
msgstr "Приостановить игру"
msgid "Exit"
msgstr "Выход"
@@ -860,9 +866,6 @@ msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Resume Game"
msgstr "Возобновить игру"
msgid "Failed to pause game"
msgstr "Не удалось приостановить игру"
@@ -967,19 +970,19 @@ msgstr "Запуск {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "Сканирование: {folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "Проверка существующих ярлыков: {path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr "Добавление: {name}"
msgstr ""
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "Не удалось создать ярлык: {name}"
msgstr "Не удалось создать папку: {error}"
msgid "Name:"
msgstr "Название:"
@@ -996,9 +999,6 @@ msgstr "Пользовательская обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
msgid "Custom Icon:"
msgstr "Пользовательский значок:"
msgid "Add shortcut to:"
msgstr "Добавить ярлык:"
@@ -1008,35 +1008,36 @@ msgstr "Предпросмотр обложки:"
msgid "Apply"
msgstr "Применить"
msgid "Invalid image"
msgstr "Недопустимое изображение"
#, fuzzy
msgid "Select folder"
msgstr "Выбрать папку"
msgstr "Удалить Папку"
#, fuzzy
msgid "Processing games…"
msgstr "Обработка игр…"
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 "В выбранной папке игры не найдены."
msgstr ""
#, fuzzy
msgid "Select games"
msgstr "Выбрать игры"
msgstr "Недавние"
msgid "Close"
msgstr "Закрыть"
msgid "Invalid image"
msgstr "Недопустимое изображение"
msgid "Failed to download cover"
msgstr "Не удалось скачать обложку"
@@ -2396,7 +2397,7 @@ msgid "Show compatibility report after game crash"
msgstr "Показывать отчёт о совместимости после сбоя игры"
msgid "Disable Cyrillic path warning"
msgstr "Отключить предупреждение о кириллице в пути"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "Отключить проверку зависимостей ALT Linux i586"
@@ -7,9 +7,9 @@
msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
"Language-Team: Chinese (Simplified Han script) <https://"
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
@@ -18,25 +18,25 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.10.1\n"
"X-Generator: Weblate 2026.6.1\n"
"Generated-By: Babel 2.18.0\n"
#, fuzzy
msgid "Resume Game"
msgstr "最近游戏"
#, fuzzy
msgid "Pause Game"
msgstr "启动游戏"
msgid "Stop Game"
msgstr "停止游戏"
msgid ""
"PortProton data folder is not readable and writable. Choose another "
"folder for PortProton data."
msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择其他文件夹。"
msgid ""
"PortProtonQt is already running. Stop it and all games, then restart for "
"the new launch?"
msgstr "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
msgid "Yes"
msgstr "是"
msgid "No"
msgstr "否"
msgid "Update complete"
msgstr "更新完成"
@@ -118,6 +118,12 @@ msgid ""
"cannot be undone."
msgstr "确定要删除文件夹\"{folder_name}\"吗?此操作无法撤销。"
msgid "Yes"
msgstr "是"
msgid "No"
msgstr "否"
#, python-brace-format
msgid "Failed to delete folder: {error}"
msgstr "删除文件夹失败:{error}"
@@ -143,20 +149,19 @@ msgstr "文件已存在:{file_name}"
msgid "Failed to rename file: {error}"
msgstr "重命名文件失败:{error}"
#, fuzzy
msgid "Select multiple"
msgstr "多选"
msgstr "全选"
msgid "Delete from PortProton"
msgstr "从 PortProton 中删除"
msgid "Stop Game"
msgstr "停止游戏"
msgid "Launch Game"
msgstr "启动游戏"
#, fuzzy
msgid "Repair"
msgstr "修复"
msgstr "已配对"
msgid "Remove from Desktop"
msgstr "从桌面移除"
@@ -174,7 +179,7 @@ msgid "Delete"
msgstr "删除"
msgid "Import"
msgstr "导入"
msgstr ""
msgid "Edit Shortcut"
msgstr "编辑快捷方式"
@@ -258,9 +263,9 @@ msgstr "桌面"
msgid "Failed to save custom data: {error}"
msgstr "保存自定义数据失败:{error}"
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Executable not found"
msgstr "未找到可执行文件"
msgstr "找不到可执行文件:{0}"
#, python-brace-format
msgid "Executable not found: {0}"
@@ -280,6 +285,14 @@ msgstr "生成 .desktop 文件数据失败"
msgid "Failed to save .desktop file: {error}"
msgstr "保存 .desktop 文件失败:{error}"
#, python-brace-format
msgid "Failed to delete old .desktop file: {error}"
msgstr "删除旧的 .desktop 文件失败:{error}"
#, python-brace-format
msgid "Removed old .desktop file for '{game_name}'"
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
#, python-brace-format
msgid "Failed to copy cover image: {error}"
msgstr "复制封面图像失败:{error}"
@@ -307,6 +320,10 @@ msgid ""
" take effect."
msgstr "\"{game_name}\"已从 Steam 中移除。请重启 Steam 以使更改生效。"
#, python-brace-format
msgid "'{game_name}' was removed from Steam."
msgstr "\"{game_name}\"已从 Steam 中移除。"
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
msgstr "无法从 Steam 中移除游戏\"{game_name}\":{error}"
@@ -454,14 +471,14 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
#, python-brace-format
msgid "Delete '{0}' and all files in its installation folder?"
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
msgstr ""
#, fuzzy
msgid "Select Epic game folder"
msgstr "删除文件夹"
msgid "Another Epic operation is running"
msgstr "另一个 Epic 操作正在运行中"
msgstr ""
#, fuzzy
msgid "Wine prefix not found"
@@ -473,12 +490,9 @@ msgid ""
"characters:\n"
"\n"
msgstr ""
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
"PortProton 移动到不包含西里尔字母字符的路径:\n"
"\n"
msgid "Don't remind again"
msgstr "不再提示"
msgstr ""
msgid "Game not found."
msgstr "未找到游戏。"
@@ -806,10 +820,6 @@ msgstr "分钟。"
msgid "sec."
msgstr "秒。"
#, fuzzy
msgid "Pause Game"
msgstr "启动游戏"
msgid "Exit"
msgstr "退出"
@@ -822,10 +832,6 @@ msgstr "收藏夹"
msgid "Recent Games"
msgstr "最近游戏"
#, fuzzy
msgid "Resume Game"
msgstr "最近游戏"
#, fuzzy
msgid "Failed to pause game"
msgstr "停止游戏失败"
@@ -931,19 +937,19 @@ msgstr "正在启动 {0}"
#, python-brace-format
msgid "Scanning: {folder}"
msgstr "正在扫描:{folder}"
msgstr ""
#, python-brace-format
msgid "Checking existing shortcuts: {path}"
msgstr "正在检查现有快捷方式:{path}"
msgstr ""
#, python-brace-format
msgid "Adding: {name}"
msgstr "正在添加:{name}"
msgstr ""
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Failed to create shortcut: {name}"
msgstr "创建快捷方式失败:{name}"
msgstr "创建文件夹失败:{error}"
msgid "Name:"
msgstr "名称:"
@@ -960,10 +966,6 @@ msgstr "自定义封面:"
msgid "Enter local path or URL for cover image"
msgstr "输入封面图像的本地路径或 URL"
#, fuzzy
msgid "Custom Icon:"
msgstr "自定义封面:"
msgid "Add shortcut to:"
msgstr "添加快捷方式到:"
@@ -973,29 +975,25 @@ msgstr "封面预览:"
msgid "Apply"
msgstr "应用"
msgid "Invalid image"
msgstr "图像无效"
#, fuzzy
msgid "Select folder"
msgstr "删除文件夹"
#, fuzzy
msgid "Processing games…"
msgstr "正在处理游戏…"
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 "在所选文件夹中未找到任何游戏。"
msgstr ""
#, fuzzy
msgid "Select games"
@@ -1005,6 +1003,9 @@ msgstr "最近游戏"
msgid "Close"
msgstr "鼠标"
msgid "Invalid image"
msgstr "图像无效"
msgid "Failed to download cover"
msgstr "下载封面失败"
@@ -1015,10 +1016,10 @@ msgid "No cover selected"
msgstr "未选择封面"
msgid "Compatibility report"
msgstr "兼容性报告"
msgstr ""
msgid "The program stopped. Compatibility details were collected automatically."
msgstr "程序已停止。已自动收集兼容性详细信息。"
msgstr ""
msgid "Downloading…"
msgstr "正在下载…"
@@ -1244,7 +1245,7 @@ msgid "Exe Settings"
msgstr "可执行文件设置"
msgid "Edit user.conf"
msgstr "编辑 user.conf"
msgstr ""
msgid "Search settings…"
msgstr "搜索设置…"
@@ -1270,8 +1271,9 @@ msgstr "编辑 PPDB"
msgid "Clear PPDB"
msgstr "清除 PPDB"
#, fuzzy
msgid "Failed to load global game settings."
msgstr "加载全局游戏设置失败"
msgstr "加载主题失败"
msgid "Executable path is not available."
msgstr "可执行文件路径不可用。"
@@ -1282,8 +1284,9 @@ msgstr "PPDB 文件不存在于: "
msgid "Failed to open PPDB file:\n"
msgstr "打开 PPDB 文件失败:\n"
#, fuzzy
msgid "Failed to open file:\n"
msgstr "打开文件失败:\n"
msgstr "打开 PPDB 文件失败:\n"
msgid "Confirm PPDB Clear"
msgstr "确认清除 PPDB"
@@ -1303,8 +1306,9 @@ msgstr "默认"
msgid "disabled"
msgstr "已禁用"
#, fuzzy
msgid "Download Wine/Proton..."
msgstr "下载 Wine/Proton…"
msgstr "将 WINE/Proton 下载到 Steam"
msgid "Enter prefix name"
msgstr "输入前缀名称"
@@ -2103,11 +2107,13 @@ msgstr "全部清除"
msgid "Now Downloading"
msgstr "正在下载"
#, fuzzy
msgid "Waiting…"
msgstr "请稍候…"
msgstr "警告"
#, fuzzy
msgid "Disk: "
msgstr "磁盘: "
msgstr "链接:"
#, fuzzy
msgid "Started at"
@@ -2121,10 +2127,10 @@ msgid "Store"
msgstr "存储"
msgid "Copy the link to clipboard"
msgstr "将链接复制到剪贴板"
msgstr ""
msgid "Failed to log out of GOG account"
msgstr "退出 GOG 账号失败"
msgstr ""
msgid "Refreshing…"
msgstr "正在刷新…"
@@ -2146,27 +2152,27 @@ msgid "Log out"
msgstr "注销"
msgid "Open login page"
msgstr "打开登录页面"
msgstr ""
msgid "GOG account not connected"
msgstr "未连接 GOG 账号"
msgstr ""
#, python-brace-format
msgid "Refreshing GOG library… {0}/{1}"
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to refresh GOG library: {0}"
msgstr "启动游戏失败:{0}"
msgid "Failed to log out of Epic account"
msgstr "退出 Epic 账号失败"
msgstr ""
msgid "Copy the authorization code to clipboard"
msgstr "将授权码复制到剪贴板"
msgstr ""
msgid "Epic authorization…"
msgstr "Epic 授权中…"
msgstr ""
#, fuzzy, python-brace-format
msgid "Epic login failed: {0}"
@@ -2180,33 +2186,33 @@ msgstr "已连接"
msgid "Epic account not connected"
msgstr "已连接"
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Refreshing Epic library… {0}/{1}"
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
msgstr "启动游戏失败:{0}"
#, fuzzy, python-brace-format
msgid "Failed to refresh Epic library: {0}"
msgstr "启动游戏失败:{0}"
msgid "Select installation folder"
msgstr "选择安装文件夹"
msgstr ""
#, fuzzy
msgid "Another download is running"
msgstr "正在下载"
msgid "Another GOG operation is already running"
msgstr "另一个 GOG 操作正在运行中"
msgstr ""
msgid "GOG installation not found"
msgstr "未找到 GOG 安装"
msgstr ""
#, fuzzy
msgid "Select GOG game folder"
msgstr "删除文件夹"
msgid "Selected folder is not this GOG game"
msgstr "所选文件夹不是此 GOG 游戏"
msgstr ""
#, fuzzy
msgid "Starting"
@@ -2218,11 +2224,11 @@ msgstr "正在下载… {0}%"
#, python-brace-format
msgid "Disk: {0} MiB/s"
msgstr "磁盘:{0} MiB/s"
msgstr ""
#, fuzzy, python-brace-format
msgid "ETA: {0}"
msgstr "ETA: {0}"
msgstr ",预计剩余时间:{}秒"
#, fuzzy
msgid "Failed"
@@ -2276,7 +2282,7 @@ msgid "Theme"
msgstr "主题"
msgid "Grid"
msgstr "网格"
msgstr ""
#, fuzzy
msgid "List"
@@ -2286,17 +2292,21 @@ msgstr "浅色"
msgid "Vertical"
msgstr "部分"
#, fuzzy
msgid "Horizontal Blocks"
msgstr "水平块"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal Cards"
msgstr "水平卡片"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal endless Blocks"
msgstr "水平无限块"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Horizontal endless Cards"
msgstr "水平无限卡片"
msgstr "水平 HUD 布局"
#, fuzzy
msgid "Layout"
@@ -2324,7 +2334,7 @@ msgid "Auto"
msgstr "自动"
msgid "Account"
msgstr "账号"
msgstr ""
msgid "Brief"
msgstr "简要"
@@ -2352,7 +2362,7 @@ msgid "Auto Fullscreen on Gamepad connected"
msgstr "连接手柄时自动全屏"
msgid "Disable Boot Animation"
msgstr "禁用开机动画"
msgstr ""
msgid "Minimize to tray on close"
msgstr "关闭时最小化到系统托盘"
@@ -2367,22 +2377,22 @@ msgid "Hide Auto-Install Tab"
msgstr "隐藏自动安装标签页"
msgid "Hide Control Hints"
msgstr "隐藏操作提示"
msgstr ""
msgid "Force English language"
msgstr "强制使用英语"
msgstr ""
msgid "Enable UI Sounds"
msgstr "启用界面音效"
msgid "Show compatibility report after game crash"
msgstr "游戏崩溃后显示兼容性报告"
msgstr ""
msgid "Disable Cyrillic path warning"
msgstr "禁用西里尔字母路径警告"
msgstr ""
msgid "Disable ALT Linux i586 dependency check"
msgstr "禁用 ALT Linux i586 依赖项检查"
msgstr ""
msgid "Disable runtime download"
msgstr "禁用运行时下载"
@@ -2391,7 +2401,7 @@ msgid "Add to Steam compatibility tools"
msgstr "添加到 Steam 兼容工具"
msgid "Disable Steam Proton scanning"
msgstr "禁用 Steam Proton 扫描"
msgstr ""
msgid "Download WINE/Proton to Steam"
msgstr "将 WINE/Proton 下载到 Steam"
@@ -2480,7 +2490,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
#, python-brace-format
msgid "AppImage integrated at: {path}"
msgstr "AppImage 已集成于:{path}"
msgstr ""
#, fuzzy, python-brace-format
msgid "Failed to integrate AppImage: {error}"
@@ -2628,12 +2638,13 @@ msgstr "作者:"
msgid "Link:"
msgstr "链接:"
#, python-brace-format
#, fuzzy, python-brace-format
msgid "Delete theme '{0}'?"
msgstr "删除主题“{0}”?"
msgstr "选择主题:"
#, fuzzy
msgid "Failed to delete theme."
msgstr "删除主题失败。"
msgstr "加载主题失败"
msgid "Prefix:"
msgstr "前缀:"
-7
View File
@@ -85,13 +85,6 @@ def get_system_locale():
if ui_config.get_force_english():
return 'en'
for language in os.getenv("LANGUAGE", "").split(":"):
if not language:
continue
try:
return str(Locale.parse(language))
except (UnknownLocaleError, ValueError):
continue
try:
loc = locale.getlocale()[0]
return str(Locale.parse(loc)) if loc else 'en'
+27 -379
View File
@@ -12,13 +12,12 @@ from datetime import datetime
from pathlib import Path
from queue import Empty, Queue
from threading import Thread
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import psutil
import orjson
from portprotonqt.logger import get_logger
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
@@ -29,7 +28,6 @@ from portprotonqt.detail_pages import DetailPageManager
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
from portprotonqt.gog_api import GOGAPI
from portprotonqt.egs_api import EGSAPI
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
from portprotonqt.debug_utils import get_prefix_name
from portprotonqt.input_manager import InputManager, MainWindowProtocol
from portprotonqt.context_menu_manager import ContextMenuManager
@@ -93,9 +91,6 @@ from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
STORE_LAUNCH_GRACE_SECONDS = 10
GAME_PROCESS_CHECK_INTERVAL_MS = 500
STEAM_PROCESS_EXIT_GRACE_SECONDS = 3
STEAM_STOP_GRACE_SECONDS = 3
if TYPE_CHECKING:
from portprotonqt.appimage_updater import AppImageUpdateWorker
@@ -108,36 +103,6 @@ ALT_BIARCH_URL = "https://www.altlinux.org/Biarch"
GAME_LAUNCH_MARKER = "PORTPROTONQT_GAME_LAUNCH_STARTED"
GAME_EXIT_MARKER = "PORTPROTONQT_GAME_EXIT_CODE"
class SteamGameScanWorker(QThread):
def __init__(self, appid: str, parent: QObject) -> None:
super().__init__(parent)
self.appid = appid
self.known: dict[int, dict[str, Any]] = {}
self.result: dict[int, dict[str, Any]] = {}
self.stop_started: float | None = None
self.error: str | None = None
def run(self) -> None:
self.error = None
try:
processes = []
for process in psutil.process_iter(["pid", "ppid", "name", "cmdline", "create_time", "status"]):
if self.isInterruptionRequested():
return
processes.append(process.info)
self.result = MainWindow._collect_steam_processes(self.appid, processes, self.known)
if self.stop_started is not None:
MainWindow._stop_steam_processes(self.appid, {
"known": self.result, "stop_started": self.stop_started,
})
except psutil.Error as error:
logger.warning("Steam process scan failed for %s: %s", self.appid, error)
self.error = str(error)
except OSError as error:
logger.warning("Steam process scan failed for %s: %s", self.appid, error)
self.error = str(error)
class MainWindow(
MainWindowControlHintsMixin,
MainWindowAutoInstallTabMixin,
@@ -1070,7 +1035,7 @@ class MainWindow(
self.games_loaded.emit(games)
# Load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
completed = dict.fromkeys(results, False)
def check_completion():
@@ -1100,24 +1065,14 @@ class MainWindow(
completed['egs'] = True
check_completion()
def native_callback(games: list[tuple]) -> None:
results['native'] = games
completed['native'] = True
check_completion()
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
self._load_gog_games_async(gog_callback)
self._load_egs_games_async(egs_callback)
self._load_native_games_async(native_callback)
else:
# Load selected sources in parallel.
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
selected_sources = set(display_filter.split(","))
completed = {
source: bool(selected_sources & results.keys()) and source not in selected_sources
for source in results
}
# For 'all' filter - load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
completed = dict.fromkeys(results, False)
def on_all_games():
seen = set()
@@ -1159,106 +1114,15 @@ class MainWindow(
completed['egs'] = True
check_completion()
def native_callback(games: list[tuple]) -> None:
results['native'] = games
completed['native'] = True
check_completion()
callbacks = {
"portproton": portproton_callback, "steam": steam_callback,
"gog": gog_callback, "egs": egs_callback, "native": native_callback,
}
for source, callback in callbacks.items():
if not completed[source]:
getattr(self, f"_load_{source}_games_async")(callback)
# Load all sources in parallel
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
self._load_gog_games_async(gog_callback)
self._load_egs_games_async(egs_callback)
# Run loading immediately to show status without delay
start_loading()
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
self._native_games_callback = callback
self.native_library_worker = NativeLibraryWorker()
self.native_library_worker.finished.connect(self._on_native_games_loaded)
self.native_library_worker.start()
def open_native_settings(self, exec_line: str) -> None:
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
try:
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
except OSError as error:
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
QMessageBox.warning(self, _("Error"), str(error))
return
dialog.exec()
dialog.deleteLater()
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
if self.game_processes:
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
self.stop_running_game(button)
else:
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
return
try:
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
raise ValueError("Invalid native desktop path")
command, directory = get_native_launch(str(desktop_path))
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
except Exception as error:
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
return
self.native_game_running = True
self.game_stopped_by_user = False
self.game_processes = [process]
self.current_running_button = button
self.target_exe = desktop_path.name
self.game_start_exe = str(desktop_path)
self.game_start_exact_path = True
self.game_start_time = datetime.now()
self.game_launch_monotonic = time.monotonic()
self.game_launch_started = True
self.wine_download_seen = False
save_last_launch(desktop_path.stem, self.game_start_time)
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
self.launch_output_queue = Queue()
self.input_manager.suspend_gamepad_polling()
self._set_running_button_stop()
SoundManager().play("game_launch")
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
@Slot()
def _on_native_games_loaded(self) -> None:
worker = self.native_library_worker
if worker is None:
return
self.native_library_worker = None
games = worker.games
worker.deleteLater()
if not games:
self._native_games_callback([])
return
completed = []
def on_info(info: dict, game: tuple) -> None:
updated = list(game)
updated[1] = info.get("description", "") or game[1]
updated[2] = game[2] or info.get("cover", "")
updated[3] = info.get("appid", "")
updated[4] = info.get("controller_support", "")
completed.append(tuple(updated))
if len(completed) == len(games):
self._native_games_callback(completed)
for game in games:
if ui_config.get_economy_mode():
on_info(get_cached_steam_game_info(game[0], game[5]), game)
else:
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
api = getattr(self, "gog_api", None) or GOGAPI()
installed = api.load_installed()
@@ -1266,11 +1130,6 @@ class MainWindow(
library = [game for game in api.load_library() if game.get("app_id")]
if self._upgrade_legacy_gog_library(api, library, callback):
return
if only_installed:
library = [
game for game in library
if api.is_game_installed(str(game["app_id"]), installed)
]
games = []
if not library:
callback(games)
@@ -1334,23 +1193,6 @@ class MainWindow(
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
only_installed = game_config.get_only_installed()
games = []
if not library:
callback(games)
return
installed_data = None
installed_by_id = {}
if only_installed:
installed_data = self.egs_api.load_installed()
installed_by_id = {
str(game["app_id"]): self.egs_api.is_game_installed(
str(game["app_id"]), installed_data
)
for game in library
}
library = [
game for game in library
if installed_by_id[str(game["app_id"])]
]
if not library:
callback(games)
return
@@ -1364,19 +1206,9 @@ class MainWindow(
name, custom_cover = MainWindow._get_custom_store_data(
"egs", app_id, str(game.get("title", app_id))
)
installed = (
installed_by_id[app_id] if only_installed
else self.egs_api.is_game_installed(app_id)
)
installed = self.egs_api.is_game_installed(app_id)
if not only_installed or installed:
target = None
if installed:
if only_installed:
target = self.egs_api.get_launch_target(
app_id, installed_data
)
else:
target = self.egs_api.get_launch_target(app_id)
target = self.egs_api.get_launch_target(app_id) if installed else None
description = str(game.get("description", ""))
if not description or description == str(game.get("title", "")):
description = str(steam_info.get("description", ""))
@@ -1551,9 +1383,6 @@ class MainWindow(
executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed)
def _generate_missing_portproton_icon(self, game_exe: str, icon_path: str, desktop_name: str) -> str:
custom_icon = get_custom_icon_path(game_exe)
if custom_icon:
return custom_icon
if not self.portproton_location:
return ""
if not game_exe.lower().endswith(".exe") or not os.path.isfile(game_exe):
@@ -1592,7 +1421,7 @@ class MainWindow(
callback: Callable[[tuple | None], None],
):
entry = parse_desktop_entry(file_path)
if not entry or read_native_game(Path(file_path)) is not None:
if not entry:
callback(None)
return
desktop_name = entry.get("Name", _("Unknown Application"))
@@ -1934,13 +1763,11 @@ class MainWindow(
self.detail_page_manager.openGameDetailPage(game_data)
pending_log_exe = self._pending_log_exe
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
if exec_line.startswith(NATIVE_URI_PREFIX):
detail_exe = exec_line
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
if exec_line.startswith(("gog://launch/", "egs://launch/")):
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
if pending_log_exe and detail_exe:
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_exe:
if os.path.abspath(detail_exe) == pending_log_exe:
self._pending_log_exe = None
log_button = self.detail_page_manager._debug_log_button
if log_button:
@@ -2257,7 +2084,7 @@ class MainWindow(
def checkTargetExe(self):
"""Update launch state and reset controls after the game exits."""
"""Update launch state from the marker and PortProton process."""
dependency_active = self._drain_launch_output_progress()
child_running = self._has_running_game_process()
@@ -2382,7 +2209,6 @@ class MainWindow(
self.game_processes = [] if launcher_only else [
proc for proc in self.game_processes if proc.poll() is None
]
self.native_game_running = False
if not getattr(self, "_animated_covers_suspended", False):
self.input_manager.resume_gamepad_polling()
self._refresh_portproton_shortcuts()
@@ -2477,8 +2303,6 @@ class MainWindow(
def _get_game_executable(self, exec_line: str) -> str:
"""Resolve a library launch entry to its tracked executable."""
if exec_line.startswith(NATIVE_URI_PREFIX):
return exec_line.removeprefix(NATIVE_URI_PREFIX)
if exec_line.startswith("gog://launch/"):
app_id = exec_line.rsplit("/", 1)[-1]
return str(self.gog_api.get_launch_target(app_id) or "")
@@ -2488,19 +2312,6 @@ class MainWindow(
return extract_exec_target_path(exec_line) or ""
def _has_running_game_process(self) -> bool:
if getattr(self, "native_game_running", False):
if any(process.poll() is None for process in self.game_processes):
return True
groups = {process.pid for process in self.game_processes}
for process in psutil.process_iter(attrs=["pid", "status"]):
try:
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
return True
except ProcessLookupError:
continue
except PermissionError:
continue
return False
if self._observe_game_processes():
return True
process_alive = any(proc.poll() is None for proc in self.game_processes)
@@ -2622,8 +2433,6 @@ class MainWindow(
self._start_launch_output_reader(game_process)
def _analyze_short_launch(self) -> None:
if getattr(self, "native_game_running", False):
return
if not ui_config.get_crash_reports_enabled():
logger.info("Compatibility report skipped: crash reports disabled")
return
@@ -2695,12 +2504,11 @@ class MainWindow(
def _terminate_game_processes(self) -> None:
for proc in self.game_processes:
if proc.poll() is not None and not getattr(self, "native_game_running", False):
if proc.poll() is not None:
continue
try:
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
if not getattr(self, "native_game_running", False):
proc.kill()
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.kill()
except ProcessLookupError:
continue
except OSError as e:
@@ -2719,14 +2527,11 @@ class MainWindow(
return True
def stop_running_game(self, button=None) -> bool:
"""Stop the current game's owned processes and launcher."""
"""Stop current game via PortProton CLI stop command."""
if button is not None:
self.current_running_button = button
self.game_stopped_by_user = True
self.egs_launch_cancelled = True
if getattr(self, "native_game_running", False):
self._terminate_game_processes()
return True
if getattr(self, "launcher_process_only", False):
if self._run_portproton_stop_command():
return True
@@ -2758,8 +2563,8 @@ class MainWindow(
msg_box.setText(_("Prefix '{0}' was not found. Install recommended libraries?").format(prefix_name))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.No:
env_vars["DISABLE_CP_DEFPFX"] = "1"
@@ -2876,8 +2681,8 @@ class MainWindow(
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
)
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Ok:
return self._install_alt_i586_dependencies()
@@ -2916,157 +2721,6 @@ class MainWindow(
else:
logger.warning("Failed to open Steam URI: %s", exec_line)
def _toggle_steam_game(
self, exec_line: str, button: QPushButton | None, launch: bool = True
) -> None:
appid = exec_line.rsplit("/", 1)[-1]
if not appid.isdigit():
logger.warning("Invalid Steam URI: %s", exec_line)
return
watches = getattr(self, "_steam_watches", None)
if watches is None:
self._steam_watches: dict[str, dict[str, Any]] = {}
watches = self._steam_watches
if appid in watches:
watch = watches[appid]
watch["button"] = button
if launch and watch["running"]:
if not watch["stop"]:
watch["stop_started"] = time.monotonic()
watch["stop"] = True
elif launch and not watch["launch_requested"]:
watch["launch_requested"] = True
self._launch_steam_game(exec_line)
if button is not None:
button.setEnabled(watch["running"] or not watch["launch_requested"])
self._scan_steam_game(appid)
return
process = SteamGameScanWorker(appid, self)
timer = QTimer(self)
watch: dict[str, Any] = {
"process": process, "timer": timer, "button": button,
"running": False, "stop": False, "launch_requested": launch,
}
watches[appid] = watch
if button is not None:
button.setEnabled(not launch)
process.finished.connect(lambda: self._steam_scan_finished(appid))
timer.timeout.connect(lambda: self._scan_steam_game(appid))
if launch:
self._launch_steam_game(exec_line)
timer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
self._scan_steam_game(appid)
def _scan_steam_game(self, appid: str) -> None:
watch = self._steam_watches[appid]
process = watch["process"]
if not process.isRunning():
process.known = watch.get("known", {})
process.stop_started = watch.get("stop_started") if watch["stop"] else None
process.start()
@staticmethod
def _collect_steam_processes(
appid: str, snapshot: list[dict[str, Any]], known: dict[int, dict[str, Any]]
) -> dict[int, dict[str, Any]]:
processes: dict[int, dict[str, Any]] = {}
children: dict[int, list[int]] = {}
selected: set[int] = set()
for info in snapshot:
if info.get("status") == psutil.STATUS_ZOMBIE or info["create_time"] is None:
continue
pid, parent = info["pid"], info["ppid"]
processes[pid] = info
children.setdefault(parent, []).append(pid)
args = info["cmdline"] or []
args = args[:args.index("--")] if "--" in args else args
if info["name"] == "reaper" and "SteamLaunch" in args and f"AppId={appid}" in args:
selected.add(pid)
elif pid in known and info["create_time"] == known[pid]["create_time"]:
selected.add(pid)
pending = list(selected)
while pending:
for child in children.get(pending.pop(), []):
if child not in selected:
selected.add(child)
pending.append(child)
return {pid: processes[pid] for pid in selected}
def _steam_scan_finished(self, appid: str) -> None:
watch = self._steam_watches.get(appid)
if watch is None:
return
if watch["process"].error is not None:
return
processes = watch["process"].result
watch["known"] = processes
now = time.monotonic()
reapers = [info["cmdline"] or []
for info in processes.values() if info["name"] == "reaper"]
if reapers:
watch["preparing"] = all("Install=1" in args for args in reapers)
if processes:
watch["last_seen"] = now
watch["running"] = True
elif (
watch["running"] and (not watch.get("preparing") or watch["stop"])
and now - watch.get("last_seen", 0) >= STEAM_PROCESS_EXIT_GRACE_SECONDS
):
watch["running"] = False
watch["launch_requested"] = False
watch["stop"] = False
running = watch["running"]
button = watch["button"]
if button is not None:
try:
button.setEnabled(running or not watch.get("launch_requested", True))
if running:
button.setText(_("Stop"))
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
elif not watch.get("launch_requested", True):
button.setText(_("Start"))
button.setIcon(self.theme_manager.get_icon("play", as_path=True))
except RuntimeError:
watch["button"] = None
@staticmethod
def _stop_steam_processes(appid: str, watch: dict[str, Any]) -> None:
now = time.monotonic()
started = watch.setdefault("stop_started", now)
stop_signal = (
signal.SIGKILL if now - started >= STEAM_STOP_GRACE_SECONDS else signal.SIGTERM
)
for pid, info in watch["known"].items():
try:
process = psutil.Process(pid)
if process.create_time() != info["create_time"]:
continue
process.send_signal(stop_signal)
except psutil.NoSuchProcess:
logger.debug("Steam game %s process %s already exited", appid, pid)
except psutil.AccessDenied as error:
logger.warning("Cannot stop Steam game %s process %s: %s", appid, pid, error)
def _finish_steam_watch(self, appid: str) -> None:
watch = self._steam_watches.pop(appid, None)
if watch is None:
return
watch["timer"].stop()
watch["timer"].deleteLater()
process = watch["process"]
process.blockSignals(True)
process.requestInterruption()
process.wait()
process.deleteLater()
button = watch["button"]
if button is not None:
try:
button.setEnabled(True)
button.setText(_("Start"))
button.setIcon(self.theme_manager.get_icon("play", as_path=True))
except RuntimeError:
logger.debug("Steam game button already deleted")
def _handle_gog_game(self, exec_line: str, button=None) -> None:
action, app_id = exec_line.removeprefix("gog://").split("/", 1)
if action == "install" and self.gog_api.is_game_installed(app_id):
@@ -3125,8 +2779,8 @@ class MainWindow(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
self._start_egs_operation(
@@ -3385,13 +3039,9 @@ 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._toggle_steam_game(exec_line, button)
self._launch_steam_game(exec_line)
return
if exec_line.startswith("gog://"):
@@ -3567,8 +3217,6 @@ class MainWindow(
if not self._run_portproton_stop_command():
logger.warning("Failed to stop installation during application shutdown")
for appid in list(getattr(self, "_steam_watches", {})):
self._finish_steam_watch(appid)
self.disc_image_manager.cleanup_iso_rw_paths()
self._stopBackgroundWorkers()
-1
View File
@@ -22,7 +22,6 @@ install_data(
'localization.py',
'logger.py',
'main_window.py',
'native_games.py',
'port_data_path_selector.py',
'portproton_api.py',
'preloader.py',
-514
View File
@@ -1,514 +0,0 @@
"""Discover installed native games from desktop application entries."""
import configparser
import platform
import os
import re
import shlex
import shutil
import tempfile
from pathlib import Path
from datetime import datetime
from PySide6.QtCore import QThread
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.icon_extractor import get_exe_icon_cache_path
from portprotonqt.time_utils import (
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
format_last_launch, format_playtime,
)
logger = get_logger(__name__)
NATIVE_URI_PREFIX = "native://"
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
def is_native_executable(path: str) -> bool:
"""Recognize native launch files while preserving Windows and disc formats."""
return (
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
and os.path.isfile(path)
and os.access(path, os.X_OK)
)
def quote_native_arguments(arguments: list[str]) -> str:
"""Quote arguments using desktop entry escaping, without a shell."""
escaped = []
for argument in arguments:
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
return " ".join(escaped)
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
"""Use the installed application launcher for native desktop shortcuts."""
flatpak_id = os.getenv("FLATPAK_ID")
appimage = os.getenv("APPIMAGE", "").strip()
launcher = ["portprotonqt"]
if flatpak_id:
launcher = ["flatpak", "run", flatpak_id]
elif appimage and os.path.isfile(appimage):
launcher = [appimage]
if mode:
launcher.append(mode)
return quote_native_arguments(launcher) + " " + command
def get_native_source_command(command: str) -> str:
"""Unwrap a PortProtonQt desktop command before applying native settings."""
parts = shlex.split(command.replace("\\\\", "\\"))
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
target = extract_exec_target_path(parts)
if target:
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
return quote_native_arguments([target])
return command
def get_native_tool_paths() -> dict[str, str]:
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
scripts = get_portproton_scripts_path()
location = get_portproton_location()
if not scripts or not location:
return {}
values = {}
try:
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
key, separator, value = line.removeprefix("export ").partition("=")
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
values[key] = value.strip().strip('"\'')
except OSError as error:
logger.warning("Cannot read native runtime versions: %s", error)
return {}
root = Path(location) / "data/tmp"
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
def read_native_variables(path: Path) -> dict[str, str]:
"""Read literal var/PPDB assignments without executing shell commands."""
values = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip().startswith(("export ", "check_variables ")):
continue
parts = shlex.split(line, comments=True)
if not parts:
continue
if parts[0] == "check_variables" and len(parts) == 3:
values[parts[1]] = parts[2]
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
key, value = parts[1].split("=", 1)
values[key] = value.replace("\\$", "$").replace("\\`", "`")
return values
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
"""Keep native settings and logs in a named per-game user data directory."""
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
def get_native_defaults() -> dict[str, str]:
"""Read supported Linux defaults from the existing PortProton var file."""
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
scripts = get_portproton_scripts_path()
if scripts and (Path(scripts) / "var").is_file():
defaults = read_native_variables(Path(scripts) / "var")
values.update({key: value for key, value in defaults.items()
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
if item not in {"wine", "winesync"})
return values
def load_native_settings(desktop_path: str) -> dict[str, str]:
"""Load var defaults, existing desktop settings, and the game PPDB."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = lambda optionstr: optionstr
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
settings = get_native_defaults()
if parser.has_section(NATIVE_SETTINGS_SECTION):
settings.update(parser[NATIVE_SETTINGS_SECTION])
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
database = Path(desktop_path + ".ppdb")
if database.is_file():
content = database.read_text(encoding="utf-8")
if content.lstrip().startswith("["):
parser.read_string(content)
settings.update(parser[NATIVE_SETTINGS_SECTION])
else:
settings.update(read_native_variables(database))
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
settings["Exec"] = get_native_source_command(settings["Exec"])
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
executable = next((part.replace("%%", "%") for part in reversed(parts)
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
if executable is None and parts:
executable = shutil.which(parts[0])
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
for legacy in legacy_paths:
if legacy.is_file():
settings.update(read_native_variables(legacy))
database = get_native_ppdb_path(desktop_path, settings)
if database.is_file():
settings.update(read_native_variables(database))
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
return settings
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
paths = get_native_tool_paths()
runtime = Path(paths.get("runtime", ""))
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
raise ValueError("PortProton container runtime is not installed")
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
if platform.freedesktop_os_release().get("ID") == "altlinux":
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
if path.is_dir()]
if drivers:
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
portable = Path(paths.get("plugins", "")) / "portable"
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
layers = portable / "share/vulkan/implicit_layer.d"
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
if settings.get("PW_MANGOHUD") == "1":
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
raise ValueError("PortProton portable MangoHud is not installed")
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
return command, environment
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
"""Compose Linux launch wrappers and environment for a desktop entry."""
prefix = []
environment = []
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
if settings.get(key) == "1":
environment.append(variable)
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
if settings.get(key) not in (None, "", "disabled"):
environment.append(f"{variable}={settings[key]}")
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
if settings.get("PW_GAMESCOPE") == "1":
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
if settings.get("PW_USE_RUNTIME") == "1":
runtime_command, runtime_env = build_native_runtime(settings)
prefix += runtime_command
environment += runtime_env
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
if cores not in ("", "disabled"):
available = sorted(os.sched_getaffinity(0))
count = int(cores)
if not 1 <= count <= len(available):
raise ValueError("Invalid CPU cores limit")
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
if settings.get("PW_USE_GAMEMODE") == "1":
prefix.append("gamemoderun")
if settings.get("PW_MANGOHUD") == "1":
if settings.get("PW_USE_RUNTIME") != "1":
prefix += ["mangohud", "--dlsym"]
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
config = settings.get("MANGOHUD_CONFIG", "")
if settings.get("FPS_LIMIT"):
config += f",fps_limit={settings['FPS_LIMIT']}"
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
if settings.get("PW_VKBASALT") == "1":
environment.append("ENABLE_VKBASALT=1")
if settings.get("VKBASALT_CONFIG_FILE"):
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
if environment:
prefix = ["env", *environment, *prefix]
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
"""Keep native vkBasalt configuration isolated from global settings."""
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
settings.pop("VKBASALT_CONFIG_FILE", None)
return
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
if shader and shader != "cas":
if Path(shader).name != shader:
raise ValueError("Invalid vkBasalt shader name")
shader_path = plugins / "shaders" / (shader + ".fx")
if not shader_path.is_file():
raise ValueError(f"vkBasalt shader is not installed: {shader}")
lines.append(f"{shader} = {shader_path}")
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
settings["VKBASALT_CONFIG_FILE"] = str(config)
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
settings = load_native_settings(desktop_path)
settings.update(changes)
settings["Exec"] = get_native_source_command(settings["Exec"])
command, directory = settings["Exec"], settings.get("Path", "")
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
raise ValueError("Invalid native launch command or directory")
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
raise ValueError("Working directory must be an existing absolute path")
database = get_native_ppdb_path(desktop_path, settings)
database.parent.mkdir(parents=True, exist_ok=True)
write_native_vkbasalt_config(desktop_path, settings)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
temporary = Path(output.name)
for key, value in sorted(settings.items()):
if key in {"Name", "Icon", "Exec", "Path"}:
continue
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
output.write(f'export {key}="{escaped}"\n')
os.replace(temporary, database)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
"""Build a native launcher through PortProtonQt with the game's directory."""
path = Path(executable).expanduser().absolute()
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
logger.warning("Not an executable native game: %s", path)
return None
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
logger.warning("Invalid native game name or path")
return None
name = " ".join(name.splitlines()).strip()
# Desktop Exec uses double quoting and doubles literal percent signs.
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
directory = str(path.parent).replace("\\", "\\\\")
icon = get_custom_icon_path(str(path)) or icon
if not icon and path.suffix.lower() == ".appimage":
icon = get_exe_icon_cache_path(str(path))
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
comment = _('Launch "{name}" with PortProton').format(name=name)
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
("RunLog", _("Run in logging mode"), "--log")):
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
location = get_portproton_location()
if not location:
logger.warning("PortProton data directory is unavailable")
return None
filename = name.replace("/", "_") + ".desktop"
destination = Path(location) / filename
try:
destination.parent.mkdir(parents=True, exist_ok=True)
except OSError as error:
logger.warning("Cannot create native library directory: %s", error)
return None
return content, str(destination)
def read_native_game(path: Path) -> tuple | None:
"""Read a game entry without modifying the installed desktop file."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
parser.read_string(path.read_text(encoding="utf-8"))
if not parser.has_section("Desktop Entry"):
return None
entry = parser["Desktop Entry"]
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
return None
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
return None
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
return None
name, command = entry.get("Name", ""), entry.get("Exec", "")
parts = shlex.split(command.replace("\\\\", "\\"))
if not name or not parts:
return None
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
return None
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
return None
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
if path.is_relative_to(data_home / "applications"):
if not os.path.isfile(parts[0]) or (len(parts) > 1 and os.path.isfile(parts[1])):
return None
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)
+1 -1
View File
@@ -175,7 +175,7 @@ def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose:
message_box.setText(text)
if allow_choose:
message_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
message_box.button(QMessageBox.StandardButton.Open).setText(_("Choose Folder"))
message_box.setButtonText(QMessageBox.StandardButton.Open, _("Choose Folder"))
main_style = getattr(theme, "MAIN_WINDOW_STYLE", "")
message_box_style = getattr(theme, "MESSAGE_BOX_STYLE", "")
message_box.setStyleSheet(main_style + message_box_style)
+5 -5
View File
@@ -1869,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:
command = args.e[0]
# functions_helper is bash-only, user shell may be zsh or fish
if _needs_portproton_functions(command):
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
return [default_shell(), "-lc", command]
return [
default_shell(),
"-lc",
_with_portproton_functions(args.e[0]),
]
return args.e
return [default_shell()]
+13 -96
View File
@@ -1,6 +1,5 @@
"""Steam API functions for PortProtonQt."""
import hashlib
import os
import shlex
import threading
@@ -16,7 +15,6 @@ from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.localization import get_steam_language
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
from portprotonqt.config.portproton import get_custom_icon_path
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
from portprotonqt.steam_api.cache import (
@@ -48,12 +46,10 @@ SGDB_CACHE_DURATION = 24 * 60 * 60
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
def fetch_sgdb_cover_async(
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
) -> None:
def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> None:
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
encoded = urllib.parse.quote(game_name, safe="")
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
def process_response(result: str | None) -> None:
if not result or not os.path.exists(result):
@@ -70,14 +66,6 @@ def fetch_sgdb_cover_async(
if text.startswith('"') and text.endswith('"'):
text = text[1:-1]
if text:
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
text = text.replace(
"https://cdn2.steamgriddb.com/",
"https://deckyloader.ru/sgdb-cdn/",
1,
)
logger.debug("Got SGDB cover URL for %s: %s", game_name, text)
callback(text)
except Exception as e:
@@ -86,7 +74,7 @@ def fetch_sgdb_cover_async(
cache_manager = CacheManager()
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}.json"
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
process_response(str(cache_file))
return
@@ -194,11 +182,6 @@ def fetch_client_icon_async(appid: int | str, callback: Callable[[str], None]) -
callback("")
return
custom_icon = get_custom_icon_path(f"steam://rungameid/{appid_str}")
if custom_icon:
callback(custom_icon)
return
cache_dir = CacheManager().cache_dir
info_path = cache_dir / f"steamcmd_app_{appid_str}_{time.time_ns()}.json"
url = f"https://api.steamcmd.net/v1/info/{appid_str}"
@@ -241,12 +224,7 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
callback(cached.get("tier", ""))
return
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
url = f"https://deckyloader.ru/protondb/{appid}.json"
else:
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
def process_response(result: str | None) -> None:
if not result or not os.path.exists(result):
@@ -575,23 +553,6 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
)
def get_cached_sgdb_cover(game_name: str) -> str:
"""Read a cached SGDB grid URL without creating a download worker."""
cache = CacheManager()
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
key = f"sgdb_{safe_name}_grids"
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
return ""
cover = cache.load_json(key)
if not isinstance(cover, str):
return ""
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
return cover
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
"""Return Steam metadata from local cache only."""
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
@@ -599,8 +560,7 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
game_exe = extract_exec_target_path(exec_line) or exec_line
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
candidates = remove_duplicates(filter_candidates(names))
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
apps_path = CacheManager().cache_dir / "steam_apps.json"
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
if not steam_apps:
@@ -663,22 +623,8 @@ def get_steam_game_info_async(
from portprotonqt.steam_api.cache import get_exiftool_data
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
cache_manager = CacheManager()
match_key = "steam_match_" + hashlib.sha256(
orjson.dumps([desktop_name, exec_line])
).hexdigest()
cached_match = (
cache_manager.load_json(match_key)
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
)
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
cached_match = None
elif cached_match["appid"] <= 0:
cached_match = None
is_autoinstall = exec_line.startswith("autoinstall:")
is_store_uri = exec_line.startswith(("gog://", "egs://"))
is_native = exec_line.startswith("native://")
if game_exe.lower().endswith(('.bat', '.cmd')):
if os.path.exists(game_exe):
@@ -700,7 +646,7 @@ def get_steam_game_info_async(
else:
logger.error("Command file not found: %s", game_exe)
if cached_match is not None or is_store_uri or is_native:
if is_store_uri:
meta_data = {}
elif not game_exe.lower().endswith('.exe'):
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
@@ -724,9 +670,9 @@ def get_steam_game_info_async(
candidates.append(file_description)
if desktop_name:
candidates.append(desktop_name)
if exe_name and not (is_store_uri or is_native):
if exe_name and not is_store_uri:
candidates.append(exe_name)
if folder_name and not (is_store_uri or is_native):
if folder_name and not is_store_uri:
candidates.append(folder_name)
candidates = filter_candidates(candidates)
candidates = remove_duplicates(candidates)
@@ -739,13 +685,10 @@ def get_steam_game_info_async(
data_and_index: tuple[list | None, dict | None]
) -> None:
steam_apps, steam_apps_index = data_and_index
matching_app = cached_match
matching_app = None
if matching_app is None and (not steam_apps or not steam_apps_index):
if not steam_apps or not steam_apps_index:
game_name = desktop_name or exe_name
if is_native:
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
return
def on_sgdb_cover_failure(cover: str) -> None:
result = _build_game_info_result(
@@ -773,20 +716,15 @@ def get_steam_game_info_async(
return
for candidate in candidates_ordered:
if matching_app:
break
if not candidate:
continue
matching_app = search_app(candidate, steam_apps_index or {})
matching_app = search_app(candidate, steam_apps_index)
if matching_app:
break
game_name = desktop_name or exe_name.capitalize()
if not matching_app:
if is_native:
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
return
def on_sgdb_cover_non_steam(cover: str) -> None:
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
result = _build_game_info_result(
@@ -811,26 +749,8 @@ 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:
@@ -880,7 +800,4 @@ def get_steam_game_info_async(
fetch_app_info_async(appid, on_app_info)
if cached_match is not None:
on_steam_apps_and_index((None, None))
else:
get_steam_apps_and_index_async(on_steam_apps_and_index)
get_steam_apps_and_index_async(on_steam_apps_and_index)
+15 -96
View File
@@ -1,26 +1,19 @@
"""Steam shortcuts management for PortProtonQt."""
import base64
import glob
import os
import random
import re
import shlex
import shutil
import zlib
from functools import partial
from pathlib import Path
from urllib.parse import urlsplit
import orjson
import requests
import vdf
import websocket
from PySide6.QtGui import QIcon, QImageReader
from PySide6.QtCore import QStandardPaths
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
@@ -40,7 +33,6 @@ from portprotonqt.steam_api.utils import (
)
from portprotonqt.steam_api.api import (
get_steam_game_info_async,
fetch_sgdb_cover_async,
)
logger = get_logger(__name__)
@@ -175,7 +167,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
"""Parse exec_line to extract executable path."""
try:
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
logger.error("Failed to parse exec_line: %s", exec_line)
return None, "Failed to parse executable command: no target path"
@@ -228,33 +220,6 @@ export LD_PRELOAD=
return script_path, ""
def _resolve_desktop_icon(source: str) -> QIcon:
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
if not source:
return QIcon()
if os.path.isfile(source):
return QIcon(source)
icon = QIcon.fromTheme(source)
if not icon.isNull() or os.path.basename(source) != source:
return icon
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
filename = glob.escape(source)
if Path(source).suffix.lstrip(".").lower() not in formats:
filename += ".*"
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
f"icons/{filename}", f"pixmaps/{filename}")
for root in roots:
for pattern in patterns:
for path in sorted(Path(root).glob(pattern)):
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
continue
icon = QIcon(str(path))
if not icon.isNull():
return icon
return QIcon()
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
"""Generate thumbnail icon for the game."""
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
@@ -267,13 +232,7 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
if os.path.exists(generated_icon_path):
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
else:
if exe_path.endswith(".desktop"):
entry = parse_desktop_entry(exe_path) or {}
source = entry.get("Icon", "")
icon = _resolve_desktop_icon(source)
success = icon.pixmap(128, 128).save(generated_icon_path)
else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path):
logger.warning(f"Failed to generate thumbnail for {exe_path}")
return ""
@@ -369,12 +328,11 @@ def _add_shortcut_via_vdf(
def _download_covers(
appid: int,
steam_appid: tuple[int | None, str],
steam_appid: int,
grid_dir: str,
was_api_used: bool
) -> None:
"""Download cover images for the game."""
source_appid, game_name = steam_appid
cover_types = [
("p.jpg", "library_600x900_2x.jpg"),
("_hero.jpg", "library_hero.jpg"),
@@ -391,7 +349,7 @@ def _download_covers(
with open(result_path, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
ext = Path(result_path).suffix.lstrip('.')
ext = Path(steam_name).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e:
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
@@ -400,54 +358,18 @@ def _download_covers(
except Exception as e:
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
def download_sgdb(index: int, cover_file: str, steam_name: str) -> None:
image_type = ("grids", "heroes", "logos", "hgrids")[index]
def on_url(url: str) -> None:
if not url.startswith((
"https://cdn2.steamgriddb.com/",
"https://deckyloader.ru/sgdb-cdn/",
)):
on_cover_download(None, steam_name, index)
return
extension = Path(urlsplit(url).path).suffix.lower()
if extension not in (".png", ".jpg", ".jpeg"):
logger.warning("Unsupported SGDB artwork format: %s", extension)
on_cover_download(None, steam_name, index)
return
destination = str(Path(cover_file).with_suffix(extension))
downloader.download_async(
url, destination, timeout=5,
callback=partial(on_cover_download, steam_name=steam_name, index=index),
)
fetch_sgdb_cover_async(game_name, on_url, image_type)
def on_steam_download(
index: int, steam_name: str, cover_file: str, result: str | None
) -> None:
if result:
on_cover_download(result, steam_name, index)
else:
download_sgdb(index, cover_file, steam_name)
for i, (suffix, steam_name) in enumerate(cover_types):
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
if not source_appid:
download_sgdb(i, cover_file, steam_name)
continue
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{source_appid}/{steam_name}"
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
downloader.download_async(
cover_url,
cover_file,
timeout=5,
callback=partial(on_steam_download, i, steam_name, cover_file)
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
)
def add_to_steam(
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
) -> tuple[bool, str]:
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
"""
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
and download Steam Grid covers with correct sizes and names.
@@ -460,9 +382,7 @@ def add_to_steam(
if not exe_path:
return (False, error)
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
local_path = exe_path.removeprefix("native://")
if not is_store_uri and not os.path.exists(local_path):
if not os.path.exists(exe_path):
logger.error(f"Executable not found: {exe_path}")
return (False, f"Executable file not found: {exe_path}")
@@ -476,9 +396,7 @@ def add_to_steam(
if error:
return (False, error)
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(local_path)
icon_source = custom_icon or icon_source or (None if is_store_uri else local_path)
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
icon_path = _generate_icon(exe_path, portproton_dir)
steam_home = get_steam_home()
if not steam_home:
@@ -553,10 +471,10 @@ def add_to_steam(
nonlocal steam_appid
steam_appid = game_info.get("appid")
if not steam_appid or not isinstance(steam_appid, int):
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
logger.info("No valid Steam appid found, skipping cover download")
return
logger.info(f"Found Steam AppID {steam_appid} for cover download")
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
get_steam_game_info_async(game_name, exec_line, on_game_info)
@@ -569,9 +487,10 @@ def add_to_steam(
def _delete_cover_files(grid_dir: str, appid: int) -> None:
"""Delete cover files for a game."""
cover_files = [
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
for suffix in ("", "p", "_hero", "_logo")
for extension in (".jpg", ".png", ".jpeg")
os.path.join(grid_dir, f"{appid}.jpg"),
os.path.join(grid_dir, f"{appid}p.jpg"),
os.path.join(grid_dir, f"{appid}_hero.jpg"),
os.path.join(grid_dir, f"{appid}_logo.png")
]
for cover_file in cover_files:
if os.path.exists(cover_file):
-8
View File
@@ -397,11 +397,6 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
"""Rebuild auto-install cards after a live library layout change."""
if not hasattr(self, "autoInstallContainer"):
return
if self.stackedWidget.currentIndex() != self.auto_install_tab_index:
self._autoinstall_layout_pending = True
self.autoInstallLoaded = False
return
self._autoinstall_layout_pending = False
mode = ui_config.get_library_layout_mode(
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
)
@@ -492,9 +487,6 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
return
def _start_autoinstall_load(self, force_refresh: bool = False) -> None:
if getattr(self, "_autoinstall_layout_pending", False):
self.refresh_autoinstall_layout()
return
if self.autoInstallLoaded or self.autoInstallLoading:
return
if not hasattr(self, "_on_autoinstall_games_loaded"):
-1
View File
@@ -226,7 +226,6 @@ class MainWindowControlHintsMixin:
keyboard_hints = [
("key_enter", _("Select"), "confirm"),
("key_space", _("Select multiple"), None),
("key_backspace", _("Back"), "back"),
("key_e", _("Add a shortcut"), "add_game"),
("key_context", _("Menu"), "context_menu"),
+40 -109
View File
@@ -4,7 +4,6 @@ import os
import re
import shutil
import subprocess
import zlib
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -49,8 +48,7 @@ GOG_LOGIN_TIMEOUT_MS = 300000
EGS_LOGIN_TIMEOUT_MS = 300000
MIB_PER_GIB = 1024.0
STORE_INFO_TIMEOUT_SECONDS = 120
STORE_DLC_METADATA_TIMEOUT_SECONDS = 15
GOG_CONTENT_GENERATION = 2
STORE_DLC_ART_TIMEOUT_SECONDS = 15
STORE_DLC_CACHE_TTL_SECONDS = 300
@@ -259,13 +257,11 @@ class StoreDLCWorker(QThread):
cache_name = f"store-dlc-{'egs' if epic else 'gog'}-{self.app_id}"
data = self._load_epic_dlc_info() if epic else cache.load_json(cache_name) if cache.is_fresh(cache_name, STORE_DLC_CACHE_TTL_SECONDS) else None
if data is None:
data = None if epic else self._load_gog_dlc_info()
if data is None:
result = subprocess.run(
self.api.build_command(arguments), env=environment, capture_output=True,
timeout=STORE_INFO_TIMEOUT_SECONDS, check=True,
)
data = orjson.loads(result.stdout)
result = subprocess.run(
self.api.build_command(arguments), env=environment, capture_output=True,
timeout=STORE_INFO_TIMEOUT_SECONDS, check=True,
)
data = orjson.loads(result.stdout)
cache.save_json(cache_name, data)
entries = data.get("game", {}).get("owned_dlc", []) if epic else data.get("dlcs", [])
installed = set(self.api.load_installed()) if epic else set()
@@ -297,43 +293,6 @@ class StoreDLCWorker(QThread):
if not self.isInterruptionRequested():
self.failed.emit(str(error))
def _load_gog_dlc_info(self) -> dict | None:
assert isinstance(self.api, GOGAPI)
try:
response = requests.get(
f"https://content-system.gog.com/products/{self.app_id}/os/windows/builds",
params={"generation": GOG_CONTENT_GENERATION},
timeout=STORE_DLC_METADATA_TIMEOUT_SECONDS,
)
response.raise_for_status()
builds = response.json().get("items", [])
build = next((item for item in builds if item.get("branch") is None), None)
if build is None or build.get("generation") != GOG_CONTENT_GENERATION:
return None
response = requests.get(build["link"], timeout=STORE_DLC_METADATA_TIMEOUT_SECONDS)
response.raise_for_status()
manifest = orjson.loads(zlib.decompress(response.content))
products = [product for product in manifest["products"]
if str(product["productId"]) != self.app_id]
if not products:
return {"dlcs": []}
credentials = self.api.get_credentials()
token = credentials.get("access_token")
if not token:
return None
response = requests.get(
"https://embed.gog.com/user/data/games",
headers={"Authorization": f"Bearer {token}"},
timeout=STORE_DLC_METADATA_TIMEOUT_SECONDS,
)
response.raise_for_status()
owned = {str(app_id) for app_id in response.json()["owned"]}
return {"dlcs": [{"id": str(product["productId"]), "title": product["name"]}
for product in products if str(product["productId"]) in owned]}
except Exception as error:
logger.warning("Failed to load GOG DLC metadata, falling back to gogdl: %s", error)
return None
def _load_epic_dlc_info(self) -> dict | None:
assets = self.api._load_json(self.api.config_dir / "assets.json", None)
data = self.api._load_json(self.api.config_dir / "metadata" / f"{self.app_id}.json", None)
@@ -369,7 +328,7 @@ class StoreDLCWorker(QThread):
try:
response = requests.get(
f"https://api.gog.com/products/{self.app_id}?expand=expanded_dlcs",
timeout=STORE_DLC_METADATA_TIMEOUT_SECONDS,
timeout=STORE_DLC_ART_TIMEOUT_SECONDS,
)
response.raise_for_status()
data = response.json()
@@ -832,27 +791,24 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
if getattr(self, "gog_repair_worker", None) is not None:
return
app_id = str(game["app_id"])
if "_install_path" not in game:
install_path = self.gog_api.get_install_path(app_id, str(game["title"]))
install_path.mkdir(parents=True, exist_ok=True)
selected_paths: list[str] = []
file_explorer = FileExplorer(
self,
theme=self.theme,
initial_path=str(install_path),
directory_only=True,
)
file_explorer.setWindowTitle(_("Select installation folder"))
file_explorer.file_signal.file_selected.connect(selected_paths.append)
file_explorer.exec()
if not selected_paths:
return
game = {**game, "_install_path": selected_paths[0]}
if self.gog_process is not None or self.egs_process is not None:
self._queue_store_download("gog", game)
return
game = game.copy()
install_path = Path(game.pop("_install_path"))
install_path = self.gog_api.get_install_path(app_id, str(game["title"]))
install_path.mkdir(parents=True, exist_ok=True)
selected_paths: list[str] = []
file_explorer = FileExplorer(
self,
theme=self.theme,
initial_path=str(install_path),
directory_only=True,
)
file_explorer.setWindowTitle(_("Select installation folder"))
file_explorer.file_signal.file_selected.connect(selected_paths.append)
file_explorer.exec()
if not selected_paths:
return
install_path = Path(selected_paths[0])
game_path = self.gog_api.find_install_path(app_id, install_path)
if game_path is not None:
self.gog_api.save_installed_game(
@@ -889,43 +845,21 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
def _select_store_dlcs(self, source: str, game: dict) -> None:
if getattr(self, "store_dlc_worker", None) is not None:
pending = getattr(self, "store_dlc_pending", [])
request = (source, game)
active_source, active_game, _results, _errors = self.store_dlc_request
if (source, str(game["app_id"])) == (active_source, str(active_game["app_id"])):
return
if not any(item_source == source and str(item_game["app_id"]) == str(game["app_id"])
for item_source, item_game in pending):
pending.append(request)
self.store_dlc_pending = pending
QMessageBox.warning(self, _("Error"), _("Another download is running"))
return
api = self.egs_api if source == "egs" else self.gog_api
worker = StoreDLCWorker(api, str(game["app_id"]))
results: list[list[dict]] = []
errors: list[str] = []
worker.loaded.connect(results.append)
worker.failed.connect(errors.append)
worker.loaded.connect(lambda dlcs: self._on_store_dlcs_loaded(source, game, dlcs))
worker.failed.connect(lambda error: QMessageBox.warning(self, _("Error"), error))
worker.finished.connect(self._on_store_dlc_worker_finished)
self.store_dlc_request = (source, game, results, errors)
self.store_dlc_worker = worker
worker.start()
def _on_store_dlc_worker_finished(self) -> None:
worker = self.store_dlc_worker
self.store_dlc_worker = None
if worker is None:
self.store_dlc_pending = []
return
worker.deleteLater()
source, game, results, errors = self.store_dlc_request
if results:
self._on_store_dlcs_loaded(source, game, results[0])
elif errors:
QMessageBox.warning(self, _("Error"), errors[0])
pending = getattr(self, "store_dlc_pending", [])
if pending and self.store_dlc_worker is None:
source, game = pending.pop(0)
self._select_store_dlcs(source, game)
if worker is not None:
worker.deleteLater()
def _on_store_dlcs_loaded(self, source: str, game: dict, dlcs: list[dict]) -> None:
selected = []
@@ -1027,23 +961,20 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
if selected_game is not None and str(selected_game["app_id"]) == app_id:
game = selected_game
self.egs_selected_dlc_game = None
if "_install_path" not in game:
selected_paths: list[str] = []
explorer = FileExplorer(
self, theme=self.theme, initial_path=str(self.egs_api.games_dir),
directory_only=True,
)
explorer.setWindowTitle(_("Select installation folder"))
explorer.file_signal.file_selected.connect(selected_paths.append)
explorer.exec()
if not selected_paths:
return
game = {**game, "_install_path": selected_paths[0]}
if self.gog_process is not None or self.egs_process is not None:
self._queue_store_download("egs", game)
return
game = game.copy()
install_path = Path(game.pop("_install_path"))
selected_paths: list[str] = []
explorer = FileExplorer(
self, theme=self.theme, initial_path=str(self.egs_api.games_dir),
directory_only=True,
)
explorer.setWindowTitle(_("Select installation folder"))
explorer.file_signal.file_selected.connect(selected_paths.append)
explorer.exec()
if not selected_paths:
return
install_path = Path(selected_paths[0])
folder_name = Path(str(game.get("folder_name") or app_id)).name
try:
game_path = install_path
@@ -1167,8 +1098,8 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
worker = GOGDeleteWorker(self.gog_api, game, self.context_menu_manager)
+19 -110
View File
@@ -1,7 +1,5 @@
import os
import re
import shutil
from pathlib import Path
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
@@ -42,7 +40,6 @@ 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
@@ -100,27 +97,10 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
def _on_library_filter_changed(self, index: int) -> None:
if index < 0 or index >= len(self.filter_keys):
return
selected = set(game_config.get_display_filter().split(","))
key = self.filter_keys[index]
if key in ("all", "favorites"):
selected = {key}
else:
selected -= {"all", "favorites"}
selected.symmetric_difference_update({key})
display_filter = ",".join(key for key in self.filter_keys if key in selected) or "all"
selected = set(display_filter.split(","))
combo = self.gamesDisplayCombo
combo.setCurrentIndex(self.filter_keys.index(display_filter.split(",")[0]))
for row, source in enumerate(self.filter_keys):
combo.setItemData(row, Qt.CheckState.Checked if source in selected
else Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
combo.setCurrentText(" + ".join(
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
if source in selected
))
display_filter = self.filter_keys[index]
game_config.set_display_filter(display_filter)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton", "native")
display_filter not in ("steam", "portproton")
)
self._position_library_controls_widget()
self._on_only_installed_changed(game_config.get_only_installed())
@@ -172,17 +152,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
card_orientation = "vertical"
elif layout_mode in {"horizontal", "horizontal_top"}:
card_orientation = "horizontal"
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
previous_mode = ui_config.get_library_layout_mode(theme_mode)
previous_orientation = ui_config.get_horizontal_card_orientation("theme")
ui_config.set_library_layout_mode(layout_mode)
ui_config.set_horizontal_card_orientation(card_orientation)
if (
previous_mode == ui_config.get_library_layout_mode(theme_mode)
and (previous_mode not in {"horizontal", "horizontal_top"}
or previous_orientation == card_orientation)
):
return
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
self.game_library_manager.rebuild_library_layout(
ui_config.get_library_layout_mode(theme_mode)
)
@@ -365,39 +337,22 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
display_filter = game_config.get_display_filter()
only_installed = game_config.get_only_installed()
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
self.filter_labels = [
_("All"),
"Steam",
"GOG",
"Epic Games",
"PortProton",
_("Native"),
_("Favorites"),
]
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
self._set_combo_current_key(
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
self.gamesDisplayCombo,
self.filter_keys,
display_filter,
)
self.gamesDisplayCombo.setEditable(True)
line_edit = self.gamesDisplayCombo.lineEdit()
if line_edit is not None:
line_edit.setReadOnly(True)
line_edit.installEventFilter(self.gamesDisplayCombo)
self.gamesDisplayCombo.setStyleSheet(
self.gamesDisplayCombo.styleSheet() + self.theme.SOURCE_FILTER_STYLE
)
selected = set(display_filter.split(","))
for row, source in enumerate(self.filter_keys):
self.gamesDisplayCombo.setItemData(
row, Qt.CheckState.Checked if source in selected else Qt.CheckState.Unchecked,
Qt.ItemDataRole.CheckStateRole,
)
self.gamesDisplayCombo.setCurrentText(" + ".join(
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
if source in selected
))
self.gamesDisplayCombo.activated.connect(self._on_library_filter_changed)
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
@@ -410,7 +365,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton", "native")
display_filter not in ("steam", "portproton")
)
self.badge_view_keys = ["detailed", "compact", "hidden"]
@@ -543,13 +498,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
source_refresh()
if not callable(source_loader):
if (
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
) and self.gog_api.auth_path.is_file():
if self.gog_api.auth_path.is_file():
self._refresh_gog_library()
if (
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
) and self.egs_api.user_path.is_file():
if self.egs_api.user_path.is_file():
self._refresh_egs_library()
# Reload games using the existing loadGames functionality
@@ -587,8 +538,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
)
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if msg_box.exec() != QMessageBox.StandardButton.Yes:
return
for game in missing_games:
@@ -656,7 +607,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
path = url.toLocalFile().lower()
if path.endswith(
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
) or (os.path.isfile(url.toLocalFile()) and is_native_executable(url.toLocalFile())):
):
event.acceptProposedAction()
return
event.ignore()
@@ -680,7 +631,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.open_local_autoinstall_card(path)
event.acceptProposedAction()
break
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
self.openAddGameDialog(path)
event.acceptProposedAction()
break
@@ -759,9 +710,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
name = dialog.nameEdit.text().strip()
exe_path = dialog.exeEdit.text().strip()
user_cover = dialog.coverEdit.text().strip()
native = is_native_executable(exe_path)
if native:
user_cover = dialog.last_cover_path or user_cover
if not name or not exe_path:
return
@@ -796,30 +744,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
entry = parse_desktop_entry(desktop_path)
if not entry:
return
if not native:
self._known_portproton_desktops.add(desktop_path)
elif cover_path:
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
self._write_desktop_file(desktop_entry, desktop_path)
if native:
try:
save_native_settings(desktop_path, {})
except Exception as error:
logger.warning("Cannot create native PPDB: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
return
for source in ("all", "native", "favorites"):
getattr(self, "_loaded_library_cache", {}).pop(source, None)
self._known_portproton_desktops.add(desktop_path)
description = entry.get("Comment", "")
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
exec_line = entry.get("Exec", exe_path)
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
# User cover fallback
user_cover_path = cover_path # Already set if user provided
native_game = read_native_game(Path(desktop_path)) if native else None
if native_game:
user_cover_path = native_game[2] or user_cover_path
# Statistics (playtime, last launch - defaults for new)
playtime_seconds = 0
@@ -868,15 +800,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"native" if native else "portproton",
"portproton",
steam_info.get("anticheat_slug", ""),
steam_info.get("ppdb_id", ""),
steam_info.get("ppdb_rating", ""),
)
# Incremental add
if native and game_config.get_display_filter() not in ("all", "native"):
return
self.game_library_manager.add_game_incremental(game_data)
# Trigger visible images load
@@ -898,7 +828,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
cached_steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"native" if native else "portproton",
"portproton",
cached_steam_info.get("anticheat_slug", ""),
cached_steam_info.get("ppdb_id", ""),
cached_steam_info.get("ppdb_rating", ""),
@@ -910,27 +840,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
"""Apply shortcut options selected in add/edit game dialog."""
if exec_line.startswith(NATIVE_URI_PREFIX):
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
for directory, checkbox in (
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
):
if directory and checkbox.isChecked():
try:
os.makedirs(directory, exist_ok=True)
shutil.copyfile(source, Path(directory) / source.name)
os.chmod(Path(directory) / source.name, 0o755)
except OSError as error:
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
QMessageBox.warning(self, _("Error"), str(error))
if dialog.add_to_steam_checkbox.isChecked():
if not is_game_in_steam(game_name):
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
elif is_game_in_steam(game_name):
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
return
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
if dialog.add_to_menu_checkbox.isChecked():
+6 -6
View File
@@ -920,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.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
if reset_settings():
@@ -940,8 +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.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
@@ -957,8 +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.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
cache_config.clear_cache()
+2 -2
View File
@@ -1530,8 +1530,8 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
msg_box.setText(request.get("message", ""))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
button = msg_box.exec()
worker.submit_pairing_response("yes" if button == QMessageBox.StandardButton.Yes else "no")
return
+2 -2
View File
@@ -287,8 +287,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
message_box.setDefaultButton(QMessageBox.StandardButton.No)
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
if message_box.exec() != QMessageBox.StandardButton.Yes:
return
removal_results = [
+4 -4
View File
@@ -336,8 +336,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Are you sure you want to clear prefix '{}'?").format(selected_prefix))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply != QMessageBox.StandardButton.Yes:
return
@@ -443,8 +443,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
msg_box.setText(_("Are you sure you want to delete prefix '{}'?").format(selected_prefix))
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
reply = msg_box.exec()
if reply == QMessageBox.StandardButton.Yes:
-1
View File
@@ -32,7 +32,6 @@ 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,
@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#3f424d"/></svg>

Before

Width:  |  Height:  |  Size: 386 B

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 383 B

@@ -1 +0,0 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0008 1a6.9996 6.9996 0 0 0-7.0008 6.9992 6.9996 6.9996 0 0 0 7.0008 7.0008 6.9996 6.9996 0 0 0 6.9992-7.0008 6.9996 6.9996 0 0 0-6.9992-6.9992zm0.03052 1.8345c0.49138 0 0.68069 0.026991 1.0522 0.151 0.77261 0.25794 1.4608 0.88663 1.857 1.698 0.36432 0.74592 0.47136 1.542 0.34538 2.5446-0.02796 0.2226-0.0498 0.49442-0.0498 0.60402v0.1992l0.24578 0.24739c0.8226 0.82687 1.5016 1.8186 1.8892 2.7614 0.0076 0.01852 0.01187 0.0377 0.01928 0.05622-0.49301 0.83904-1.1794 1.5485-2.0016 2.0691-0.04447-0.3775-0.13313-0.76334-0.26667-1.147-0.33295-0.95658-0.86947-1.775-1.5422-2.3534-0.22323-0.19199-0.53694-0.41582-0.75181-0.53655-0.0648-0.036441-0.11405-0.069295-0.11084-0.072289 0.0035-0.00285 0.10384-0.043441 0.22329-0.08996 0.47005-0.18305 0.84651-0.41233 1.1438-0.69558 0.4802-0.45754 0.7046-0.96565 0.70361-1.5984-0.0012-0.60346-0.212-1.0975-0.64418-1.5133-0.37522-0.36091-0.8574-0.54398-1.3639-0.53815-0.30388 0.003495-0.61702 0.07465-0.92048 0.21687-0.40675 0.1906-0.86555 0.57266-1.2353 1.0265-0.19966 0.24497-0.64341 0.92641-0.61687 1.0008 0.17687 0.49573 0.05014 0.89774-0.68755 1.1213-0.14863 0.00956-0.2752 0.022977-0.28112 0.028916-0.01608 0.016029 0.06854 0.1696 0.16707 0.30843 0.14523 0.20473 0.39623 0.4208 0.6763 0.57992 0.0689 0.039149 0.12571 0.076335 0.1253 0.081928 0 0.00577-0.07689 0.054243-0.16867 0.10763-0.66042 0.38426-1.248 1.1985-1.6 2.2201-0.14407 0.4181-0.23536 0.77816-0.28755 1.1454-0.55629-0.51797-1.0112-1.1354-1.3414-1.8201 0.18238-0.35341 0.3666-0.624 0.69237-1.0217 0.32937-0.40208 0.89278-0.92816 1.3494-1.2594l0.17671-0.12851v-0.12209c1e-6 -0.11617-0.0027-0.11963-0.04659-0.10602-0.13353 0.041394-0.41447 0.20677-0.53012 0.31165l-0.05783 0.053012 0.01125-0.24418c0.01809-0.40899 0.1444-0.77469 0.40482-1.1647l0.13815-0.20562-0.0498-0.1751c-0.1341-0.46267-0.11978-1.0338 0.04016-1.5133 0.15572-0.46694 0.38428-0.82639 0.76144-1.2032 0.5179-0.51738 1.0636-0.80828 1.8201-0.96867 0.24284-0.051483 0.30063-0.056286 0.71165-0.056225zm0.45783 3.1325c0.39072-0.00731 0.65853 0.36199 0.5494 0.75663-0.03707 0.13416-0.22104 0.31614-0.36466 0.35823-0.13428 0.039318-0.26887 0.039149-0.38072 0v-0.00161c-0.19556-0.069357-0.35076-0.27993-0.36787-0.5012-0.01367-0.17688 0.04474-0.32481 0.17831-0.4498 0.12845-0.12009 0.21938-0.15913 0.38554-0.16225zm-0.14297 0.33574c-0.06851 0.00128-0.10608 0.017958-0.15904 0.06747-0.05507 0.051532-0.07953 0.11181-0.0739 0.18474 0.0071 0.091227 0.07199 0.17863 0.15261 0.20723 0.04612 0.016141 0.10046 0.01621 0.15582 0 0.05921-0.017353 0.13572-0.090873 0.151-0.14618 0.045-0.1627-0.06542-0.31627-0.22651-0.31325zm-3.0185 0.55904c-0.01687-1.298e-4 -0.03412 0.00176-0.05301 0.00321-0.12511 0.00961-0.15006 0.020885-0.27149 0.12048-0.15317 0.12564-0.3168 0.33226-0.45301 0.57349-0.10345 0.18332-0.24198 0.53868-0.2008 0.51566 0.0143-0.00799 0.11568-0.087633 0.22329-0.17671 0.26056-0.21554 0.35801-0.28499 0.67148-0.47068 0.36751-0.21785 0.41804-0.28276 0.32932-0.42892-0.05724-0.094184-0.12772-0.13564-0.24578-0.13655z" fill="#fff" stroke-width="0"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

@@ -727,25 +727,6 @@ COMBOBOX_STYLE = f"""
}}
"""
SOURCE_FILTER_STYLE = f"""
QComboBox QAbstractItemView {{
background: {color_surface};
}}
QListView::indicator {{
width: {sourceFilterIndicatorSize}px;
height: {sourceFilterIndicatorSize}px;
border: {border_thin} {color_border_input};
border-radius: {sourceFilterIndicatorRadius}px;
background: {color_bg};
image: none;
}}
QListView::indicator:checked {{
background: {color_accent};
border: {border_thin} {color_text};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
}}
"""
SCROLL_STYLE = f"""
QScrollBar:vertical {{
width: 10px;
@@ -10,8 +10,6 @@ bootAnimationTimeoutMs = 30000
cursorHideTimeoutMs = 5000
startupLibraryLoadDelayMs = 500
autoSizeButtonPadding = (10, 20)
sourceFilterIndicatorSize = 18
sourceFilterIndicatorRadius = 2
favoriteLabelSize = 48, 48
favoriteLabelIconSize = 32
detailCompactCoverFrameSize = 128
@@ -370,5 +368,3 @@ WINETRICKS_CHECK_COLUMN_WIDTH = 80
WINETRICKS_ROW_HEIGHT = 36
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
RUN_AFTER_VALUE_WIDTH = 230
-3
View File
@@ -177,9 +177,6 @@ def test_app_restarts_installed_appimage_after_integration(
lambda: SimpleNamespace(
debug_level="NOTSET",
reinstall_steam_compat_tool=True,
list_games=False,
game_info=None,
edit_game=None,
),
)
monkeypatch.setattr(
-22
View File
@@ -207,34 +207,12 @@ 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,
):
+52 -559
View File
@@ -35,9 +35,53 @@ class TestParseArgs:
assert args.file_or_url == "file:///tmp/game.exe"
@pytest.mark.parametrize("stopped", [False, True])
def test_silent_tray_toggles_pause(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stopped: bool) -> None:
import signal
import psutil
from PySide6.QtWidgets import QApplication
from portprotonqt import app, time_utils
qt_app = QApplication.instance() or QApplication([])
assert isinstance(qt_app, QApplication)
tray = MagicMock()
timer = MagicMock()
launcher = MagicMock(pid=123)
launcher.poll.return_value = None
child = MagicMock(pid=124)
status = psutil.STATUS_STOPPED if stopped else psutil.STATUS_RUNNING
child.status.return_value = status
parent = MagicMock()
parent.children.return_value = [child]
monkeypatch.setattr(app, "QSystemTrayIcon", MagicMock(return_value=tray))
monkeypatch.setattr(app, "QTimer", MagicMock(return_value=timer))
monkeypatch.setattr(app, "get_portproton_tray_icon", MagicMock())
monkeypatch.setattr(app.subprocess, "Popen", MagicMock(return_value=launcher))
monkeypatch.setattr(app.psutil, "Process", MagicMock(return_value=parent))
monkeypatch.setattr(time_utils, "save_last_launch", MagicMock())
monkeypatch.setattr(time_utils, "save_playtime", MagicMock())
app.run_silent_tray(qt_app, ["start.sh"], str(tmp_path / "game.exe"))
menu = tray.setContextMenu.call_args.args[0]
menu.aboutToShow.emit()
pause, stop = menu.actions()
assert pause.isEnabled()
assert pause.text() == app._("Resume Game" if stopped else "Pause Game")
assert stop.text() == app._("Stop Game")
pause.trigger()
child.send_signal.assert_called_once_with(signal.SIGCONT if stopped else signal.SIGSTOP)
parent.children.assert_called_with(recursive=True)
parent.children.return_value = []
menu.aboutToShow.emit()
assert not pause.isEnabled()
pause.trigger()
assert child.send_signal.call_count == 1
@pytest.fixture
def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
from PySide6 import QtCore, QtNetwork
from PySide6 import QtNetwork
from portprotonqt import app, main_window, port_data_path_selector
monkeypatch.delenv("STEAM_COMPAT", raising=False)
@@ -49,8 +93,6 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
@@ -60,21 +102,19 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
qt_app = MagicMock()
qt_app.exec.return_value = 0
monkeypatch.setattr(app, "QApplication", MagicMock(return_value=qt_app))
lock = MagicMock()
lock.tryLock.return_value = True
monkeypatch.setattr(QtCore, "QLockFile", MagicMock(return_value=lock))
monkeypatch.setattr(QtCore.QStandardPaths, "writableLocation", lambda _location: str(tmp_path))
socket = MagicMock()
socket.waitForConnected.return_value = False
server = MagicMock()
monkeypatch.setattr(QtNetwork, "QLocalSocket", MagicMock(return_value=socket))
monkeypatch.setattr(QtNetwork, "QLocalServer", server)
tray = MagicMock()
window = MagicMock(side_effect=RuntimeError("main window reached"))
monkeypatch.setattr(app, "run_silent_tray", tray)
monkeypatch.setattr(main_window, "MainWindow", window)
exe = tmp_path / "my game.exe"
exe.touch()
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server, lock=lock,
window=window, exe=exe)
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server,
tray=tray, window=window, exe=exe)
@pytest.mark.parametrize("file_uri", [False, True])
@@ -88,6 +128,7 @@ def test_silent_exe_keeps_main_window_for_emulation(
with pytest.raises(RuntimeError, match="main window reached"):
startup.app.main()
startup.tray.assert_not_called()
startup.window.assert_called_once()
assert startup.window.call_args.kwargs["launch_exe"] == str(startup.exe)
assert os.environ["MANGOHUD"] == "1"
@@ -104,21 +145,7 @@ def test_silent_exe_uses_existing_instance(
startup.app.main()
startup.socket.write.assert_called_once_with(f"silent:{startup.exe}".encode())
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.tray.assert_not_called()
startup.window.assert_not_called()
startup.qt_app.exec.assert_not_called()
@@ -155,63 +182,6 @@ def test_silent_launch_skips_startup_workers(
startup.window.assert_called_once()
@pytest.mark.parametrize("target", ["exe", "gog://launch/123", "egs://launch/example"])
@pytest.mark.parametrize("decision", [
("No", True, False), ("Yes", True, False), ("No", False, False),
("Yes", False, False), ("Yes", True, True),
])
def test_silent_ipc_confirms_restarting_application(
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
target: str, decision: tuple[str, bool, bool],
) -> None:
from PySide6.QtCore import QThread
from PySide6.QtWidgets import QMessageBox
startup = silent_startup
reply, running, stop_failed = decision
window = MagicMock(game_processes=[])
startup.window.side_effect = None
startup.window.return_value = window
timers = MagicMock()
monkeypatch.setattr(startup.app, "QTimer", timers)
monkeypatch.setattr(QThread, "start", MagicMock())
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
question = MagicMock()
question.exec.return_value = getattr(QMessageBox.StandardButton, reply)
message_box = MagicMock(return_value=question, StandardButton=QMessageBox.StandardButton,
Icon=QMessageBox.Icon)
monkeypatch.setattr(startup.app, "QMessageBox", message_box)
restart, stop = MagicMock(), MagicMock()
monkeypatch.setattr(startup.app.os, "execv", restart)
monkeypatch.setattr(startup.app.psutil, "Process", MagicMock(return_value=MagicMock(children=lambda **kw: [])))
monkeypatch.setattr(startup.app.psutil, "wait_procs", MagicMock(return_value=([], [])))
stop.side_effect = OSError("stop failed") if stop_failed else None
monkeypatch.setattr(startup.app.subprocess, "run", stop)
target = str(startup.exe) if target == "exe" else target
def event_loop() -> int:
timers.singleShot.call_args_list[0].args[1]()
message_box.assert_not_called()
window.game_processes = [MagicMock()] if running else []
conn = MagicMock()
conn.readAll.return_value.data.return_value = f"silent:{target}".encode()
startup.server.return_value.nextPendingConnection.return_value = conn
startup.server.return_value.newConnection.connect.call_args.args[0]()
timers.singleShot.call_args.args[1]()
return 0
startup.qt_app.exec.side_effect = event_loop
with pytest.raises(SystemExit):
startup.app.main()
question.setDefaultButton.assert_called_once_with(QMessageBox.StandardButton.No)
window.toggleGame.assert_called_once_with("'" + str(startup.exe) + "'")
assert startup.qt_app.quit.called is (reply == "Yes")
assert (stop.called, window._stopBackgroundWorkers.called) == (reply == "Yes",) * 2
if reply == "Yes" and not stop_failed:
restart.assert_called_once_with(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", target])
else:
assert not restart.called and not startup.lock.unlock.called
@pytest.mark.parametrize("flags,target", [
([], "exe"),
(["--silent", "--log"], "exe"),
@@ -231,62 +201,10 @@ def test_other_launch_modes_keep_main_window(
with pytest.raises(RuntimeError, match="main window reached"):
startup.app.main()
startup.tray.assert_not_called()
startup.window.assert_called_once()
@pytest.mark.parametrize("flags", [[], ["--silent"], ["--log"], ["--silent", "--log"]])
@pytest.mark.parametrize("existing", [False, True])
def test_native_shortcut_launch_modes(
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, flags: list[str], existing: bool,
) -> None:
from urllib.parse import quote
from PySide6.QtCore import QThread
from portprotonqt.native_games import create_native_desktop_file
startup = silent_startup
executable = startup.exe.with_name("native game")
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
result = create_native_desktop_file(str(executable), "Native")
assert result is not None
content, destination = result
Path(destination).write_text(content)
uri = "native://" + destination
startup.socket.waitForConnected.return_value = existing
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(executable)])
mode = "log" if "--log" in flags else "silent" if "--silent" in flags else "open"
if existing:
startup.app.main()
startup.socket.write.assert_called_once_with(f"{mode}:{quote(uri, safe='')}".encode())
startup.window.assert_not_called()
return
window, timers = MagicMock(), MagicMock()
window.game_processes = []
startup.window.side_effect, startup.window.return_value = None, window
monkeypatch.setattr(startup.app, "QTimer", timers)
monkeypatch.setattr(QThread, "start", MagicMock())
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
def event_loop() -> int:
callback = next(call.args[1] for call in timers.singleShot.call_args_list
if getattr(call.args[1], "__name__", "") == "handle_native_launch")
callback()
return 0
startup.qt_app.exec.side_effect = event_loop
with pytest.raises(SystemExit):
startup.app.main()
assert window.silent_launch_mode is (mode == "silent")
if mode == "silent":
window.toggleGame.assert_called_once_with(uri)
else:
window._load_native_games_async.assert_called_once()
window._load_native_games_async.call_args.args[0]([
("Native", "", "", "", "", uri, "", "", "", "", 0, 0, "native", "", "", "")])
assert window.openGameDetailPage.call_args.args[0]["exec_line"] == uri
if mode == "log":
assert window._pending_log_exe == uri
class TestNormalizeLaunchPath:
def test_plain_path(self):
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
@@ -463,428 +381,3 @@ class TestParseResolution:
def test_empty_string(self):
assert parse_resolution("") is None
def test_list_games_json_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_portproton_games
from portprotonqt.config import portproton
executable = tmp_path / "Game with spaces.exe"
shortcut = tmp_path / "game.desktop"
shortcut.write_text(
'[Desktop Entry]\nName=Test Game\n'
f'Exec=portprotonqt --silent "{executable}"\nIcon=test-icon\n',
encoding="utf-8",
)
monkeypatch.setattr(portproton, "get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr(portproton, "get_portproton_scripts_path", lambda: None)
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {
"name": "Test Game", "description": "Description", "cover": "cover.png",
"appid": 123, "custom_icon": "",
})
games = _list_portproton_games(str(tmp_path), {}, {})
assert len(games) == 1
assert games[0]["description"] == "Description"
assert games[0]["cover"] == "cover.png"
assert games[0]["appid"] == 123
assert not {"metadata", "desktop_entry", "custom_icon"} & games[0].keys()
assert {key: games[0][key] for key in (
"id", "name", "desktop_file", "exec", "executable", "directory", "icon", "ppdb"
)} == {
"id": "portproton:game.desktop", "name": "Test Game",
"desktop_file": str(shortcut),
"exec": f'portprotonqt --silent "{executable}"',
"executable": str(executable), "directory": str(tmp_path),
"icon": "test-icon", "ppdb": f"{executable}.ppdb",
}
def test_list_games_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_portproton_games
from portprotonqt.config import portproton
monkeypatch.setattr(portproton, "get_portproton_location", lambda: None)
assert _list_portproton_games(None, {}, {}) == []
def test_parse_list_games(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games"])
assert parse_args().list_games
def test_export_custom_artwork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _get_game_metadata
from portprotonqt.config.portproton import resolve_custom_data_dir
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_steam_game_info", lambda *args: {
"name": "Cached", "description": "Cached description", "appid": "123",
})
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
monkeypatch.setattr("portprotonqt.time_utils.get_playtime_for_exe", lambda *args: 42)
monkeypatch.setattr("portprotonqt.time_utils.get_last_launch_timestamp", lambda *args: 100)
executable = str(tmp_path / "Game.exe")
folder = Path(resolve_custom_data_dir(str(tmp_path / "PortProtonQt/custom_data"), executable))
folder.mkdir(parents=True)
(folder / "metadata.txt").write_text("name_en=Custom\ndescription_en=Description\n")
(folder / "cover.png").touch()
(folder / "icon.png").touch()
metadata = _get_game_metadata("Original", "launch", executable)
assert metadata["name"] == "Custom"
assert metadata["description"] == "Description"
assert metadata["cover"] == str(folder / "cover.png")
assert metadata["custom_icon"] == str(folder / "icon.png")
assert metadata["appid"] == "123"
assert metadata["playtime_seconds"] == 42
def test_export_runtime(tmp_path: Path) -> None:
from portprotonqt.cli import _get_game_runtime
executable = str(tmp_path / "Game.exe")
Path(f"{executable}.ppdb").write_text(
'export PW_WINE_USE="PROTON_LG"\nexport PW_PREFIX_NAME="GAME"\n'
)
runner = tmp_path / "data/dist/PROTON_TEST"
runner.mkdir(parents=True)
runtime = _get_game_runtime(tmp_path, executable, {"PW_PROTON_LG_VER": "PROTON_TEST"}, {})
assert runtime["runner"] == "PROTON_TEST"
assert "runner_version" not in runtime
assert runtime["runner_path"] == str(runner)
assert runtime["runner_installed"]
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/GAME")
def test_settings_do_not_execute_shell(tmp_path: Path) -> None:
from portprotonqt.cli import _read_game_settings
marker = tmp_path / "should-not-exist"
settings = tmp_path / "settings"
settings.write_text(
f'export PW_WINE_USE="$(touch {marker})"\n'
'export PW_PREFIX_NAME="Game prefix"\n'
'check_variables PW_VULKAN_USE "1"\n'
)
assert _read_game_settings(settings) == {"PW_PREFIX_NAME": "Game prefix", "PW_VULKAN_USE": "1"}
assert not marker.exists()
def test_list_games_exits_before_gui(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(list_games=True, game_info=None, edit_game=None, pretty=True, fields=None, all_games=True))
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{"name": "Игра"}])
gui = MagicMock(side_effect=AssertionError("GUI must not start"))
monkeypatch.setattr(app, "QApplication", gui)
assert app.main() == 0
output = capsys.readouterr().out
assert orjson.loads(output) == [{"name": "Игра"}]
assert output == '[\n {\n "name": "Игра"\n }\n]\n'
gui.assert_not_called()
@pytest.mark.parametrize("external", [False, True])
def test_runtime_wine_layout(tmp_path: Path, external: bool) -> None:
from portprotonqt.cli import _get_game_runtime
runner = tmp_path / "External Wine" if external else tmp_path / "data/dist/PROTON_TEST"
(runner / "files/bin-wow64").mkdir(parents=True)
selected = str(runner) if external else "PROTON_TEST"
runtime = _get_game_runtime(tmp_path, None, {}, {
"PW_WINE_USE": selected, "PW_USE_WOW64": "1", "PW_PREFIX_NAME": "CUSTOM",
})
assert runtime["wine_path"] == str(runner / "files/bin-wow64/wine")
assert runtime["wineserver_path"] == str(runner / "files/bin-wow64/wineserver")
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/CUSTOM")
@pytest.mark.parametrize("source,backend", [("gog", "gog_api.GOGAPI"), ("egs", "egs_api.EGSAPI")])
def test_list_store_games(source: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_store_games
api = MagicMock()
api.load_library.return_value = [
{"app_id": "123", "title": "Installed", "description": "Details", "cover": "store.jpg"},
{"app_id": "456", "title": "Owned"},
]
api.load_installed.return_value = {"123": {}}
api.is_game_installed.side_effect = lambda app_id, installed: app_id in installed
api.get_launch_target.return_value = "/games/game.exe"
monkeypatch.setattr(f"portprotonqt.{backend}", lambda: api)
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
lambda source, app_id, name: (name, "custom.jpg"))
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {"custom_icon": "icon.png"})
games = _list_store_games(source, None, {}, {})
assert [game["id"] for game in games] == [f"{source}:123", f"{source}:456"]
assert all(game["source"] == source for game in games)
assert games[0]["description"] == "Details"
assert games[0]["cover"] == "custom.jpg"
assert games[0]["icon"] == "icon.png"
assert games[0]["exec"] == f"{source}://launch/123"
assert games[1]["exec"] == f"{source}://install/456"
assert games[1]["executable"] is None
api.get_launch_target.assert_called_once_with("123")
def test_list_steam_games(monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import _list_steam_games
monkeypatch.setattr("portprotonqt.steam_api.get_steam_installed_games", lambda: [("Game", 123, 100, 42)])
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_full_steam_game_info",
lambda *args: {"description": "Details", "cover": "cached.jpg"})
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
lambda *args: ("Custom", "custom.jpg"))
game = _list_steam_games()[0]
assert game["id"] == "steam:123"
assert game["source"] == "steam"
assert game["name"] == "Custom"
assert game["cover"] == "custom.jpg"
assert game["exec"] == "steam://rungameid/123"
assert game["playtime_seconds"] == 42
def test_list_all_sources_without_portproton(monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import list_games
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: None)
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_scripts_path", lambda: None)
monkeypatch.setattr("portprotonqt.cli._list_steam_games", lambda: [{"source": "steam"}])
monkeypatch.setattr("portprotonqt.cli._list_store_games", lambda source, *args: [{"source": source}])
assert [game["source"] for game in list_games()] == ["steam", "gog", "egs"]
@pytest.mark.parametrize("format_flag,pretty", [("--pretty", True), ("--raw", False)])
def test_json_format_args(format_flag: str, pretty: bool, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--game-info", "steam:123", format_flag])
args = parse_args()
assert args.game_info == "steam:123"
assert args.pretty is pretty
@pytest.mark.parametrize("game_id", ["steam:123", "gog:123", "missing"])
def test_game_info_output(game_id: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(
list_games=False, game_info=game_id, edit_game=None, pretty=False, fields=None,
))
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
{"id": "steam:123", "source": "steam"}, {"id": "gog:123", "source": "gog"},
])
code = app.main()
output = capsys.readouterr().out
if game_id == "missing":
assert code == 1
assert output == ""
else:
assert code == 0
assert orjson.loads(output)["id"] == game_id
assert len(output.splitlines()) == 1
@pytest.mark.parametrize("flags", [
["--pretty", "--raw"], ["--list-games", "--game-info", "steam:123"], ["--game-info"],
])
def test_library_conflicting_args(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
@pytest.fixture
def editable_game(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
from portprotonqt.cli import _read_game_settings
executable = tmp_path / "Game with spaces.exe"
executable.touch()
ppdb = Path(f"{executable}.ppdb")
ppdb.write_text('# Keep this comment\nexport PW_MANGOHUD="0"\nexport PW_WINDOWS_VER="10"\n')
game = {"id": "portproton:game.desktop", "source": "portproton", "executable": str(executable)}
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
{**game, "runtime": {"settings": _read_game_settings(ppdb)}},
])
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
functions = "\n".join(
name + helper.split(name, 1)[1].split("\n}\n", 1)[0] + "\n}"
for name in ("set_several_variables () {", "edit_db_from_gui () {")
)
script = tmp_path / "start.sh"
script.write_text(
'#!/usr/bin/env bash\nset -e\n' + functions + '\n'
'print_info () { printf "%s\\n" "$*"; }\n'
'print_error () { printf "%s\\n" "$*" >&2; }\n'
'PORTWINE_DB_FILE="${3}.ppdb"\n'
'set_several_variables "${@:4}"\nedit_db_from_gui $keys_all\n'
)
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command",
lambda: ["bash", str(script)])
return {**game, "ppdb": ppdb, "script": script}
def test_edit_game_roundtrip(editable_game: dict) -> None:
from portprotonqt.cli import edit_game
result = edit_game(editable_game["id"], [
"PW_MANGOHUD=1", 'LAUNCH_PARAMETERS=--option=value "quoted"',
"PW_PREFIX_NAME=GAME", "WINEDLLOVERRIDES=d3d11=n,b;dxgi=n",
])
settings = result["runtime"]["settings"]
assert settings["PW_MANGOHUD"] == "1"
assert settings["LAUNCH_PARAMETERS"] == '--option=value "quoted"'
assert settings["WINEDLLOVERRIDES"] == "d3d11=n,b;dxgi=n"
assert settings["PW_PREFIX_NAME"] == "GAME"
assert settings["PW_WINDOWS_VER"] == "10"
assert editable_game["ppdb"].read_text().startswith("# Keep this comment\n")
@pytest.mark.parametrize("change", [
"PW_MANGOHUD=2", "NOT_A_SETTING=1", "PW_MANGOHUD", "PW_PREFIX_NAME=../other",
"PW_PREFIX_NAME=", "PW_MANGOHUD=$(touch /tmp/forbidden)", "PW_MANGOHUD=1\ntrue",
"LAUNCH_PARAMETERS=a|b", "LAUNCH_PARAMETERS=`id`",
])
def test_edit_game_rejects_invalid_values(editable_game: dict, change: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import edit_game
run = MagicMock()
monkeypatch.setattr("subprocess.run", run)
before = editable_game["ppdb"].read_bytes()
with pytest.raises(ValueError):
edit_game(editable_game["id"], ["PW_MANGOHUD=1", change])
run.assert_not_called()
assert editable_game["ppdb"].read_bytes() == before
@pytest.mark.parametrize("source", ["portproton", "gog", "egs"])
def test_edit_game_sources(editable_game: dict, source: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import edit_game
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{**editable_game, "source": source}])
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
assert 'export PW_MANGOHUD="1"' in editable_game["ppdb"].read_text()
@pytest.mark.parametrize("mode", ["missing", "steam", "uninstalled", "no-script", "failed", "not-saved"])
def test_edit_game_errors(editable_game: dict, mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
from portprotonqt.cli import edit_game
if mode == "missing":
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
elif mode in {"steam", "uninstalled"}:
game = {**editable_game, "source": "steam"} if mode == "steam" else {**editable_game, "executable": None}
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [game])
elif mode == "no-script":
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command", lambda: None)
else:
editable_game["script"].write_text("exit 4\n" if mode == "failed" else "exit 0\n")
expected = RuntimeError if mode in {"failed", "not-saved"} else ValueError
with pytest.raises(expected):
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
@pytest.mark.parametrize("flags", [
["--set", "PW_MANGOHUD=1"], ["--edit-game", "gog:123"],
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--list-games"],
])
def test_edit_game_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
@pytest.mark.parametrize("failure", [False, True])
def test_edit_game_cli_output(failure: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
monkeypatch.setattr(sys, "argv", [
"portprotonqt", "--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--raw",
])
edit = MagicMock(return_value={"id": "gog:123"})
if failure:
edit.side_effect = RuntimeError("write failed")
monkeypatch.setattr("portprotonqt.cli.edit_game", edit)
assert app.main() == (1 if failure else 0)
output = capsys.readouterr().out
assert output == "" if failure else orjson.loads(output) == {"id": "gog:123"}
edit.assert_called_once_with("gog:123", ["PW_MANGOHUD=1"])
@pytest.mark.parametrize("single", [False, True])
@pytest.mark.parametrize("pretty", [False, True])
def test_library_selected_fields(single: bool, pretty: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
command = ["--game-info", "gog:123"] if single else ["--list-games"]
monkeypatch.setattr(sys, "argv", [
"portprotonqt", *command, "--fields", "executable, icon,name,source",
"--pretty" if pretty else "--raw",
])
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
{"id": "gog:123", "name": "Game", "icon": "icon.png", "source": "gog",
"executable": "/games/game.exe", "description": "Not selected", "installed": True},
{"id": "steam:456", "name": "Steam Game", "source": "steam", "installed": True},
])
assert app.main() == 0
output = capsys.readouterr().out
expected = {"executable": "/games/game.exe", "icon": "icon.png", "name": "Game", "source": "gog"}
assert orjson.loads(output) == (expected if single else [
expected, {"executable": None, "icon": None, "name": "Steam Game", "source": "steam"},
])
assert (len(output.splitlines()) > 1) is pretty
def test_library_selected_fields_empty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
from portprotonqt import app
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games", "--fields", "name"])
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
assert app.main() == 0
assert capsys.readouterr().out == "[]\n"
@pytest.mark.parametrize("flags", [
["--fields", "name"], ["--list-games", "--fields", ""],
["--list-games", "--fields", "name,,icon"], ["--list-games", "--fields", "name,"],
["--list-games", "--fields", "name;icon"],
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--fields", "name"],
])
def test_library_selected_fields_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
@pytest.mark.parametrize("include_all", [False, True])
def test_list_games_installed_filter(include_all: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
import orjson
from portprotonqt import app
arguments = ["portprotonqt", "--list-games", "--fields", "id", "--raw"]
if include_all:
arguments.append("--all")
monkeypatch.setattr(sys, "argv", arguments)
games = [
{"id": "portproton:missing.desktop", "installed": False},
{"id": "steam:123", "installed": True},
{"id": "gog:456", "installed": False},
{"id": "egs:789", "installed": True},
]
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: games)
assert app.main() == 0
expected = [game for game in games if include_all or game["installed"]]
assert orjson.loads(capsys.readouterr().out) == [{"id": game["id"]} for game in expected]
@pytest.mark.parametrize("flags", [["--all"], ["--game-info", "gog:123", "--all"]])
def test_all_requires_list_games(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
with pytest.raises(SystemExit) as error:
parse_args()
assert error.value.code == 2
+1 -37
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
from pytest import MonkeyPatch, mark
from pytest import MonkeyPatch
from PySide6.QtWidgets import QWidget
import portprotonqt.detail_pages.utils as detail_utils
@@ -701,39 +701,3 @@ def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
add_count = sum(1 for t in button_texts if "Add" in t or "Добавить" in t)
assert edit_count == 1
assert add_count == 0
@mark.parametrize("managed", [False, True])
def test_native_detail_folder_button_only_for_managed_games(
tmp_path: Path, monkeypatch: MonkeyPatch, managed: bool,
) -> None:
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
data = tmp_path / "data"
monkeypatch.setenv("XDG_DATA_HOME", str(data))
directory = data / "PortProtonQt/native_games" if managed else tmp_path / "usr/share/applications"
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
manager._create_game_buttons_layout({
"game_source": "native",
"exec_line": "native://" + str(directory / "hytale.desktop"),
"name": "Hytale Launcher",
})
buttons = _get_added_buttons(manager)
assert "Settings" in buttons
assert "Create Log" in buttons
assert ("Open Folder" in buttons) is managed
def test_steam_detail_button_attaches_to_existing_game_watch() -> None:
button = MagicMock()
window = SimpleNamespace(
target_exe=None, theme_manager=MagicMock(), toggleGame=MagicMock(),
_toggle_steam_game=MagicMock(),
)
manager = SimpleNamespace(main_window=window, _make_action_button=MagicMock(return_value=button))
result = DetailPageManager._create_play_button(
cast(Any, manager), "steam://rungameid/3263320", None
)
assert result is button
window._toggle_steam_game.assert_called_once_with(
"steam://rungameid/3263320", button, launch=False
)
+1 -46
View File
@@ -1,4 +1,3 @@
import argparse
import time
from types import SimpleNamespace
@@ -7,53 +6,9 @@ from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication
from portprotonqt.scripts_utils.easyterm import (
CONF_FALLBACK_SHELL,
TerminalWidget,
build_command,
)
from portprotonqt.scripts_utils.easyterm import TerminalWidget
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(
-110
View File
@@ -1,17 +1,14 @@
"""Tests for themed file explorer icons."""
import os
from pathlib import Path
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
from pytest import MonkeyPatch
from PySide6.QtCore import QSize
from PySide6.QtGui import QColor, QIcon, QPixmap
from PySide6.QtWidgets import (
QApplication,
QDialog,
QListWidget,
QListWidgetItem,
QStyle,
@@ -19,8 +16,6 @@ from PySide6.QtWidgets import (
)
import portprotonqt.dialogs.file_explorer as file_explorer_module
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
from portprotonqt.input_manager.constants import BUTTONS
from portprotonqt.dialogs.file_explorer import (
FileExplorer,
FileIconDelegate,
@@ -30,53 +25,6 @@ from portprotonqt.dialogs.file_explorer import (
_application = QApplication.instance() or QApplication([])
def test_executable_filter_keeps_exe_and_linux_executables(tmp_path: Path) -> None:
from PySide6.QtCore import QMimeDatabase
from PySide6.QtWidgets import QLabel
for name in ("Game.EXE", "launcher", "notes.txt"):
(tmp_path / name).touch()
(tmp_path / "launcher").chmod(0o755)
(tmp_path / "folder").mkdir()
explorer = SimpleNamespace(
file_list=QListWidget(), current_path=str(tmp_path), directory_only=False,
thumbnail_cache={}, pending_thumbnails={}, deferred_thumbnails={}, file_items={},
path_history={}, path_label=QLabel(), mime_db=QMimeDatabase(),
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
_get_list_icon=lambda name: None, _get_file_type_icon=lambda path: None,
schedule_thumbnail_loading=lambda: None,
)
FileExplorer.update_file_list(cast(FileExplorer, explorer))
assert [explorer.file_list.item(i).text() for i in range(explorer.file_list.count())] == [
"../", "folder/", "Game.EXE", "launcher",
]
def test_gamepad_opens_file_explorer_context_menu(monkeypatch: MonkeyPatch) -> None:
explorer = FileExplorer.__new__(FileExplorer)
QDialog.__init__(explorer)
explorer.file_list = QListWidget(explorer)
explorer.file_list.addItem("folder/")
explorer.file_list.setCurrentRow(0)
context_manager = MagicMock()
explorer.context_menu_manager = context_manager
manager = SimpleNamespace(file_explorer=explorer, _parent=SimpleNamespace())
monkeypatch.setattr(QApplication, "activeWindow", lambda: explorer)
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
FileExplorerInputMixin.handle_file_explorer_button(
cast(FileExplorerInputMixin, manager), next(iter(BUTTONS["context_menu"])), 1,
)
item = explorer.file_list.currentItem()
assert item is not None
context_manager.show_file_explorer_context_menu.assert_called_once_with(
explorer, explorer.file_list.visualItemRect(item).center(),
)
class FakeIcon:
"""Record icon files assigned to Qt modes."""
@@ -147,61 +95,3 @@ def test_list_icon_uses_theme_state_colors(
QIcon.Mode.Selected: "folder-#222222.svg",
QIcon.Mode.Disabled: "folder-#333333.svg",
}
def test_appimage_thumbnail_worker_caches_icon_and_finishes(
tmp_path: Path, monkeypatch: MonkeyPatch,
) -> None:
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QImage
source = tmp_path / "game.AppImage"
source.touch()
cache = tmp_path / "cache" / "icon.png"
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
calls = []
def generate(path: str, output: str) -> bool:
calls.append(path)
image = QImage(16, 16, QImage.Format.Format_ARGB32)
image.fill(QColor("red"))
return image.save(output)
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", generate)
pool = QThreadPool()
for _attempt in range(2):
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
received = []
loader.signals.thumbnail_ready.connect(lambda path, image, received=received: received.append((path, image)))
pool.start(loader)
assert pool.waitForDone(5000)
_application.processEvents()
assert len(received) == 1
assert received[0][0] == str(source)
assert not received[0][1].isNull()
assert calls == [str(source)]
assert pool.activeThreadCount() == 0
def test_appimage_thumbnail_failure_completes_without_replacing_icon(
tmp_path: Path, monkeypatch: MonkeyPatch,
) -> None:
from PySide6.QtGui import QImage
source = tmp_path / "game.AppImage"
source.touch()
cache = tmp_path / "cache" / "icon.png"
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", lambda *_args: False)
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
received = []
loader.signals.thumbnail_ready.connect(lambda path, image: received.append((path, image)))
loader.run()
assert len(received) == 1
assert received[0][0] == str(source)
assert received[0][1].isNull()
item = MagicMock()
explorer = SimpleNamespace(thumbnail_cache={}, file_items={str(source): item})
FileExplorer._set_thumbnail_icon(cast(FileExplorer, explorer), str(source), QImage())
assert str(source) in explorer.thumbnail_cache
item.setIcon.assert_not_called()
+3 -100
View File
@@ -8,7 +8,7 @@ from typing import Any, cast
from unittest.mock import patch
from PIL import Image
from pytest import MonkeyPatch, raises, mark
from pytest import MonkeyPatch, raises
import portprotonqt.game_card as game_card_module
import portprotonqt.image_utils as image_utils
@@ -608,16 +608,9 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
]
@mark.parametrize("url, filename", [
("https://example.org/game.jpg", "game.jpg"),
("https://cdn2.steamgriddb.com/grid/unique-cover.png", "unique-cover.png"),
("https://deckyloader.ru/sgdb-cdn/grid/unique-cover.png", "unique-cover.png"),
])
def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
tmp_path: Path,
monkeypatch: MonkeyPatch,
url: str,
filename: str,
) -> None:
exe_path = tmp_path / "game.exe"
exe_path.write_bytes(b"MZ")
@@ -633,8 +626,6 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
timeout: int,
callback: Callable[[str | None], None],
) -> None:
assert _url == url
assert Path(_path).name == filename
callbacks.append(callback)
monkeypatch.setattr("portprotonqt.icon_extractor.CACHE_DIR", tmp_path / "cache" / "PortProtonQt")
@@ -651,102 +642,14 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
)
image_utils.load_pixmap_async(
url, 100, 150,
"https://example.org/game.jpg", 100, 150,
lambda pixmap: loaded_paths.append(cast(FakePixmap, pixmap).path),
app_name="game", fallback_exe=str(exe_path),
fallback_icon_path=str(fallback_path),
)
callbacks[0](None)
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / "game.jpg"
downloaded_path.write_bytes(b"cover")
callbacks[0](str(downloaded_path))
assert loaded_paths == [str(fallback_path), str(downloaded_path)]
def test_custom_icon_overrides_extracted_icon(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
from portprotonqt.config.portproton import get_custom_data_dir_name, get_custom_icon_path
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
executable = "/games/custom/game.exe"
folder = tmp_path / "PortProtonQt" / "custom_data" / get_custom_data_dir_name(executable)
folder.mkdir(parents=True)
icon = folder / "icon.png"
icon.write_bytes(b"custom icon")
assert get_custom_icon_path(executable) == str(icon)
assert get_exe_icon_cache_path(executable) == str(icon)
icon.unlink()
assert get_custom_icon_path(executable) == ""
assert get_exe_icon_cache_path(executable) != str(icon)
@mark.parametrize("elf_class,endian", [(1, "<"), (2, "<"), (2, ">")])
@mark.parametrize("svg", [False, True])
def test_appimage_icon_extraction(tmp_path: Path, elf_class: int, endian: str, svg: bool) -> None:
from portprotonqt.icon_extractor import APPIMAGE_EXTRACT_TIMEOUT, ELF_SECTION_LAYOUTS
header = bytearray(64)
header[:4] = b"\x7fELF"
header[4:6] = bytes([elf_class, 1 if endian == "<" else 2])
header[8:11] = b"AI\x02"
offset_position, offset_format, size_position = ELF_SECTION_LAYOUTS[elf_class]
struct.pack_into(endian + offset_format, header, offset_position, len(header))
struct.pack_into(endian + "HH", header, size_position, 0, 0)
source = tmp_path / "game.AppImage"
source.write_bytes(header + b"hsqs")
output = tmp_path / "icon.png"
icon = io.BytesIO()
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">'
b'<rect width="16" height="16" fill="red"/></svg>') if svg else icon.getvalue()
with patch("portprotonqt.icon_extractor.subprocess.run", return_value=SimpleNamespace(stdout=payload)) as run:
assert generate_thumbnail(str(source), str(output))
run.assert_called_once_with(
["unsquashfs", "-cat", "-o", "64", str(source), ".DirIcon"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
with Image.open(output) as image:
assert image.size == (128, 128)
assert image.convert("RGBA").getpixel((64, 64)) == (255, 0, 0, 255)
def test_appimage_icon_missing_tool(tmp_path: Path) -> None:
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
header[8:11] = b"AI\x02"
struct.pack_into("<Q", header, 40, len(header))
source = tmp_path / "game.AppImage"
source.write_bytes(header + b"hsqs")
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=FileNotFoundError):
assert not generate_thumbnail(str(source), str(tmp_path / "icon.png"))
@mark.parametrize("target", ["icon.png", "../outside", ".DirIcon"])
def test_dwarfs_icon_symlink(tmp_path: Path, target: str) -> None:
import tarfile
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
header[8:11] = b"AI\x02"
struct.pack_into("<Q", header, 40, len(header))
source = tmp_path / "game.AppImage"
source.write_bytes(header + b"DWARFS")
link_archive = io.BytesIO()
with tarfile.open(fileobj=link_archive, mode="w") as archive:
member = tarfile.TarInfo(".DirIcon")
member.type = tarfile.SYMTYPE
member.linkname = target
archive.addfile(member)
icon = io.BytesIO()
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
icon_archive = io.BytesIO()
with tarfile.open(fileobj=icon_archive, mode="w") as archive:
member = tarfile.TarInfo("icon.png")
member.size = len(icon.getvalue())
archive.addfile(member, io.BytesIO(icon.getvalue()))
results = [SimpleNamespace(stdout=data.getvalue()) for data in (link_archive, icon_archive)]
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=results) as run:
assert generate_thumbnail(str(source), str(tmp_path / "icon.png")) is (target == "icon.png")
assert run.call_count == (2 if target == "icon.png" else 1)
assert run.call_args_list[0].args[0][0] == "dwarfsextract"
assert "--skip-specials" in run.call_args_list[0].args[0]
-137
View File
@@ -1567,140 +1567,3 @@ def test_x_edits_keyboard_without_triggering_dialog_action(monkeypatch: MonkeyPa
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
+9 -37
View File
@@ -13,7 +13,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
from portprotonqt import image_utils
from portprotonqt.game_card import GameCard
from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.theme_manager import ThemeManager, load_theme
from portprotonqt.theme_manager import load_theme
LOAD_TIMEOUT_MS = 30000
@@ -54,23 +54,6 @@ def library_scene(
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
def test_native_card_uses_linux_source_corner(
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, _ = library_scene
monkeypatch.setattr(ThemeManager(), "current_theme_name", "standart")
card = manager._create_game_card((
"Native", "", "", "", "", "native:///game.desktop", "", "", "", "", 0, 0, "native",
))
assert card.native_visible
assert isinstance(card.nativeLabel._icon, str)
assert Path(card.nativeLabel._icon).name == "badge_linux.svg"
assert Path(card.nativeLabel._icon).is_file()
assert not card.portproton_visible
card.deleteLater()
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
def test_new_horizontal_cards_fit_before_layout_insertion(
@@ -468,7 +451,6 @@ def test_failed_protondb_completion_preserves_library_scroll(
) -> None:
from portprotonqt.main_window import MainWindow
from portprotonqt.steam_api import api
import portprotonqt.portproton_api as portproton_api
manager, _ = library_scene
manager.rebuild_library_layout(scenario[0])
@@ -477,7 +459,6 @@ def test_failed_protondb_completion_preserves_library_scroll(
main.getColorPalette_from_pixmap = MagicMock()
main.game_library_manager = manager
main._get_portproton_desktop_files = lambda: set()
monkeypatch.setattr(portproton_api, "get_user_conf_setting", lambda _key: None)
monkeypatch.setattr(api, "load_protondb_status", lambda _appid: None)
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
responses: list[Callable[[str | None], None]] = []
@@ -562,28 +543,19 @@ def test_vertical_library_headers_align_with_values(
- value.mapToGlobal(value.rect().center()).x()) <= 1
def test_space_enters_and_toggles_card_selection_without_opening_details(
def test_control_click_toggles_card_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
position = card.rect().center().toPointF()
for selected in (True, False):
event = QMouseEvent(QEvent.Type.MouseButtonPress, position, position,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.ControlModifier)
card.mousePressEvent(event)
assert card.batch_selected is selected
cast(Any, manager.main_window).openGameDetailPage.assert_not_called()
+23 -466
View File
@@ -14,7 +14,6 @@ import psutil
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,
@@ -24,8 +23,6 @@ from PySide6.QtWidgets import (
QMenu,
QMainWindow,
QMessageBox,
QStyle,
QStyleOptionComboBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
@@ -35,11 +32,10 @@ from PySide6.QtWidgets import (
from portprotonqt.animations.library_controls import _animation_duration
from portprotonqt.animations.game_card import GameCardAnimations
from portprotonqt.config import game_config
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, CustomComboBox
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton
from portprotonqt.detail_pages import DetailPageManager
from portprotonqt.game_card import GameCard, SourceCorner
from portprotonqt.game_library_manager import FullLibraryTile, GameLibraryManager
from portprotonqt.input_manager import InputManager
from portprotonqt.main_window import MainWindow
from portprotonqt.search_utils import SearchOptimizer, search_index
from portprotonqt.tray_manager import TrayManager
@@ -316,7 +312,7 @@ def _tile_theme() -> Any:
@mark.parametrize(
"worker_name",
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "native_library_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
)
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
worker = MagicMock()
@@ -434,17 +430,15 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
manager.tray_icon.hide.assert_called_once_with()
quit_app.assert_called_once_with()
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
@mark.parametrize("source", ["portproton", "native"])
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, False)])
def test_source_ribbon_visibility(
monkeypatch: MonkeyPatch,
vertical_layout: bool,
ribbon_visible: bool,
source: str,
) -> None:
card = MagicMock()
card.coverLabel = MagicMock()
card.game_source = source
card.game_source = "portproton"
card.ppdb_id = "report-id"
card.protondb_tier = "Platinum"
card.anticheat_status = "Supported"
@@ -455,9 +449,9 @@ def test_source_ribbon_visibility(
card.parentWidget.return_value = None
monkeypatch.setattr("portprotonqt.game_card.ui_config.get_economy_mode", lambda: False)
GameCard.update_badge_visibility(card, source)
GameCard.update_badge_visibility(card, "portproton")
getattr(card, f"{source}Label").setVisible.assert_called_with(ribbon_visible)
card.portprotonLabel.setVisible.assert_called_with(ribbon_visible)
card.ppdbLabel.setVisible.assert_called_with(False)
card.protondbLabel.setVisible.assert_called_with(False)
card.anticheatLabel.setVisible.assert_called_with(False)
@@ -793,9 +787,6 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
window.auto_size_slider.maximum.return_value = 250
window.autoInstallLoading = False
window._start_autoinstall_load = MagicMock()
window.auto_install_tab_index = 1
window.stackedWidget = MagicMock()
window.stackedWidget.currentIndex.return_value = 1
window.refresh_autoinstall_layout()
@@ -1427,7 +1418,7 @@ def test_library_source_filter_stays_top_aligned_without_checkbox(
_application = QApplication.instance() or QApplication([])
window = MainWindow.__new__(MainWindow)
test_window = cast(Any, window)
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE="")
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="")
test_window._create_library_combo = lambda labels, _tooltip: QComboBox()
test_window._set_combo_current_key = lambda *_args: None
test_window._register_gamepad_tooltip = lambda *_args: None
@@ -1709,8 +1700,7 @@ def test_open_local_autoinstall_card_uses_autoinstall_page(tmp_path: Path) -> No
assert game_data["exec_line"] == f"autoinstall:{shlex.quote(str(script_path))}"
assert return_tab_index == 0
@mark.parametrize("native", [False, True])
def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> None:
def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
exe_path = tmp_path / "Game.exe"
exe_path.touch()
started = []
@@ -1735,12 +1725,11 @@ def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> No
window.detail_page_manager = FakeDetailPageManager()
window.stackedWidget = FakeStackedWidget()
window.currentDetailPage = None
target = "native://" + str(tmp_path / "Native Game.desktop") if native else str(exe_path)
window._pending_log_exe = target
window._pending_log_exe = str(exe_path)
window.openGameDetailPage({"name": "Game", "exec_line": target})
window.openGameDetailPage({"name": "Game", "exec_line": str(exe_path)})
assert started == [(target, window.detail_page_manager._debug_log_button)]
assert started == [(str(exe_path), window.detail_page_manager._debug_log_button)]
assert window._pending_log_exe is None
def test_launch_exe_skips_library_load_for_ppai() -> None:
@@ -2268,12 +2257,10 @@ def test_steam_launch_plays_game_launch_sound(monkeypatch: MonkeyPatch) -> None:
assert launch_events == ["popen", "game_launch"]
@mark.parametrize("custom_name", [None, "Edited Local Game"])
def test_process_portproton_desktop_calls_callback_without_asset_download(
tmp_config_dir: Path,
tmp_path: Path,
monkeypatch: Any,
custom_name: str | None,
) -> None:
exe_path = tmp_path / "Game.exe"
exe_path.write_text("", encoding="utf-8")
@@ -2289,15 +2276,8 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
window = MainWindow.__new__(MainWindow)
window.portproton_location = str(tmp_path)
results: list[tuple | None] = []
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
if custom_name:
from portprotonqt.config import get_custom_data_dir_name
game_dir = custom_data_path / get_custom_data_dir_name(str(exe_path))
game_dir.mkdir(parents=True)
(game_dir / "metadata.txt").write_text(f"name={custom_name}\n", encoding="utf-8")
def fake_steam_info(_name: str, _exec_line: str, callback: Any) -> None:
assert _name == "Test Game"
callback({})
monkeypatch.setattr(
@@ -2317,9 +2297,10 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
assert len(results) == 1
assert results[0] is not None
assert results[0][0] == (custom_name or "Test Game")
assert results[0][0] == "Test Game"
assert results[0][5] == f"portproton {exe_path}"
assert custom_data_path.exists() == bool(custom_name)
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
assert not custom_data_path.exists()
def test_remove_empty_custom_data_dirs_keeps_non_empty_dirs(tmp_config_dir: Path) -> None:
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
@@ -2620,7 +2601,7 @@ def test_horizontal_layout_menu_maps_endless_modes(
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "theme")
window = cast(Any, SimpleNamespace(
theme=SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=""),
theme=SimpleNamespace(CHECKBOX_STYLE=""),
_create_library_combo=lambda labels, tooltip: QComboBox(),
_set_combo_current_key=MagicMock(),
_register_gamepad_tooltip=MagicMock(),
@@ -2661,7 +2642,7 @@ def test_library_layout_control_rebuilds_both_libraries(
monkeypatch.setattr(
library_tab_module.ui_config,
"get_library_layout_mode",
MagicMock(side_effect=["list", "grid", "grid"]),
lambda theme_mode: "grid",
)
LibraryMixin._on_library_layout_changed(window, 1)
@@ -2738,7 +2719,6 @@ def test_add_game_dialog_does_not_reprocess_its_shortcut(
exeEdit=SimpleNamespace(text=lambda: "/games/praest.exe"),
coverEdit=SimpleNamespace(text=lambda: ""),
getDesktopEntryData=lambda: (entry, str(desktop_path)),
native_checkbox=SimpleNamespace(isChecked=lambda: False),
last_cover_path="",
)
window: Any = MainWindow.__new__(MainWindow)
@@ -2893,22 +2873,15 @@ def test_library_refresh_updates_connected_gog_library(
("load", {"force_load": True}),
]
@mark.parametrize("display_filter, stores", [
("all", ["gog", "egs"]),
("gog,portproton", ["gog"]),
("steam,portproton", []),
])
def test_library_refresh_updates_connected_stores_for_all_filter(
tmp_path: Path,
monkeypatch: MonkeyPatch,
display_filter: str,
stores: list[str],
) -> None:
gog_auth_path = tmp_path / "gog_auth.json"
egs_user_path = tmp_path / "egs_user.json"
gog_auth_path.touch()
egs_user_path.touch()
monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter)
monkeypatch.setattr(game_config, "get_display_filter", lambda: "all")
monkeypatch.setattr(
library_tab_module.QTimer,
"singleShot",
@@ -2930,7 +2903,11 @@ def test_library_refresh_updates_connected_stores_for_all_filter(
LibraryMixin.refreshGames(cast(Any, window))
assert calls == [*stores, ("load", {"force_load": True})]
assert calls == [
"gog",
"egs",
("load", {"force_load": True}),
]
def test_library_refresh_skips_gog_for_portproton_filter(
tmp_path: Path,
@@ -3133,6 +3110,7 @@ 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:
@@ -3150,424 +3128,3 @@ def test_shutdown_stops_batch_before_store_processes() -> None:
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()
def _steam_snapshot(text: str | bytes, created: float = 1.0) -> list[dict[str, Any]]:
if isinstance(text, bytes):
text = text.decode()
snapshot = []
for line in text.splitlines():
pid, parent, name, args = line.split(maxsplit=3)
snapshot.append({
"pid": int(pid), "ppid": int(parent), "name": name,
"cmdline": args.split(), "create_time": created,
})
return snapshot
@mark.parametrize(
("command", "running"),
[
("123 789 reaper /steam/reaper SteamLaunch AppId=620 -- /game", True),
("123 789 reaper /steam/reaper SteamLaunch AppId=6200 -- /game", False),
("123 1 steam steam SteamLaunch AppId=620", False),
("123 789 reaper /steam/reaper SteamLaunch AppId=1 -- AppId=620", False),
],
)
def test_steam_watch_matches_exact_game(command: str, running: bool) -> None:
process = MagicMock()
process.error = None
process.result = MainWindow._collect_steam_processes("620", _steam_snapshot(command.encode()), {})
button = MagicMock()
watch = {
"process": process, "button": button, "running": False,
"stop": False,
}
window = SimpleNamespace(
_steam_watches={"620": watch}, theme_manager=MagicMock(),
_finish_steam_watch=MagicMock(),
)
MainWindow._steam_scan_finished(cast(Any, window), "620")
assert watch["running"] is running
button.setEnabled.assert_called_once_with(running)
window._finish_steam_watch.assert_not_called()
def test_steam_stop_signals_only_selected_reaper(monkeypatch: MonkeyPatch) -> None:
snapshot = _steam_snapshot(
"123 789 reaper /steam/reaper SteamLaunch AppId=620 -- /game\n"
"456 789 reaper /steam/reaper SteamLaunch AppId=730 -- /other\n"
"789 1 steam steam\n"
)
process = MagicMock()
process.create_time.return_value = 1.0
factory = MagicMock(return_value=process)
monkeypatch.setattr(main_window_module.psutil, "Process", factory)
monkeypatch.setattr(main_window_module.psutil, "process_iter", lambda _attrs: [
SimpleNamespace(info=info) for info in snapshot
])
parent = QObject()
worker = main_window_module.SteamGameScanWorker("620", parent)
worker.stop_started = main_window_module.time.monotonic()
worker.run()
factory.assert_called_once_with(123)
process.send_signal.assert_called_once_with(signal.SIGTERM)
@mark.parametrize("was_running", [False, True])
def test_steam_watch_waits_until_game_has_run(was_running: bool) -> None:
process = MagicMock()
process.error = None
process.result = MainWindow._collect_steam_processes("620", _steam_snapshot(b""), {})
window = SimpleNamespace(
_steam_watches={"620": {
"process": process, "button": None, "running": was_running,
"stop": False,
}},
_finish_steam_watch=MagicMock(),
)
MainWindow._steam_scan_finished(cast(Any, window), "620")
window._finish_steam_watch.assert_not_called()
assert window._steam_watches["620"]["running"] is False
if was_running:
assert window._steam_watches["620"]["launch_requested"] is False
def test_steam_watch_cleanup_waits_for_scan_and_restores_button() -> None:
process, timer, button = MagicMock(), MagicMock(), MagicMock()
window = SimpleNamespace(
_steam_watches={"620": {"process": process, "timer": timer, "button": button}},
theme_manager=MagicMock(),
)
MainWindow._finish_steam_watch(cast(Any, window), "620")
assert window._steam_watches == {}
timer.stop.assert_called_once()
process.blockSignals.assert_called_once_with(True)
process.requestInterruption.assert_called_once()
process.wait.assert_called_once()
process.deleteLater.assert_called_once()
button.setEnabled.assert_called_once_with(True)
def test_steam_watch_rebinds_detail_button_without_stopping() -> None:
button = MagicMock()
watch = {"running": True, "launch_requested": True, "stop": False, "button": None}
window = SimpleNamespace(
_steam_watches={"620": watch}, _scan_steam_game=MagicMock(),
_launch_steam_game=MagicMock(),
)
MainWindow._toggle_steam_game(cast(Any, window), "steam://rungameid/620", button, False)
assert watch["button"] is button
assert watch["stop"] is False
window._launch_steam_game.assert_not_called()
window._scan_steam_game.assert_called_once_with("620")
MainWindow._toggle_steam_game(cast(Any, window), "steam://rungameid/620", button)
assert watch["stop"] is True
def test_steam_watch_launches_previously_observed_idle_game() -> None:
button = MagicMock()
watch = {"running": False, "launch_requested": False, "stop": False}
window = SimpleNamespace(
_steam_watches={"620": watch}, _scan_steam_game=MagicMock(),
_launch_steam_game=MagicMock(),
)
MainWindow._toggle_steam_game(cast(Any, window), "steam://rungameid/620", button)
window._launch_steam_game.assert_called_once_with("steam://rungameid/620")
assert watch["launch_requested"] is True
button.setEnabled.assert_called_once_with(False)
def test_steam_watch_retains_orphaned_game_but_not_reused_pid() -> None:
output = (
"100 1 steam steam\n"
"123 100 reaper /steam/reaper SteamLaunch AppId=620 -- /game\n"
"124 123 wine wine /game.exe\n"
"125 124 game.exe /game.exe\n"
"200 100 reaper /steam/reaper SteamLaunch AppId=730 -- /other\n"
)
known = MainWindow._collect_steam_processes("620", _steam_snapshot(output), {})
assert set(known) == {123, 124, 125}
orphaned = "124 1 wine wine /game.exe\n125 1 game.exe /game.exe\n"
known = MainWindow._collect_steam_processes("620", _steam_snapshot(orphaned), known)
assert set(known) == {124, 125}
assert MainWindow._collect_steam_processes("620", _steam_snapshot("124 1 other /other\n", created=2.0), known) == {}
def test_steam_stop_signals_descendants_and_escalates(monkeypatch: MonkeyPatch) -> None:
processes = {123: MagicMock(), 124: MagicMock()}
for process in processes.values():
process.create_time.return_value = 1.0
monkeypatch.setattr(main_window_module.psutil, "Process", lambda pid: processes[pid])
monkeypatch.setattr(main_window_module.time, "monotonic", lambda: 10.0)
watch = {"known": {123: {"create_time": 1.0}, 124: {"create_time": 1.0}}}
MainWindow._stop_steam_processes("620", watch)
for process in processes.values():
process.send_signal.assert_called_once_with(signal.SIGTERM)
process.send_signal.reset_mock()
monkeypatch.setattr(main_window_module.time, "monotonic", lambda: 14.0)
MainWindow._stop_steam_processes("620", watch)
for process in processes.values():
process.send_signal.assert_called_once_with(signal.SIGKILL)
def test_steam_watch_keeps_stop_during_preparation_gap(monkeypatch: MonkeyPatch) -> None:
process = MagicMock()
process.error = None
process.result = MainWindow._collect_steam_processes("620", _steam_snapshot(b"123 1 reaper /steam/reaper SteamLaunch AppId=620 Install=1 -- /setup\n"), {})
watch = {"process": process, "button": None, "running": False, "stop": False}
window = SimpleNamespace(_steam_watches={"620": watch})
monkeypatch.setattr(main_window_module.time, "monotonic", lambda: 10.0)
MainWindow._steam_scan_finished(cast(Any, window), "620")
process.error = None
process.result = MainWindow._collect_steam_processes("620", _steam_snapshot(b""), {})
monkeypatch.setattr(main_window_module.time, "monotonic", lambda: 1000.0)
MainWindow._steam_scan_finished(cast(Any, window), "620")
assert watch["running"] is True
process.error = None
process.result = MainWindow._collect_steam_processes("620", _steam_snapshot(b"456 1 reaper /steam/reaper SteamLaunch AppId=620 -- /game\n"), {})
MainWindow._steam_scan_finished(cast(Any, window), "620")
assert watch["preparing"] is False
process.error = None
process.result = MainWindow._collect_steam_processes("620", _steam_snapshot(b""), {})
MainWindow._steam_scan_finished(cast(Any, window), "620")
assert watch["running"] is True
monkeypatch.setattr(main_window_module.time, "monotonic", lambda: 1004.0)
MainWindow._steam_scan_finished(cast(Any, window), "620")
assert watch["running"] is False
File diff suppressed because it is too large. Load diff
+6 -100
View File
@@ -42,46 +42,21 @@ gui_config () {
assert ppdb.read_text() == expected
@mark.parametrize("enabled", ["0", "1", ""])
@mark.parametrize("inherited", [None, "0", "1"])
def test_video_fix_controls_dxgi_device_manager(
tmp_path: Path, enabled: str, inherited: str | None,
) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
block = ' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]' + helper.split(
' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]', 1,
)[1].split(" fi", 1)[0] + " fi\n"
env = {"PATH": os.defpath, "PW_FIX_VIDEO_IN_GAME": enabled}
if inherited is not None:
env["WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER"] = inherited
result = subprocess.run(
["bash", "-c", block
+ 'printf "%s" "${WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER-unset}"'],
cwd=tmp_path, env=env, capture_output=True, text=True, check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout == ("1" if enabled == "1" else "unset")
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
@mark.parametrize("copies", [1, 3])
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
condition = helper.split(' local -a run_after_exes=', 1)[1].split(
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 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", 'check_run_after() {\n' + condition
+ "then return 0; else return 1; fi; }; check_run_after"],
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
+ "then exit 0; else exit 1; fi"],
env={**os.environ, "PW_EXE_FILE": str(main_exe),
"PW_RUN_AFTER_EXE": ",".join([str(second_exe)] * copies)},
"PW_RUN_AFTER_EXE": str(second_exe)},
capture_output=True, text=True, check=False,
)
assert result.returncode == 0, result.stderr
@@ -137,9 +112,7 @@ 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; }\nlaunch() {\n'
'local -a run_after_exes=(unused) run_after_environment=()\n'
'cat > "$run_after_bat" <<EOF\n' + block + '\n}; launch'],
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
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": ""},
@@ -147,7 +120,6 @@ 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
@@ -260,69 +232,3 @@ def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback(
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"]
-71
View File
@@ -219,74 +219,3 @@ 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()
+1 -107
View File
@@ -3,7 +3,7 @@
import subprocess
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from typing import cast
import pytest
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
@@ -458,22 +458,6 @@ 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 &lt;description&gt;",
),
)
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()
@@ -941,93 +925,3 @@ def test_settings_close_waits_for_pending_save(monkeypatch) -> None:
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()
+22 -348
View File
@@ -69,18 +69,16 @@ def test_game_card_context_menu_does_not_block_gamepad_polling(monkeypatch) -> N
assert app is not None
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id", "native"])
def test_add_to_steam_for_all_accounts(
tmp_path: Path,
monkeypatch: Any,
target: str,
) -> None:
steam_home = tmp_path / "steam"
user_ids = ("76561197960265729", "76561197960265730")
executable = tmp_path / ("Hytale Launcher.desktop" if target == "native" else "game.exe")
executable = tmp_path / "game.exe"
executable.touch()
if target == "native":
target = "native://" + str(executable)
script_path = tmp_path / "game.sh"
monkeypatch.setattr(steam_shortcuts, "get_steam_home", lambda: steam_home)
monkeypatch.setattr(
steam_shortcuts,
@@ -93,15 +91,12 @@ def test_add_to_steam_for_all_accounts(
monkeypatch.setattr(
steam_shortcuts, "get_portproton_start_command", lambda: "start"
)
icon_path = str(tmp_path / "game-icon.png")
selected_icon = str(tmp_path / "custom_data" / "icon.png")
custom_icon_path = target if target.startswith(("gog://", "egs://")) else str(executable)
custom_icon_lookup = MagicMock(
side_effect=lambda path: selected_icon if path == custom_icon_path else ""
monkeypatch.setattr(
steam_shortcuts,
"_create_launch_script",
lambda *_args: (str(script_path), ""),
)
monkeypatch.setattr(steam_shortcuts, "get_custom_icon_path", custom_icon_lookup)
generate_icon = MagicMock(return_value=icon_path)
monkeypatch.setattr(steam_shortcuts, "_generate_icon", generate_icon)
monkeypatch.setattr(steam_shortcuts, "_generate_icon", lambda *_args: "")
monkeypatch.setattr(
steam_shortcuts.game_config, "get_steam_account_id", lambda: "all"
)
@@ -110,18 +105,10 @@ def test_add_to_steam_for_all_accounts(
)
result, _message = steam_shortcuts.add_to_steam(
"Test Game", str(executable) if target == "game.exe" else target, "", str(executable)
"Test Game", str(executable), ""
)
assert result is True
if target.startswith("native://"):
script = tmp_path / "steam_scripts" / "Test Game.sh"
assert "'" + target + "'" in script.read_text()
generate_icon.assert_not_called()
assert custom_icon_lookup.call_args_list[0].args == (
str(tmp_path / "steam_scripts" / "Test Game.sh"),
)
assert custom_icon_lookup.call_args_list[1].args == (custom_icon_path,)
for account_id in ("1", "2"):
shortcuts_path = (
steam_home / "userdata" / account_id / "config" / "shortcuts.vdf"
@@ -129,9 +116,6 @@ def test_add_to_steam_for_all_accounts(
assert shortcuts_path.is_file()
shortcuts = steam_shortcuts.safe_vdf_load(shortcuts_path)["shortcuts"]
assert shortcuts["0"]["AppName"] == "Test Game"
assert shortcuts["0"]["icon"] == selected_icon
script = (tmp_path / "steam_scripts" / "Test Game.sh").read_text()
assert target in script
def test_custom_data_hash_distinguishes_same_exe_names(tmp_path: Path) -> None:
@@ -176,7 +160,6 @@ def _make_exe(tmp_path: Path, name: str = "game.exe", subdir: str = "") -> str:
def _patch_location(monkeypatch: Any, tmp_path: Path) -> None:
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setattr(
"portprotonqt.config.portproton.get_portproton_location",
lambda: str(tmp_path / "PortProtonQt"),
@@ -877,8 +860,7 @@ class TestGetSteamShortcutPath:
class TestEditSteamShortcut:
@pytest.mark.parametrize("store", ["steam", "gog", "egs"])
def test_steam_dialog_has_no_executable_row(self, monkeypatch: Any, store: str) -> None:
def test_steam_dialog_has_no_executable_row(self, monkeypatch: Any) -> None:
from portprotonqt.dialogs.base import AddGameDialog
_application = QApplication.instance() or QApplication([])
@@ -902,15 +884,11 @@ class TestEditSteamShortcut:
edit_mode=True,
game_name="Game",
steam_appid="730",
exe_path=f"{store}://launch/730" if store != "steam" else None,
)
labels = [label.text() for label in dialog.findChildren(QLabel)]
assert "Path to Executable:" not in labels
assert dialog.exeEdit.isHidden()
assert not dialog.iconEdit.isHidden()
if store == "steam":
assert dialog.last_exe_path == "steam://rungameid/730"
assert all(
label.isHidden()
for label in dialog.findChildren(QLabel)
@@ -999,16 +977,11 @@ class TestEditSteamShortcut:
"", "", "", "", 0, 0, "egs",
)
manager.game_library_manager = MagicMock(games=[gog_game, egs_game])
make_dialog = MagicMock(return_value=dialog)
monkeypatch.setattr(context_menu, "AddGameDialog", make_dialog)
monkeypatch.setattr(context_menu, "AddGameDialog", lambda **_kwargs: dialog)
manager._edit_id_shortcut("GOG Game", "/old.jpg", ("gog", "123"))
manager._edit_id_shortcut("EGS Game", "/old.jpg", ("egs", "ABC"))
assert [call.kwargs["exe_path"] for call in make_dialog.call_args_list] == [
"gog://launch/123", "egs://launch/ABC",
]
custom_root = tmp_path / "PortProtonQt" / "custom_data"
for storage_id in ("gog-123", "egs-ABC"):
game_dir = custom_root / storage_id
@@ -1470,14 +1443,11 @@ def test_missing_dialog_buttons_fit_translated_text(monkeypatch: Any) -> None:
assert all(actual >= required for actual, required in widths)
@pytest.mark.parametrize("edited_name", ["Renamed", "Custom Alias"])
@pytest.mark.parametrize("edited_executable", ["/games/game.exe", "/games/other.exe"])
def test_edit_shortcut_removes_desktop_file_with_original_filename(
tmp_path: Path, monkeypatch: Any, edited_name: str, edited_executable: str,
tmp_path: Path, monkeypatch: Any,
) -> None:
import portprotonqt.context_menu_manager as context_menu
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
desktop_dir = tmp_path / "Desktop"
desktop_dir.mkdir()
source = tmp_path / "Original.desktop"
@@ -1493,15 +1463,12 @@ def test_edit_shortcut_removes_desktop_file_with_original_filename(
manager._get_desktop_path = MagicMock(return_value=str(source))
manager._get_menu_shortcut_path = MagicMock(return_value=str(tmp_path / "missing.desktop"))
manager._relocate_shortcut_data = MagicMock(return_value=[])
manager._relocate_shortcut_statistics = MagicMock()
dialog = MagicMock()
dialog.exec.return_value = context_menu.QDialog.DialogCode.Accepted
dialog.nameEdit.text.return_value = edited_name
dialog.exeEdit.text.return_value = edited_executable
dialog.nameEdit.text.return_value = "Renamed"
dialog.exeEdit.text.return_value = "/games/game.exe"
dialog.coverEdit.text.return_value = ""
dialog.getDesktopEntryData.return_value = (
source.read_text().replace("/games/game.exe", edited_executable), str(source),
)
dialog.getDesktopEntryData.return_value = (source.read_text(), str(source))
dialog.add_to_menu_checkbox.isChecked.return_value = False
dialog.add_to_desktop_checkbox.isChecked.return_value = False
dialog.add_to_steam_checkbox.isChecked.return_value = False
@@ -1515,66 +1482,9 @@ def test_edit_shortcut_removes_desktop_file_with_original_filename(
assert not desktop_shortcut.exists()
assert source.exists()
assert "Name=Renamed\n" in source.read_text()
assert f"Exec={edited_executable}\n" in source.read_text()
if edited_name != "Renamed":
metadata = next((tmp_path / "data/PortProtonQt/custom_data").glob("*/metadata.txt"))
assert metadata.read_text() == f"name={edited_name}\n"
manager.signals.show_warning_dialog.emit.assert_not_called()
@pytest.mark.parametrize("legacy", [False, True])
def test_local_shortcut_name_preserves_metadata_and_resolves_desktop(
tmp_path: Path, monkeypatch: Any, legacy: bool,
) -> None:
from portprotonqt.context_menu_manager import ContextMenuManager
from portprotonqt.config import get_custom_data_dir_name
from portprotonqt.localization import read_metadata_translations
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
executable = str(tmp_path / "Game.exe")
desktop = tmp_path / "Original.desktop"
desktop.write_text(f"[Desktop Entry]\nName=Original\nExec=portprotonqt --silent {executable}\n")
folder = "Game" if legacy else get_custom_data_dir_name(executable)
game_dir = tmp_path / "data/PortProtonQt/custom_data" / folder
game_dir.mkdir(parents=True)
metadata = game_dir / "metadata.txt"
metadata.write_text("name=Original\nname_ru=Original RU\ndescription=Details\ndescription_ru=Details RU\n")
manager = object.__new__(ContextMenuManager)
manager.portproton_location = str(tmp_path)
for name in ["Custom Alias", "Another Alias"]:
manager._save_local_shortcut_name(executable, name)
assert manager._get_desktop_path(name) == str(desktop)
assert read_metadata_translations(str(metadata), "ru") == {
"name": name, "description": "Details RU",
}
assert "description=Details\n" in metadata.read_text()
assert "Name=Original\n" in desktop.read_text()
def test_local_shortcut_name_keeps_metadata_on_write_failure(
tmp_path: Path, monkeypatch: Any,
) -> None:
import portprotonqt.context_menu_manager as context_menu
from portprotonqt.config import get_custom_data_dir_name
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
executable = str(tmp_path / "Game.exe")
game_dir = tmp_path / "PortProtonQt/custom_data" / get_custom_data_dir_name(executable)
game_dir.mkdir(parents=True)
metadata = game_dir / "metadata.txt"
metadata.write_text("name=Original\ndescription=Details\n")
manager = object.__new__(context_menu.ContextMenuManager)
monkeypatch.setattr(context_menu.os, "replace", MagicMock(side_effect=OSError("Cannot replace")))
with pytest.raises(OSError, match="Cannot replace"):
manager._save_local_shortcut_name(executable, "Renamed")
assert metadata.read_text() == "name=Original\ndescription=Details\n"
assert list(game_dir.iterdir()) == [metadata]
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id",
"native:///usr/share/applications/com.hypixel.HytaleLauncher.desktop"])
@pytest.mark.parametrize("batch,result,expected", [
(True, (True, "restart"), (0, 0)),
(False, (True, "Game added to Steam via CEF API"), (0, 0)),
@@ -1582,29 +1492,17 @@ def test_local_shortcut_name_keeps_metadata_on_write_failure(
(False, (False, "Failed to create shortcut"), (0, 1)),
])
def test_steam_addition_dialogs(monkeypatch: Any, batch: bool, result: tuple[bool, str],
expected: tuple[int, int], target: str) -> None:
expected: tuple[int, int]) -> None:
from portprotonqt.context_menu_manager import ContextMenuManager
manager = object.__new__(ContextMenuManager)
manager._batch_action = batch
manager._check_portproton = lambda: True
resolve_exec = MagicMock(side_effect=lambda game_name, exec_line: exec_line)
monkeypatch.setattr(manager, "_get_exec_line", resolve_exec)
if target == "game.exe":
monkeypatch.setattr(manager, "_parse_exe_path", lambda exec_line, game_name: exec_line)
monkeypatch.setattr(manager, "_get_exec_line", lambda game_name, exec_line: exec_line)
monkeypatch.setattr(manager, "_parse_exe_path", lambda exec_line, game_name: exec_line)
manager.signals = MagicMock()
api = SimpleNamespace(get_launch_target=MagicMock(return_value="installed.exe"))
manager.parent = SimpleNamespace(gog_api=api, egs_api=api)
add_shortcut = MagicMock(return_value=result)
monkeypatch.setattr("portprotonqt.context_menu_manager.add_to_steam", add_shortcut)
manager.add_to_steam("Game", target, "cover")
if target.startswith("native://"):
resolve_exec.assert_not_called()
api.get_launch_target.assert_not_called()
add_shortcut.assert_called_once_with("Game", target, "cover")
elif target != "game.exe":
api.get_launch_target.assert_called_once_with(target.rsplit("/", 1)[-1])
add_shortcut.assert_called_once_with("Game", target, "cover", "installed.exe")
monkeypatch.setattr("portprotonqt.context_menu_manager.add_to_steam", lambda *args: result)
manager.add_to_steam("Game", "game.exe", "cover")
assert manager.signals.show_info_dialog.emit.call_count == expected[0]
assert manager.signals.show_warning_dialog.emit.call_count == expected[1]
@@ -1655,20 +1553,6 @@ def test_batch_steam_removal_has_no_success_dialog(monkeypatch: Any) -> None:
manager.signals.show_info_dialog.emit.assert_not_called()
def test_steam_removal_has_no_success_dialog(monkeypatch: Any) -> None:
from portprotonqt.context_menu_manager import ContextMenuManager
manager = object.__new__(ContextMenuManager)
manager._batch_action = False
manager._check_portproton = lambda: True
monkeypatch.setattr(manager, "_get_exec_line", lambda name, command: command)
monkeypatch.setattr("portprotonqt.context_menu_manager.extract_exec_target_path", lambda command: command)
monkeypatch.setattr("portprotonqt.context_menu_manager.remove_from_steam", lambda *args: (True, "removed"))
manager.signals = MagicMock()
manager.remove_from_steam("Game", "game.exe", "portproton")
manager.signals.show_info_dialog.emit.assert_not_called()
@pytest.fixture
def selection_scene(tmp_path: Path, monkeypatch: Any) -> Any:
@@ -1705,34 +1589,6 @@ def selection_scene(tmp_path: Path, monkeypatch: Any) -> Any:
parent.deleteLater()
@pytest.mark.parametrize("source", ["gog", "egs"])
@pytest.mark.parametrize("in_steam", [True, False])
@pytest.mark.parametrize("installed", [True, False])
def test_store_steam_menu_action(selection_scene: Any, source: str,
in_steam: bool, installed: bool) -> None:
from portprotonqt.localization import _
scene = selection_scene
card = scene.cards[0]
card.game_source = source
card.exec_line = f"{source}://{'launch' if installed else 'install'}/{card.appid}"
if in_steam:
scene.steam.add(card.name)
scene.manager.add_to_steam = MagicMock()
scene.manager.remove_from_steam = MagicMock()
menu = scene.manager.show_context_menu(card, QPoint(), build_only=True)
text = _("Remove from Steam") if in_steam else _("Add to Steam")
actions = [action for action in menu.actions() if action.text() == text]
assert len(actions) == int(installed)
if not installed:
return
actions[0].trigger()
if in_steam:
scene.manager.remove_from_steam.assert_called_once_with(card.name, card.exec_line, source)
else:
scene.manager.add_to_steam.assert_called_once_with(card.name, card.exec_line, card.cover_path)
@pytest.mark.parametrize("source", ["portproton", "gog", "egs", "steam"])
@pytest.mark.parametrize("installed", [True, False])
def test_batch_menu_contains_all_normal_card_actions(selection_scene: Any, source: str, installed: bool) -> None:
@@ -2026,7 +1882,7 @@ def test_game_folder_import_completion_refreshes_completed_games(
@pytest.mark.parametrize("edit_mode", [False, True])
def test_add_game_file_picker_offers_folder_selection_only_for_adding(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, edit_mode: bool,
monkeypatch: pytest.MonkeyPatch, edit_mode: bool,
) -> None:
from PySide6.QtWidgets import QHBoxLayout
import portprotonqt.dialogs.base as module
@@ -2034,8 +1890,7 @@ def test_add_game_file_picker_offers_folder_selection_only_for_adding(
app = QApplication.instance() or QApplication([])
explorer = SimpleNamespace(file_signal=MagicMock(), button_layout=QHBoxLayout(), show=MagicMock())
create_explorer = MagicMock(return_value=explorer)
monkeypatch.setattr(explorer_module, "FileExplorer", create_explorer)
monkeypatch.setattr(explorer_module, "FileExplorer", lambda *_args, **_kwargs: explorer)
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
dialog = SimpleNamespace(
last_exe_path=None, edit_mode=edit_mode, onExeSelected=MagicMock(),
@@ -2045,17 +1900,6 @@ def test_add_game_file_picker_offers_folder_selection_only_for_adding(
module.AddGameDialog.browseExe(cast(Any, dialog))
file_filter = create_explorer.call_args.kwargs["file_filter"]
assert file_filter("/game.exe")
for filename in ("game", "game.AppImage", "game.sh", "game.txt"):
path = tmp_path / filename
path.touch()
path.chmod(0o644)
assert not file_filter(str(path))
path.chmod(0o755)
assert file_filter(str(path))
assert not file_filter(str(tmp_path))
assert not file_filter(str(tmp_path / "missing"))
explorer.file_signal.file_selected.connect.assert_called_once_with(dialog.onExeSelected)
assert explorer.button_layout.count() == int(not edit_mode)
if not edit_mode:
@@ -2592,173 +2436,3 @@ def test_game_folder_review_x_imports_edited_rows(tmp_path: Path, monkeypatch: p
dialog._show_folder_games([("Game", "Wrong.exe")])
assert started[0].games == [("Game", str(executable))]
dialog.deleteLater()
@pytest.mark.parametrize("store", [None, "gog", "egs", "steam"])
def test_exe_dialog_saves_custom_icon(tmp_path: Path, monkeypatch: Any, store: str | None) -> None:
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QDialog
from portprotonqt.context_menu_manager import CustomLineEdit
from portprotonqt.dialogs.base import AddGameDialog
from portprotonqt.config.portproton import get_custom_icon_path
_application = QApplication.instance() or QApplication([])
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
source = tmp_path / "selected.png"
image = QPixmap(16, 16)
image.fill()
assert image.save(str(source))
executable = f"{store}://launch/123" if store else str(tmp_path / "game.exe")
if store == "steam":
executable = "steam://rungameid/123"
dialog = AddGameDialog.__new__(AddGameDialog)
QDialog.__init__(dialog)
dialog.edit_mode = bool(store)
dialog.last_exe_path = executable
dialog.iconEdit = CustomLineEdit(str(source), dialog)
dialog.exeEdit = CustomLineEdit(executable, dialog)
dialog.accept()
saved_icon = get_custom_icon_path(executable)
assert saved_icon
assert not QPixmap(saved_icon).isNull()
assert source.is_file()
if store == "steam":
from portprotonqt.steam_api.api import fetch_client_icon_async
callback = MagicMock()
fetch_client_icon_async(123, callback)
callback.assert_called_once_with(saved_icon)
assert dialog.result() == QDialog.DialogCode.Accepted
dialog.close()
@pytest.mark.parametrize("source_appid", [None, 620])
@pytest.mark.parametrize("extension", ["png", "jpg"])
def test_steam_artwork_sgdb_fallback(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, source_appid: int | None, extension: str
) -> None:
requested = []
applied = []
original = b"original artwork bytes"
def fetch(name: str, callback: Any, image_type: str) -> None:
requested.append((name, image_type))
callback(f"https://cdn2.steamgriddb.com/grid/{image_type}.{extension}")
def download(url: str, destination: str, timeout: int, callback: Any) -> None:
if "steamstatic.com" in url:
callback(None)
return
Path(destination).write_bytes(original)
callback(destination)
monkeypatch.setattr(steam_shortcuts, "fetch_sgdb_cover_async", fetch)
monkeypatch.setattr(steam_shortcuts.downloader, "download_async", download)
monkeypatch.setattr(steam_shortcuts, "call_steam_api", lambda *args: applied.append(args))
steam_shortcuts._download_covers(123, (source_appid, "Portal 2"), str(tmp_path), True)
assert requested == [("Portal 2", kind) for kind in ("grids", "heroes", "logos", "hgrids")]
assert len(applied) == 4
assert all(call[3] == extension for call in applied)
for suffix in ("p", "_hero", "_logo", ""):
assert (tmp_path / f"123{suffix}.{extension}").read_bytes() == original
def test_sgdb_artwork_cache_separates_types(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
import portprotonqt.steam_api.api as api
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
downloads = []
def download(url: str, path: str, timeout: int, callback: Any) -> None:
downloads.append(url)
Path(path).write_text(f'"{url}"', encoding="utf-8")
callback(path)
monkeypatch.setattr(api.downloader, "download_async", download)
completed = []
for image_type in ("grids", "heroes", "grids", "heroes"):
api.fetch_sgdb_cover_async("Portal/2", completed.append, image_type)
assert len(downloads) == 2
assert downloads[0].endswith("Portal%2F2/grids")
assert downloads[1].endswith("Portal%2F2/heroes")
assert completed == downloads + downloads
@pytest.mark.parametrize("image_type", ["grids", "heroes", "logos", "hgrids"])
def test_sgdb_cloud_urls_use_proxy(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, image_type: str
) -> None:
import portprotonqt.steam_api.api as api
import portprotonqt.portproton_api as portproton_api
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
monkeypatch.setattr(portproton_api, "get_user_conf_setting", lambda name: "CLOUD")
url = f"https://cdn2.steamgriddb.com/{image_type}/asset.png"
def download(_url: str, path: str, timeout: int, callback: Any) -> None:
Path(path).write_text(f'"{url}"', encoding="utf-8")
callback(path)
monkeypatch.setattr(api.downloader, "download_async", download)
completed = []
api.fetch_sgdb_cover_async("Portal 2", completed.append, image_type)
assert completed == [f"https://deckyloader.ru/sgdb-cdn/{image_type}/asset.png"]
@pytest.mark.parametrize("themed", [False, True])
def test_native_steam_icon_uses_desktop_icon(tmp_path: Path, monkeypatch: Any, themed: bool) -> None:
from PySide6.QtGui import QPixmap
app = QApplication.instance() or QApplication([])
image = tmp_path / "pixmaps/hytale-launcher.png"
image.parent.mkdir()
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.GlobalColor.red)
assert pixmap.save(str(image))
source = tmp_path / "hytale.desktop"
icon = "hytale-launcher" if themed else str(image)
source.write_text(f"[Desktop Entry]\nType=Application\nName=Hytale\nIcon={icon}\nExec=hytale-launcher\n")
monkeypatch.setattr(steam_shortcuts.QIcon, "fromTheme", lambda _name: QIcon())
monkeypatch.setattr(steam_shortcuts.QStandardPaths, "standardLocations", lambda _location: [str(tmp_path)])
generated = steam_shortcuts._generate_icon(str(source), str(tmp_path))
assert generated
assert not QPixmap(generated).isNull()
assert app is not None
@pytest.mark.parametrize("relative", [
"pixmaps/hytale.xpm", "icons/hytale.png", "icons/hicolor/128x128/apps/hytale.png",
"icons/hicolor/scalable/apps/hytale.svg", "icons/other/64x64/games/hytale.png",
])
def test_desktop_icon_searches_xdg_formats(tmp_path: Path, monkeypatch: Any, relative: str) -> None:
from PySide6.QtGui import QPixmap
app = QApplication.instance() or QApplication([])
image = tmp_path / relative
image.parent.mkdir(parents=True)
if image.suffix == ".svg":
image.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="red"/></svg>')
else:
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.GlobalColor.red)
assert pixmap.save(str(image))
monkeypatch.setattr(steam_shortcuts.QIcon, "fromTheme", lambda _name: QIcon())
monkeypatch.setattr(steam_shortcuts.QStandardPaths, "standardLocations", lambda _location: [str(tmp_path)])
assert not steam_shortcuts._resolve_desktop_icon("hytale").pixmap(32, 32).isNull()
assert not steam_shortcuts._resolve_desktop_icon(image.name).pixmap(32, 32).isNull()
assert steam_shortcuts._resolve_desktop_icon("missing").isNull()
assert app is not None
def test_native_cached_metadata_does_not_search_desktop_directory(monkeypatch: Any) -> None:
import portprotonqt.steam_api.api as api
monkeypatch.setattr(api, "_get_cached_data_and_index", lambda *_args: ([{"appid": 1}], {}))
search = MagicMock(return_value=None)
monkeypatch.setattr(api, "search_app", search)
assert api.get_cached_steam_game_info("Hytale Launcher", "native:///usr/share/applications/com.hypixel.HytaleLauncher.desktop") == {}
assert search.call_count == 1
assert search.call_args.args[0] == "Hytale Launcher"
+3 -20
View File
@@ -118,16 +118,11 @@ class TestDeleteCachedAppFiles:
assert (tmp_path / "other.txt").exists()
@pytest.mark.parametrize("source", ["exe", "gog", "egs"])
def test_matched_steam_game_keeps_cover_when_app_details_fail(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str,
) -> None:
def test_matched_steam_game_keeps_cover_when_app_details_fail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import portprotonqt.steam_api.api as api
monkeypatch.setattr("portprotonqt.config.cache.CACHE_DIR", tmp_path / "cache")
executable = tmp_path / "Praest.exe"
executable.touch()
exec_line = str(executable) if source == "exe" else f"{source}://launch/graven"
app = {"appid": 1371690, "normalized_name": "graven"}
monkeypatch.setattr(api.ui_config, "get_economy_mode", lambda: False)
monkeypatch.setattr("portprotonqt.steam_api.cache.get_exiftool_data", lambda _exe: {})
@@ -138,20 +133,8 @@ def test_matched_steam_game_keeps_cover_when_app_details_fail(
monkeypatch.setattr(api, "get_weanticheatyet_info_async", lambda _name, cb: cb({}))
monkeypatch.setattr(api, "_add_ppdb_info", lambda result, _name, cb: cb(result))
results = []
api.get_steam_game_info_async("GRAVEN", exec_line, results.append)
api.get_steam_game_info_async("GRAVEN", str(executable), results.append)
assert len(results) == 1
assert results[0]["appid"] == 1371690
assert results[0]["steam_game"] == "true"
expected_cover = (
"https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
if source == "exe" else ""
)
assert results[0]["cover"] == expected_cover
with (
patch("portprotonqt.steam_api.cache.get_exiftool_data", side_effect=AssertionError("EXE reread")),
patch.object(api, "get_steam_apps_and_index_async", side_effect=AssertionError("Catalog reloaded")),
patch.object(api, "search_app", side_effect=AssertionError("AppID searched again")),
):
api.get_steam_game_info_async("GRAVEN", exec_line, results.append)
assert len(results) == 2
assert results[1] == results[0]
assert results[0]["cover"] == "https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
+3 -276
View File
@@ -3,15 +3,12 @@
from collections.abc import Callable
from datetime import datetime, timedelta
from pathlib import Path
from threading import Event
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
import orjson
from PySide6.QtCore import QEventLoop, QThread, QTimer
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMainWindow
from pytest import MonkeyPatch, mark
from shiboken6 import delete
@@ -23,7 +20,6 @@ from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin
from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin as GOGMixin
import portprotonqt.main_window as main_window_module
import portprotonqt.tabs.download_tab as download_tab_module
from portprotonqt.tabs.workers import MainWindowWorkersMixin
@mark.parametrize("source", ("gog", "egs"))
@@ -56,7 +52,6 @@ def test_store_dlc_worker_filters_and_preserves_installed(
cache.is_fresh.return_value = False
monkeypatch.setattr(download_tab_module, "CacheManager", lambda: cache)
worker = download_tab_module.StoreDLCWorker(api, "111")
monkeypatch.setattr(worker, "_load_gog_dlc_info", lambda: None)
monkeypatch.setattr(worker, "_load_dlc_covers", lambda: {})
results = []
worker.loaded.connect(results.append)
@@ -77,7 +72,6 @@ def test_store_dlc_worker_failure_does_not_continue_install(
api = GOGAPI()
monkeypatch.setattr(api, "build_command", MagicMock(side_effect=FileNotFoundError("gogdl")))
worker = download_tab_module.StoreDLCWorker(api, "111")
monkeypatch.setattr(worker, "_load_gog_dlc_info", lambda: None)
loaded, errors = [], []
worker.loaded.connect(loaded.append)
worker.failed.connect(errors.append)
@@ -109,95 +103,6 @@ def test_store_dlc_selection_remains_available_during_download(
assert window.store_dlc_worker is worker
@mark.parametrize("source", ("gog", "egs"))
@mark.parametrize("failed", (False, True))
def test_dlc_worker_released_before_modal_and_retry(
monkeypatch: MonkeyPatch, source: str, failed: bool
) -> None:
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
loop = QEventLoop()
observations = []
class Window(GOGMixin, QMainWindow):
def _on_store_dlcs_loaded(self, source: str, game: dict, dlcs: list[dict]) -> None:
observations.append((self.store_dlc_worker, QThread.currentThread()))
dialog = download_tab_module.QDialog(self)
QTimer.singleShot(0, dialog.reject)
dialog.exec()
window: Any = Window()
window.gog_api = cast(Any, object())
window.egs_api = cast(Any, object())
def run(worker: download_tab_module.StoreDLCWorker) -> None:
if failed:
worker.failed.emit("failed")
else:
worker.loaded.emit([])
def warning(*args: Any) -> None:
observations.append((window.store_dlc_worker, QThread.currentThread()))
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
timeout = QTimer()
timeout.setSingleShot(True)
timeout.timeout.connect(loop.quit)
ready = QTimer()
ready.setInterval(10)
for attempt in range(2):
def finish_when_observed(expected: int = attempt + 1) -> None:
if len(observations) == expected and window.store_dlc_worker is None:
loop.quit()
ready.timeout.connect(finish_when_observed)
window._select_store_dlcs(source, {"app_id": "123", "title": "Game"})
ready.start()
timeout.start(3000)
loop.exec()
ready.stop()
ready.timeout.disconnect(finish_when_observed)
timeout.stop()
assert len(observations) == attempt + 1
assert window.store_dlc_worker is None
assert observations == [(None, application.thread())] * 2
window.deleteLater()
def test_dlc_worker_shutdown_suppresses_picker(monkeypatch: MonkeyPatch) -> None:
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
class Window(GOGMixin, QMainWindow, MainWindowWorkersMixin):
pass
window: Any = Window()
window.gog_api = cast(Any, object())
window._on_store_dlcs_loaded = MagicMock()
warning = MagicMock()
started = Event()
def run(worker: download_tab_module.StoreDLCWorker) -> None:
started.set()
while not worker.isInterruptionRequested():
Event().wait(0.001)
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
window._select_store_dlcs("gog", {"app_id": "123", "title": "Game"})
worker = window.store_dlc_worker
window._select_store_dlcs("gog", {"app_id": "456", "title": "Queued"})
assert started.wait(3)
window._stopWorkerThread("store_dlc_worker", None)
assert not worker.isRunning()
application.processEvents()
assert window.store_dlc_worker is None
window._on_store_dlcs_loaded.assert_not_called()
warning.assert_not_called()
assert window.store_dlc_pending == []
window.deleteLater()
def test_store_download_queues_reject_duplicates() -> None:
active = {"app_id": "active", "title": "Active"}
queued = {"app_id": "queued", "title": "Queued"}
@@ -215,8 +120,8 @@ def test_store_download_queues_reject_duplicates() -> None:
)
for game in (queued, queued, active):
window._queue_store_download("gog", game)
window._queue_store_download("egs", game)
GOGMixin._install_gog_game(cast(Any, window), game)
GOGMixin._install_egs_download(cast(Any, window), str(game["app_id"]))
assert window.store_download_queue == [
("gog", queued), ("egs", queued), ("egs", active),
@@ -1015,19 +920,15 @@ def test_installed_filter_excludes_uninstalled_gog_games(
get_launch_target=lambda app_id: f"/games/{app_id}/game.exe",
)
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
loaded_metadata = []
monkeypatch.setattr(
"portprotonqt.main_window.get_steam_game_info_async",
lambda name, _uri, callback: (
loaded_metadata.append(name) or callback({})
),
lambda _name, _uri, callback: callback({}),
)
results = []
MainWindow._load_gog_games_async(window, results.append)
assert [game[3] for game in results[0]] == ["installed"]
assert loaded_metadata == ["Installed"]
assert results[0][0][5] == "gog://launch/installed"
def test_installed_filter_disabled_includes_uninstalled_gog_games(
@@ -1151,38 +1052,6 @@ def test_egs_library_uses_steam_description_when_epic_has_title_only(
assert results[0][0][1] == "Witch It — игра в прятки по сети."
def test_installed_filter_skips_uninstalled_egs_metadata(
monkeypatch: MonkeyPatch,
) -> None:
library = [
{"app_id": "installed", "title": "Installed"},
{"app_id": "uninstalled", "title": "Uninstalled"},
]
installed_data = {"installed": {}}
api = SimpleNamespace(
load_library=lambda: library,
load_installed=lambda: installed_data,
is_game_installed=lambda app_id, data: app_id in data,
get_launch_target=lambda app_id, _data: f"/games/{app_id}.exe",
)
loaded_metadata = []
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
monkeypatch.setattr(
"portprotonqt.main_window.get_steam_game_info_async",
lambda name, _uri, callback: (
loaded_metadata.append(name) or callback({})
),
)
results = []
MainWindow._load_egs_games_async(
cast(MainWindow, SimpleNamespace(egs_api=api)), results.append
)
assert loaded_metadata == ["Installed"]
assert [game[3] for game in results[0]] == ["installed"]
def test_egs_maintenance_uses_legendary_commands() -> None:
start_operation = MagicMock()
window = cast(MainWindow, SimpleNamespace(_start_egs_operation=start_operation))
@@ -1556,145 +1425,3 @@ def test_failed_store_process_completes_callback_path() -> None:
process.finished.emit.assert_called_once_with(
download_tab_module.os.EX_UNAVAILABLE, download_tab_module.QProcess.ExitStatus.CrashExit,
)
@mark.parametrize("failed", (False, True))
def test_store_dlc_requests_queue_while_worker_is_busy(
monkeypatch: MonkeyPatch, failed: bool,
) -> None:
workers = []
def create_worker(*args: Any) -> Any:
worker = MagicMock()
workers.append(worker)
return worker
monkeypatch.setattr(download_tab_module, "StoreDLCWorker", create_worker)
warning = MagicMock()
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
window = SimpleNamespace(
store_dlc_worker=None, gog_api=object(), egs_api=object(),
_on_store_dlcs_loaded=MagicMock(), _on_store_dlc_worker_finished=MagicMock(),
)
window._select_store_dlcs = lambda source, game: GOGMixin._select_store_dlcs(
cast(Any, window), source, game
)
first = {"app_id": "1", "title": "First"}
second = {"app_id": "2", "title": "Second"}
third = {"app_id": "3", "title": "Third"}
for source, game in (("gog", first), ("gog", first), ("gog", second),
("gog", second), ("egs", third)):
window._select_store_dlcs(source, game)
assert len(workers) == 1
assert window.store_dlc_pending == [("gog", second), ("egs", third)]
warning.assert_not_called()
for source, game in (("gog", first), ("gog", second), ("egs", third)):
assert window.store_dlc_request[:2] == (source, game)
window.store_dlc_request[3 if failed else 2].append("failed" if failed else [])
GOGMixin._on_store_dlc_worker_finished(cast(Any, window))
assert len(workers) == 3
assert window.store_dlc_worker is None
assert window.store_dlc_pending == []
assert warning.call_count == (3 if failed else 0)
assert window._on_store_dlcs_loaded.call_count == (0 if failed else 3)
@mark.parametrize("source", ("gog", "egs"))
@mark.parametrize("cancelled", (False, True))
def test_store_selects_folder_before_queue_and_reuses_it(
tmp_path: Path, monkeypatch: MonkeyPatch, source: str, cancelled: bool,
) -> None:
game = {"app_id": "123", "title": "Game", "_dlcs": []}
selected_path = tmp_path / "Chosen"
explorer = MagicMock()
if not cancelled:
explorer.file_signal.file_selected.connect.side_effect = (
lambda callback: callback(str(selected_path))
)
factory = MagicMock(return_value=explorer)
monkeypatch.setattr(download_tab_module, "FileExplorer", factory)
api = SimpleNamespace(
games_dir=tmp_path, config_dir=tmp_path, load_library=lambda: [game],
get_install_path=lambda *_args: tmp_path,
find_install_path=lambda *_args: None,
build_command=lambda args: [source, *args],
)
window = SimpleNamespace(
gog_process=object(), egs_process=None, gog_api=api, egs_api=api,
theme=object(), egs_selected_dlc_game=game,
_queue_store_download=MagicMock(), _start_gog_download=MagicMock(),
_start_egs_download=MagicMock(),
)
if source == "gog":
GOGMixin._install_gog_game(cast(Any, window), game)
else:
GOGMixin._install_egs_download(cast(Any, window), "123")
explorer.exec.assert_called_once_with()
if cancelled:
window._queue_store_download.assert_not_called()
return
prepared = {**game, "_install_path": str(selected_path)}
window._queue_store_download.assert_called_once_with(source, prepared)
window.gog_process = None
if source == "gog":
GOGMixin._install_gog_game(cast(Any, window), prepared)
started = window._start_gog_download
else:
window.egs_selected_dlc_game = prepared
GOGMixin._install_egs_download(cast(Any, window), "123")
started = window._start_egs_download
factory.assert_called_once()
assert started.call_args.args[:2] == (game, selected_path)
@mark.parametrize("has_dlcs", (False, True))
def test_gog_dlc_metadata_does_not_calculate_download_sizes(
tmp_path: Path, monkeypatch: MonkeyPatch, has_dlcs: bool,
) -> None:
import zlib
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
api = GOGAPI()
monkeypatch.setattr(api, "get_credentials", lambda: {"access_token": "test-token"})
products = [{"productId": "111", "name": "Base"}]
if has_dlcs:
products.extend([{"productId": "222", "name": "Owned"},
{"productId": "333", "name": "Not owned"}])
builds = MagicMock()
builds.json.return_value = {"items": [
{"branch": None, "generation": download_tab_module.GOG_CONTENT_GENERATION,
"link": "https://example.com/manifest"},
]}
manifest = MagicMock(content=zlib.compress(orjson.dumps({"products": products})))
owned = MagicMock()
owned.json.return_value = {"owned": [111, 222]}
request = MagicMock(side_effect=[builds, manifest, owned])
monkeypatch.setattr(download_tab_module.requests, "get", request)
subprocess = MagicMock(side_effect=AssertionError("Must not calculate sizes"))
monkeypatch.setattr(download_tab_module.subprocess, "run", subprocess)
worker = download_tab_module.StoreDLCWorker(api, "111")
assert worker._load_gog_dlc_info() == {
"dlcs": [{"id": "222", "title": "Owned"}] if has_dlcs else [],
}
assert request.call_count == (3 if has_dlcs else 2)
subprocess.assert_not_called()
@mark.parametrize("failure", ("network", "legacy", "manifest"))
def test_gog_dlc_metadata_falls_back_safely(
tmp_path: Path, monkeypatch: MonkeyPatch, failure: str,
) -> None:
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
api = GOGAPI()
response = MagicMock(content=b"invalid compressed manifest")
response.json.return_value = {"items": [
{"branch": None, "generation": 1 if failure == "legacy" else
download_tab_module.GOG_CONTENT_GENERATION, "link": "https://example.com/manifest"},
]}
if failure == "network":
response.raise_for_status.side_effect = download_tab_module.requests.ConnectionError("offline")
monkeypatch.setattr(download_tab_module.requests, "get", MagicMock(return_value=response))
worker = download_tab_module.StoreDLCWorker(api, "111")
assert worker._load_gog_dlc_info() is None