Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9f935222
|
||
|
|
4fb74c1d15
|
||
|
|
78dab249c1
|
||
|
|
9e91eeb0dc
|
||
|
|
cb8d149b2a
|
||
|
|
98475c0e59
|
||
|
|
0391b894ee
|
||
|
|
ca352d0ae9
|
||
|
|
b0b22481c1
|
||
|
|
147187a02a
|
||
|
|
a874ea6979
|
||
|
|
ca658fa84f
|
||
|
|
3bd78f5c73
|
||
|
|
b3824a3556
|
||
|
|
4d9e39d9e3
|
||
|
|
9c148c2710 | ||
|
|
0dae987520
|
||
|
|
00ec0baf35 | ||
|
|
28726a5cbd | ||
|
|
36a1994e4f
|
||
|
|
6bae4616ec
|
||
|
|
e1080d0185
|
||
|
|
e224904d4f | ||
|
|
2b4a0ede44 | ||
|
|
13079b3a50
|
||
|
|
6eae0612b8
|
||
|
|
d77d972da6
|
||
|
|
0e9bfa9d44
|
||
|
|
00c1d086b1
|
||
|
|
a3a7f61f8b
|
||
|
|
8adc0ebda7
|
||
|
|
6f73fa2b67
|
||
|
|
c4f172faf1 | ||
|
|
974802c409 | ||
|
|
4d4c392d85
|
||
|
|
10d034031b
|
||
|
|
04d1e44447
|
||
|
|
10cc22a88a
|
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
|
||||
@@ -45,3 +45,4 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
.pz
|
||||
+26
-2
@@ -7,8 +7,14 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: ЛКМ и кнопка A геймпада переключают выделение, Ctrl+клик позволяет выделять карточки напрямую
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
@@ -16,16 +22,31 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- В 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/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
|
||||
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
@@ -36,10 +57,13 @@
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Release" alt="Releases"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-14.1k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-17.9k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&logo=linux" alt="Platform: Linux"></a>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Релиз" alt="Релизы"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-14.1k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-17.9k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&logo=linux" alt="Платформа: Linux"></a>
|
||||
|
||||
@@ -615,20 +615,6 @@ 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
|
||||
@@ -1154,12 +1140,7 @@ init_wine_ver () {
|
||||
export WINEDIR="${WINEDIR}/files"
|
||||
fi
|
||||
|
||||
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" ]] \
|
||||
if [[ "$PW_USE_WOW64" == "1" ]] \
|
||||
&& [[ -d "${WINEDIR}/bin-wow64" ]]
|
||||
then
|
||||
print_warning "wine wow64 mode in use!"
|
||||
@@ -1205,9 +1186,7 @@ init_wine_ver () {
|
||||
|
||||
if ! check_flatpak ; then
|
||||
WINE_LIBRARY_PATH="${WINEDIR}/lib"
|
||||
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" ]]
|
||||
if [[ -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"
|
||||
@@ -1220,19 +1199,10 @@ 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
|
||||
@@ -1241,11 +1211,7 @@ pw_init_runtime () {
|
||||
if [[ "$PW_USE_RUNTIME" != "1" ]] \
|
||||
|| [[ "$PW_WINE_USE" == "USE_SYSTEM_WINE" ]]
|
||||
then
|
||||
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_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
|
||||
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"
|
||||
@@ -1278,13 +1244,7 @@ ${nvidia_lib32_path}"
|
||||
PW_PV_OVERRIDES="/usr/lib/pressure-vessel/overrides/lib"
|
||||
|
||||
if ! check_flatpak; then
|
||||
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="\
|
||||
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:\
|
||||
@@ -1293,7 +1253,6 @@ ${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}"
|
||||
@@ -1336,11 +1295,7 @@ ${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"
|
||||
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 PRESSURE_VESSEL_ARCHITECTURES="x86_64-linux-gnu:i386-linux-gnu"
|
||||
export FONTCONFIG_PATH=''
|
||||
|
||||
unset PRESSURE_VESSEL_FILESYSTEMS_RO PRESSURE_VESSEL_FILESYSTEMS_RW
|
||||
@@ -1696,18 +1651,13 @@ 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
|
||||
@@ -1796,10 +1746,6 @@ 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
|
||||
@@ -2701,10 +2647,7 @@ pw_enable_gamemode_basic () {
|
||||
start_portproton () {
|
||||
pw_check_and_download_plugins
|
||||
|
||||
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
|
||||
if [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
|
||||
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
|
||||
else
|
||||
pw_check_and_download_runtime
|
||||
@@ -2844,19 +2787,15 @@ 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 "${wine_pe64_path}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib64/wine/x86_64-windows/${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 "${wine_pe64_path}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_copy_file "${WINEDIR}/lib64/wine/x86_64-windows/${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
|
||||
|
||||
@@ -2868,7 +2807,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/${wine_pe64_path##*/}/${dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${dll}.dll"
|
||||
try_force_link_file "${WINEDIR}/lib/wine/icu/x86_64-windows/${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"
|
||||
@@ -2890,9 +2829,7 @@ start_portproton () {
|
||||
then
|
||||
pw_clear_pfx
|
||||
check_dirs_and_files_in_pfx
|
||||
if check_arm ; then
|
||||
pw_wineboot -i
|
||||
elif [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
|
||||
if [[ "${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"
|
||||
@@ -3288,18 +3225,9 @@ 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/${PW_SUPPLIED_PE64_ARCH}/d3d9.dll" ]] ; then
|
||||
&& [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d9.dll" || -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/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=""
|
||||
@@ -3312,13 +3240,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/${PW_SUPPLIED_PE64_ARCH}/d3d8.dll" ]]
|
||||
elif [[ -f "${WINEDIR}/lib64/wine/dxvk/d3d8.dll" || -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/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/${PW_SUPPLIED_PE64_ARCH}/d3d12core.dll" ]] ; then
|
||||
|| [[ -f "${WINEDIR}/lib/wine/vkd3d-proton/x86_64-windows/d3d12core.dll" ]] ; then
|
||||
CP_VKD3D_FILES="d3d12 d3d12core"
|
||||
elif [[ -f "${WINEDIR}/lib64/wine/vkd3d-proton/d3d12.dll" ]] ; then
|
||||
CP_VKD3D_FILES="d3d12"
|
||||
@@ -3326,7 +3254,7 @@ start_portproton () {
|
||||
else
|
||||
add_to_var CP_WINE_FILES "d3d12 d3d12core"
|
||||
fi
|
||||
[[ -f "${WINEDIR}/lib/wine/dxvk/${PW_SUPPLIED_PE64_ARCH}/d3d9.dll" ]] && PW_USE_SUPPLIED_DXVK_VKD3D="2"
|
||||
[[ -f "${WINEDIR}/lib/wine/dxvk/x86_64-windows/d3d9.dll" ]] && PW_USE_SUPPLIED_DXVK_VKD3D="2"
|
||||
else
|
||||
PW_USE_SUPPLIED_DXVK_VKD3D="0"
|
||||
fi
|
||||
@@ -3564,7 +3492,7 @@ start_portproton () {
|
||||
|
||||
print_info "Try create symlink WINE files..."
|
||||
for copy_wine_dll in $CP_WINE_FILES ; do
|
||||
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}/lib64/wine/x86_64-windows/${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
|
||||
|
||||
@@ -3593,7 +3521,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/${PW_SUPPLIED_PE64_ARCH}/${wine_dxvk_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${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" ;;
|
||||
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" ;;
|
||||
@@ -3640,7 +3568,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/${PW_SUPPLIED_PE64_ARCH}/${wine_vkd3d_dll}.dll" "${WINEPREFIX}/drive_c/windows/system32/${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"
|
||||
fi ;;
|
||||
1)
|
||||
if [[ $wine_vkd3d_dll =~ d3d12 ]] ; then
|
||||
@@ -4084,6 +4012,25 @@ pw_launch_wrapper () {
|
||||
return
|
||||
fi
|
||||
|
||||
local -a wrapper_scripts=()
|
||||
local wrapper_script
|
||||
local wrapper_scripts_valid=1
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
|
||||
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
|
||||
wrapper_scripts_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
"${wrapper_script}"
|
||||
done
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a wrapper_cmd
|
||||
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
|
||||
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
|
||||
@@ -4265,9 +4212,22 @@ portwine_launch () {
|
||||
fi
|
||||
done
|
||||
else
|
||||
local -a run_after_exes=() run_after_environment=()
|
||||
local run_after_entry run_after_index
|
||||
local run_after_valid=1
|
||||
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
|
||||
run_after_exes=("${PW_RUN_AFTER_EXE}")
|
||||
else
|
||||
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
|
||||
fi
|
||||
for run_after_entry in "${run_after_exes[@]}" ; do
|
||||
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
|
||||
run_after_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${run_after_valid}" == 1 ]] \
|
||||
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
@@ -4291,6 +4251,7 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
|
||||
@@ -4309,10 +4270,30 @@ timeout /t ${wait_delay_int} /nobreak
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
EOF
|
||||
|
||||
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
local run_after_first_win="${run_after_exe_win}"
|
||||
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
|
||||
run_after_entry="${run_after_exes[run_after_index]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${run_after_entry//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
|
||||
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
|
||||
done
|
||||
|
||||
(
|
||||
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
|
||||
export "${run_after_environment[@]}"
|
||||
fi
|
||||
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
)
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
@@ -4428,8 +4409,7 @@ gui_edit_db () {
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_SUPPLIED_DXVK_VKD3D"
|
||||
fi
|
||||
|
||||
if check_arm \
|
||||
|| [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|
||||
if [[ ! -f "${PW_WINELIB}/runtime/files/bin/vkcube" ]] \
|
||||
|| [[ ! -f "${PW_WINELIB}/pressure-vessel/bin/pressure-vessel-wrap" ]]
|
||||
then
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_RUNTIME"
|
||||
|
||||
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# All available options
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,11 +73,19 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
'--silent[Launch .exe file without GUI]'
|
||||
'--log[Enable PortProton logging]'
|
||||
'--silent[Launch Windows file or native game in tray]'
|
||||
'--log[Enable game logging]'
|
||||
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
|
||||
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
@@ -94,18 +102,26 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
|
||||
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
|
||||
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
|
||||
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
|
||||
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
|
||||
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
|
||||
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
|
||||
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
|
||||
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
|
||||
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
|
||||
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
|
||||
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
|
||||
complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
|
||||
complete -c portprotonqt -l log -d "Enable PortProton logging"
|
||||
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
|
||||
complete -c portprotonqt -l log -d "Enable game logging"
|
||||
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
|
||||
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
|
||||
complete -c portprotonqt -s h -l help -d "Show help message"
|
||||
|
||||
+144
-120
@@ -2,7 +2,7 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
import shlex
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -11,21 +11,21 @@ from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
display_config,
|
||||
find_game_by_exe,
|
||||
get_portproton_start_command,
|
||||
get_portproton_location,
|
||||
save_portdata_path_to_config,
|
||||
@@ -34,6 +34,7 @@ from portprotonqt.config import (
|
||||
window_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
|
||||
from portprotonqt.cli import (
|
||||
parse_args,
|
||||
is_portproton_url,
|
||||
@@ -66,6 +67,8 @@ if APP_VERSION == "@APP" "_VERSION@":
|
||||
|
||||
__app_version__ = APP_VERSION
|
||||
|
||||
RESTART_STOP_TIMEOUT_SECONDS = 30
|
||||
|
||||
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
|
||||
|
||||
def get_version():
|
||||
@@ -82,103 +85,6 @@ def get_version():
|
||||
return __app_version__
|
||||
|
||||
|
||||
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
start_sh + ["cli", "--stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to execute PortProton stop command: %s", e)
|
||||
|
||||
|
||||
def get_portproton_tray_icon() -> QIcon:
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with minimal tray game controls."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
tray_icon.setToolTip(__app_name__)
|
||||
|
||||
from datetime import datetime
|
||||
from portprotonqt.time_utils import save_last_launch, save_playtime
|
||||
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
|
||||
start_time = datetime.now()
|
||||
|
||||
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
|
||||
tray_menu = QMenu()
|
||||
|
||||
def end_silent_run():
|
||||
monitor_timer.stop()
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
if elapsed > 0:
|
||||
save_playtime(exe_path, elapsed)
|
||||
tray_icon.hide()
|
||||
app.quit()
|
||||
|
||||
def stop_game() -> None:
|
||||
stop_portproton_game(start_sh, logger)
|
||||
end_silent_run()
|
||||
|
||||
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
def update_pause_action() -> list[psutil.Process]:
|
||||
try:
|
||||
children = psutil.Process(process.pid).children(recursive=True)
|
||||
paused = bool(children) and all(
|
||||
child.status() == psutil.STATUS_STOPPED for child in children
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
children = []
|
||||
paused = False
|
||||
pause_action.setEnabled(bool(children))
|
||||
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
return children
|
||||
|
||||
def toggle_pause() -> None:
|
||||
children = update_pause_action()
|
||||
if not children:
|
||||
return
|
||||
try:
|
||||
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
|
||||
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
|
||||
for child in children:
|
||||
try:
|
||||
child.send_signal(pause_signal)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
|
||||
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
|
||||
logger.warning("Failed to inspect game pause state: %s", error)
|
||||
update_pause_action()
|
||||
|
||||
pause_action = QAction(_("Pause Game"), tray_menu)
|
||||
pause_action.triggered.connect(toggle_pause)
|
||||
tray_menu.addAction(pause_action)
|
||||
tray_menu.aboutToShow.connect(update_pause_action)
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
tray_icon.setContextMenu(tray_menu)
|
||||
tray_icon.show()
|
||||
|
||||
monitor_timer = QTimer(app)
|
||||
monitor_timer.timeout.connect(close_when_game_exits)
|
||||
monitor_timer.start(1000)
|
||||
|
||||
|
||||
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
|
||||
"""Restore a PortProton prefix backup."""
|
||||
logger = get_logger(__name__)
|
||||
@@ -236,6 +142,38 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
|
||||
import orjson
|
||||
|
||||
from portprotonqt.cli import edit_game, list_games
|
||||
|
||||
try:
|
||||
if parsed_args.edit_game:
|
||||
result = edit_game(parsed_args.edit_game, parsed_args.settings)
|
||||
elif parsed_args.game_info:
|
||||
games = list_games()
|
||||
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
|
||||
if result is None:
|
||||
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
|
||||
return 1
|
||||
else:
|
||||
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
|
||||
if parsed_args.fields:
|
||||
entries = result if isinstance(result, list) else [result]
|
||||
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
|
||||
result = selected if isinstance(result, list) else selected[0]
|
||||
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
|
||||
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
|
||||
except OSError as error:
|
||||
get_logger(__name__).error("Library command failed: %s", error)
|
||||
return 1
|
||||
except ValueError as error:
|
||||
get_logger(__name__).error("Invalid library command: %s", error)
|
||||
return 1
|
||||
except RuntimeError as error:
|
||||
get_logger(__name__).error("Failed to edit game settings: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
@@ -335,6 +273,17 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parsed_args
|
||||
native_launch_uri = None
|
||||
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
|
||||
native_launch_uri = args.file_or_url
|
||||
elif args.file_or_url and not is_launch_file(args.file_or_url):
|
||||
target = normalize_launch_path(args.file_or_url)
|
||||
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
|
||||
entry = find_game_by_exe(target)
|
||||
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
|
||||
result = create_native_desktop_file(target, name)
|
||||
if result and os.path.isfile(result[1]):
|
||||
native_launch_uri = NATIVE_URI_PREFIX + result[1]
|
||||
setup_logger(args.debug_level)
|
||||
logger = get_logger(__name__)
|
||||
if consume_download_counter_skip():
|
||||
@@ -366,6 +315,9 @@ def main():
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif native_launch_uri:
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
@@ -393,7 +345,7 @@ def main():
|
||||
if theme_store_id is not None:
|
||||
ipc_message = f"theme:{theme_store_id}"
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from requests import RequestException
|
||||
from portprotonqt.appimage_updater import (
|
||||
@@ -419,7 +371,10 @@ def main():
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
|
||||
server_name = os.path.join(runtime_dir, __app_id__)
|
||||
instance_lock = QLockFile(server_name + ".lock")
|
||||
owns_instance = instance_lock.tryLock(0)
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
@@ -432,7 +387,11 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Remove old socket if it exists
|
||||
if not owns_instance:
|
||||
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
|
||||
return
|
||||
|
||||
# Only the lock owner can remove a stale socket.
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -456,7 +415,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -541,22 +500,19 @@ def main():
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri
|
||||
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
@@ -570,17 +526,44 @@ def main():
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16],
|
||||
"protondb_appid": game[16] if len(game) > 16 else "",
|
||||
})
|
||||
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
restart_target = None
|
||||
|
||||
def handle_game_request(
|
||||
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
|
||||
) -> None:
|
||||
nonlocal restart_target
|
||||
if restart_target is not None:
|
||||
return
|
||||
if silent and (confirm or window.game_processes):
|
||||
message_box = QMessageBox(window)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(__app_name__)
|
||||
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
restart_target = target
|
||||
app.quit()
|
||||
return
|
||||
if target.startswith(NATIVE_URI_PREFIX):
|
||||
if silent and not log_mode:
|
||||
window.toggleGame(target)
|
||||
else:
|
||||
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
|
||||
return
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
run_silent_tray(app, start_sh, path)
|
||||
window.toggleGame(shlex.quote(path))
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
@@ -591,7 +574,11 @@ def main():
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if exe_path:
|
||||
if native_launch_uri:
|
||||
def handle_native_launch() -> None:
|
||||
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_native_launch)
|
||||
elif exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
@@ -665,15 +652,15 @@ def main():
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
handle_game_request(target, True, confirm=True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
|
||||
handle_game_request(unquote(launch_path), False, log_mode)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -924,7 +911,44 @@ def main():
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
sys.exit(app.exec())
|
||||
exit_code = app.exec()
|
||||
if restart_target is not None:
|
||||
if window.game_processes:
|
||||
window._terminate_game_processes()
|
||||
window.resetPlayButton()
|
||||
window._stopBackgroundWorkers()
|
||||
try:
|
||||
children = psutil.Process().children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
for child in alive:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
if alive:
|
||||
logger.error("Cannot restart while child processes are still running")
|
||||
sys.exit(1)
|
||||
if start_sh:
|
||||
subprocess.run(start_sh + ["cli", "--stop"], check=True,
|
||||
timeout=RESTART_STOP_TIMEOUT_SECONDS)
|
||||
instance_lock.unlock()
|
||||
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
|
||||
except OSError as error:
|
||||
logger.error("Failed to restart PortProtonQt: %s", error)
|
||||
exit_code = 1
|
||||
except psutil.Error as error:
|
||||
logger.error("Failed to stop PortProtonQt child processes: %s", error)
|
||||
exit_code = 1
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.error("Failed to stop PortProton before restart: %s", error)
|
||||
exit_code = 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+291
-3
@@ -2,6 +2,7 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,27 @@ def parse_args():
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
library = parser.add_mutually_exclusive_group()
|
||||
library.add_argument(
|
||||
"--list-games", action="store_true",
|
||||
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
|
||||
)
|
||||
library.add_argument(
|
||||
"--game-info", metavar="ID",
|
||||
help="Print one game as JSON using its source-qualified ID from --list-games"
|
||||
)
|
||||
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
|
||||
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
|
||||
help="Set a game setting; repeat to change multiple values with --edit-game")
|
||||
parser.add_argument("--all", dest="all_games", action="store_true",
|
||||
help="Include uninstalled games from cached libraries in --list-games")
|
||||
parser.add_argument("--fields", metavar="FIELD,...",
|
||||
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
|
||||
formatting = parser.add_mutually_exclusive_group()
|
||||
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
|
||||
help="Format JSON with indentation (default)")
|
||||
formatting.add_argument("--raw", dest="pretty", action="store_false",
|
||||
help="Print compact JSON without indentation")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
@@ -67,12 +89,12 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--silent",
|
||||
action="store_true",
|
||||
help="Launch supported Windows file in tray without showing the main window"
|
||||
help="Launch supported Windows file or native game in tray without showing the main window"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log",
|
||||
action="store_true",
|
||||
help="Enable PortProton logging for the launched Windows file"
|
||||
help="Enable logging for the launched Windows file or native game"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-prefix",
|
||||
@@ -89,7 +111,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
'file_or_url',
|
||||
nargs='?',
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
|
||||
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
|
||||
)
|
||||
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
|
||||
separator_index = sys.argv.index("--")
|
||||
@@ -106,10 +128,276 @@ def parse_args():
|
||||
args = parser.parse_args()
|
||||
launch_args = []
|
||||
|
||||
if args.settings and not args.edit_game:
|
||||
parser.error("--set requires --edit-game")
|
||||
if args.edit_game and not args.settings:
|
||||
parser.error("--edit-game requires at least one --set KEY=VALUE")
|
||||
if args.all_games and not args.list_games:
|
||||
parser.error("--all requires --list-games")
|
||||
if args.fields is not None:
|
||||
if not (args.list_games or args.game_info):
|
||||
parser.error("--fields requires --list-games or --game-info")
|
||||
args.fields = [field.strip() for field in args.fields.split(",")]
|
||||
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
|
||||
parser.error("--fields requires comma-separated field names without empty entries")
|
||||
args.launch_args = launch_args
|
||||
return args
|
||||
|
||||
|
||||
def _read_game_settings(path: Path) -> dict[str, str]:
|
||||
"""Read literal shell assignments without executing configuration code."""
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
settings: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return settings
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
get_logger(__name__).warning("Failed to read %s: %s", path, error)
|
||||
return settings
|
||||
for line in content.splitlines():
|
||||
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
value = match[2]
|
||||
if any(token in value for token in ("$", "`")):
|
||||
continue
|
||||
try:
|
||||
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) <= 1:
|
||||
settings[match[1]] = parts[0] if parts else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
|
||||
"""Read custom artwork and cached metadata without fetching remote resources."""
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.steam_api.api import get_cached_steam_game_info
|
||||
from portprotonqt.time_utils import (
|
||||
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
|
||||
)
|
||||
|
||||
metadata = get_cached_steam_game_info(name, exec_line)
|
||||
metadata.setdefault("name", name)
|
||||
metadata.setdefault("description", "")
|
||||
metadata.setdefault("cover", "")
|
||||
if not executable:
|
||||
return metadata
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
|
||||
metadata["custom_data_directory"] = str(folder)
|
||||
metadata_file = folder / "metadata.txt"
|
||||
if metadata_file.is_file():
|
||||
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
|
||||
metadata.update({key: value for key, value in translations.items() if value})
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover = folder / f"cover{extension}"
|
||||
if cover.is_file():
|
||||
metadata["cover"] = str(cover)
|
||||
break
|
||||
metadata["custom_icon"] = get_custom_icon_path(executable)
|
||||
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
|
||||
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
|
||||
return metadata
|
||||
|
||||
|
||||
def _get_game_runtime(
|
||||
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
|
||||
) -> dict:
|
||||
"""Export configured runtime values; never source PPDB files or launch Wine."""
|
||||
user_conf = location / "data/user.conf"
|
||||
ppdb = Path(f"{executable}.ppdb") if executable else None
|
||||
settings = _read_game_settings(ppdb) if ppdb else {}
|
||||
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
|
||||
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
|
||||
settings.update(overrides)
|
||||
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
|
||||
prefix = overrides.get("PW_PREFIX_NAME", prefix)
|
||||
resolved_runner = runner
|
||||
if runner == "PROTON_LG":
|
||||
resolved_runner = variables.get("PW_PROTON_LG_VER")
|
||||
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
|
||||
resolved_runner = variables.get("PW_WINE_LG_VER")
|
||||
runner_path = None
|
||||
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
|
||||
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
|
||||
location / "data/dist" / resolved_runner.upper()
|
||||
)
|
||||
elif resolved_runner == "USE_SYSTEM_WINE":
|
||||
wine = shutil.which("wine")
|
||||
runner_path = Path(wine).parent.parent if wine else None
|
||||
wine_directory = runner_path
|
||||
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
|
||||
wine_directory /= "files"
|
||||
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
|
||||
wine_directory / "bin-wow64"
|
||||
).is_dir() else "bin"
|
||||
prefix_path = location / "data/prefixes" / prefix if prefix else None
|
||||
return {
|
||||
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
|
||||
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
|
||||
"runner": resolved_runner,
|
||||
"runner_path": str(runner_path) if runner_path else None,
|
||||
"runner_installed": runner_path.is_dir() if runner_path else False,
|
||||
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
|
||||
"settings": settings, "user_config": str(user_conf),
|
||||
"ppdb_exists": ppdb.is_file() if ppdb else False,
|
||||
}
|
||||
|
||||
|
||||
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Export a cached GOG or Epic library using the existing store backends."""
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
api = GOGAPI() if source == "gog" else EGSAPI()
|
||||
installed = api.load_installed()
|
||||
games = []
|
||||
for record in api.load_library():
|
||||
app_id = str(record.get("app_id", ""))
|
||||
if not app_id:
|
||||
continue
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
|
||||
metadata = _get_game_metadata(name, target, target) if target else {}
|
||||
metadata.pop("name", None)
|
||||
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
|
||||
games.append({
|
||||
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
|
||||
**metadata,
|
||||
"id": f"{source}:{app_id}", "source": source, "name": name,
|
||||
"description": record.get("description") or metadata.get("description", ""),
|
||||
"cover": cover or record.get("cover") or metadata.get("cover", ""),
|
||||
"icon": icon, "installed": is_installed,
|
||||
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
|
||||
"executable": target, "directory": str(Path(target).parent) if target else None,
|
||||
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_steam_games() -> list[dict]:
|
||||
"""Export installed Steam games and their locally cached metadata."""
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import get_steam_installed_games
|
||||
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
|
||||
|
||||
games = []
|
||||
for name, appid, last_played, playtime in get_steam_installed_games():
|
||||
metadata = get_cached_full_steam_game_info(appid, name)
|
||||
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"steam:{appid}", "source": "steam", "name": name,
|
||||
"cover": cover or metadata.get("cover", ""),
|
||||
"installed": True, "exec": f"steam://rungameid/{appid}",
|
||||
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Return local PortProton shortcuts without launching games or downloading metadata."""
|
||||
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
|
||||
|
||||
if not location:
|
||||
return []
|
||||
games = []
|
||||
for desktop_path in sorted(Path(location).glob("*.desktop")):
|
||||
entry = parse_desktop_entry(str(desktop_path))
|
||||
if entry is None:
|
||||
continue
|
||||
exec_line = entry.get("Exec", "")
|
||||
executable = extract_exec_target_path(exec_line)
|
||||
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
|
||||
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"portproton:{desktop_path.name}", "source": "portproton",
|
||||
"installed": bool(executable and Path(executable).is_file()),
|
||||
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
|
||||
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
|
||||
"desktop_file": str(desktop_path),
|
||||
"exec": exec_line,
|
||||
"executable": executable,
|
||||
"directory": str(Path(executable).parent) if executable else None,
|
||||
"icon": icon,
|
||||
"ppdb": f"{executable}.ppdb" if executable else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def list_games() -> list[dict]:
|
||||
"""Return locally available library data from all supported game sources."""
|
||||
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
|
||||
|
||||
location = get_portproton_location()
|
||||
scripts = get_portproton_scripts_path()
|
||||
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
|
||||
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
|
||||
games = _list_portproton_games(location, variables, overrides)
|
||||
games.extend(_list_steam_games())
|
||||
for source in ("gog", "egs"):
|
||||
games.extend(_list_store_games(source, location, variables, overrides))
|
||||
return games
|
||||
|
||||
|
||||
def edit_game(game_id: str, changes: list[str]) -> dict:
|
||||
"""Update a game's PPDB through PortProton and verify the saved settings."""
|
||||
import subprocess
|
||||
|
||||
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
|
||||
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
|
||||
|
||||
game = next((game for game in list_games() if game["id"] == game_id), None)
|
||||
if game is None:
|
||||
raise ValueError(f"Game not found: {game_id}")
|
||||
executable = game.get("executable")
|
||||
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
|
||||
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
|
||||
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
|
||||
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
|
||||
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
|
||||
values: dict[str, str] = {}
|
||||
for change in changes:
|
||||
key, separator, value = change.partition("=")
|
||||
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
|
||||
raise ValueError(f"Unknown or invalid game setting: {key}")
|
||||
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
|
||||
raise ValueError(f"Unsupported shell syntax in setting: {key}")
|
||||
if key in toggles and value not in {"0", "1"}:
|
||||
raise ValueError(f"{key} must be 0 or 1")
|
||||
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
|
||||
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
|
||||
values[key] = value
|
||||
if not values:
|
||||
raise ValueError("At least one setting is required")
|
||||
command = get_portproton_start_command()
|
||||
if not command:
|
||||
raise ValueError("PortProton start.sh was not found")
|
||||
result = subprocess.run(
|
||||
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
|
||||
capture_output=True, text=True, errors="replace", check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
|
||||
saved = _read_game_settings(Path(f"{executable}.ppdb"))
|
||||
if any(saved.get(key) != value for key, value in values.items()):
|
||||
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
|
||||
return next(game for game in list_games() if game["id"] == game_id)
|
||||
|
||||
|
||||
def add_steam_compat_tool(force_install: bool = False) -> bool:
|
||||
"""
|
||||
Add PortProtonQt as a Steam compatibility tool.
|
||||
|
||||
@@ -581,6 +581,14 @@ def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
|
||||
return hashed_dir
|
||||
|
||||
|
||||
def get_custom_icon_path(exe_path: str) -> str:
|
||||
"""Return the existing custom icon for an executable."""
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
path = os.path.join(folder, "icon.png")
|
||||
return path if os.path.isfile(path) else ""
|
||||
|
||||
|
||||
def create_desktop_file(
|
||||
exe_path: str,
|
||||
game_name: str | None = None,
|
||||
@@ -606,6 +614,7 @@ def create_desktop_file(
|
||||
"applications-games" if is_store_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
icon_path = get_custom_icon_path(exe_path) or icon_path
|
||||
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
|
||||
if icon_path != "applications-games":
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -9,6 +11,7 @@ from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
get_portproton_location,
|
||||
extract_exec_target_path,
|
||||
favorites_config,
|
||||
favorites_folders_config,
|
||||
@@ -29,6 +32,7 @@ from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
@@ -127,6 +131,8 @@ class ContextMenuManager:
|
||||
Returns:
|
||||
bool: True if the game is running, False otherwise.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
|
||||
if game_card.game_source == "steam":
|
||||
return False
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -363,8 +369,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -392,8 +398,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -537,7 +543,7 @@ class ContextMenuManager:
|
||||
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
|
||||
)
|
||||
menu.addSeparator()
|
||||
cancel_action = menu.addAction(_("Cancel"))
|
||||
cancel_action = menu.addAction(self._get_safe_icon("cancel"), _("Cancel"))
|
||||
cancel_action.triggered.connect(self.clear_card_selection)
|
||||
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
@@ -561,7 +567,7 @@ class ContextMenuManager:
|
||||
self._show_card_selection_menu(menu, game_card, pos)
|
||||
return
|
||||
self.clear_card_selection()
|
||||
selection_action = menu.addAction(_("Select multiple"))
|
||||
selection_action = menu.addAction(self._get_safe_icon("edit"), _("Select multiple"))
|
||||
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
|
||||
menu.setActiveAction(selection_action)
|
||||
menu.addSeparator()
|
||||
@@ -731,6 +737,9 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "native":
|
||||
self._add_native_actions(menu, game_card)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -803,16 +812,21 @@ class ContextMenuManager:
|
||||
if os.path.exists(menu_path)
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if (
|
||||
game_card.game_source in ("portproton", "gog", "egs")
|
||||
and not is_uninstalled_store_game
|
||||
and get_steam_home() is not None
|
||||
):
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if build_only:
|
||||
return menu
|
||||
@@ -824,6 +838,96 @@ class ContextMenuManager:
|
||||
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
|
||||
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
for directory, add_text, remove_text, icon in (
|
||||
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
|
||||
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
|
||||
):
|
||||
if not directory:
|
||||
continue
|
||||
exists = (Path(directory) / source.name).is_file()
|
||||
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
|
||||
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(card.name)
|
||||
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
|
||||
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
|
||||
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
|
||||
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
action.triggered.connect(lambda: self._edit_native_shortcut(card))
|
||||
if managed:
|
||||
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
|
||||
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
|
||||
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
|
||||
|
||||
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
target = Path(directory) / source.name
|
||||
try:
|
||||
if target.is_file():
|
||||
if target == source:
|
||||
retained = Path(get_portproton_location() or source.parent) / source.name
|
||||
retained.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, retained)
|
||||
target.unlink()
|
||||
else:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(source.read_text(encoding="utf-8"))
|
||||
command = quote_native_arguments([exec_line])
|
||||
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
|
||||
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
|
||||
section = f"Desktop Action {action}"
|
||||
if parser.has_section(section):
|
||||
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with target.open("w", encoding="utf-8") as output:
|
||||
parser.write(output, space_around_delimiters=False)
|
||||
target.chmod(0o755)
|
||||
if directory == self._get_applications_dir():
|
||||
self._update_desktop_database()
|
||||
self.parent.refreshGames()
|
||||
except Exception as error:
|
||||
logger.warning("Cannot update native shortcut: %s", error)
|
||||
self.signals.show_warning_dialog.emit(_("Error"), str(error))
|
||||
|
||||
def _edit_native_shortcut(self, card: "GameCard") -> None:
|
||||
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=card.name, exe_path=source, cover_path=card.cover_path)
|
||||
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
name = dialog.nameEdit.text().strip()
|
||||
if not name:
|
||||
return
|
||||
cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(cover) and dialog.last_cover_path:
|
||||
cover = dialog.last_cover_path
|
||||
try:
|
||||
self._save_local_shortcut_name(source, name)
|
||||
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
|
||||
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
|
||||
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
|
||||
if os.path.abspath(cover) != str(target):
|
||||
self._remove_old_cover_files(directory)
|
||||
shutil.copyfile(cover, target)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(card.name):
|
||||
self.add_to_steam(name, card.exec_line, cover)
|
||||
elif is_game_in_steam(card.name):
|
||||
self.remove_from_steam(card.name, card.exec_line, "native")
|
||||
self.parent.refreshGames()
|
||||
except OSError as error:
|
||||
logger.warning("Cannot edit native metadata: %s", error)
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
Launch or stop a game based on its current state.
|
||||
@@ -831,6 +935,9 @@ class ContextMenuManager:
|
||||
Args:
|
||||
game_card: The GameCard instance containing game data.
|
||||
"""
|
||||
if game_card.game_source == "native":
|
||||
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@@ -913,7 +1020,9 @@ class ContextMenuManager:
|
||||
if not entry:
|
||||
continue
|
||||
exec_line = entry.get("Exec", entry.get("exec", ""))
|
||||
if script_name.lower() in exec_line.lower():
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
|
||||
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
|
||||
return file_path
|
||||
except OSError:
|
||||
continue
|
||||
@@ -989,6 +1098,9 @@ class ContextMenuManager:
|
||||
|
||||
def _get_exec_line(self, game_name, exec_line):
|
||||
"""Retrieve and validate exec_line from .desktop file if necessary."""
|
||||
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
return entry.get("Exec") if entry else None
|
||||
if exec_line and exec_line.strip() != "full":
|
||||
return exec_line
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1120,6 +1232,20 @@ class ContextMenuManager:
|
||||
|
||||
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
|
||||
card_exec_line = exec_line
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
|
||||
str(Path(self._get_applications_dir()) / source.name)]
|
||||
if desktop:
|
||||
paths.append(str(Path(desktop) / source.name))
|
||||
self._remove_installed_shortcuts(game_name, paths)
|
||||
self._remove_statistics_entry(str(source), game_name)
|
||||
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||
for key in ("all", "native", "favorites"):
|
||||
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
@@ -1171,8 +1297,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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -1422,7 +1548,8 @@ class ContextMenuManager:
|
||||
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id,
|
||||
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
@@ -1536,6 +1663,27 @@ class ContextMenuManager:
|
||||
except OSError as error:
|
||||
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
|
||||
|
||||
def _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
game_dir = resolve_custom_data_dir(custom_root, exe_path)
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
metadata_lines = []
|
||||
if os.path.isfile(metadata_path):
|
||||
with open(metadata_path, encoding="utf-8") as metadata_file:
|
||||
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
|
||||
clean_name = " ".join(name.splitlines()).strip()
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
|
||||
temp_path = metadata_file.name
|
||||
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
|
||||
os.replace(temp_path, metadata_path)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1576,7 +1724,12 @@ class ContextMenuManager:
|
||||
_("Game name and executable path are required")
|
||||
)
|
||||
return
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
old_entry = parse_desktop_entry(old_path)
|
||||
original_name = old_entry.get("Name", game_name) if old_entry else game_name
|
||||
dialog.nameEdit.setText(original_name)
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
dialog.nameEdit.setText(new_name)
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1584,10 +1737,15 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
new_desktop_path = old_path
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
if exe_path == new_exe_path and os.path.isfile(old_path):
|
||||
with open(old_path, encoding="utf-8") as source:
|
||||
desktop_entry = source.read()
|
||||
if new_name != game_name:
|
||||
self._save_local_shortcut_name(new_exe_path, new_name)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
@@ -1606,13 +1764,6 @@ class ContextMenuManager:
|
||||
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
|
||||
if exe_path != new_exe_path:
|
||||
self._relocate_shortcut_statistics(exe_path, new_exe_path)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
_("Failed to delete old .desktop file: {error}"),
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1632,11 +1783,9 @@ class ContextMenuManager:
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
custom_folder = resolve_custom_data_dir(
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
|
||||
new_exe_path,
|
||||
)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
@@ -1724,15 +1873,22 @@ class ContextMenuManager:
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
if not exec_line.startswith((NATIVE_URI_PREFIX, "gog://launch/", "egs://launch/")):
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
|
||||
else:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
@@ -1769,22 +1925,18 @@ class ContextMenuManager:
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam.").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_(message).format(game_name=game_name)
|
||||
)
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
from itertools import accumulate
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
|
||||
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -18,6 +18,19 @@ class CustomComboBox(QComboBox):
|
||||
super().__init__(parent)
|
||||
self.theme = theme
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
line_edit = self.lineEdit()
|
||||
if (
|
||||
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
|
||||
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
return True
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
self.showPopup()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
line_edit = self.lineEdit()
|
||||
if line_edit is None:
|
||||
|
||||
@@ -6,6 +6,9 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
from typing import IO
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import get_portproton_location
|
||||
@@ -18,7 +21,7 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DebugLogManager:
|
||||
"""Manages debug log creation with game launch and Wine output capture."""
|
||||
"""Manage debug logs for Wine and native game launches."""
|
||||
|
||||
def __init__(self):
|
||||
self.process: subprocess.Popen | None = None
|
||||
@@ -26,15 +29,19 @@ class DebugLogManager:
|
||||
self.start_command: list[str] | None = None
|
||||
self.wine_output: list[str] = []
|
||||
self.runtime_env: dict[str, str] | None = None
|
||||
self.native_output: IO[str] | None = None
|
||||
self.native_directory: str | None = None
|
||||
self.is_running = False
|
||||
self.output_queue = queue.Queue()
|
||||
self.output_thread = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, exe_path: str, start_command: list[str]) -> bool:
|
||||
"""Start game with PW_LOG=1 and capture output."""
|
||||
"""Capture game output, enabling PW_LOG for PortProton launches."""
|
||||
if self.is_running:
|
||||
return False
|
||||
if exe_path.startswith("native://"):
|
||||
return self._start_native(exe_path)
|
||||
|
||||
self.exe_path = exe_path
|
||||
self.start_command = start_command
|
||||
@@ -86,6 +93,52 @@ class DebugLogManager:
|
||||
logger.error("Failed to start debug session: %s", e)
|
||||
return False
|
||||
|
||||
def _start_native(self, uri: str) -> bool:
|
||||
from portprotonqt.native_games import get_native_launch
|
||||
|
||||
try:
|
||||
command, directory = get_native_launch(uri.removeprefix("native://"))
|
||||
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
|
||||
self.process = subprocess.Popen(
|
||||
command, cwd=directory, stdout=self.native_output,
|
||||
stderr=subprocess.STDOUT, start_new_session=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error("Failed to start native debug session: %s", error)
|
||||
if self.native_output is not None:
|
||||
self.native_output.close()
|
||||
self.native_output = None
|
||||
return False
|
||||
self.exe_path = uri.removeprefix("native://")
|
||||
self.start_command = command
|
||||
self.native_directory = directory
|
||||
self.is_running = True
|
||||
return True
|
||||
|
||||
def _save_native_log(self) -> str | None:
|
||||
output = self.native_output
|
||||
if output is None:
|
||||
return None
|
||||
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
|
||||
|
||||
try:
|
||||
settings = load_native_settings(str(self.exe_path))
|
||||
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.seek(0)
|
||||
with destination.open("w", encoding="utf-8") as log:
|
||||
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
|
||||
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
|
||||
shutil.copyfileobj(output, log)
|
||||
return str(destination)
|
||||
except OSError as error:
|
||||
logger.error("Failed to save native debug log: %s", error)
|
||||
return None
|
||||
finally:
|
||||
output.close()
|
||||
self.native_output = None
|
||||
self.native_directory = None
|
||||
|
||||
def _read_output(self):
|
||||
"""Read output from subprocess in a separate thread."""
|
||||
if self.process and self.process.stdout:
|
||||
@@ -98,19 +151,20 @@ class DebugLogManager:
|
||||
logger.debug("Error reading output: %s", e)
|
||||
|
||||
def stop(self) -> str | None:
|
||||
"""Stop game and save debug log with captured Wine output."""
|
||||
self._capture_env_vars()
|
||||
"""Stop the game and save its diagnostic log."""
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
self._stop_event.set()
|
||||
|
||||
if self.is_running and self.process:
|
||||
try:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
|
||||
|
||||
try:
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.process.poll() is None:
|
||||
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -123,7 +177,7 @@ class DebugLogManager:
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
log_file = self._save_log()
|
||||
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
|
||||
|
||||
if self.process and self.process.stdout:
|
||||
try:
|
||||
@@ -146,7 +200,8 @@ class DebugLogManager:
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
self._capture_env_vars()
|
||||
if self.native_output is None:
|
||||
self._capture_env_vars()
|
||||
return True
|
||||
|
||||
def _capture_env_vars(self) -> None:
|
||||
|
||||
@@ -290,7 +290,8 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
|
||||
game_dir = os.path.dirname(exe_path)
|
||||
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
|
||||
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
if portproton_path:
|
||||
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
|
||||
|
||||
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -313,8 +314,17 @@ def generate_system_info(
|
||||
get_d3d_extras_status,
|
||||
)
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
|
||||
|
||||
native = bool(exe_path and exe_path.startswith("native://"))
|
||||
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
|
||||
if desktop_path:
|
||||
runtime_env = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
exe_path = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
|
||||
portproton_path = get_portproton_location() or ""
|
||||
if not portproton_path and not native:
|
||||
return "Error: PortProton location not found"
|
||||
|
||||
lines = []
|
||||
@@ -344,16 +354,17 @@ def generate_system_info(
|
||||
lines.append(get_libc_version())
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
if not native:
|
||||
lines.append(get_vulkan_use_info(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Version WINE in use:")
|
||||
lines.append(get_wine_version(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
lines.append("Program bit depth:")
|
||||
lines.append(get_program_bit_depth(exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append("Date and time of start debug for PortProton:")
|
||||
lines.append(datetime.now().strftime("%c %z"))
|
||||
@@ -419,26 +430,28 @@ def generate_system_info(
|
||||
lines.append(locale_avail)
|
||||
lines.append("-" * 61)
|
||||
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
if not native:
|
||||
lines.append(get_d3d_extras_status(portproton_path, exe_path))
|
||||
lines.append("-" * 61)
|
||||
|
||||
prefix_name = get_prefix_name(exe_path)
|
||||
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
|
||||
if winetricks_content:
|
||||
lines.append("winetricks.log:")
|
||||
lines.append(winetricks_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
if exe_path:
|
||||
ppdb_content = get_ppdb_content(exe_path, start_cmd)
|
||||
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
|
||||
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
|
||||
if ppdb_content:
|
||||
lines.append(f"Use {exe_path}.ppdb db file:")
|
||||
lines.append(f"Use {ppdb_path}.ppdb db file:")
|
||||
if ppdb_log_vars:
|
||||
lines.append("\n".join(ppdb_log_vars))
|
||||
lines.append(ppdb_content)
|
||||
lines.append("-" * 61)
|
||||
|
||||
user_overrides = get_user_overrides(portproton_path)
|
||||
user_overrides = "" if native else get_user_overrides(portproton_path)
|
||||
if user_overrides:
|
||||
lines.append(user_overrides)
|
||||
lines.append("-" * 61)
|
||||
|
||||
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from portprotonqt.native_games import get_native_launch, load_native_settings
|
||||
|
||||
from portprotonqt.detail_pages.widgets import (
|
||||
create_scroll_area,
|
||||
create_back_button,
|
||||
@@ -93,6 +95,9 @@ class DetailPageManager:
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
self._current_detail_source = ("game", dict(game_data))
|
||||
self._return_to_library_card_key = (
|
||||
game_data.get("name", ""), game_data.get("exec_line", "")
|
||||
)
|
||||
detail_page = QWidget()
|
||||
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
|
||||
@@ -652,7 +657,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
str(game_source).lower() not in {"gog", "egs", "native"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -667,7 +672,24 @@ class DetailPageManager:
|
||||
buttons_layout.addWidget(add_button)
|
||||
|
||||
# Show settings button for PortProton games or Steam games using PortProtonQt
|
||||
if str(game_source).lower() == "portproton":
|
||||
if source == "native":
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
|
||||
buttons_layout.addWidget(settings_button)
|
||||
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
if os.path.dirname(exec_line.removeprefix("native://")) in (
|
||||
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
|
||||
):
|
||||
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
|
||||
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
source in {"gog", "egs"}
|
||||
@@ -722,6 +744,8 @@ class DetailPageManager:
|
||||
|
||||
def _get_current_exe(self, exec_line: str) -> str | None:
|
||||
"""Extract current executable from exec line."""
|
||||
if exec_line.startswith("native://"):
|
||||
return os.path.basename(exec_line.removeprefix("native://"))
|
||||
file_to_check = extract_exec_target_path(exec_line)
|
||||
return os.path.basename(file_to_check) if file_to_check else None
|
||||
|
||||
@@ -819,9 +843,26 @@ class DetailPageManager:
|
||||
|
||||
def _open_executable_folder(self, exe_path: str | None) -> None:
|
||||
"""Open the folder containing an executable."""
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
if not exe_path:
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
if exe_path.startswith("native://"):
|
||||
try:
|
||||
desktop_path = exe_path.removeprefix("native://")
|
||||
settings = load_native_settings(desktop_path)
|
||||
command, directory = get_native_launch(desktop_path)
|
||||
executable = next((part for part in reversed(command)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
|
||||
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
raise ValueError("Game directory is not available")
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native game folder: %s", error)
|
||||
QMessageBox.warning(self.main_window, _("Error"), str(error))
|
||||
return
|
||||
else:
|
||||
if not os.path.exists(exe_path):
|
||||
return
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
|
||||
def _has_game_shortcut(self, game_name: str) -> bool:
|
||||
@@ -1451,14 +1492,15 @@ class DetailPageManager:
|
||||
"""Start debug logging session."""
|
||||
if exe_path is None:
|
||||
return
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
native = exe_path.startswith("native://")
|
||||
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
start_command = [] if native else get_portproton_start_command()
|
||||
if start_command is None:
|
||||
return
|
||||
|
||||
if self.debug_log_manager.start(resolved_exe_path, start_command):
|
||||
@@ -1480,7 +1522,7 @@ class DetailPageManager:
|
||||
msg_box.setWindowTitle(_("Log Saved"))
|
||||
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1638,10 +1680,15 @@ class DetailPageManager:
|
||||
container = getattr(self.main_window, "gamesListWidget", None)
|
||||
if container is None:
|
||||
return
|
||||
manager = getattr(self.main_window, "game_library_manager", None)
|
||||
card_key = getattr(self, "_return_to_library_card_key", None)
|
||||
card = getattr(manager, "game_card_cache", {}).get(card_key)
|
||||
cards = [
|
||||
widget for widget in container.findChildren(QWidget)
|
||||
if widget.__class__.__name__ == "GameCard"
|
||||
]
|
||||
if card is not None and card.isVisible():
|
||||
cards = [card]
|
||||
if not cards:
|
||||
return
|
||||
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
|
||||
|
||||
@@ -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
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
import psutil
|
||||
@@ -30,6 +30,8 @@ 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
|
||||
@@ -364,6 +366,8 @@ 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)
|
||||
@@ -429,6 +433,19 @@ class AddGameDialog(DraggableDialog):
|
||||
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
|
||||
layout.addRow(cover_button_label, coverBrowseButton)
|
||||
|
||||
self.iconEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
|
||||
icon_label = QLabel(_("Custom Icon:"))
|
||||
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
icon_row = QHBoxLayout()
|
||||
icon_row.addWidget(self.iconEdit)
|
||||
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
|
||||
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
icon_browse.clicked.connect(self.browseIcon)
|
||||
icon_row.addWidget(icon_browse)
|
||||
layout.addRow(icon_label, icon_row)
|
||||
|
||||
self.add_to_steam_checkbox = QCheckBox()
|
||||
self.add_to_menu_checkbox = QCheckBox()
|
||||
self.add_to_desktop_checkbox = QCheckBox()
|
||||
@@ -467,6 +484,9 @@ class AddGameDialog(DraggableDialog):
|
||||
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
|
||||
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
|
||||
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
|
||||
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
|
||||
self.add_to_menu_checkbox.setChecked(True)
|
||||
self.add_to_desktop_checkbox.setChecked(True)
|
||||
|
||||
self.coverPreview = QLabel(self)
|
||||
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -561,16 +581,53 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
def file_filter(path: str) -> bool:
|
||||
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
|
||||
|
||||
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
@@ -910,6 +967,13 @@ class AddGameDialog(DraggableDialog):
|
||||
if not name:
|
||||
return None, None
|
||||
|
||||
if is_native_executable(exe_path):
|
||||
cover = self.last_cover_path or self.coverEdit.text().strip()
|
||||
result = create_native_desktop_file(exe_path, name, cover)
|
||||
if result is None:
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
|
||||
return result or (None, None)
|
||||
|
||||
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
|
||||
parent_widget = cast("MainWindow | None", self.parentWidget())
|
||||
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
|
||||
@@ -926,6 +990,8 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
|
||||
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
|
||||
if get_custom_icon_path(exe_path):
|
||||
return desktop_entry, desktop_path
|
||||
if not self.last_cover_path and file_icon_path:
|
||||
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
|
||||
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
|
||||
|
||||
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
|
||||
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
thumbnail_source = self.file_path
|
||||
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
|
||||
resolved_path = self.resolve_launch_file_path(self.file_path)
|
||||
@@ -125,6 +125,14 @@ class FileExplorer(DraggableDialog):
|
||||
return
|
||||
thumbnail_source = resolved_path
|
||||
|
||||
if thumbnail_source.lower().endswith(".appimage"):
|
||||
icon_path = get_exe_icon_cache_path(thumbnail_source)
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
image = QImage()
|
||||
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
|
||||
image = QImage(icon_path)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, image)
|
||||
return
|
||||
if not thumbnail_source.lower().endswith(".exe"):
|
||||
return
|
||||
|
||||
@@ -142,6 +150,8 @@ class FileExplorer(DraggableDialog):
|
||||
os.unlink(tmp_name)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
if self.file_path.lower().endswith(".appimage"):
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QImage())
|
||||
|
||||
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
|
||||
super().__init__(parent)
|
||||
@@ -214,7 +224,7 @@ class FileExplorer(DraggableDialog):
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
resolver = None
|
||||
main_window = cast("MainWindow | None", self.main_window)
|
||||
@@ -246,6 +256,8 @@ class FileExplorer(DraggableDialog):
|
||||
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
|
||||
icon = QIcon(QPixmap.fromImage(image))
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
if image.isNull():
|
||||
return
|
||||
item = self.file_items.get(file_path)
|
||||
if item:
|
||||
item.setData(THEMED_ICON_ROLE, None)
|
||||
@@ -607,7 +619,9 @@ class FileExplorer(DraggableDialog):
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
if isinstance(self.file_filter, str):
|
||||
if callable(self.file_filter):
|
||||
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
|
||||
elif isinstance(self.file_filter, str):
|
||||
files = [f for f in files if f.lower().endswith(self.file_filter)]
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
@@ -352,14 +352,9 @@ class ProtonManager(DraggableDialog):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
|
||||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if hasattr(self, 'tab_widget'):
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -679,7 +674,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -958,8 +953,8 @@ class ProtonManager(DraggableDialog):
|
||||
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -60,6 +60,7 @@ from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, Gamescop
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_games import NATIVE_TOGGLE_KEYS, NATIVE_ADVANCED_KEYS, load_native_settings, save_native_settings, get_native_ppdb_path
|
||||
from portprotonqt.preloader import Preloader
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
from portprotonqt.settings_manager import (
|
||||
@@ -166,11 +167,11 @@ class ExeSettingsDialog(
|
||||
if not self.user_conf and not self.exe_path and not self.appid:
|
||||
return
|
||||
self.portproton_path = get_portproton_location()
|
||||
if self.portproton_path is None:
|
||||
if self.portproton_path is None and self.game_source != "native":
|
||||
logger.error("PortProton location not found")
|
||||
return
|
||||
self.start_sh = get_portproton_start_command()
|
||||
if self.start_sh is None:
|
||||
if self.start_sh is None and self.game_source != "native":
|
||||
logger.error("PortProton start command not found")
|
||||
return
|
||||
|
||||
@@ -178,7 +179,7 @@ class ExeSettingsDialog(
|
||||
self.lg_dist_aliases = {}
|
||||
self.plugins_ver = ""
|
||||
self.prefix_options = []
|
||||
if self.portproton_path:
|
||||
if self.portproton_path and self.game_source != "native":
|
||||
scripts_path = get_portproton_scripts_path()
|
||||
if scripts_path:
|
||||
var_path = os.path.join(scripts_path, "var")
|
||||
@@ -229,6 +230,9 @@ class ExeSettingsDialog(
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
self.toggle_settings = get_toggle_settings()
|
||||
if self.game_source == "native":
|
||||
self.toggle_settings = {key: description for key, description in self.toggle_settings.items() if key in NATIVE_TOGGLE_KEYS}
|
||||
self.setWindowTitle(_("Native") + " — " + _("Settings"))
|
||||
|
||||
self.setup_ui()
|
||||
if self.user_conf:
|
||||
@@ -290,6 +294,11 @@ class ExeSettingsDialog(
|
||||
"""Resolve run-after executable relative to the main executable."""
|
||||
if not exe_path:
|
||||
return exe_path
|
||||
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
|
||||
return ",".join(
|
||||
self._resolve_run_after_exe_path(path.strip())
|
||||
for path in exe_path.split(",") if path.strip()
|
||||
)
|
||||
normalized = os.path.normpath(os.path.expanduser(exe_path))
|
||||
if os.path.isabs(normalized):
|
||||
return normalized
|
||||
@@ -300,6 +309,70 @@ class ExeSettingsDialog(
|
||||
return normalized
|
||||
return os.path.normpath(os.path.join(game_dir, normalized))
|
||||
|
||||
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
row = next(index for index in range(table.rowCount())
|
||||
if table.cellWidget(index, 1) is target.parentWidget())
|
||||
expanded = bool(target.property("ppqt_run_after_expanded"))
|
||||
target.setProperty("ppqt_run_after_expanded", not expanded)
|
||||
if expanded:
|
||||
while row + 1 < table.rowCount():
|
||||
item = table.item(row + 1, 0)
|
||||
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
|
||||
break
|
||||
table.removeRow(row + 1)
|
||||
return
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
if not target.text():
|
||||
entries = [""]
|
||||
for index, path in enumerate(entries):
|
||||
entry_row = row + index + 1
|
||||
table.insertRow(entry_row)
|
||||
item = QTableWidgetItem(path)
|
||||
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
|
||||
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
table.setItem(entry_row, 0, item)
|
||||
entry_edit = QLineEdit(path)
|
||||
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
entry_edit.installEventFilter(self)
|
||||
entry_edit.textChanged.connect(
|
||||
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
|
||||
)
|
||||
table.setCellWidget(entry_row, 0, entry_edit)
|
||||
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
|
||||
)
|
||||
table.setCellWidget(entry_row, 1, button)
|
||||
table.resizeRowsToContents()
|
||||
|
||||
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
|
||||
entries = target.text().split(",")
|
||||
if index < len(entries):
|
||||
entries[index] = value
|
||||
target.setText(",".join(entries))
|
||||
|
||||
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
entries.pop(index)
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setText(",".join(entries))
|
||||
if entries:
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _clear_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
for row in reversed(range(table.rowCount())):
|
||||
item = table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
|
||||
table.removeRow(row)
|
||||
target.setProperty("ppqt_run_after_expanded", False)
|
||||
target.clear()
|
||||
if target.isVisible():
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _get_setting_file_selector_path(self, current_path: str) -> str:
|
||||
"""Get initial path for setting file selectors."""
|
||||
initial_path = os.path.expanduser("~")
|
||||
@@ -403,7 +476,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, 230)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
@@ -512,6 +585,10 @@ 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 [
|
||||
@@ -521,7 +598,7 @@ class ExeSettingsDialog(
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
"""Handle --show-ppdb output."""
|
||||
"""Populate shared settings controls from the selected game's data."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.current_settings = {}
|
||||
self.blocked_keys = set()
|
||||
@@ -536,7 +613,15 @@ class ExeSettingsDialog(
|
||||
self.reject()
|
||||
return
|
||||
|
||||
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
self.current_settings = load_native_settings(cast(str, self.exe_path))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot load native settings: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
self.reject()
|
||||
return
|
||||
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
|
||||
for line in output.splitlines():
|
||||
line_stripped = line.strip()
|
||||
@@ -638,6 +723,16 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
if not os.path.exists(db_path):
|
||||
save_native_settings(self.exe_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.critical(self, _("Error"), _("PPDB file does not exist at: ") + db_path)
|
||||
@@ -661,6 +756,14 @@ class ExeSettingsDialog(
|
||||
return
|
||||
|
||||
db_path = self.exe_path + ".ppdb"
|
||||
if self.game_source == "native":
|
||||
try:
|
||||
settings = load_native_settings(self.exe_path)
|
||||
db_path = str(get_native_ppdb_path(self.exe_path, settings))
|
||||
except Exception as error:
|
||||
logger.warning("Cannot resolve native PPDB: %s", error)
|
||||
QMessageBox.critical(self, _("Error"), str(error))
|
||||
return
|
||||
if not os.path.exists(db_path):
|
||||
QMessageBox.information(self, _("Information"), _("PPDB file does not exist at: ") + db_path)
|
||||
self.load_current_settings()
|
||||
@@ -672,8 +775,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -693,8 +796,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -821,6 +924,14 @@ class ExeSettingsDialog(
|
||||
dist_options=self.dist_options,
|
||||
prefix_options=self.prefix_options
|
||||
)
|
||||
if self.game_source == "native":
|
||||
advanced_settings = [setting for setting in advanced_settings if setting['key'] in NATIVE_ADVANCED_KEYS]
|
||||
for setting in advanced_settings:
|
||||
if setting["key"] == "PW_WINE_CPU_TOPOLOGY":
|
||||
setting["options"] = [disabled_text] + [str(count) for count in range(1, len(os.sched_getaffinity(0)) + 1)]
|
||||
for setting in advanced_settings:
|
||||
if setting['key'] == 'LAUNCH_PARAMETERS':
|
||||
setting['description'] = _("Launch Arguments")
|
||||
if self.user_conf:
|
||||
for setting in advanced_settings:
|
||||
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
|
||||
@@ -951,10 +1062,12 @@ class ExeSettingsDialog(
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
text_layout.setContentsMargins(0, 0, 0, 0)
|
||||
text_layout.setSpacing(6)
|
||||
text_layout.addWidget(line_edit)
|
||||
container_layout = QVBoxLayout(text_container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.addWidget(line_edit)
|
||||
line_edit.hide()
|
||||
text_layout = QHBoxLayout()
|
||||
container_layout.addLayout(text_layout)
|
||||
|
||||
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -966,26 +1079,38 @@ class ExeSettingsDialog(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter="",
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
):
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = self._get_setting_file_selector_path(
|
||||
target_line_edit.text().strip()
|
||||
)
|
||||
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
file_filter=file_filter,
|
||||
initial_path=initial_path,
|
||||
initial_path=self._get_setting_file_selector_path(
|
||||
target_line_edit.text().rsplit(",", 1)[-1].strip()
|
||||
),
|
||||
)
|
||||
file_explorer.file_signal.file_selected.connect(
|
||||
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
|
||||
lambda path, target=target_line_edit: target.setText(
|
||||
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
|
||||
)
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
expand_button = AutoSizeButton("≡")
|
||||
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
expand_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
|
||||
)
|
||||
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
|
||||
)
|
||||
text_layout.addWidget(clear_button)
|
||||
text_layout.addWidget(expand_button)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
@@ -1108,6 +1233,8 @@ class ExeSettingsDialog(
|
||||
break
|
||||
|
||||
source_header = source_table.horizontalHeader()
|
||||
if source_header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents:
|
||||
source_table.resizeColumnToContents(1)
|
||||
target_header = self.favorites_table.horizontalHeader()
|
||||
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
@@ -1587,6 +1714,17 @@ 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()
|
||||
@@ -1730,15 +1868,20 @@ class ExeSettingsDialog(
|
||||
item = table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
widget = table.cellWidget(row, 0)
|
||||
if widget is not None and not isinstance(widget, QLabel):
|
||||
continue
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
if text is None:
|
||||
text = item.text()
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
label = cast(QLabel | None, table.cellWidget(row, 0))
|
||||
if label is None:
|
||||
text = item.text()
|
||||
label = QLabel()
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setWordWrap(True)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
table.setCellWidget(row, 0, label)
|
||||
label.setAutoFillBackground(False)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
@@ -1753,7 +1896,6 @@ 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:
|
||||
|
||||
+10
-4
@@ -266,9 +266,11 @@ class EGSAPI:
|
||||
data = self._load_json(self.config_dir / "installed.json", {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def is_game_installed(self, app_id: str) -> bool:
|
||||
def is_game_installed(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id) is not None
|
||||
return self.get_launch_target(app_id, installed_data) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
@@ -285,9 +287,13 @@ class EGSAPI:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
def get_launch_target(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = self.load_installed().get(app_id, {})
|
||||
installed = (
|
||||
installed_data if installed_data is not None else self.load_installed()
|
||||
).get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
executable = str(installed.get("executable", "")).replace("\\", "/")
|
||||
target = install_path.joinpath(*executable.split("/"))
|
||||
|
||||
+32
-17
@@ -16,6 +16,7 @@ from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QKeyEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
@@ -401,6 +402,7 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.native_visible = str(game_source).lower() == "native"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
@@ -514,6 +516,13 @@ class GameCard(AnimatedCard):
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
self.nativeLabel = SourceCorner(
|
||||
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
icon=portproton_icon,
|
||||
@@ -660,6 +669,7 @@ class GameCard(AnimatedCard):
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.nativeLabel, "badge_linux"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
@@ -878,10 +888,11 @@ class GameCard(AnimatedCard):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.egs_visible, self.egsLabel),
|
||||
(self.native_visible, self.nativeLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -904,6 +915,7 @@ class GameCard(AnimatedCard):
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.nativeLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -1045,21 +1057,19 @@ class GameCard(AnimatedCard):
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.native_visible = str(self.game_source).lower() == "native"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.nativeLabel.setVisible(self.native_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -1293,14 +1303,19 @@ 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:
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"""Unified icon extractor for NE and PE files."""
|
||||
"""Unified icon extractor for NE, PE and type-2 AppImages."""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
import posixpath
|
||||
import glob
|
||||
import pefile
|
||||
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
|
||||
from PySide6.QtGui import QImage, QImageWriter
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -15,6 +23,82 @@ logger = get_logger(__name__)
|
||||
RT_ICON = 3
|
||||
RT_GROUP_ICON = 14
|
||||
THUMBNAIL_SIZE = 128
|
||||
APPIMAGE_EXTRACT_TIMEOUT = 10
|
||||
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
|
||||
|
||||
|
||||
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
|
||||
"""Read an icon and resolve archive-local links without extracting to disk."""
|
||||
filename = ".DirIcon"
|
||||
seen: set[str] = set()
|
||||
while filename not in seen:
|
||||
seen.add(filename)
|
||||
result = subprocess.run(
|
||||
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
|
||||
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
|
||||
"--skip-devices", "--skip-specials", "--log-level", "error"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
|
||||
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
|
||||
if member is None:
|
||||
return b""
|
||||
if member.issym() or member.islnk():
|
||||
target = member.linkname
|
||||
base = posixpath.dirname(filename) if member.issym() else ""
|
||||
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
|
||||
else posixpath.join(base, target))
|
||||
if filename == ".." or filename.startswith("../"):
|
||||
return b""
|
||||
continue
|
||||
if not member.isfile():
|
||||
return b""
|
||||
stream = archive.extractfile(member)
|
||||
return stream.read() if stream is not None else b""
|
||||
return b""
|
||||
|
||||
|
||||
def _get_appimage_icon(path: str) -> io.BytesIO | None:
|
||||
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
|
||||
with open(path, "rb") as source:
|
||||
header = source.read(64)
|
||||
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
|
||||
return None
|
||||
layout = ELF_SECTION_LAYOUTS.get(header[4])
|
||||
if layout is None or header[5] not in (1, 2):
|
||||
return None
|
||||
endian = "<" if header[5] == 1 else ">"
|
||||
section_offset, offset_format, size_offset = layout
|
||||
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
|
||||
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
|
||||
offset += entry_size * entry_count
|
||||
source.seek(offset)
|
||||
magic = source.read(6)
|
||||
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
|
||||
return None
|
||||
try:
|
||||
if magic == b"DWARFS":
|
||||
data = _read_dwarfs_icon(path, offset)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
data = result.stdout
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
|
||||
return None
|
||||
image = QImage.fromData(data)
|
||||
if image.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
|
||||
return None
|
||||
return io.BytesIO(buffer.data().data())
|
||||
|
||||
|
||||
def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
@@ -49,7 +133,12 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
custom_icon = get_custom_icon_path(exe_path)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
if exe_path.lower().endswith(".appimage"):
|
||||
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
"images",
|
||||
@@ -60,7 +149,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
|
||||
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
|
||||
"""
|
||||
Generates a thumbnail for an .exe file.
|
||||
Generates a thumbnail for a Windows executable or type-2 AppImage.
|
||||
|
||||
inputfile: the input file path
|
||||
outfile: output filename
|
||||
@@ -113,6 +202,8 @@ class IconExtractor:
|
||||
"""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
if f.read(4) == b"\x7fELF":
|
||||
return _get_appimage_icon(self.file_path)
|
||||
if not self._parse_file(f):
|
||||
return None
|
||||
|
||||
|
||||
@@ -498,7 +498,7 @@ def load_pixmap_async(
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
|
||||
@@ -86,9 +86,8 @@ class MouseEmulationThread(QThread):
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
self.finished.emit(True)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
@@ -214,10 +213,6 @@ class InputManager(
|
||||
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
self.nav_timer = QTimer(self)
|
||||
@@ -265,6 +260,8 @@ class InputManager(
|
||||
|
||||
def _on_mouse_emulation_finished(self, success: bool):
|
||||
"""Handle mouse emulation thread completion."""
|
||||
if not self.mouse_emulation_enabled:
|
||||
return
|
||||
if success and self.mouse_emulation_thread:
|
||||
device = self.mouse_emulation_thread.get_device()
|
||||
if device:
|
||||
@@ -281,6 +278,22 @@ class InputManager(
|
||||
self.emulation_active = self.mouse_emulation_enabled
|
||||
if not self.emulation_active:
|
||||
self.emulation_triggered = False
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
return
|
||||
if self.emulation_triggered:
|
||||
if self.ui is None:
|
||||
self._async_enable_mouse_emulation()
|
||||
elif self.ui is not None:
|
||||
try:
|
||||
self.ui.close()
|
||||
except Exception as e:
|
||||
logger.error("EMUL: Error closing virtual mouse: %s", e)
|
||||
self.ui = None
|
||||
self.mouse_emulation_thread = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
|
||||
"""Common navigation logic for game cards in a container."""
|
||||
@@ -414,8 +427,10 @@ class InputManager(
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
if thread:
|
||||
thread.wait()
|
||||
if self.ui is None:
|
||||
self.ui = thread.get_device()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
|
||||
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
@@ -134,10 +135,15 @@ class ButtonInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if isinstance(focused, QLineEdit) and focused.isReadOnly():
|
||||
focused = self._find_parent_combo(focused)
|
||||
if not isinstance(focused, QComboBox):
|
||||
return False
|
||||
if button_code in BUTTONS['confirm']:
|
||||
focused.showPopup()
|
||||
if focused.view().isVisible():
|
||||
self._activate_list_view_selection(focused.view())
|
||||
else:
|
||||
focused.showPopup()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and focused.view().isVisible():
|
||||
focused.hidePopup()
|
||||
@@ -162,7 +168,10 @@ 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):
|
||||
if (
|
||||
button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit)
|
||||
and not focused.isReadOnly()
|
||||
):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
return True
|
||||
@@ -173,7 +182,7 @@ class ButtonInputMixin(InputMixin):
|
||||
combo = self._find_parent_combo(focused)
|
||||
if combo is not None and combo.isEditable():
|
||||
line_edit = combo.lineEdit()
|
||||
if line_edit is not None and keyboard:
|
||||
if line_edit is not None and not line_edit.isReadOnly() and keyboard:
|
||||
line_edit.setFocus()
|
||||
keyboard.show_for_widget(line_edit)
|
||||
return True
|
||||
@@ -193,7 +202,7 @@ class ButtonInputMixin(InputMixin):
|
||||
parent = parent.parentWidget()
|
||||
return None
|
||||
|
||||
def _activate_list_view_selection(self, view: QListView) -> None:
|
||||
def _activate_list_view_selection(self, view: QAbstractItemView) -> None:
|
||||
index = view.currentIndex()
|
||||
if not index.isValid():
|
||||
return
|
||||
@@ -202,6 +211,7 @@ class ButtonInputMixin(InputMixin):
|
||||
combo.setCurrentIndex(index.row())
|
||||
combo.hidePopup()
|
||||
combo.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
combo.activated.emit(index.row())
|
||||
return
|
||||
view.activated.emit(index)
|
||||
view.clicked.emit(index)
|
||||
|
||||
@@ -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_folder_context_menu(item_rect.center())
|
||||
self.file_explorer.show_context_menu(item_rect.center())
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self._handle_active_window_button(self.file_explorer, button_code):
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import cast
|
||||
|
||||
from shiboken6 import isValid
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
|
||||
from PySide6.QtGui import QKeySequence
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QApplication,
|
||||
@@ -130,7 +130,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if self._redirect_gamecard_input_to_search(event):
|
||||
if isinstance(event, QKeyEvent) and self._redirect_gamecard_input_to_search(event):
|
||||
return True
|
||||
key = self._qt_event_to_input_key(event)
|
||||
if key is not None:
|
||||
@@ -235,7 +235,9 @@ class KeyboardInputMixin(InputMixin):
|
||||
1: 'autoInstallSearchLineEdit',
|
||||
}
|
||||
|
||||
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
|
||||
def _redirect_gamecard_input_to_search(self, event: QKeyEvent) -> bool:
|
||||
if event.key() == Qt.Key.Key_Space:
|
||||
return False
|
||||
focused = self._focused_widget()
|
||||
if not isinstance(focused, GameCard):
|
||||
return False
|
||||
|
||||
@@ -476,7 +476,14 @@ class SettingsInputMixin(InputMixin):
|
||||
if table != self.settings_dialog.advanced_table:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
if isinstance(entry_widget, QLineEdit):
|
||||
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QPushButton):
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if isinstance(cell_widget, QComboBox):
|
||||
line_edit = cell_widget.lineEdit()
|
||||
if cell_widget.isEditable() and line_edit is not None:
|
||||
@@ -492,15 +499,33 @@ class SettingsInputMixin(InputMixin):
|
||||
and cell_widget.property("ppqt_run_after_exe_widget")
|
||||
):
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
if line_edit and line_edit.isEnabled():
|
||||
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
if button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
|
||||
"""Move focus between inline line edit and button in settings cell."""
|
||||
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
for row in range(table.rowCount()):
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
delete_button = table.cellWidget(row, 1)
|
||||
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
|
||||
continue
|
||||
if focused not in (entry_widget, delete_button):
|
||||
continue
|
||||
widget = delete_button if focused == entry_widget else entry_widget
|
||||
if not widget.isEnabled():
|
||||
return False
|
||||
table.setCurrentCell(row, 1)
|
||||
widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if table.currentColumn() != 1:
|
||||
return False
|
||||
|
||||
@@ -508,15 +533,14 @@ class SettingsInputMixin(InputMixin):
|
||||
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
|
||||
return False
|
||||
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
|
||||
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
|
||||
focused = QApplication.focusWidget()
|
||||
if value > 0 and focused == line_edit and button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if focused in widgets:
|
||||
index = widgets.index(focused) + (1 if value > 0 else -1)
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focus_first_row_in_current_settings_table(self):
|
||||
|
||||
@@ -22,20 +22,22 @@ 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 ""
|
||||
|
||||
@@ -117,12 +119,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -154,6 +150,9 @@ msgstr ""
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -278,14 +277,6 @@ msgstr ""
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -313,10 +304,6 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -781,6 +768,9 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
@@ -793,6 +783,9 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
@@ -926,6 +919,9 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -935,6 +931,9 @@ msgstr ""
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -959,9 +958,6 @@ 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-12 19:09+0000\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -21,20 +21,24 @@ 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"
|
||||
|
||||
@@ -120,12 +124,6 @@ msgstr ""
|
||||
"Soll der Ordner '{folder_name}' wirklich gelöscht werden? Dieser Vorgang ist "
|
||||
"unwiderruflich."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Nein"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Ordner konnte nicht gelöscht werden: {error}"
|
||||
@@ -153,13 +151,15 @@ 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 "Alle auswählen"
|
||||
msgstr "Mehrere auswählen"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Von PortProton löschen"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Spiel starten"
|
||||
|
||||
@@ -289,14 +289,6 @@ msgstr "Fehler beim Generieren der .desktop-Datei"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
|
||||
@@ -328,10 +320,6 @@ msgstr ""
|
||||
"'{game_name}' wurde von Steam entfernt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' wurde von Steam entfernt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Das Spiel '{game_name}' konnte nicht von Steam entfernt werden: {error}"
|
||||
@@ -495,9 +483,13 @@ 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 ""
|
||||
msgstr "Nicht erneut erinnern"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
@@ -853,6 +845,9 @@ msgstr "Min."
|
||||
msgid "sec."
|
||||
msgstr "Sek."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Spiel anhalten"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Exit"
|
||||
|
||||
@@ -865,6 +860,9 @@ 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"
|
||||
|
||||
@@ -970,19 +968,19 @@ msgstr "Start {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
msgstr "Scannen: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
msgstr "Überprüfung vorhandener Verknüpfungen: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
msgstr "Hinzufügen: {name}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Ordner konnte nicht erstellt werden: {error}"
|
||||
msgstr "Verknüpfung konnte nicht erstellt werden: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Titel:"
|
||||
@@ -999,6 +997,10 @@ msgstr "Eigenes Titelbild:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Lokaler Pfad oder die URL für das Titelbild"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Eigenes Titelbild:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Verknüpfung hinzufügen zu:"
|
||||
|
||||
@@ -1008,36 +1010,35 @@ msgstr "Titelbild-Vorschau:"
|
||||
msgid "Apply"
|
||||
msgstr "Anwenden"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid image"
|
||||
msgstr "Ungültiges Bild"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Ordner löschen"
|
||||
msgstr "Ordner auswählen"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Aktuelle Spiele"
|
||||
msgstr "Spiele werden verarbeitet…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Verarbeitet {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
msgstr "Im ausgewählten Ordner wurden keine Spiele gefunden."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Aktuelle Spiele"
|
||||
msgstr "Spiele auswählen"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Ungültiges Bild"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Herunterladen des Titelbildes fehlgeschlagen"
|
||||
|
||||
@@ -2406,7 +2407,7 @@ msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
msgstr "Warnung bei kyrillischen Pfaden deaktivieren"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
|
||||
|
||||
@@ -21,15 +21,6 @@ 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."
|
||||
@@ -37,6 +28,17 @@ msgstr ""
|
||||
"La carpeta de datos de PortProton no tiene permisos de lectura ni escritura. "
|
||||
"Elija otra carpeta para los datos de PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Actualización completada"
|
||||
|
||||
@@ -122,12 +124,6 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar la carpeta «{folder_name}»? Esta "
|
||||
"acción no se puede deshacer."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "No se ha podido eliminar la carpeta: {error}"
|
||||
@@ -162,6 +158,9 @@ msgstr "Seleccionar todo"
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Eliminar de PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar juego"
|
||||
|
||||
@@ -291,14 +290,6 @@ msgstr "No se han podido generar los datos del archivo .desktop"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "No se ha podido copiar la imagen de portada: {error}"
|
||||
@@ -330,10 +321,6 @@ msgstr ""
|
||||
"«{game_name}» se ha eliminado de Steam. Por favor, reinicia Steam para que "
|
||||
"los cambios surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "«{game_name}» se ha eliminado de Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "No se ha podido eliminar el juego «{game_name}» de Steam: {error}"
|
||||
@@ -873,6 +860,9 @@ msgstr "min"
|
||||
msgid "sec."
|
||||
msgstr "s"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar el juego"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Salir"
|
||||
|
||||
@@ -885,6 +875,9 @@ 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"
|
||||
|
||||
@@ -1018,6 +1011,10 @@ msgstr "Portada personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Ingrese la ruta local o la URL de la imagen de portada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Portada personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Agregar acceso directo a:"
|
||||
|
||||
@@ -1027,6 +1024,9 @@ 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,9 +1054,6 @@ 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"
|
||||
|
||||
|
||||
@@ -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-21 00:34+0500\n"
|
||||
"POT-Creation-Date: 2026-10-24 18:13+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -18,20 +18,22 @@ 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 ""
|
||||
|
||||
@@ -113,12 +115,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -150,6 +146,9 @@ msgstr ""
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -274,14 +273,6 @@ msgstr ""
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -309,10 +300,6 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -777,6 +764,9 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
@@ -789,6 +779,9 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
@@ -922,6 +915,9 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -931,6 +927,9 @@ msgstr ""
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -955,9 +954,6 @@ msgstr ""
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -21,15 +21,6 @@ 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."
|
||||
@@ -37,6 +28,17 @@ msgstr ""
|
||||
"A pasta de dados do PortProton não pode ser lida nem gravada. Escolha "
|
||||
"outra pasta para os dados do PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
@@ -120,12 +122,6 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir a pasta '{folder_name}'? Esta ação não "
|
||||
"pode ser desfeita."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Falha ao excluir a pasta: {error}"
|
||||
@@ -160,6 +156,9 @@ 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,14 +288,6 @@ msgstr "Falha ao gerar os dados do arquivo .desktop"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Falha ao copiar a imagem de capa: {error}"
|
||||
@@ -328,10 +319,6 @@ msgstr ""
|
||||
"'{game_name}' foi removido da Steam. Reinicie à Steam para que as "
|
||||
"alterações tenham efeito."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' foi removido da Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Falha ao remover o jogo '{game_name}' da Steam: {error}"
|
||||
@@ -855,6 +842,9 @@ msgstr "min."
|
||||
msgid "sec."
|
||||
msgstr "s."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar jogo"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Sair"
|
||||
|
||||
@@ -867,6 +857,9 @@ 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"
|
||||
|
||||
@@ -1000,6 +993,10 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Capa Personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
|
||||
@@ -1009,6 +1006,9 @@ msgstr "Pré-visualização da Capa:"
|
||||
msgid "Apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Excluir Pasta"
|
||||
@@ -1036,9 +1036,6 @@ 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"
|
||||
|
||||
|
||||
@@ -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-15 19:27+0000\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -22,15 +22,6 @@ 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."
|
||||
@@ -38,6 +29,19 @@ msgstr ""
|
||||
"Папка с данными PortProton не доступна для чтения и записи. Выберите "
|
||||
"другую папку для данных PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"PortProtonQt уже запущен. Остановить его и все игры, а затем перезапустить "
|
||||
"для нового запуска?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Обновление завершено"
|
||||
|
||||
@@ -121,12 +125,6 @@ msgstr ""
|
||||
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Не удалось удалить папку: {error}"
|
||||
@@ -153,13 +151,15 @@ 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,14 +287,6 @@ msgstr "Не удалось сгенерировать данные файла .
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Не удалось удалить старый файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Не удалось скопировать обложку: {error}"
|
||||
@@ -326,10 +318,6 @@ msgstr ""
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
|
||||
" чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' был(а) удален(а) из Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Не удалось удалить игру '{game_name}' из Steam: {error}"
|
||||
@@ -492,9 +480,12 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"В следующих путях обнаружены символы кириллицы. Wine может работать "
|
||||
"неправильно. Переместите игру или PortProton в путь без символов кириллицы:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
msgstr "Больше не напоминать"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
@@ -854,6 +845,9 @@ msgstr "мин."
|
||||
msgid "sec."
|
||||
msgstr "сек."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Приостановить игру"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Выход"
|
||||
|
||||
@@ -866,6 +860,9 @@ msgstr "Избранное"
|
||||
msgid "Recent Games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Возобновить игру"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Не удалось приостановить игру"
|
||||
|
||||
@@ -970,19 +967,19 @@ msgstr "Запуск {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
msgstr "Сканирование: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
msgstr "Проверка существующих ярлыков: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
msgstr "Добавление: {name}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Не удалось создать папку: {error}"
|
||||
msgstr "Не удалось создать ярлык: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
@@ -999,6 +996,10 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -1008,36 +1009,35 @@ msgstr "Предпросмотр обложки:"
|
||||
msgid "Apply"
|
||||
msgstr "Применить"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid image"
|
||||
msgstr "Недопустимое изображение"
|
||||
|
||||
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 "Не удалось скачать обложку"
|
||||
|
||||
@@ -2397,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"
|
||||
|
||||
@@ -21,22 +21,22 @@ msgstr ""
|
||||
"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 ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "更新完成"
|
||||
|
||||
@@ -118,12 +118,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr "确定要删除文件夹\"{folder_name}\"吗?此操作无法撤销。"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "删除文件夹失败:{error}"
|
||||
@@ -156,6 +150,9 @@ msgstr "全选"
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
@@ -285,14 +282,6 @@ msgstr "生成 .desktop 文件数据失败"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "复制封面图像失败:{error}"
|
||||
@@ -320,10 +309,6 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr "\"{game_name}\"已从 Steam 中移除。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "\"{game_name}\"已从 Steam 中移除。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "无法从 Steam 中移除游戏\"{game_name}\":{error}"
|
||||
@@ -820,6 +805,10 @@ msgstr "分钟。"
|
||||
msgid "sec."
|
||||
msgstr "秒。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "退出"
|
||||
|
||||
@@ -832,6 +821,10 @@ msgstr "收藏夹"
|
||||
msgid "Recent Games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to pause game"
|
||||
msgstr "停止游戏失败"
|
||||
@@ -966,6 +959,10 @@ msgstr "自定义封面:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "输入封面图像的本地路径或 URL"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "自定义封面:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "添加快捷方式到:"
|
||||
|
||||
@@ -975,6 +972,9 @@ msgstr "封面预览:"
|
||||
msgid "Apply"
|
||||
msgstr "应用"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
@@ -1003,9 +1003,6 @@ msgstr "最近游戏"
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "下载封面失败"
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@ 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'
|
||||
|
||||
+192
-25
@@ -18,6 +18,7 @@ import psutil
|
||||
import orjson
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
@@ -28,6 +29,7 @@ from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
|
||||
from portprotonqt.debug_utils import get_prefix_name
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
@@ -91,6 +93,7 @@ from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
GAME_PROCESS_CHECK_INTERVAL_MS = 500
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
@@ -1035,7 +1038,7 @@ class MainWindow(
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
# Load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def check_completion():
|
||||
@@ -1065,14 +1068,24 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
self._load_native_games_async(native_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
for source in results
|
||||
}
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
@@ -1114,15 +1127,106 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
# Load all sources in parallel
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
def native_callback(games: list[tuple]) -> None:
|
||||
results['native'] = games
|
||||
completed['native'] = True
|
||||
check_completion()
|
||||
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback, "native": native_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
getattr(self, f"_load_{source}_games_async")(callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
|
||||
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
self._native_games_callback = callback
|
||||
self.native_library_worker = NativeLibraryWorker()
|
||||
self.native_library_worker.finished.connect(self._on_native_games_loaded)
|
||||
self.native_library_worker.start()
|
||||
|
||||
def open_native_settings(self, exec_line: str) -> None:
|
||||
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
try:
|
||||
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
|
||||
except OSError as error:
|
||||
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
dialog.exec()
|
||||
dialog.deleteLater()
|
||||
|
||||
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
|
||||
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
if self.game_processes:
|
||||
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
|
||||
self.stop_running_game(button)
|
||||
else:
|
||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||
return
|
||||
try:
|
||||
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
|
||||
raise ValueError("Invalid native desktop path")
|
||||
command, directory = get_native_launch(str(desktop_path))
|
||||
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
|
||||
except Exception as error:
|
||||
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
|
||||
return
|
||||
self.native_game_running = True
|
||||
self.game_stopped_by_user = False
|
||||
self.game_processes = [process]
|
||||
self.current_running_button = button
|
||||
self.target_exe = desktop_path.name
|
||||
self.game_start_exe = str(desktop_path)
|
||||
self.game_start_exact_path = True
|
||||
self.game_start_time = datetime.now()
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_launch_started = True
|
||||
self.wine_download_seen = False
|
||||
save_last_launch(desktop_path.stem, self.game_start_time)
|
||||
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
|
||||
self.launch_output_queue = Queue()
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
SoundManager().play("game_launch")
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
|
||||
|
||||
@Slot()
|
||||
def _on_native_games_loaded(self) -> None:
|
||||
worker = self.native_library_worker
|
||||
if worker is None:
|
||||
return
|
||||
self.native_library_worker = None
|
||||
games = worker.games
|
||||
worker.deleteLater()
|
||||
if not games:
|
||||
self._native_games_callback([])
|
||||
return
|
||||
completed = []
|
||||
|
||||
def on_info(info: dict, game: tuple) -> None:
|
||||
updated = list(game)
|
||||
updated[1] = info.get("description", "") or game[1]
|
||||
updated[2] = game[2] or info.get("cover", "")
|
||||
updated[3] = info.get("appid", "")
|
||||
updated[4] = info.get("controller_support", "")
|
||||
completed.append(tuple(updated))
|
||||
if len(completed) == len(games):
|
||||
self._native_games_callback(completed)
|
||||
|
||||
for game in games:
|
||||
if ui_config.get_economy_mode():
|
||||
on_info(get_cached_steam_game_info(game[0], game[5]), game)
|
||||
else:
|
||||
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
|
||||
|
||||
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
api = getattr(self, "gog_api", None) or GOGAPI()
|
||||
installed = api.load_installed()
|
||||
@@ -1130,6 +1234,11 @@ class MainWindow(
|
||||
library = [game for game in api.load_library() if game.get("app_id")]
|
||||
if self._upgrade_legacy_gog_library(api, library, callback):
|
||||
return
|
||||
if only_installed:
|
||||
library = [
|
||||
game for game in library
|
||||
if api.is_game_installed(str(game["app_id"]), installed)
|
||||
]
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
@@ -1193,6 +1302,23 @@ class MainWindow(
|
||||
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
|
||||
only_installed = game_config.get_only_installed()
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
installed_data = None
|
||||
installed_by_id = {}
|
||||
if only_installed:
|
||||
installed_data = self.egs_api.load_installed()
|
||||
installed_by_id = {
|
||||
str(game["app_id"]): self.egs_api.is_game_installed(
|
||||
str(game["app_id"]), installed_data
|
||||
)
|
||||
for game in library
|
||||
}
|
||||
library = [
|
||||
game for game in library
|
||||
if installed_by_id[str(game["app_id"])]
|
||||
]
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
@@ -1206,9 +1332,19 @@ class MainWindow(
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"egs", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
installed = (
|
||||
installed_by_id[app_id] if only_installed
|
||||
else self.egs_api.is_game_installed(app_id)
|
||||
)
|
||||
if not only_installed or installed:
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
target = None
|
||||
if installed:
|
||||
if only_installed:
|
||||
target = self.egs_api.get_launch_target(
|
||||
app_id, installed_data
|
||||
)
|
||||
else:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
description = str(game.get("description", ""))
|
||||
if not description or description == str(game.get("title", "")):
|
||||
description = str(steam_info.get("description", ""))
|
||||
@@ -1383,6 +1519,9 @@ class MainWindow(
|
||||
executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed)
|
||||
|
||||
def _generate_missing_portproton_icon(self, game_exe: str, icon_path: str, desktop_name: str) -> str:
|
||||
custom_icon = get_custom_icon_path(game_exe)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
if not self.portproton_location:
|
||||
return ""
|
||||
if not game_exe.lower().endswith(".exe") or not os.path.isfile(game_exe):
|
||||
@@ -1421,7 +1560,7 @@ class MainWindow(
|
||||
callback: Callable[[tuple | None], None],
|
||||
):
|
||||
entry = parse_desktop_entry(file_path)
|
||||
if not entry:
|
||||
if not entry or read_native_game(Path(file_path)) is not None:
|
||||
callback(None)
|
||||
return
|
||||
desktop_name = entry.get("Name", _("Unknown Application"))
|
||||
@@ -1763,11 +1902,13 @@ class MainWindow(
|
||||
self.detail_page_manager.openGameDetailPage(game_data)
|
||||
pending_log_exe = self._pending_log_exe
|
||||
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
detail_exe = exec_line
|
||||
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
|
||||
if pending_log_exe and detail_exe:
|
||||
if os.path.abspath(detail_exe) == pending_log_exe:
|
||||
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_exe:
|
||||
self._pending_log_exe = None
|
||||
log_button = self.detail_page_manager._debug_log_button
|
||||
if log_button:
|
||||
@@ -2084,7 +2225,7 @@ class MainWindow(
|
||||
|
||||
|
||||
def checkTargetExe(self):
|
||||
"""Update launch state from the marker and PortProton process."""
|
||||
"""Update launch state and reset controls after the game exits."""
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
child_running = self._has_running_game_process()
|
||||
|
||||
@@ -2209,6 +2350,7 @@ class MainWindow(
|
||||
self.game_processes = [] if launcher_only else [
|
||||
proc for proc in self.game_processes if proc.poll() is None
|
||||
]
|
||||
self.native_game_running = False
|
||||
if not getattr(self, "_animated_covers_suspended", False):
|
||||
self.input_manager.resume_gamepad_polling()
|
||||
self._refresh_portproton_shortcuts()
|
||||
@@ -2303,6 +2445,8 @@ class MainWindow(
|
||||
|
||||
def _get_game_executable(self, exec_line: str) -> str:
|
||||
"""Resolve a library launch entry to its tracked executable."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
return exec_line.removeprefix(NATIVE_URI_PREFIX)
|
||||
if exec_line.startswith("gog://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.gog_api.get_launch_target(app_id) or "")
|
||||
@@ -2312,6 +2456,19 @@ class MainWindow(
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
if getattr(self, "native_game_running", False):
|
||||
if any(process.poll() is None for process in self.game_processes):
|
||||
return True
|
||||
groups = {process.pid for process in self.game_processes}
|
||||
for process in psutil.process_iter(attrs=["pid", "status"]):
|
||||
try:
|
||||
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except PermissionError:
|
||||
continue
|
||||
return False
|
||||
if self._observe_game_processes():
|
||||
return True
|
||||
process_alive = any(proc.poll() is None for proc in self.game_processes)
|
||||
@@ -2433,6 +2590,8 @@ class MainWindow(
|
||||
self._start_launch_output_reader(game_process)
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if getattr(self, "native_game_running", False):
|
||||
return
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
logger.info("Compatibility report skipped: crash reports disabled")
|
||||
return
|
||||
@@ -2504,11 +2663,12 @@ class MainWindow(
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
for proc in self.game_processes:
|
||||
if proc.poll() is not None:
|
||||
if proc.poll() is not None and not getattr(self, "native_game_running", False):
|
||||
continue
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.kill()
|
||||
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
|
||||
if not getattr(self, "native_game_running", False):
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as e:
|
||||
@@ -2527,11 +2687,14 @@ class MainWindow(
|
||||
return True
|
||||
|
||||
def stop_running_game(self, button=None) -> bool:
|
||||
"""Stop current game via PortProton CLI stop command."""
|
||||
"""Stop the current game's owned processes and launcher."""
|
||||
if button is not None:
|
||||
self.current_running_button = button
|
||||
self.game_stopped_by_user = True
|
||||
self.egs_launch_cancelled = True
|
||||
if getattr(self, "native_game_running", False):
|
||||
self._terminate_game_processes()
|
||||
return True
|
||||
if getattr(self, "launcher_process_only", False):
|
||||
if self._run_portproton_stop_command():
|
||||
return True
|
||||
@@ -2563,8 +2726,8 @@ class MainWindow(
|
||||
msg_box.setText(_("Prefix '{0}' was not found. Install recommended libraries?").format(prefix_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
env_vars["DISABLE_CP_DEFPFX"] = "1"
|
||||
@@ -2681,8 +2844,8 @@ class MainWindow(
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
|
||||
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
|
||||
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
return self._install_alt_i586_dependencies()
|
||||
@@ -2779,8 +2942,8 @@ class MainWindow(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._start_egs_operation(
|
||||
@@ -3039,6 +3202,10 @@ class MainWindow(
|
||||
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
def toggleGame(self, exec_line, button=None, game_name=None):
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
self._launch_native_game(exec_line, button)
|
||||
return
|
||||
|
||||
# Handle Steam games
|
||||
if exec_line.startswith("steam://"):
|
||||
self._launch_steam_game(exec_line)
|
||||
|
||||
@@ -22,6 +22,7 @@ install_data(
|
||||
'localization.py',
|
||||
'logger.py',
|
||||
'main_window.py',
|
||||
'native_games.py',
|
||||
'port_data_path_selector.py',
|
||||
'portproton_api.py',
|
||||
'preloader.py',
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Discover installed native games from desktop application entries."""
|
||||
|
||||
import configparser
|
||||
import platform
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.icon_extractor import get_exe_icon_cache_path
|
||||
from portprotonqt.time_utils import (
|
||||
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
|
||||
format_last_launch, format_playtime,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
NATIVE_URI_PREFIX = "native://"
|
||||
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
|
||||
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
|
||||
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
|
||||
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
|
||||
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
|
||||
|
||||
|
||||
def is_native_executable(path: str) -> bool:
|
||||
"""Recognize native launch files while preserving Windows and disc formats."""
|
||||
return (
|
||||
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
|
||||
and os.path.isfile(path)
|
||||
and os.access(path, os.X_OK)
|
||||
)
|
||||
|
||||
|
||||
def quote_native_arguments(arguments: list[str]) -> str:
|
||||
"""Quote arguments using desktop entry escaping, without a shell."""
|
||||
escaped = []
|
||||
for argument in arguments:
|
||||
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
|
||||
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
|
||||
return " ".join(escaped)
|
||||
|
||||
|
||||
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
|
||||
"""Use the installed application launcher for native desktop shortcuts."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID")
|
||||
appimage = os.getenv("APPIMAGE", "").strip()
|
||||
launcher = ["portprotonqt"]
|
||||
if flatpak_id:
|
||||
launcher = ["flatpak", "run", flatpak_id]
|
||||
elif appimage and os.path.isfile(appimage):
|
||||
launcher = [appimage]
|
||||
if mode:
|
||||
launcher.append(mode)
|
||||
return quote_native_arguments(launcher) + " " + command
|
||||
|
||||
|
||||
def get_native_source_command(command: str) -> str:
|
||||
"""Unwrap a PortProtonQt desktop command before applying native settings."""
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
|
||||
target = extract_exec_target_path(parts)
|
||||
if target:
|
||||
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
|
||||
return quote_native_arguments([target])
|
||||
return command
|
||||
|
||||
|
||||
def get_native_tool_paths() -> dict[str, str]:
|
||||
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
|
||||
scripts = get_portproton_scripts_path()
|
||||
location = get_portproton_location()
|
||||
if not scripts or not location:
|
||||
return {}
|
||||
values = {}
|
||||
try:
|
||||
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
|
||||
key, separator, value = line.removeprefix("export ").partition("=")
|
||||
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
|
||||
values[key] = value.strip().strip('"\'')
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native runtime versions: %s", error)
|
||||
return {}
|
||||
root = Path(location) / "data/tmp"
|
||||
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
|
||||
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
|
||||
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
|
||||
|
||||
|
||||
def read_native_variables(path: Path) -> dict[str, str]:
|
||||
"""Read literal var/PPDB assignments without executing shell commands."""
|
||||
values = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip().startswith(("export ", "check_variables ")):
|
||||
continue
|
||||
parts = shlex.split(line, comments=True)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0] == "check_variables" and len(parts) == 3:
|
||||
values[parts[1]] = parts[2]
|
||||
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
|
||||
key, value = parts[1].split("=", 1)
|
||||
values[key] = value.replace("\\$", "$").replace("\\`", "`")
|
||||
return values
|
||||
|
||||
|
||||
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
|
||||
"""Keep native settings and logs in a named per-game user data directory."""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
|
||||
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
|
||||
|
||||
|
||||
def get_native_defaults() -> dict[str, str]:
|
||||
"""Read supported Linux defaults from the existing PortProton var file."""
|
||||
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
|
||||
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
|
||||
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
|
||||
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
|
||||
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
|
||||
scripts = get_portproton_scripts_path()
|
||||
if scripts and (Path(scripts) / "var").is_file():
|
||||
defaults = read_native_variables(Path(scripts) / "var")
|
||||
values.update({key: value for key, value in defaults.items()
|
||||
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
|
||||
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
|
||||
if item not in {"wine", "winesync"})
|
||||
return values
|
||||
|
||||
|
||||
def load_native_settings(desktop_path: str) -> dict[str, str]:
|
||||
"""Load var defaults, existing desktop settings, and the game PPDB."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = lambda optionstr: optionstr
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
settings = get_native_defaults()
|
||||
if parser.has_section(NATIVE_SETTINGS_SECTION):
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
|
||||
database = Path(desktop_path + ".ppdb")
|
||||
if database.is_file():
|
||||
content = database.read_text(encoding="utf-8")
|
||||
if content.lstrip().startswith("["):
|
||||
parser.read_string(content)
|
||||
settings.update(parser[NATIVE_SETTINGS_SECTION])
|
||||
else:
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
|
||||
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
|
||||
executable = next((part.replace("%%", "%") for part in reversed(parts)
|
||||
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
|
||||
if executable is None and parts:
|
||||
executable = shutil.which(parts[0])
|
||||
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
|
||||
for legacy in legacy_paths:
|
||||
if legacy.is_file():
|
||||
settings.update(read_native_variables(legacy))
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
if database.is_file():
|
||||
settings.update(read_native_variables(database))
|
||||
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
|
||||
return settings
|
||||
|
||||
|
||||
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
|
||||
paths = get_native_tool_paths()
|
||||
runtime = Path(paths.get("runtime", ""))
|
||||
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
|
||||
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
|
||||
raise ValueError("PortProton container runtime is not installed")
|
||||
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
|
||||
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
|
||||
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
|
||||
if platform.freedesktop_os_release().get("ID") == "altlinux":
|
||||
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
|
||||
if path.is_dir()]
|
||||
if drivers:
|
||||
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
|
||||
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
|
||||
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
|
||||
portable = Path(paths.get("plugins", "")) / "portable"
|
||||
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
|
||||
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
|
||||
layers = portable / "share/vulkan/implicit_layer.d"
|
||||
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
|
||||
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
|
||||
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
|
||||
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
|
||||
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
|
||||
raise ValueError("PortProton portable MangoHud is not installed")
|
||||
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
|
||||
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
|
||||
return command, environment
|
||||
|
||||
|
||||
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
|
||||
"""Compose Linux launch wrappers and environment for a desktop entry."""
|
||||
prefix = []
|
||||
environment = []
|
||||
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
|
||||
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
|
||||
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
|
||||
if settings.get(key) == "1":
|
||||
environment.append(variable)
|
||||
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
|
||||
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
|
||||
if settings.get(key) not in (None, "", "disabled"):
|
||||
environment.append(f"{variable}={settings[key]}")
|
||||
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
|
||||
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
|
||||
if settings.get("PW_GAMESCOPE") == "1":
|
||||
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
|
||||
if settings.get("PW_USE_RUNTIME") == "1":
|
||||
runtime_command, runtime_env = build_native_runtime(settings)
|
||||
prefix += runtime_command
|
||||
environment += runtime_env
|
||||
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
|
||||
if cores not in ("", "disabled"):
|
||||
available = sorted(os.sched_getaffinity(0))
|
||||
count = int(cores)
|
||||
if not 1 <= count <= len(available):
|
||||
raise ValueError("Invalid CPU cores limit")
|
||||
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
|
||||
if settings.get("PW_USE_GAMEMODE") == "1":
|
||||
prefix.append("gamemoderun")
|
||||
if settings.get("PW_MANGOHUD") == "1":
|
||||
if settings.get("PW_USE_RUNTIME") != "1":
|
||||
prefix += ["mangohud", "--dlsym"]
|
||||
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
|
||||
config = settings.get("MANGOHUD_CONFIG", "")
|
||||
if settings.get("FPS_LIMIT"):
|
||||
config += f",fps_limit={settings['FPS_LIMIT']}"
|
||||
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
|
||||
if settings.get("PW_VKBASALT") == "1":
|
||||
environment.append("ENABLE_VKBASALT=1")
|
||||
if settings.get("VKBASALT_CONFIG_FILE"):
|
||||
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
|
||||
if environment:
|
||||
prefix = ["env", *environment, *prefix]
|
||||
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
|
||||
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
|
||||
|
||||
|
||||
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
|
||||
"""Keep native vkBasalt configuration isolated from global settings."""
|
||||
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
|
||||
settings.pop("VKBASALT_CONFIG_FILE", None)
|
||||
return
|
||||
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
|
||||
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
|
||||
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
|
||||
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
|
||||
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
|
||||
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
|
||||
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
|
||||
if shader and shader != "cas":
|
||||
if Path(shader).name != shader:
|
||||
raise ValueError("Invalid vkBasalt shader name")
|
||||
shader_path = plugins / "shaders" / (shader + ".fx")
|
||||
if not shader_path.is_file():
|
||||
raise ValueError(f"vkBasalt shader is not installed: {shader}")
|
||||
lines.append(f"{shader} = {shader_path}")
|
||||
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
settings["VKBASALT_CONFIG_FILE"] = str(config)
|
||||
|
||||
|
||||
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
|
||||
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
|
||||
settings = load_native_settings(desktop_path)
|
||||
settings.update(changes)
|
||||
settings["Exec"] = get_native_source_command(settings["Exec"])
|
||||
command, directory = settings["Exec"], settings.get("Path", "")
|
||||
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
|
||||
raise ValueError("Invalid native launch command or directory")
|
||||
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
|
||||
raise ValueError("Working directory must be an existing absolute path")
|
||||
database = get_native_ppdb_path(desktop_path, settings)
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_native_vkbasalt_config(desktop_path, settings)
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
for key, value in sorted(settings.items()):
|
||||
if key in {"Name", "Icon", "Exec", "Path"}:
|
||||
continue
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
|
||||
output.write(f'export {key}="{escaped}"\n')
|
||||
os.replace(temporary, database)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
|
||||
"""Build a native launcher through PortProtonQt with the game's directory."""
|
||||
path = Path(executable).expanduser().absolute()
|
||||
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
|
||||
logger.warning("Not an executable native game: %s", path)
|
||||
return None
|
||||
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
|
||||
logger.warning("Invalid native game name or path")
|
||||
return None
|
||||
name = " ".join(name.splitlines()).strip()
|
||||
# Desktop Exec uses double quoting and doubles literal percent signs.
|
||||
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
|
||||
directory = str(path.parent).replace("\\", "\\\\")
|
||||
icon = get_custom_icon_path(str(path)) or icon
|
||||
if not icon and path.suffix.lower() == ".appimage":
|
||||
icon = get_exe_icon_cache_path(str(path))
|
||||
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
|
||||
comment = _('Launch "{name}" with PortProton').format(name=name)
|
||||
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
|
||||
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
|
||||
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
|
||||
("RunLog", _("Run in logging mode"), "--log")):
|
||||
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
|
||||
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
logger.warning("PortProton data directory is unavailable")
|
||||
return None
|
||||
filename = name.replace("/", "_") + ".desktop"
|
||||
destination = Path(location) / filename
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot create native library directory: %s", error)
|
||||
return None
|
||||
return content, str(destination)
|
||||
|
||||
|
||||
def read_native_game(path: Path) -> tuple | None:
|
||||
"""Read a game entry without modifying the installed desktop file."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read_string(path.read_text(encoding="utf-8"))
|
||||
if not parser.has_section("Desktop Entry"):
|
||||
return None
|
||||
entry = parser["Desktop Entry"]
|
||||
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
|
||||
return None
|
||||
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
|
||||
return None
|
||||
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
|
||||
return None
|
||||
name, command = entry.get("Name", ""), entry.get("Exec", "")
|
||||
parts = shlex.split(command.replace("\\\\", "\\"))
|
||||
if not name or not parts:
|
||||
return None
|
||||
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
|
||||
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
|
||||
return None
|
||||
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
|
||||
return None
|
||||
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
|
||||
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
root = str(data_home / "PortProtonQt/custom_data")
|
||||
custom = Path(resolve_custom_data_dir(root, str(path)))
|
||||
if not custom.is_dir() and executable:
|
||||
custom = Path(resolve_custom_data_dir(root, executable))
|
||||
description = entry.get("Comment", "")
|
||||
metadata = custom / "metadata.txt"
|
||||
if metadata.is_file():
|
||||
translations = read_metadata_translations(str(metadata), get_metadata_language())
|
||||
name = translations.get("name") or name
|
||||
description = translations.get("description") or description
|
||||
cover = next((str(path) for path in custom.glob("cover.*") if path.is_file()), "")
|
||||
if not cover:
|
||||
from portprotonqt.steam_api.api import get_cached_sgdb_cover
|
||||
|
||||
cover = get_cached_sgdb_cover(name)
|
||||
if not cover and executable.lower().endswith(".appimage"):
|
||||
cached_icon = get_exe_icon_cache_path(executable)
|
||||
if os.path.isfile(cached_icon):
|
||||
cover = cached_icon
|
||||
return (name, description, cover,
|
||||
"", "", NATIVE_URI_PREFIX + str(path), _("Never"), "", "", "", 0, 0,
|
||||
"native", "", "", "")
|
||||
except configparser.Error as error:
|
||||
logger.warning("Invalid native desktop entry %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native desktop entry %s: %s", path, error)
|
||||
except UnicodeError as error:
|
||||
logger.warning("Invalid native desktop encoding %s: %s", path, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native desktop command %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def get_native_launch(desktop_path: str) -> tuple[list[str], str]:
|
||||
"""Resolve desktop field codes into a direct Linux process command."""
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
|
||||
entry = parser["Desktop Entry"]
|
||||
settings = load_native_settings(desktop_path)
|
||||
entry["Exec"] = settings["Exec"]
|
||||
if get_native_ppdb_path(desktop_path, settings).is_file():
|
||||
entry["Exec"] = build_native_launch_command(settings["Exec"], settings)
|
||||
entry["Path"] = settings.get("Path", "").replace("\\", "\\\\")
|
||||
escapes = {"\\": "\\", "s": " ", "n": "\n", "t": "\t", "r": "\r"}
|
||||
command = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Exec", ""))
|
||||
arguments = []
|
||||
for part in shlex.split(command):
|
||||
part = part.replace("\\$", "$").replace("\\`", "`")
|
||||
if part == "%i":
|
||||
if entry.get("Icon"):
|
||||
arguments.extend(["--icon", entry["Icon"]])
|
||||
continue
|
||||
value = re.sub(r"%%|%[fFuUdDnNvmick]", lambda match:
|
||||
{"%%": "%", "%c": entry.get("Name", ""), "%k": desktop_path}.get(match[0], ""), part)
|
||||
if value:
|
||||
arguments.append(value)
|
||||
if not arguments:
|
||||
raise ValueError("Empty native launch command")
|
||||
directory = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Path", ""))
|
||||
if directory and not os.path.isdir(directory):
|
||||
raise ValueError(f"Working directory does not exist: {directory}")
|
||||
executable = next((part for part in reversed(arguments)
|
||||
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), arguments[0])
|
||||
fallback = Path(executable).parent if os.path.isabs(executable) else Path.home()
|
||||
return arguments, directory or str(fallback)
|
||||
|
||||
|
||||
def load_native_game_times(games: list[tuple]) -> list[tuple]:
|
||||
"""Read shared statistics once for the native library batch."""
|
||||
statistics, launches = {}, {}
|
||||
try:
|
||||
path = get_statistics_path()
|
||||
if os.path.isfile(path):
|
||||
statistics = parse_playtime_file(path)
|
||||
path = get_last_launch_path()
|
||||
if os.path.isfile(path):
|
||||
launches = dict(parsed for line in Path(path).read_text(encoding="utf-8").splitlines()
|
||||
if (parsed := _parse_last_launch_line(line)))
|
||||
except OSError as error:
|
||||
logger.warning("Cannot read native game statistics: %s", error)
|
||||
updated_games = []
|
||||
for game in games:
|
||||
path = Path(game[5].removeprefix(NATIVE_URI_PREFIX))
|
||||
updated = list(game)
|
||||
updated[11] = statistics.get(str(path).replace(" ", "#@_@#"), 0)
|
||||
updated[7] = format_playtime(updated[11])
|
||||
if path.stem in launches:
|
||||
try:
|
||||
launched = datetime.fromisoformat(launches[path.stem])
|
||||
updated[6], updated[10] = format_last_launch(launched), launched.timestamp()
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid native last launch time for %s: %s", path, error)
|
||||
updated_games.append(tuple(updated))
|
||||
return updated_games
|
||||
|
||||
|
||||
class NativeLibraryWorker(QThread):
|
||||
"""Scan application directories outside the UI thread."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.games: list[tuple] = []
|
||||
|
||||
def run(self) -> None:
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(os.pathsep)
|
||||
roots = [data_home, *(Path(directory) for directory in data_dirs if directory),
|
||||
data_home / "flatpak/exports/share", Path("/var/lib/flatpak/exports/share")]
|
||||
directories = [root / "applications" for root in roots]
|
||||
directories.insert(0, data_home / "PortProtonQt/native_games")
|
||||
location = get_portproton_location()
|
||||
if location:
|
||||
directories.insert(0, Path(location))
|
||||
seen: set[str] = set()
|
||||
commands: set[tuple[str, str, str]] = set()
|
||||
for applications in directories:
|
||||
try:
|
||||
for path in sorted(applications.glob("*.desktop") if location and applications == Path(location)
|
||||
else applications.rglob("*.desktop")):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
desktop_id = str(path.relative_to(applications)).replace(os.sep, "-")
|
||||
if desktop_id in seen:
|
||||
continue
|
||||
seen.add(desktop_id)
|
||||
game = read_native_game(path)
|
||||
if not game:
|
||||
continue
|
||||
entry = parse_desktop_entry(str(path)) or {}
|
||||
command = get_native_source_command(entry.get("Exec", ""))
|
||||
identity = (command, entry.get("Path", ""), entry.get("Name", ""))
|
||||
if identity in commands:
|
||||
continue
|
||||
commands.add(identity)
|
||||
self.games.append(game)
|
||||
except OSError as error:
|
||||
logger.warning("Cannot scan native games in %s: %s", applications, error)
|
||||
self.games = load_native_game_times(self.games)
|
||||
@@ -175,7 +175,7 @@ def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose:
|
||||
message_box.setText(text)
|
||||
if allow_choose:
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Open, _("Choose Folder"))
|
||||
message_box.button(QMessageBox.StandardButton.Open).setText(_("Choose Folder"))
|
||||
main_style = getattr(theme, "MAIN_WINDOW_STYLE", "")
|
||||
message_box_style = getattr(theme, "MESSAGE_BOX_STYLE", "")
|
||||
message_box.setStyleSheet(main_style + message_box_style)
|
||||
|
||||
@@ -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:
|
||||
return [
|
||||
default_shell(),
|
||||
"-lc",
|
||||
_with_portproton_functions(args.e[0]),
|
||||
]
|
||||
command = args.e[0]
|
||||
# functions_helper is bash-only, user shell may be zsh or fish
|
||||
if _needs_portproton_functions(command):
|
||||
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
|
||||
return [default_shell(), "-lc", command]
|
||||
return args.e
|
||||
return [default_shell()]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Steam API functions for PortProtonQt."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
@@ -15,6 +16,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
|
||||
from portprotonqt.steam_api.cache import (
|
||||
@@ -46,10 +48,12 @@ SGDB_CACHE_DURATION = 24 * 60 * 60
|
||||
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
|
||||
|
||||
|
||||
def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> None:
|
||||
def fetch_sgdb_cover_async(
|
||||
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
|
||||
) -> None:
|
||||
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
encoded = urllib.parse.quote(game_name, safe="")
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -66,6 +70,14 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
if text:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
text = text.replace(
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
1,
|
||||
)
|
||||
logger.debug("Got SGDB cover URL for %s: %s", game_name, text)
|
||||
callback(text)
|
||||
except Exception as e:
|
||||
@@ -74,7 +86,7 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
|
||||
cache_manager = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}.json"
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
|
||||
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
|
||||
process_response(str(cache_file))
|
||||
return
|
||||
@@ -182,6 +194,11 @@ def fetch_client_icon_async(appid: int | str, callback: Callable[[str], None]) -
|
||||
callback("")
|
||||
return
|
||||
|
||||
custom_icon = get_custom_icon_path(f"steam://rungameid/{appid_str}")
|
||||
if custom_icon:
|
||||
callback(custom_icon)
|
||||
return
|
||||
|
||||
cache_dir = CacheManager().cache_dir
|
||||
info_path = cache_dir / f"steamcmd_app_{appid_str}_{time.time_ns()}.json"
|
||||
url = f"https://api.steamcmd.net/v1/info/{appid_str}"
|
||||
@@ -224,7 +241,12 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
|
||||
callback(cached.get("tier", ""))
|
||||
return
|
||||
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
url = f"https://deckyloader.ru/protondb/{appid}.json"
|
||||
else:
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -553,6 +575,23 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
|
||||
)
|
||||
|
||||
|
||||
def get_cached_sgdb_cover(game_name: str) -> str:
|
||||
"""Read a cached SGDB grid URL without creating a download worker."""
|
||||
cache = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
key = f"sgdb_{safe_name}_grids"
|
||||
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
|
||||
return ""
|
||||
cover = cache.load_json(key)
|
||||
if not isinstance(cover, str):
|
||||
return ""
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
|
||||
return cover
|
||||
|
||||
|
||||
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
"""Return Steam metadata from local cache only."""
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
@@ -560,7 +599,8 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
game_exe = extract_exec_target_path(exec_line) or exec_line
|
||||
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
|
||||
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
|
||||
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
|
||||
candidates = remove_duplicates(filter_candidates(names))
|
||||
apps_path = CacheManager().cache_dir / "steam_apps.json"
|
||||
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
|
||||
if not steam_apps:
|
||||
@@ -623,8 +663,22 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.cache import get_exiftool_data
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
cache_manager = CacheManager()
|
||||
match_key = "steam_match_" + hashlib.sha256(
|
||||
orjson.dumps([desktop_name, exec_line])
|
||||
).hexdigest()
|
||||
cached_match = (
|
||||
cache_manager.load_json(match_key)
|
||||
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
|
||||
)
|
||||
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
|
||||
cached_match = None
|
||||
elif cached_match["appid"] <= 0:
|
||||
cached_match = None
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
is_native = exec_line.startswith("native://")
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -646,7 +700,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_store_uri:
|
||||
if cached_match is not None or is_store_uri or is_native:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -670,9 +724,9 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not is_store_uri:
|
||||
if exe_name and not (is_store_uri or is_native):
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not is_store_uri:
|
||||
if folder_name and not (is_store_uri or is_native):
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
@@ -685,10 +739,13 @@ def get_steam_game_info_async(
|
||||
data_and_index: tuple[list | None, dict | None]
|
||||
) -> None:
|
||||
steam_apps, steam_apps_index = data_and_index
|
||||
matching_app = None
|
||||
matching_app = cached_match
|
||||
|
||||
if not steam_apps or not steam_apps_index:
|
||||
if matching_app is None and (not steam_apps or not steam_apps_index):
|
||||
game_name = desktop_name or exe_name
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -716,15 +773,20 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
for candidate in candidates_ordered:
|
||||
if matching_app:
|
||||
break
|
||||
if not candidate:
|
||||
continue
|
||||
matching_app = search_app(candidate, steam_apps_index)
|
||||
matching_app = search_app(candidate, steam_apps_index or {})
|
||||
if matching_app:
|
||||
break
|
||||
|
||||
game_name = desktop_name or exe_name.capitalize()
|
||||
|
||||
if not matching_app:
|
||||
if is_native:
|
||||
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
|
||||
return
|
||||
def on_sgdb_cover_non_steam(cover: str) -> None:
|
||||
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
@@ -749,8 +811,26 @@ 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:
|
||||
@@ -800,4 +880,7 @@ def get_steam_game_info_async(
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
if cached_match is not None:
|
||||
on_steam_apps_and_index((None, None))
|
||||
else:
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Steam shortcuts management for PortProtonQt."""
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import zlib
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
import vdf
|
||||
import websocket
|
||||
|
||||
from PySide6.QtGui import QIcon, QImageReader
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -33,6 +40,7 @@ from portprotonqt.steam_api.utils import (
|
||||
)
|
||||
from portprotonqt.steam_api.api import (
|
||||
get_steam_game_info_async,
|
||||
fetch_sgdb_cover_async,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -167,7 +175,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
|
||||
"""Parse exec_line to extract executable path."""
|
||||
try:
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
logger.error("Failed to parse exec_line: %s", exec_line)
|
||||
return None, "Failed to parse executable command: no target path"
|
||||
@@ -220,6 +228,33 @@ export LD_PRELOAD=
|
||||
return script_path, ""
|
||||
|
||||
|
||||
def _resolve_desktop_icon(source: str) -> QIcon:
|
||||
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
|
||||
if not source:
|
||||
return QIcon()
|
||||
if os.path.isfile(source):
|
||||
return QIcon(source)
|
||||
icon = QIcon.fromTheme(source)
|
||||
if not icon.isNull() or os.path.basename(source) != source:
|
||||
return icon
|
||||
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
|
||||
filename = glob.escape(source)
|
||||
if Path(source).suffix.lstrip(".").lower() not in formats:
|
||||
filename += ".*"
|
||||
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
|
||||
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
|
||||
f"icons/{filename}", f"pixmaps/{filename}")
|
||||
for root in roots:
|
||||
for pattern in patterns:
|
||||
for path in sorted(Path(root).glob(pattern)):
|
||||
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
|
||||
continue
|
||||
icon = QIcon(str(path))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
"""Generate thumbnail icon for the game."""
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
|
||||
@@ -232,7 +267,13 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
|
||||
if os.path.exists(generated_icon_path):
|
||||
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if exe_path.endswith(".desktop"):
|
||||
entry = parse_desktop_entry(exe_path) or {}
|
||||
source = entry.get("Icon", "")
|
||||
icon = _resolve_desktop_icon(source)
|
||||
success = icon.pixmap(128, 128).save(generated_icon_path)
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"Failed to generate thumbnail for {exe_path}")
|
||||
return ""
|
||||
@@ -328,11 +369,12 @@ def _add_shortcut_via_vdf(
|
||||
|
||||
def _download_covers(
|
||||
appid: int,
|
||||
steam_appid: int,
|
||||
steam_appid: tuple[int | None, str],
|
||||
grid_dir: str,
|
||||
was_api_used: bool
|
||||
) -> None:
|
||||
"""Download cover images for the game."""
|
||||
source_appid, game_name = steam_appid
|
||||
cover_types = [
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
@@ -349,7 +391,7 @@ def _download_covers(
|
||||
with open(result_path, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
|
||||
ext = Path(steam_name).suffix.lstrip('.')
|
||||
ext = Path(result_path).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
|
||||
@@ -358,18 +400,54 @@ def _download_covers(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
|
||||
|
||||
def download_sgdb(index: int, cover_file: str, steam_name: str) -> None:
|
||||
image_type = ("grids", "heroes", "logos", "hgrids")[index]
|
||||
|
||||
def on_url(url: str) -> None:
|
||||
if not url.startswith((
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
)):
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
extension = Path(urlsplit(url).path).suffix.lower()
|
||||
if extension not in (".png", ".jpg", ".jpeg"):
|
||||
logger.warning("Unsupported SGDB artwork format: %s", extension)
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
destination = str(Path(cover_file).with_suffix(extension))
|
||||
downloader.download_async(
|
||||
url, destination, timeout=5,
|
||||
callback=partial(on_cover_download, steam_name=steam_name, index=index),
|
||||
)
|
||||
|
||||
fetch_sgdb_cover_async(game_name, on_url, image_type)
|
||||
|
||||
def on_steam_download(
|
||||
index: int, steam_name: str, cover_file: str, result: str | None
|
||||
) -> None:
|
||||
if result:
|
||||
on_cover_download(result, steam_name, index)
|
||||
else:
|
||||
download_sgdb(index, cover_file, steam_name)
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
|
||||
if not source_appid:
|
||||
download_sgdb(i, cover_file, steam_name)
|
||||
continue
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{source_appid}/{steam_name}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
|
||||
callback=partial(on_steam_download, i, steam_name, cover_file)
|
||||
)
|
||||
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
def add_to_steam(
|
||||
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
|
||||
and download Steam Grid covers with correct sizes and names.
|
||||
@@ -382,7 +460,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if not exe_path:
|
||||
return (False, error)
|
||||
|
||||
if not os.path.exists(exe_path):
|
||||
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
|
||||
local_path = exe_path.removeprefix("native://")
|
||||
if not is_store_uri and not os.path.exists(local_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -396,7 +476,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
icon_path = _generate_icon(exe_path, portproton_dir)
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(local_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else local_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
@@ -471,10 +553,10 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
nonlocal steam_appid
|
||||
steam_appid = game_info.get("appid")
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
|
||||
return
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
|
||||
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
|
||||
|
||||
get_steam_game_info_async(game_name, exec_line, on_game_info)
|
||||
|
||||
@@ -487,10 +569,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
def _delete_cover_files(grid_dir: str, appid: int) -> None:
|
||||
"""Delete cover files for a game."""
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
|
||||
for suffix in ("", "p", "_hero", "_logo")
|
||||
for extension in (".jpg", ".png", ".jpeg")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
|
||||
@@ -397,6 +397,11 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
"""Rebuild auto-install cards after a live library layout change."""
|
||||
if not hasattr(self, "autoInstallContainer"):
|
||||
return
|
||||
if self.stackedWidget.currentIndex() != self.auto_install_tab_index:
|
||||
self._autoinstall_layout_pending = True
|
||||
self.autoInstallLoaded = False
|
||||
return
|
||||
self._autoinstall_layout_pending = False
|
||||
mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
@@ -487,6 +492,9 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
|
||||
def _start_autoinstall_load(self, force_refresh: bool = False) -> None:
|
||||
if getattr(self, "_autoinstall_layout_pending", False):
|
||||
self.refresh_autoinstall_layout()
|
||||
return
|
||||
if self.autoInstallLoaded or self.autoInstallLoading:
|
||||
return
|
||||
if not hasattr(self, "_on_autoinstall_games_loaded"):
|
||||
|
||||
@@ -226,6 +226,7 @@ class MainWindowControlHintsMixin:
|
||||
|
||||
keyboard_hints = [
|
||||
("key_enter", _("Select"), "confirm"),
|
||||
("key_space", _("Select multiple"), None),
|
||||
("key_backspace", _("Back"), "back"),
|
||||
("key_e", _("Add a shortcut"), "add_game"),
|
||||
("key_context", _("Menu"), "context_menu"),
|
||||
|
||||
@@ -849,9 +849,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
api = self.egs_api if source == "egs" else self.gog_api
|
||||
worker = StoreDLCWorker(api, str(game["app_id"]))
|
||||
worker.loaded.connect(lambda dlcs: self._on_store_dlcs_loaded(source, game, dlcs))
|
||||
worker.failed.connect(lambda error: QMessageBox.warning(self, _("Error"), error))
|
||||
results: list[list[dict]] = []
|
||||
errors: list[str] = []
|
||||
worker.loaded.connect(results.append)
|
||||
worker.failed.connect(errors.append)
|
||||
worker.finished.connect(self._on_store_dlc_worker_finished)
|
||||
worker.finished.connect(
|
||||
lambda: self._on_store_dlcs_loaded(source, game, results[0]) if results else None
|
||||
)
|
||||
worker.finished.connect(
|
||||
lambda: QMessageBox.warning(self, _("Error"), errors[0]) if errors else None
|
||||
)
|
||||
self.store_dlc_worker = worker
|
||||
worker.start()
|
||||
|
||||
@@ -1098,8 +1106,8 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
worker = GOGDeleteWorker(self.gog_api, game, self.context_menu_manager)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -40,6 +42,7 @@ 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
|
||||
@@ -97,10 +100,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_library_filter_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.filter_keys):
|
||||
return
|
||||
display_filter = self.filter_keys[index]
|
||||
selected = set(game_config.get_display_filter().split(","))
|
||||
key = self.filter_keys[index]
|
||||
if key in ("all", "favorites"):
|
||||
selected = {key}
|
||||
else:
|
||||
selected -= {"all", "favorites"}
|
||||
selected.symmetric_difference_update({key})
|
||||
display_filter = ",".join(key for key in self.filter_keys if key in selected) or "all"
|
||||
selected = set(display_filter.split(","))
|
||||
combo = self.gamesDisplayCombo
|
||||
combo.setCurrentIndex(self.filter_keys.index(display_filter.split(",")[0]))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
combo.setItemData(row, Qt.CheckState.Checked if source in selected
|
||||
else Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
|
||||
combo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
)
|
||||
self._position_library_controls_widget()
|
||||
self._on_only_installed_changed(game_config.get_only_installed())
|
||||
@@ -152,9 +172,17 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
card_orientation = "vertical"
|
||||
elif layout_mode in {"horizontal", "horizontal_top"}:
|
||||
card_orientation = "horizontal"
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
previous_mode = ui_config.get_library_layout_mode(theme_mode)
|
||||
previous_orientation = ui_config.get_horizontal_card_orientation("theme")
|
||||
ui_config.set_library_layout_mode(layout_mode)
|
||||
ui_config.set_horizontal_card_orientation(card_orientation)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
if (
|
||||
previous_mode == ui_config.get_library_layout_mode(theme_mode)
|
||||
and (previous_mode not in {"horizontal", "horizontal_top"}
|
||||
or previous_orientation == card_orientation)
|
||||
):
|
||||
return
|
||||
self.game_library_manager.rebuild_library_layout(
|
||||
ui_config.get_library_layout_mode(theme_mode)
|
||||
)
|
||||
@@ -337,22 +365,39 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"Epic Games",
|
||||
"PortProton",
|
||||
_("Native"),
|
||||
_("Favorites"),
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
display_filter,
|
||||
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
|
||||
)
|
||||
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.setEditable(True)
|
||||
line_edit = self.gamesDisplayCombo.lineEdit()
|
||||
if line_edit is not None:
|
||||
line_edit.setReadOnly(True)
|
||||
line_edit.installEventFilter(self.gamesDisplayCombo)
|
||||
self.gamesDisplayCombo.setStyleSheet(
|
||||
self.gamesDisplayCombo.styleSheet() + self.theme.SOURCE_FILTER_STYLE
|
||||
)
|
||||
selected = set(display_filter.split(","))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
self.gamesDisplayCombo.setItemData(
|
||||
row, Qt.CheckState.Checked if source in selected else Qt.CheckState.Unchecked,
|
||||
Qt.ItemDataRole.CheckStateRole,
|
||||
)
|
||||
self.gamesDisplayCombo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
self.gamesDisplayCombo.activated.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
|
||||
@@ -365,7 +410,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
display_filter not in ("steam", "portproton", "native")
|
||||
)
|
||||
|
||||
self.badge_view_keys = ["detailed", "compact", "hidden"]
|
||||
@@ -498,9 +543,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader):
|
||||
if self.gog_api.auth_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
|
||||
) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if self.egs_api.user_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
|
||||
) and self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
@@ -538,8 +587,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for game in missing_games:
|
||||
@@ -607,7 +656,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()
|
||||
@@ -631,7 +680,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.open_local_autoinstall_card(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
|
||||
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
|
||||
self.openAddGameDialog(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
@@ -710,6 +759,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
name = dialog.nameEdit.text().strip()
|
||||
exe_path = dialog.exeEdit.text().strip()
|
||||
user_cover = dialog.coverEdit.text().strip()
|
||||
native = is_native_executable(exe_path)
|
||||
if native:
|
||||
user_cover = dialog.last_cover_path or user_cover
|
||||
|
||||
if not name or not exe_path:
|
||||
return
|
||||
@@ -744,14 +796,30 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
entry = parse_desktop_entry(desktop_path)
|
||||
if not entry:
|
||||
return
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
if not native:
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
elif cover_path:
|
||||
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
|
||||
self._write_desktop_file(desktop_entry, desktop_path)
|
||||
if native:
|
||||
try:
|
||||
save_native_settings(desktop_path, {})
|
||||
except Exception as error:
|
||||
logger.warning("Cannot create native PPDB: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
for source in ("all", "native", "favorites"):
|
||||
getattr(self, "_loaded_library_cache", {}).pop(source, None)
|
||||
description = entry.get("Comment", "")
|
||||
exec_line = entry.get("Exec", exe_path)
|
||||
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
|
||||
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
|
||||
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
|
||||
|
||||
# User cover fallback
|
||||
user_cover_path = cover_path # Already set if user provided
|
||||
native_game = read_native_game(Path(desktop_path)) if native else None
|
||||
if native_game:
|
||||
user_cover_path = native_game[2] or user_cover_path
|
||||
|
||||
# Statistics (playtime, last launch - defaults for new)
|
||||
playtime_seconds = 0
|
||||
@@ -800,13 +868,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"portproton",
|
||||
"native" if native else "portproton",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""),
|
||||
steam_info.get("ppdb_rating", ""),
|
||||
)
|
||||
|
||||
# Incremental add
|
||||
if native and game_config.get_display_filter() not in ("all", "native"):
|
||||
return
|
||||
self.game_library_manager.add_game_incremental(game_data)
|
||||
|
||||
# Trigger visible images load
|
||||
@@ -828,7 +898,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
cached_steam_info.get("anticheat_status", ""),
|
||||
last_played_timestamp,
|
||||
playtime_seconds,
|
||||
"portproton",
|
||||
"native" if native else "portproton",
|
||||
cached_steam_info.get("anticheat_slug", ""),
|
||||
cached_steam_info.get("ppdb_id", ""),
|
||||
cached_steam_info.get("ppdb_rating", ""),
|
||||
@@ -840,6 +910,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
|
||||
"""Apply shortcut options selected in add/edit game dialog."""
|
||||
if exec_line.startswith(NATIVE_URI_PREFIX):
|
||||
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
for directory, checkbox in (
|
||||
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
|
||||
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
|
||||
):
|
||||
if directory and checkbox.isChecked():
|
||||
try:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
shutil.copyfile(source, Path(directory) / source.name)
|
||||
os.chmod(Path(directory) / source.name, 0o755)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if not is_game_in_steam(game_name):
|
||||
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
|
||||
elif is_game_in_steam(game_name):
|
||||
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
|
||||
return
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
if dialog.add_to_menu_checkbox.isChecked():
|
||||
|
||||
@@ -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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if reset_settings():
|
||||
@@ -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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -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.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
|
||||
@@ -1530,8 +1530,8 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(request.get("message", ""))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
button = msg_box.exec()
|
||||
worker.submit_pairing_response("yes" if button == QMessageBox.StandardButton.Yes else "no")
|
||||
return
|
||||
|
||||
@@ -287,8 +287,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
removal_results = [
|
||||
|
||||
@@ -336,8 +336,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to clear prefix '{}'?").format(selected_prefix))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -443,8 +443,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to delete prefix '{}'?").format(selected_prefix))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
|
||||
@@ -32,6 +32,7 @@ class MainWindowWorkersMixin:
|
||||
"egs_auth_worker": None,
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"native_library_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#3f424d"/></svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0008 1a6.9996 6.9996 0 0 0-7.0008 6.9992 6.9996 6.9996 0 0 0 7.0008 7.0008 6.9996 6.9996 0 0 0 6.9992-7.0008 6.9996 6.9996 0 0 0-6.9992-6.9992zm0.03052 1.8345c0.49138 0 0.68069 0.026991 1.0522 0.151 0.77261 0.25794 1.4608 0.88663 1.857 1.698 0.36432 0.74592 0.47136 1.542 0.34538 2.5446-0.02796 0.2226-0.0498 0.49442-0.0498 0.60402v0.1992l0.24578 0.24739c0.8226 0.82687 1.5016 1.8186 1.8892 2.7614 0.0076 0.01852 0.01187 0.0377 0.01928 0.05622-0.49301 0.83904-1.1794 1.5485-2.0016 2.0691-0.04447-0.3775-0.13313-0.76334-0.26667-1.147-0.33295-0.95658-0.86947-1.775-1.5422-2.3534-0.22323-0.19199-0.53694-0.41582-0.75181-0.53655-0.0648-0.036441-0.11405-0.069295-0.11084-0.072289 0.0035-0.00285 0.10384-0.043441 0.22329-0.08996 0.47005-0.18305 0.84651-0.41233 1.1438-0.69558 0.4802-0.45754 0.7046-0.96565 0.70361-1.5984-0.0012-0.60346-0.212-1.0975-0.64418-1.5133-0.37522-0.36091-0.8574-0.54398-1.3639-0.53815-0.30388 0.003495-0.61702 0.07465-0.92048 0.21687-0.40675 0.1906-0.86555 0.57266-1.2353 1.0265-0.19966 0.24497-0.64341 0.92641-0.61687 1.0008 0.17687 0.49573 0.05014 0.89774-0.68755 1.1213-0.14863 0.00956-0.2752 0.022977-0.28112 0.028916-0.01608 0.016029 0.06854 0.1696 0.16707 0.30843 0.14523 0.20473 0.39623 0.4208 0.6763 0.57992 0.0689 0.039149 0.12571 0.076335 0.1253 0.081928 0 0.00577-0.07689 0.054243-0.16867 0.10763-0.66042 0.38426-1.248 1.1985-1.6 2.2201-0.14407 0.4181-0.23536 0.77816-0.28755 1.1454-0.55629-0.51797-1.0112-1.1354-1.3414-1.8201 0.18238-0.35341 0.3666-0.624 0.69237-1.0217 0.32937-0.40208 0.89278-0.92816 1.3494-1.2594l0.17671-0.12851v-0.12209c1e-6 -0.11617-0.0027-0.11963-0.04659-0.10602-0.13353 0.041394-0.41447 0.20677-0.53012 0.31165l-0.05783 0.053012 0.01125-0.24418c0.01809-0.40899 0.1444-0.77469 0.40482-1.1647l0.13815-0.20562-0.0498-0.1751c-0.1341-0.46267-0.11978-1.0338 0.04016-1.5133 0.15572-0.46694 0.38428-0.82639 0.76144-1.2032 0.5179-0.51738 1.0636-0.80828 1.8201-0.96867 0.24284-0.051483 0.30063-0.056286 0.71165-0.056225zm0.45783 3.1325c0.39072-0.00731 0.65853 0.36199 0.5494 0.75663-0.03707 0.13416-0.22104 0.31614-0.36466 0.35823-0.13428 0.039318-0.26887 0.039149-0.38072 0v-0.00161c-0.19556-0.069357-0.35076-0.27993-0.36787-0.5012-0.01367-0.17688 0.04474-0.32481 0.17831-0.4498 0.12845-0.12009 0.21938-0.15913 0.38554-0.16225zm-0.14297 0.33574c-0.06851 0.00128-0.10608 0.017958-0.15904 0.06747-0.05507 0.051532-0.07953 0.11181-0.0739 0.18474 0.0071 0.091227 0.07199 0.17863 0.15261 0.20723 0.04612 0.016141 0.10046 0.01621 0.15582 0 0.05921-0.017353 0.13572-0.090873 0.151-0.14618 0.045-0.1627-0.06542-0.31627-0.22651-0.31325zm-3.0185 0.55904c-0.01687-1.298e-4 -0.03412 0.00176-0.05301 0.00321-0.12511 0.00961-0.15006 0.020885-0.27149 0.12048-0.15317 0.12564-0.3168 0.33226-0.45301 0.57349-0.10345 0.18332-0.24198 0.53868-0.2008 0.51566 0.0143-0.00799 0.11568-0.087633 0.22329-0.17671 0.26056-0.21554 0.35801-0.28499 0.67148-0.47068 0.36751-0.21785 0.41804-0.28276 0.32932-0.42892-0.05724-0.094184-0.12772-0.13564-0.24578-0.13655z" fill="#fff" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -727,6 +727,25 @@ COMBOBOX_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
SOURCE_FILTER_STYLE = f"""
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {color_surface};
|
||||
}}
|
||||
QListView::indicator {{
|
||||
width: {sourceFilterIndicatorSize}px;
|
||||
height: {sourceFilterIndicatorSize}px;
|
||||
border: {border_thin} {color_border_input};
|
||||
border-radius: {sourceFilterIndicatorRadius}px;
|
||||
background: {color_bg};
|
||||
image: none;
|
||||
}}
|
||||
QListView::indicator:checked {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_text};
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
}}
|
||||
"""
|
||||
|
||||
SCROLL_STYLE = f"""
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
|
||||
@@ -10,6 +10,8 @@ bootAnimationTimeoutMs = 30000
|
||||
cursorHideTimeoutMs = 5000
|
||||
startupLibraryLoadDelayMs = 500
|
||||
autoSizeButtonPadding = (10, 20)
|
||||
sourceFilterIndicatorSize = 18
|
||||
sourceFilterIndicatorRadius = 2
|
||||
favoriteLabelSize = 48, 48
|
||||
favoriteLabelIconSize = 32
|
||||
detailCompactCoverFrameSize = 128
|
||||
@@ -368,3 +370,5 @@ WINETRICKS_CHECK_COLUMN_WIDTH = 80
|
||||
WINETRICKS_ROW_HEIGHT = 36
|
||||
|
||||
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
|
||||
|
||||
RUN_AFTER_VALUE_WIDTH = 230
|
||||
@@ -177,6 +177,9 @@ def test_app_restarts_installed_appimage_after_integration(
|
||||
lambda: SimpleNamespace(
|
||||
debug_level="NOTSET",
|
||||
reinstall_steam_compat_tool=True,
|
||||
list_games=False,
|
||||
game_info=None,
|
||||
edit_game=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -207,12 +207,34 @@ 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,
|
||||
):
|
||||
|
||||
+559
-52
@@ -35,53 +35,9 @@ class TestParseArgs:
|
||||
assert args.file_or_url == "file:///tmp/game.exe"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stopped", [False, True])
|
||||
def test_silent_tray_toggles_pause(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stopped: bool) -> None:
|
||||
import signal
|
||||
import psutil
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from portprotonqt import app, time_utils
|
||||
|
||||
qt_app = QApplication.instance() or QApplication([])
|
||||
assert isinstance(qt_app, QApplication)
|
||||
tray = MagicMock()
|
||||
timer = MagicMock()
|
||||
launcher = MagicMock(pid=123)
|
||||
launcher.poll.return_value = None
|
||||
child = MagicMock(pid=124)
|
||||
status = psutil.STATUS_STOPPED if stopped else psutil.STATUS_RUNNING
|
||||
child.status.return_value = status
|
||||
parent = MagicMock()
|
||||
parent.children.return_value = [child]
|
||||
monkeypatch.setattr(app, "QSystemTrayIcon", MagicMock(return_value=tray))
|
||||
monkeypatch.setattr(app, "QTimer", MagicMock(return_value=timer))
|
||||
monkeypatch.setattr(app, "get_portproton_tray_icon", MagicMock())
|
||||
monkeypatch.setattr(app.subprocess, "Popen", MagicMock(return_value=launcher))
|
||||
monkeypatch.setattr(app.psutil, "Process", MagicMock(return_value=parent))
|
||||
monkeypatch.setattr(time_utils, "save_last_launch", MagicMock())
|
||||
monkeypatch.setattr(time_utils, "save_playtime", MagicMock())
|
||||
|
||||
app.run_silent_tray(qt_app, ["start.sh"], str(tmp_path / "game.exe"))
|
||||
menu = tray.setContextMenu.call_args.args[0]
|
||||
menu.aboutToShow.emit()
|
||||
pause, stop = menu.actions()
|
||||
assert pause.isEnabled()
|
||||
assert pause.text() == app._("Resume Game" if stopped else "Pause Game")
|
||||
assert stop.text() == app._("Stop Game")
|
||||
pause.trigger()
|
||||
child.send_signal.assert_called_once_with(signal.SIGCONT if stopped else signal.SIGSTOP)
|
||||
parent.children.assert_called_with(recursive=True)
|
||||
|
||||
parent.children.return_value = []
|
||||
menu.aboutToShow.emit()
|
||||
assert not pause.isEnabled()
|
||||
pause.trigger()
|
||||
assert child.send_signal.call_count == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
|
||||
from PySide6 import QtNetwork
|
||||
from PySide6 import QtCore, QtNetwork
|
||||
from portprotonqt import app, main_window, port_data_path_selector
|
||||
|
||||
monkeypatch.delenv("STEAM_COMPAT", raising=False)
|
||||
@@ -93,6 +49,8 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
|
||||
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
|
||||
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
|
||||
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
|
||||
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
|
||||
@@ -102,19 +60,21 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
qt_app = MagicMock()
|
||||
qt_app.exec.return_value = 0
|
||||
monkeypatch.setattr(app, "QApplication", MagicMock(return_value=qt_app))
|
||||
lock = MagicMock()
|
||||
lock.tryLock.return_value = True
|
||||
monkeypatch.setattr(QtCore, "QLockFile", MagicMock(return_value=lock))
|
||||
monkeypatch.setattr(QtCore.QStandardPaths, "writableLocation", lambda _location: str(tmp_path))
|
||||
socket = MagicMock()
|
||||
socket.waitForConnected.return_value = False
|
||||
server = MagicMock()
|
||||
monkeypatch.setattr(QtNetwork, "QLocalSocket", MagicMock(return_value=socket))
|
||||
monkeypatch.setattr(QtNetwork, "QLocalServer", server)
|
||||
tray = MagicMock()
|
||||
window = MagicMock(side_effect=RuntimeError("main window reached"))
|
||||
monkeypatch.setattr(app, "run_silent_tray", tray)
|
||||
monkeypatch.setattr(main_window, "MainWindow", window)
|
||||
exe = tmp_path / "my game.exe"
|
||||
exe.touch()
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server,
|
||||
tray=tray, window=window, exe=exe)
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server, lock=lock,
|
||||
window=window, exe=exe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_uri", [False, True])
|
||||
@@ -128,7 +88,6 @@ def test_silent_exe_keeps_main_window_for_emulation(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
assert startup.window.call_args.kwargs["launch_exe"] == str(startup.exe)
|
||||
assert os.environ["MANGOHUD"] == "1"
|
||||
@@ -145,7 +104,21 @@ def test_silent_exe_uses_existing_instance(
|
||||
startup.app.main()
|
||||
|
||||
startup.socket.write.assert_called_once_with(f"silent:{startup.exe}".encode())
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_not_called()
|
||||
startup.qt_app.exec.assert_not_called()
|
||||
|
||||
|
||||
def test_existing_instance_with_unavailable_ipc_does_not_start_another(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
startup = silent_startup
|
||||
startup.lock.tryLock.return_value = False
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
|
||||
|
||||
startup.app.main()
|
||||
|
||||
startup.server.removeServer.assert_not_called()
|
||||
startup.server.assert_not_called()
|
||||
startup.window.assert_not_called()
|
||||
startup.qt_app.exec.assert_not_called()
|
||||
|
||||
@@ -182,6 +155,63 @@ 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"),
|
||||
@@ -201,10 +231,62 @@ def test_other_launch_modes_keep_main_window(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [[], ["--silent"], ["--log"], ["--silent", "--log"]])
|
||||
@pytest.mark.parametrize("existing", [False, True])
|
||||
def test_native_shortcut_launch_modes(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, flags: list[str], existing: bool,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
from PySide6.QtCore import QThread
|
||||
from portprotonqt.native_games import create_native_desktop_file
|
||||
|
||||
startup = silent_startup
|
||||
executable = startup.exe.with_name("native game")
|
||||
executable.write_text("#!/bin/sh\nexit 0\n")
|
||||
executable.chmod(0o755)
|
||||
result = create_native_desktop_file(str(executable), "Native")
|
||||
assert result is not None
|
||||
content, destination = result
|
||||
Path(destination).write_text(content)
|
||||
uri = "native://" + destination
|
||||
startup.socket.waitForConnected.return_value = existing
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(executable)])
|
||||
mode = "log" if "--log" in flags else "silent" if "--silent" in flags else "open"
|
||||
if existing:
|
||||
startup.app.main()
|
||||
startup.socket.write.assert_called_once_with(f"{mode}:{quote(uri, safe='')}".encode())
|
||||
startup.window.assert_not_called()
|
||||
return
|
||||
window, timers = MagicMock(), MagicMock()
|
||||
window.game_processes = []
|
||||
startup.window.side_effect, startup.window.return_value = None, window
|
||||
monkeypatch.setattr(startup.app, "QTimer", timers)
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
def event_loop() -> int:
|
||||
callback = next(call.args[1] for call in timers.singleShot.call_args_list
|
||||
if getattr(call.args[1], "__name__", "") == "handle_native_launch")
|
||||
callback()
|
||||
return 0
|
||||
startup.qt_app.exec.side_effect = event_loop
|
||||
with pytest.raises(SystemExit):
|
||||
startup.app.main()
|
||||
assert window.silent_launch_mode is (mode == "silent")
|
||||
if mode == "silent":
|
||||
window.toggleGame.assert_called_once_with(uri)
|
||||
else:
|
||||
window._load_native_games_async.assert_called_once()
|
||||
window._load_native_games_async.call_args.args[0]([
|
||||
("Native", "", "", "", "", uri, "", "", "", "", 0, 0, "native", "", "", "")])
|
||||
assert window.openGameDetailPage.call_args.args[0]["exec_line"] == uri
|
||||
if mode == "log":
|
||||
assert window._pending_log_exe == uri
|
||||
|
||||
|
||||
class TestNormalizeLaunchPath:
|
||||
def test_plain_path(self):
|
||||
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
|
||||
@@ -381,3 +463,428 @@ class TestParseResolution:
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_resolution("") is None
|
||||
|
||||
|
||||
def test_list_games_json_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
shortcut = tmp_path / "game.desktop"
|
||||
shortcut.write_text(
|
||||
'[Desktop Entry]\nName=Test Game\n'
|
||||
f'Exec=portprotonqt --silent "{executable}"\nIcon=test-icon\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(portproton, "get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {
|
||||
"name": "Test Game", "description": "Description", "cover": "cover.png",
|
||||
"appid": 123, "custom_icon": "",
|
||||
})
|
||||
games = _list_portproton_games(str(tmp_path), {}, {})
|
||||
assert len(games) == 1
|
||||
assert games[0]["description"] == "Description"
|
||||
assert games[0]["cover"] == "cover.png"
|
||||
assert games[0]["appid"] == 123
|
||||
assert not {"metadata", "desktop_entry", "custom_icon"} & games[0].keys()
|
||||
assert {key: games[0][key] for key in (
|
||||
"id", "name", "desktop_file", "exec", "executable", "directory", "icon", "ppdb"
|
||||
)} == {
|
||||
"id": "portproton:game.desktop", "name": "Test Game",
|
||||
"desktop_file": str(shortcut),
|
||||
"exec": f'portprotonqt --silent "{executable}"',
|
||||
"executable": str(executable), "directory": str(tmp_path),
|
||||
"icon": "test-icon", "ppdb": f"{executable}.ppdb",
|
||||
}
|
||||
|
||||
|
||||
def test_list_games_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: None)
|
||||
assert _list_portproton_games(None, {}, {}) == []
|
||||
|
||||
|
||||
def test_parse_list_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games"])
|
||||
assert parse_args().list_games
|
||||
|
||||
|
||||
def test_export_custom_artwork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _get_game_metadata
|
||||
from portprotonqt.config.portproton import resolve_custom_data_dir
|
||||
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_steam_game_info", lambda *args: {
|
||||
"name": "Cached", "description": "Cached description", "appid": "123",
|
||||
})
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_playtime_for_exe", lambda *args: 42)
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_last_launch_timestamp", lambda *args: 100)
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
folder = Path(resolve_custom_data_dir(str(tmp_path / "PortProtonQt/custom_data"), executable))
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "metadata.txt").write_text("name_en=Custom\ndescription_en=Description\n")
|
||||
(folder / "cover.png").touch()
|
||||
(folder / "icon.png").touch()
|
||||
metadata = _get_game_metadata("Original", "launch", executable)
|
||||
assert metadata["name"] == "Custom"
|
||||
assert metadata["description"] == "Description"
|
||||
assert metadata["cover"] == str(folder / "cover.png")
|
||||
assert metadata["custom_icon"] == str(folder / "icon.png")
|
||||
assert metadata["appid"] == "123"
|
||||
assert metadata["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_export_runtime(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
Path(f"{executable}.ppdb").write_text(
|
||||
'export PW_WINE_USE="PROTON_LG"\nexport PW_PREFIX_NAME="GAME"\n'
|
||||
)
|
||||
runner = tmp_path / "data/dist/PROTON_TEST"
|
||||
runner.mkdir(parents=True)
|
||||
runtime = _get_game_runtime(tmp_path, executable, {"PW_PROTON_LG_VER": "PROTON_TEST"}, {})
|
||||
assert runtime["runner"] == "PROTON_TEST"
|
||||
assert "runner_version" not in runtime
|
||||
assert runtime["runner_path"] == str(runner)
|
||||
assert runtime["runner_installed"]
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/GAME")
|
||||
|
||||
|
||||
def test_settings_do_not_execute_shell(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
marker = tmp_path / "should-not-exist"
|
||||
settings = tmp_path / "settings"
|
||||
settings.write_text(
|
||||
f'export PW_WINE_USE="$(touch {marker})"\n'
|
||||
'export PW_PREFIX_NAME="Game prefix"\n'
|
||||
'check_variables PW_VULKAN_USE "1"\n'
|
||||
)
|
||||
assert _read_game_settings(settings) == {"PW_PREFIX_NAME": "Game prefix", "PW_VULKAN_USE": "1"}
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_list_games_exits_before_gui(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(list_games=True, game_info=None, edit_game=None, pretty=True, fields=None, all_games=True))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{"name": "Игра"}])
|
||||
gui = MagicMock(side_effect=AssertionError("GUI must not start"))
|
||||
monkeypatch.setattr(app, "QApplication", gui)
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
assert orjson.loads(output) == [{"name": "Игра"}]
|
||||
assert output == '[\n {\n "name": "Игра"\n }\n]\n'
|
||||
gui.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("external", [False, True])
|
||||
def test_runtime_wine_layout(tmp_path: Path, external: bool) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
runner = tmp_path / "External Wine" if external else tmp_path / "data/dist/PROTON_TEST"
|
||||
(runner / "files/bin-wow64").mkdir(parents=True)
|
||||
selected = str(runner) if external else "PROTON_TEST"
|
||||
runtime = _get_game_runtime(tmp_path, None, {}, {
|
||||
"PW_WINE_USE": selected, "PW_USE_WOW64": "1", "PW_PREFIX_NAME": "CUSTOM",
|
||||
})
|
||||
assert runtime["wine_path"] == str(runner / "files/bin-wow64/wine")
|
||||
assert runtime["wineserver_path"] == str(runner / "files/bin-wow64/wineserver")
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/CUSTOM")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,backend", [("gog", "gog_api.GOGAPI"), ("egs", "egs_api.EGSAPI")])
|
||||
def test_list_store_games(source: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_store_games
|
||||
|
||||
api = MagicMock()
|
||||
api.load_library.return_value = [
|
||||
{"app_id": "123", "title": "Installed", "description": "Details", "cover": "store.jpg"},
|
||||
{"app_id": "456", "title": "Owned"},
|
||||
]
|
||||
api.load_installed.return_value = {"123": {}}
|
||||
api.is_game_installed.side_effect = lambda app_id, installed: app_id in installed
|
||||
api.get_launch_target.return_value = "/games/game.exe"
|
||||
monkeypatch.setattr(f"portprotonqt.{backend}", lambda: api)
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda source, app_id, name: (name, "custom.jpg"))
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {"custom_icon": "icon.png"})
|
||||
games = _list_store_games(source, None, {}, {})
|
||||
assert [game["id"] for game in games] == [f"{source}:123", f"{source}:456"]
|
||||
assert all(game["source"] == source for game in games)
|
||||
assert games[0]["description"] == "Details"
|
||||
assert games[0]["cover"] == "custom.jpg"
|
||||
assert games[0]["icon"] == "icon.png"
|
||||
assert games[0]["exec"] == f"{source}://launch/123"
|
||||
assert games[1]["exec"] == f"{source}://install/456"
|
||||
assert games[1]["executable"] is None
|
||||
api.get_launch_target.assert_called_once_with("123")
|
||||
|
||||
|
||||
def test_list_steam_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_steam_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.steam_api.get_steam_installed_games", lambda: [("Game", 123, 100, 42)])
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_full_steam_game_info",
|
||||
lambda *args: {"description": "Details", "cover": "cached.jpg"})
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda *args: ("Custom", "custom.jpg"))
|
||||
game = _list_steam_games()[0]
|
||||
assert game["id"] == "steam:123"
|
||||
assert game["source"] == "steam"
|
||||
assert game["name"] == "Custom"
|
||||
assert game["cover"] == "custom.jpg"
|
||||
assert game["exec"] == "steam://rungameid/123"
|
||||
assert game["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_list_all_sources_without_portproton(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import list_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._list_steam_games", lambda: [{"source": "steam"}])
|
||||
monkeypatch.setattr("portprotonqt.cli._list_store_games", lambda source, *args: [{"source": source}])
|
||||
assert [game["source"] for game in list_games()] == ["steam", "gog", "egs"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_flag,pretty", [("--pretty", True), ("--raw", False)])
|
||||
def test_json_format_args(format_flag: str, pretty: bool, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--game-info", "steam:123", format_flag])
|
||||
args = parse_args()
|
||||
assert args.game_info == "steam:123"
|
||||
assert args.pretty is pretty
|
||||
|
||||
|
||||
@pytest.mark.parametrize("game_id", ["steam:123", "gog:123", "missing"])
|
||||
def test_game_info_output(game_id: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(
|
||||
list_games=False, game_info=game_id, edit_game=None, pretty=False, fields=None,
|
||||
))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "steam:123", "source": "steam"}, {"id": "gog:123", "source": "gog"},
|
||||
])
|
||||
code = app.main()
|
||||
output = capsys.readouterr().out
|
||||
if game_id == "missing":
|
||||
assert code == 1
|
||||
assert output == ""
|
||||
else:
|
||||
assert code == 0
|
||||
assert orjson.loads(output)["id"] == game_id
|
||||
assert len(output.splitlines()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--pretty", "--raw"], ["--list-games", "--game-info", "steam:123"], ["--game-info"],
|
||||
])
|
||||
def test_library_conflicting_args(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editable_game(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
executable.touch()
|
||||
ppdb = Path(f"{executable}.ppdb")
|
||||
ppdb.write_text('# Keep this comment\nexport PW_MANGOHUD="0"\nexport PW_WINDOWS_VER="10"\n')
|
||||
game = {"id": "portproton:game.desktop", "source": "portproton", "executable": str(executable)}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{**game, "runtime": {"settings": _read_game_settings(ppdb)}},
|
||||
])
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
functions = "\n".join(
|
||||
name + helper.split(name, 1)[1].split("\n}\n", 1)[0] + "\n}"
|
||||
for name in ("set_several_variables () {", "edit_db_from_gui () {")
|
||||
)
|
||||
script = tmp_path / "start.sh"
|
||||
script.write_text(
|
||||
'#!/usr/bin/env bash\nset -e\n' + functions + '\n'
|
||||
'print_info () { printf "%s\\n" "$*"; }\n'
|
||||
'print_error () { printf "%s\\n" "$*" >&2; }\n'
|
||||
'PORTWINE_DB_FILE="${3}.ppdb"\n'
|
||||
'set_several_variables "${@:4}"\nedit_db_from_gui $keys_all\n'
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command",
|
||||
lambda: ["bash", str(script)])
|
||||
return {**game, "ppdb": ppdb, "script": script}
|
||||
|
||||
|
||||
def test_edit_game_roundtrip(editable_game: dict) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
result = edit_game(editable_game["id"], [
|
||||
"PW_MANGOHUD=1", 'LAUNCH_PARAMETERS=--option=value "quoted"',
|
||||
"PW_PREFIX_NAME=GAME", "WINEDLLOVERRIDES=d3d11=n,b;dxgi=n",
|
||||
])
|
||||
settings = result["runtime"]["settings"]
|
||||
assert settings["PW_MANGOHUD"] == "1"
|
||||
assert settings["LAUNCH_PARAMETERS"] == '--option=value "quoted"'
|
||||
assert settings["WINEDLLOVERRIDES"] == "d3d11=n,b;dxgi=n"
|
||||
assert settings["PW_PREFIX_NAME"] == "GAME"
|
||||
assert settings["PW_WINDOWS_VER"] == "10"
|
||||
assert editable_game["ppdb"].read_text().startswith("# Keep this comment\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
"PW_MANGOHUD=2", "NOT_A_SETTING=1", "PW_MANGOHUD", "PW_PREFIX_NAME=../other",
|
||||
"PW_PREFIX_NAME=", "PW_MANGOHUD=$(touch /tmp/forbidden)", "PW_MANGOHUD=1\ntrue",
|
||||
"LAUNCH_PARAMETERS=a|b", "LAUNCH_PARAMETERS=`id`",
|
||||
])
|
||||
def test_edit_game_rejects_invalid_values(editable_game: dict, change: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
before = editable_game["ppdb"].read_bytes()
|
||||
with pytest.raises(ValueError):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1", change])
|
||||
run.assert_not_called()
|
||||
assert editable_game["ppdb"].read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["portproton", "gog", "egs"])
|
||||
def test_edit_game_sources(editable_game: dict, source: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{**editable_game, "source": source}])
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
assert 'export PW_MANGOHUD="1"' in editable_game["ppdb"].read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["missing", "steam", "uninstalled", "no-script", "failed", "not-saved"])
|
||||
def test_edit_game_errors(editable_game: dict, mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
if mode == "missing":
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
elif mode in {"steam", "uninstalled"}:
|
||||
game = {**editable_game, "source": "steam"} if mode == "steam" else {**editable_game, "executable": None}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [game])
|
||||
elif mode == "no-script":
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command", lambda: None)
|
||||
else:
|
||||
editable_game["script"].write_text("exit 4\n" if mode == "failed" else "exit 0\n")
|
||||
expected = RuntimeError if mode in {"failed", "not-saved"} else ValueError
|
||||
with pytest.raises(expected):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--set", "PW_MANGOHUD=1"], ["--edit-game", "gog:123"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--list-games"],
|
||||
])
|
||||
def test_edit_game_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [False, True])
|
||||
def test_edit_game_cli_output(failure: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", "--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--raw",
|
||||
])
|
||||
edit = MagicMock(return_value={"id": "gog:123"})
|
||||
if failure:
|
||||
edit.side_effect = RuntimeError("write failed")
|
||||
monkeypatch.setattr("portprotonqt.cli.edit_game", edit)
|
||||
assert app.main() == (1 if failure else 0)
|
||||
output = capsys.readouterr().out
|
||||
assert output == "" if failure else orjson.loads(output) == {"id": "gog:123"}
|
||||
edit.assert_called_once_with("gog:123", ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("single", [False, True])
|
||||
@pytest.mark.parametrize("pretty", [False, True])
|
||||
def test_library_selected_fields(single: bool, pretty: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
command = ["--game-info", "gog:123"] if single else ["--list-games"]
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", *command, "--fields", "executable, icon,name,source",
|
||||
"--pretty" if pretty else "--raw",
|
||||
])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "gog:123", "name": "Game", "icon": "icon.png", "source": "gog",
|
||||
"executable": "/games/game.exe", "description": "Not selected", "installed": True},
|
||||
{"id": "steam:456", "name": "Steam Game", "source": "steam", "installed": True},
|
||||
])
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
expected = {"executable": "/games/game.exe", "icon": "icon.png", "name": "Game", "source": "gog"}
|
||||
assert orjson.loads(output) == (expected if single else [
|
||||
expected, {"executable": None, "icon": None, "name": "Steam Game", "source": "steam"},
|
||||
])
|
||||
assert (len(output.splitlines()) > 1) is pretty
|
||||
|
||||
|
||||
def test_library_selected_fields_empty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games", "--fields", "name"])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
assert app.main() == 0
|
||||
assert capsys.readouterr().out == "[]\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--fields", "name"], ["--list-games", "--fields", ""],
|
||||
["--list-games", "--fields", "name,,icon"], ["--list-games", "--fields", "name,"],
|
||||
["--list-games", "--fields", "name;icon"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--fields", "name"],
|
||||
])
|
||||
def test_library_selected_fields_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_all", [False, True])
|
||||
def test_list_games_installed_filter(include_all: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
arguments = ["portprotonqt", "--list-games", "--fields", "id", "--raw"]
|
||||
if include_all:
|
||||
arguments.append("--all")
|
||||
monkeypatch.setattr(sys, "argv", arguments)
|
||||
games = [
|
||||
{"id": "portproton:missing.desktop", "installed": False},
|
||||
{"id": "steam:123", "installed": True},
|
||||
{"id": "gog:456", "installed": False},
|
||||
{"id": "egs:789", "installed": True},
|
||||
]
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: games)
|
||||
assert app.main() == 0
|
||||
expected = [game for game in games if include_all or game["installed"]]
|
||||
assert orjson.loads(capsys.readouterr().out) == [{"id": game["id"]} for game in expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [["--all"], ["--game-info", "gog:123", "--all"]])
|
||||
def test_all_requires_list_games(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from pytest import MonkeyPatch, mark
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
import portprotonqt.detail_pages.utils as detail_utils
|
||||
@@ -701,3 +701,23 @@ def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
|
||||
add_count = sum(1 for t in button_texts if "Add" in t or "Добавить" in t)
|
||||
assert edit_count == 1
|
||||
assert add_count == 0
|
||||
|
||||
|
||||
@mark.parametrize("managed", [False, True])
|
||||
def test_native_detail_folder_button_only_for_managed_games(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, managed: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
data = tmp_path / "data"
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(data))
|
||||
directory = data / "PortProtonQt/native_games" if managed else tmp_path / "usr/share/applications"
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
manager._create_game_buttons_layout({
|
||||
"game_source": "native",
|
||||
"exec_line": "native://" + str(directory / "hytale.desktop"),
|
||||
"name": "Hytale Launcher",
|
||||
})
|
||||
buttons = _get_added_buttons(manager)
|
||||
assert "Settings" in buttons
|
||||
assert "Create Log" in buttons
|
||||
assert ("Open Folder" in buttons) is managed
|
||||
+46
-1
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -6,9 +7,53 @@ from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.scripts_utils.easyterm import TerminalWidget
|
||||
from portprotonqt.scripts_utils.easyterm import (
|
||||
CONF_FALLBACK_SHELL,
|
||||
TerminalWidget,
|
||||
build_command,
|
||||
)
|
||||
|
||||
PROCESS_TIMEOUT_SECONDS = 30
|
||||
HELPER_PATH = "/usr/share/portproton/scripts/functions_helper"
|
||||
USER_SHELL = "/usr/bin/zsh"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_shell(monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm.default_shell", lambda: USER_SHELL
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm._portproton_functions_helper_path",
|
||||
lambda: HELPER_PATH,
|
||||
)
|
||||
return USER_SHELL
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_portproton_functions(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["pw_run Game"]))
|
||||
|
||||
assert command == [
|
||||
CONF_FALLBACK_SHELL,
|
||||
"-lc",
|
||||
f"source {HELPER_PATH}; pw_run Game",
|
||||
]
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_exported_functions(
|
||||
user_shell: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("BASH_FUNC_launch_game%%", "() { :; }")
|
||||
|
||||
command = build_command(argparse.Namespace(e=["launch_game"]))
|
||||
|
||||
assert command[0] == CONF_FALLBACK_SHELL
|
||||
|
||||
|
||||
def test_build_command_keeps_user_shell_for_plain_commands(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["ls -l"]))
|
||||
|
||||
assert command == [user_shell, "-lc", "ls -l"]
|
||||
|
||||
|
||||
def test_terminal_keeps_failed_command_output(
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Tests for themed file explorer icons."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from PySide6.QtCore import QSize
|
||||
from PySide6.QtGui import QColor, QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QStyle,
|
||||
@@ -16,6 +19,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
import portprotonqt.dialogs.file_explorer as file_explorer_module
|
||||
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.dialogs.file_explorer import (
|
||||
FileExplorer,
|
||||
FileIconDelegate,
|
||||
@@ -25,6 +30,53 @@ from portprotonqt.dialogs.file_explorer import (
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_executable_filter_keeps_exe_and_linux_executables(tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QMimeDatabase
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
for name in ("Game.EXE", "launcher", "notes.txt"):
|
||||
(tmp_path / name).touch()
|
||||
(tmp_path / "launcher").chmod(0o755)
|
||||
(tmp_path / "folder").mkdir()
|
||||
explorer = SimpleNamespace(
|
||||
file_list=QListWidget(), current_path=str(tmp_path), directory_only=False,
|
||||
thumbnail_cache={}, pending_thumbnails={}, deferred_thumbnails={}, file_items={},
|
||||
path_history={}, path_label=QLabel(), mime_db=QMimeDatabase(),
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
_get_list_icon=lambda name: None, _get_file_type_icon=lambda path: None,
|
||||
schedule_thumbnail_loading=lambda: None,
|
||||
)
|
||||
|
||||
FileExplorer.update_file_list(cast(FileExplorer, explorer))
|
||||
|
||||
assert [explorer.file_list.item(i).text() for i in range(explorer.file_list.count())] == [
|
||||
"../", "folder/", "Game.EXE", "launcher",
|
||||
]
|
||||
|
||||
|
||||
def test_gamepad_opens_file_explorer_context_menu(monkeypatch: MonkeyPatch) -> None:
|
||||
explorer = FileExplorer.__new__(FileExplorer)
|
||||
QDialog.__init__(explorer)
|
||||
explorer.file_list = QListWidget(explorer)
|
||||
explorer.file_list.addItem("folder/")
|
||||
explorer.file_list.setCurrentRow(0)
|
||||
context_manager = MagicMock()
|
||||
explorer.context_menu_manager = context_manager
|
||||
manager = SimpleNamespace(file_explorer=explorer, _parent=SimpleNamespace())
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: explorer)
|
||||
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
|
||||
|
||||
FileExplorerInputMixin.handle_file_explorer_button(
|
||||
cast(FileExplorerInputMixin, manager), next(iter(BUTTONS["context_menu"])), 1,
|
||||
)
|
||||
|
||||
item = explorer.file_list.currentItem()
|
||||
assert item is not None
|
||||
context_manager.show_file_explorer_context_menu.assert_called_once_with(
|
||||
explorer, explorer.file_list.visualItemRect(item).center(),
|
||||
)
|
||||
|
||||
|
||||
class FakeIcon:
|
||||
"""Record icon files assigned to Qt modes."""
|
||||
|
||||
@@ -95,3 +147,61 @@ def test_list_icon_uses_theme_state_colors(
|
||||
QIcon.Mode.Selected: "folder-#222222.svg",
|
||||
QIcon.Mode.Disabled: "folder-#333333.svg",
|
||||
}
|
||||
|
||||
|
||||
def test_appimage_thumbnail_worker_caches_icon_and_finishes(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
calls = []
|
||||
|
||||
def generate(path: str, output: str) -> bool:
|
||||
calls.append(path)
|
||||
image = QImage(16, 16, QImage.Format.Format_ARGB32)
|
||||
image.fill(QColor("red"))
|
||||
return image.save(output)
|
||||
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", generate)
|
||||
pool = QThreadPool()
|
||||
for _attempt in range(2):
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image, received=received: received.append((path, image)))
|
||||
pool.start(loader)
|
||||
assert pool.waitForDone(5000)
|
||||
_application.processEvents()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert not received[0][1].isNull()
|
||||
assert calls == [str(source)]
|
||||
assert pool.activeThreadCount() == 0
|
||||
|
||||
|
||||
def test_appimage_thumbnail_failure_completes_without_replacing_icon(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.touch()
|
||||
cache = tmp_path / "cache" / "icon.png"
|
||||
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
|
||||
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", lambda *_args: False)
|
||||
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
|
||||
received = []
|
||||
loader.signals.thumbnail_ready.connect(lambda path, image: received.append((path, image)))
|
||||
loader.run()
|
||||
assert len(received) == 1
|
||||
assert received[0][0] == str(source)
|
||||
assert received[0][1].isNull()
|
||||
item = MagicMock()
|
||||
explorer = SimpleNamespace(thumbnail_cache={}, file_items={str(source): item})
|
||||
FileExplorer._set_thumbnail_icon(cast(FileExplorer, explorer), str(source), QImage())
|
||||
assert str(source) in explorer.thumbnail_cache
|
||||
item.setIcon.assert_not_called()
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch, raises
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
|
||||
import portprotonqt.game_card as game_card_module
|
||||
import portprotonqt.image_utils as image_utils
|
||||
@@ -608,9 +608,16 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
|
||||
]
|
||||
|
||||
|
||||
@mark.parametrize("url, filename", [
|
||||
("https://example.org/game.jpg", "game.jpg"),
|
||||
("https://cdn2.steamgriddb.com/grid/unique-cover.png", "unique-cover.png"),
|
||||
("https://deckyloader.ru/sgdb-cdn/grid/unique-cover.png", "unique-cover.png"),
|
||||
])
|
||||
def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
url: str,
|
||||
filename: str,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "game.exe"
|
||||
exe_path.write_bytes(b"MZ")
|
||||
@@ -626,6 +633,8 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
timeout: int,
|
||||
callback: Callable[[str | None], None],
|
||||
) -> None:
|
||||
assert _url == url
|
||||
assert Path(_path).name == filename
|
||||
callbacks.append(callback)
|
||||
|
||||
monkeypatch.setattr("portprotonqt.icon_extractor.CACHE_DIR", tmp_path / "cache" / "PortProtonQt")
|
||||
@@ -642,14 +651,102 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
)
|
||||
|
||||
image_utils.load_pixmap_async(
|
||||
"https://example.org/game.jpg", 100, 150,
|
||||
url, 100, 150,
|
||||
lambda pixmap: loaded_paths.append(cast(FakePixmap, pixmap).path),
|
||||
app_name="game", fallback_exe=str(exe_path),
|
||||
fallback_icon_path=str(fallback_path),
|
||||
)
|
||||
callbacks[0](None)
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / "game.jpg"
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
|
||||
downloaded_path.write_bytes(b"cover")
|
||||
callbacks[0](str(downloaded_path))
|
||||
|
||||
assert loaded_paths == [str(fallback_path), str(downloaded_path)]
|
||||
|
||||
|
||||
def test_custom_icon_overrides_extracted_icon(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
|
||||
from portprotonqt.config.portproton import get_custom_data_dir_name, get_custom_icon_path
|
||||
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
executable = "/games/custom/game.exe"
|
||||
folder = tmp_path / "PortProtonQt" / "custom_data" / get_custom_data_dir_name(executable)
|
||||
folder.mkdir(parents=True)
|
||||
icon = folder / "icon.png"
|
||||
icon.write_bytes(b"custom icon")
|
||||
assert get_custom_icon_path(executable) == str(icon)
|
||||
assert get_exe_icon_cache_path(executable) == str(icon)
|
||||
icon.unlink()
|
||||
assert get_custom_icon_path(executable) == ""
|
||||
assert get_exe_icon_cache_path(executable) != str(icon)
|
||||
|
||||
|
||||
@mark.parametrize("elf_class,endian", [(1, "<"), (2, "<"), (2, ">")])
|
||||
@mark.parametrize("svg", [False, True])
|
||||
def test_appimage_icon_extraction(tmp_path: Path, elf_class: int, endian: str, svg: bool) -> None:
|
||||
from portprotonqt.icon_extractor import APPIMAGE_EXTRACT_TIMEOUT, ELF_SECTION_LAYOUTS
|
||||
|
||||
header = bytearray(64)
|
||||
header[:4] = b"\x7fELF"
|
||||
header[4:6] = bytes([elf_class, 1 if endian == "<" else 2])
|
||||
header[8:11] = b"AI\x02"
|
||||
offset_position, offset_format, size_position = ELF_SECTION_LAYOUTS[elf_class]
|
||||
struct.pack_into(endian + offset_format, header, offset_position, len(header))
|
||||
struct.pack_into(endian + "HH", header, size_position, 0, 0)
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
output = tmp_path / "icon.png"
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">'
|
||||
b'<rect width="16" height="16" fill="red"/></svg>') if svg else icon.getvalue()
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", return_value=SimpleNamespace(stdout=payload)) as run:
|
||||
assert generate_thumbnail(str(source), str(output))
|
||||
run.assert_called_once_with(
|
||||
["unsquashfs", "-cat", "-o", "64", str(source), ".DirIcon"],
|
||||
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
|
||||
)
|
||||
with Image.open(output) as image:
|
||||
assert image.size == (128, 128)
|
||||
assert image.convert("RGBA").getpixel((64, 64)) == (255, 0, 0, 255)
|
||||
|
||||
|
||||
def test_appimage_icon_missing_tool(tmp_path: Path) -> None:
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"hsqs")
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=FileNotFoundError):
|
||||
assert not generate_thumbnail(str(source), str(tmp_path / "icon.png"))
|
||||
|
||||
|
||||
@mark.parametrize("target", ["icon.png", "../outside", ".DirIcon"])
|
||||
def test_dwarfs_icon_symlink(tmp_path: Path, target: str) -> None:
|
||||
import tarfile
|
||||
|
||||
header = bytearray(64)
|
||||
header[:6] = b"\x7fELF\x02\x01"
|
||||
header[8:11] = b"AI\x02"
|
||||
struct.pack_into("<Q", header, 40, len(header))
|
||||
source = tmp_path / "game.AppImage"
|
||||
source.write_bytes(header + b"DWARFS")
|
||||
link_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=link_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo(".DirIcon")
|
||||
member.type = tarfile.SYMTYPE
|
||||
member.linkname = target
|
||||
archive.addfile(member)
|
||||
icon = io.BytesIO()
|
||||
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
|
||||
icon_archive = io.BytesIO()
|
||||
with tarfile.open(fileobj=icon_archive, mode="w") as archive:
|
||||
member = tarfile.TarInfo("icon.png")
|
||||
member.size = len(icon.getvalue())
|
||||
archive.addfile(member, io.BytesIO(icon.getvalue()))
|
||||
results = [SimpleNamespace(stdout=data.getvalue()) for data in (link_archive, icon_archive)]
|
||||
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=results) as run:
|
||||
assert generate_thumbnail(str(source), str(tmp_path / "icon.png")) is (target == "icon.png")
|
||||
assert run.call_count == (2 if target == "icon.png" else 1)
|
||||
assert run.call_args_list[0].args[0][0] == "dwarfsextract"
|
||||
assert "--skip-specials" in run.call_args_list[0].args[0]
|
||||
@@ -1567,3 +1567,140 @@ 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
|
||||
@@ -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 load_theme
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
LOAD_TIMEOUT_MS = 30000
|
||||
|
||||
@@ -54,6 +54,23 @@ def library_scene(
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def test_native_card_uses_linux_source_corner(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr(ThemeManager(), "current_theme_name", "standart")
|
||||
card = manager._create_game_card((
|
||||
"Native", "", "", "", "", "native:///game.desktop", "", "", "", "", 0, 0, "native",
|
||||
))
|
||||
assert card.native_visible
|
||||
assert isinstance(card.nativeLabel._icon, str)
|
||||
assert Path(card.nativeLabel._icon).name == "badge_linux.svg"
|
||||
assert Path(card.nativeLabel._icon).is_file()
|
||||
assert not card.portproton_visible
|
||||
card.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_new_horizontal_cards_fit_before_layout_insertion(
|
||||
@@ -451,6 +468,7 @@ def test_failed_protondb_completion_preserves_library_scroll(
|
||||
) -> None:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import api
|
||||
import portprotonqt.portproton_api as portproton_api
|
||||
|
||||
manager, _ = library_scene
|
||||
manager.rebuild_library_layout(scenario[0])
|
||||
@@ -459,6 +477,7 @@ def test_failed_protondb_completion_preserves_library_scroll(
|
||||
main.getColorPalette_from_pixmap = MagicMock()
|
||||
main.game_library_manager = manager
|
||||
main._get_portproton_desktop_files = lambda: set()
|
||||
monkeypatch.setattr(portproton_api, "get_user_conf_setting", lambda _key: None)
|
||||
monkeypatch.setattr(api, "load_protondb_status", lambda _appid: None)
|
||||
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
|
||||
responses: list[Callable[[str | None], None]] = []
|
||||
@@ -543,19 +562,28 @@ def test_vertical_library_headers_align_with_values(
|
||||
- value.mapToGlobal(value.rect().center()).x()) <= 1
|
||||
|
||||
|
||||
def test_control_click_toggles_card_without_opening_details(
|
||||
def test_space_enters_and_toggles_card_selection_without_opening_details(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
game = ("Game", "", "", "", "", "game.exe", "", "", "", "", 0, 0, "portproton")
|
||||
card = manager._create_game_card(game)
|
||||
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
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
|
||||
selection_manager = object.__new__(ContextMenuManager)
|
||||
selection_manager.game_library_manager = manager
|
||||
selection_manager.card_selection_mode = False
|
||||
manager.game_card_cache[(card.name, card.exec_line)] = card
|
||||
card.context_menu_manager = selection_manager
|
||||
event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier)
|
||||
card.keyPressEvent(event)
|
||||
assert card.batch_selected
|
||||
assert selection_manager.card_selection_mode
|
||||
event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Space, Qt.KeyboardModifier.NoModifier)
|
||||
card.keyPressEvent(event)
|
||||
assert not card.batch_selected
|
||||
assert not selection_manager.card_selection_mode
|
||||
cast(Any, manager.main_window).openGameDetailPage.assert_not_called()
|
||||
|
||||
|
||||
|
||||
+281
-23
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -23,6 +24,8 @@ from PySide6.QtWidgets import (
|
||||
QMenu,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QStyle,
|
||||
QStyleOptionComboBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
@@ -32,10 +35,11 @@ from PySide6.QtWidgets import (
|
||||
from portprotonqt.animations.library_controls import _animation_duration
|
||||
from portprotonqt.animations.game_card import GameCardAnimations
|
||||
from portprotonqt.config import game_config
|
||||
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton
|
||||
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.game_card import GameCard, SourceCorner
|
||||
from portprotonqt.game_library_manager import FullLibraryTile, GameLibraryManager
|
||||
from portprotonqt.input_manager import InputManager
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.search_utils import SearchOptimizer, search_index
|
||||
from portprotonqt.tray_manager import TrayManager
|
||||
@@ -312,7 +316,7 @@ def _tile_theme() -> Any:
|
||||
|
||||
@mark.parametrize(
|
||||
"worker_name",
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "native_library_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
)
|
||||
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
|
||||
worker = MagicMock()
|
||||
@@ -430,15 +434,17 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
|
||||
manager.tray_icon.hide.assert_called_once_with()
|
||||
quit_app.assert_called_once_with()
|
||||
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, False)])
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
|
||||
@mark.parametrize("source", ["portproton", "native"])
|
||||
def test_source_ribbon_visibility(
|
||||
monkeypatch: MonkeyPatch,
|
||||
vertical_layout: bool,
|
||||
ribbon_visible: bool,
|
||||
source: str,
|
||||
) -> None:
|
||||
card = MagicMock()
|
||||
card.coverLabel = MagicMock()
|
||||
card.game_source = "portproton"
|
||||
card.game_source = source
|
||||
card.ppdb_id = "report-id"
|
||||
card.protondb_tier = "Platinum"
|
||||
card.anticheat_status = "Supported"
|
||||
@@ -449,9 +455,9 @@ def test_source_ribbon_visibility(
|
||||
card.parentWidget.return_value = None
|
||||
monkeypatch.setattr("portprotonqt.game_card.ui_config.get_economy_mode", lambda: False)
|
||||
|
||||
GameCard.update_badge_visibility(card, "portproton")
|
||||
GameCard.update_badge_visibility(card, source)
|
||||
|
||||
card.portprotonLabel.setVisible.assert_called_with(ribbon_visible)
|
||||
getattr(card, f"{source}Label").setVisible.assert_called_with(ribbon_visible)
|
||||
card.ppdbLabel.setVisible.assert_called_with(False)
|
||||
card.protondbLabel.setVisible.assert_called_with(False)
|
||||
card.anticheatLabel.setVisible.assert_called_with(False)
|
||||
@@ -787,6 +793,9 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
|
||||
window.auto_size_slider.maximum.return_value = 250
|
||||
window.autoInstallLoading = False
|
||||
window._start_autoinstall_load = MagicMock()
|
||||
window.auto_install_tab_index = 1
|
||||
window.stackedWidget = MagicMock()
|
||||
window.stackedWidget.currentIndex.return_value = 1
|
||||
|
||||
window.refresh_autoinstall_layout()
|
||||
|
||||
@@ -1418,7 +1427,7 @@ def test_library_source_filter_stays_top_aligned_without_checkbox(
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
test_window = cast(Any, window)
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="")
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE="")
|
||||
test_window._create_library_combo = lambda labels, _tooltip: QComboBox()
|
||||
test_window._set_combo_current_key = lambda *_args: None
|
||||
test_window._register_gamepad_tooltip = lambda *_args: None
|
||||
@@ -1700,7 +1709,8 @@ def test_open_local_autoinstall_card_uses_autoinstall_page(tmp_path: Path) -> No
|
||||
assert game_data["exec_line"] == f"autoinstall:{shlex.quote(str(script_path))}"
|
||||
assert return_tab_index == 0
|
||||
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
@mark.parametrize("native", [False, True])
|
||||
def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.touch()
|
||||
started = []
|
||||
@@ -1725,11 +1735,12 @@ def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
|
||||
window.detail_page_manager = FakeDetailPageManager()
|
||||
window.stackedWidget = FakeStackedWidget()
|
||||
window.currentDetailPage = None
|
||||
window._pending_log_exe = str(exe_path)
|
||||
target = "native://" + str(tmp_path / "Native Game.desktop") if native else str(exe_path)
|
||||
window._pending_log_exe = target
|
||||
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": str(exe_path)})
|
||||
window.openGameDetailPage({"name": "Game", "exec_line": target})
|
||||
|
||||
assert started == [(str(exe_path), window.detail_page_manager._debug_log_button)]
|
||||
assert started == [(target, window.detail_page_manager._debug_log_button)]
|
||||
assert window._pending_log_exe is None
|
||||
|
||||
def test_launch_exe_skips_library_load_for_ppai() -> None:
|
||||
@@ -2257,10 +2268,12 @@ def test_steam_launch_plays_game_launch_sound(monkeypatch: MonkeyPatch) -> None:
|
||||
|
||||
assert launch_events == ["popen", "game_launch"]
|
||||
|
||||
@mark.parametrize("custom_name", [None, "Edited Local Game"])
|
||||
def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
tmp_config_dir: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: Any,
|
||||
custom_name: str | None,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.write_text("", encoding="utf-8")
|
||||
@@ -2276,8 +2289,15 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
window.portproton_location = str(tmp_path)
|
||||
results: list[tuple | None] = []
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
if custom_name:
|
||||
from portprotonqt.config import get_custom_data_dir_name
|
||||
game_dir = custom_data_path / get_custom_data_dir_name(str(exe_path))
|
||||
game_dir.mkdir(parents=True)
|
||||
(game_dir / "metadata.txt").write_text(f"name={custom_name}\n", encoding="utf-8")
|
||||
|
||||
def fake_steam_info(_name: str, _exec_line: str, callback: Any) -> None:
|
||||
assert _name == "Test Game"
|
||||
callback({})
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -2297,10 +2317,9 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] is not None
|
||||
assert results[0][0] == "Test Game"
|
||||
assert results[0][0] == (custom_name or "Test Game")
|
||||
assert results[0][5] == f"portproton {exe_path}"
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
assert not custom_data_path.exists()
|
||||
assert custom_data_path.exists() == bool(custom_name)
|
||||
|
||||
def test_remove_empty_custom_data_dirs_keeps_non_empty_dirs(tmp_config_dir: Path) -> None:
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
@@ -2601,7 +2620,7 @@ def test_horizontal_layout_menu_maps_endless_modes(
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "theme")
|
||||
window = cast(Any, SimpleNamespace(
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE=""),
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=""),
|
||||
_create_library_combo=lambda labels, tooltip: QComboBox(),
|
||||
_set_combo_current_key=MagicMock(),
|
||||
_register_gamepad_tooltip=MagicMock(),
|
||||
@@ -2642,7 +2661,7 @@ def test_library_layout_control_rebuilds_both_libraries(
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.ui_config,
|
||||
"get_library_layout_mode",
|
||||
lambda theme_mode: "grid",
|
||||
MagicMock(side_effect=["list", "grid", "grid"]),
|
||||
)
|
||||
|
||||
LibraryMixin._on_library_layout_changed(window, 1)
|
||||
@@ -2719,6 +2738,7 @@ def test_add_game_dialog_does_not_reprocess_its_shortcut(
|
||||
exeEdit=SimpleNamespace(text=lambda: "/games/praest.exe"),
|
||||
coverEdit=SimpleNamespace(text=lambda: ""),
|
||||
getDesktopEntryData=lambda: (entry, str(desktop_path)),
|
||||
native_checkbox=SimpleNamespace(isChecked=lambda: False),
|
||||
last_cover_path="",
|
||||
)
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -2873,15 +2893,22 @@ def test_library_refresh_updates_connected_gog_library(
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
|
||||
@mark.parametrize("display_filter, stores", [
|
||||
("all", ["gog", "egs"]),
|
||||
("gog,portproton", ["gog"]),
|
||||
("steam,portproton", []),
|
||||
])
|
||||
def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
display_filter: str,
|
||||
stores: list[str],
|
||||
) -> None:
|
||||
gog_auth_path = tmp_path / "gog_auth.json"
|
||||
egs_user_path = tmp_path / "egs_user.json"
|
||||
gog_auth_path.touch()
|
||||
egs_user_path.touch()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "all")
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter)
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.QTimer,
|
||||
"singleShot",
|
||||
@@ -2903,11 +2930,7 @@ def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
|
||||
LibraryMixin.refreshGames(cast(Any, window))
|
||||
|
||||
assert calls == [
|
||||
"gog",
|
||||
"egs",
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
assert calls == [*stores, ("load", {"force_load": True})]
|
||||
|
||||
def test_library_refresh_skips_gog_for_portproton_filter(
|
||||
tmp_path: Path,
|
||||
@@ -3110,7 +3133,6 @@ 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:
|
||||
@@ -3128,3 +3150,239 @@ 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()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -43,20 +43,22 @@ gui_config () {
|
||||
|
||||
|
||||
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
|
||||
@mark.parametrize("copies", [1, 3])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 1)[1].split(
|
||||
condition = helper.split(' local -a run_after_exes=', 1)[1].split(
|
||||
" then", 1,
|
||||
)[0]
|
||||
condition = ' local -a run_after_exes=' + condition
|
||||
main_exe = tmp_path / "Game.exe"
|
||||
second_exe = tmp_path / second_name
|
||||
main_exe.touch()
|
||||
second_exe.touch()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
|
||||
+ "then exit 0; else exit 1; fi"],
|
||||
["bash", "-c", 'check_run_after() {\n' + condition
|
||||
+ "then return 0; else return 1; fi; }; check_run_after"],
|
||||
env={**os.environ, "PW_EXE_FILE": str(main_exe),
|
||||
"PW_RUN_AFTER_EXE": str(second_exe)},
|
||||
"PW_RUN_AFTER_EXE": ",".join([str(second_exe)] * copies)},
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -112,7 +114,9 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
|
||||
)[0]
|
||||
main_path = "D:\\Games\\Test Game\\Test Game.exe"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
|
||||
["bash", "-c", 'pw_run() { env; }\nlaunch() {\n'
|
||||
'local -a run_after_exes=(unused) run_after_environment=()\n'
|
||||
'cat > "$run_after_bat" <<EOF\n' + block + '\n}; launch'],
|
||||
env={**os.environ, "run_after_bat": str(tmp_path / "launch.bat"),
|
||||
"pw_exe_file_win": main_path, "run_after_exe_win": trainer_path,
|
||||
"wait_delay_int": "3", "LAUNCH_PARAMETERS": ""},
|
||||
@@ -120,6 +124,7 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
|
||||
)
|
||||
assert f"PW_RUN_AFTER_MAIN_PATH={main_path}" in result.stdout
|
||||
assert f"PW_RUN_AFTER_SECOND_PATH={trainer_path}" in result.stdout
|
||||
assert "PW_RUN_AFTER_EXE=\n" in result.stdout
|
||||
batch = (tmp_path / "launch.bat").read_text(encoding="ascii")
|
||||
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in batch
|
||||
|
||||
@@ -232,3 +237,69 @@ 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"]
|
||||
@@ -219,3 +219,74 @@ def test_disabled_steam_proton_scanning_keeps_local_versions(
|
||||
assert manager.get_installed_versions() == [("LocalWine", str(local_wine))]
|
||||
compat.assert_not_called()
|
||||
home.assert_not_called()
|
||||
|
||||
|
||||
def test_metadata_error_preserves_local_versions(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtWidgets import QLabel, QStackedWidget, QTabWidget
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
local_version = tmp_path / "GE-Proton"
|
||||
local_version.mkdir()
|
||||
manager = SimpleNamespace(
|
||||
content_stack=QStackedWidget(),
|
||||
tab_widget=QTabWidget(),
|
||||
theme=SimpleNamespace(GETWINE_WINDOW_STYLE=""),
|
||||
)
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.tab_widget.addTab(QWidget(), "Loading")
|
||||
|
||||
def create_installed_tab() -> None:
|
||||
assert local_version.is_dir()
|
||||
manager.tab_widget.addTab(QLabel(local_version.name), "Installed")
|
||||
|
||||
manager.create_installed_tab = create_installed_tab
|
||||
monkeypatch.setattr(module, "_", lambda text: text)
|
||||
module.ProtonManager.on_wine_data_load_error(cast(Any, manager), "ConnectionError: " + "x" * 2000)
|
||||
|
||||
assert manager.content_stack.currentIndex() == 1
|
||||
assert manager.tab_widget.count() == 1
|
||||
assert manager.tab_widget.currentIndex() == 0
|
||||
assert manager.tab_widget.widget(0).text() == local_version.name
|
||||
manager.tab_widget.deleteLater()
|
||||
manager.content_stack.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_installed_sizes_update_after_metadata_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QEventLoop, QTimer
|
||||
from PySide6.QtWidgets import QPushButton, QTableWidget
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
version = tmp_path / "GE-Proton"
|
||||
version.mkdir()
|
||||
(version / "wine").write_bytes(b"x" * 2048)
|
||||
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **kwargs: QPushButton(text))
|
||||
monkeypatch.setattr(module.ProtonManager, "get_installed_versions", lambda self: [("GE-Proton", str(version))])
|
||||
manager = module.ProtonManager(portproton_location=str(tmp_path), theme=ThemeWrapper(load_theme("standart")))
|
||||
manager.on_wine_data_load_error("Offline")
|
||||
workers = list(manager._installed_size_tables)
|
||||
loop = QEventLoop()
|
||||
workers[0].finished.connect(loop.quit)
|
||||
QTimer.singleShot(3000, loop.quit)
|
||||
loop.exec()
|
||||
app.processEvents()
|
||||
try:
|
||||
installed_tab = manager.tab_widget.widget(0)
|
||||
assert installed_tab is not None
|
||||
table = installed_tab.findChild(QTableWidget)
|
||||
assert table is not None
|
||||
size_item = table.item(0, 2)
|
||||
assert size_item is not None
|
||||
assert size_item.text() == "2.0 KiB"
|
||||
finally:
|
||||
from shiboken6 import isValid
|
||||
for worker in workers:
|
||||
if isValid(worker):
|
||||
worker.wait()
|
||||
manager.close()
|
||||
app.processEvents()
|
||||
@@ -3,7 +3,7 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
|
||||
@@ -458,6 +458,22 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
|
||||
table.selectRow(1)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert isinstance(table.cellWidget(1, 0), QLabel)
|
||||
label = table.cellWidget(1, 0)
|
||||
ExeSettingsDialog._set_favorite_text_cells(
|
||||
cast(ExeSettingsDialog, dialog), 1, "key", "Updated", "New <description>",
|
||||
)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert table.cellWidget(1, 0) is label
|
||||
assert isinstance(label, QLabel)
|
||||
assert label.text() == theme.SETTINGS_SELECTED_TEXT_HTML.format(
|
||||
text="Updated<br>" + theme.SETTINGS_SELECTED_DESCRIPTION_HTML.format(
|
||||
description="New <description>",
|
||||
),
|
||||
)
|
||||
updated_item = table.item(1, 0)
|
||||
assert updated_item is not None
|
||||
assert updated_item.data(Qt.ItemDataRole.AccessibleTextRole) == "Updated\nNew <description>"
|
||||
assert updated_item.text() == ""
|
||||
table.close()
|
||||
|
||||
|
||||
@@ -925,3 +941,93 @@ 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()
|
||||
+348
-22
@@ -69,16 +69,18 @@ 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 / "game.exe"
|
||||
executable = tmp_path / ("Hytale Launcher.desktop" if target == "native" else "game.exe")
|
||||
executable.touch()
|
||||
script_path = tmp_path / "game.sh"
|
||||
|
||||
if target == "native":
|
||||
target = "native://" + str(executable)
|
||||
monkeypatch.setattr(steam_shortcuts, "get_steam_home", lambda: steam_home)
|
||||
monkeypatch.setattr(
|
||||
steam_shortcuts,
|
||||
@@ -91,12 +93,15 @@ def test_add_to_steam_for_all_accounts(
|
||||
monkeypatch.setattr(
|
||||
steam_shortcuts, "get_portproton_start_command", lambda: "start"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
steam_shortcuts,
|
||||
"_create_launch_script",
|
||||
lambda *_args: (str(script_path), ""),
|
||||
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, "_generate_icon", lambda *_args: "")
|
||||
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.game_config, "get_steam_account_id", lambda: "all"
|
||||
)
|
||||
@@ -105,10 +110,18 @@ def test_add_to_steam_for_all_accounts(
|
||||
)
|
||||
|
||||
result, _message = steam_shortcuts.add_to_steam(
|
||||
"Test Game", str(executable), ""
|
||||
"Test Game", str(executable) if target == "game.exe" else target, "", 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"
|
||||
@@ -116,6 +129,9 @@ 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:
|
||||
@@ -160,6 +176,7 @@ 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"),
|
||||
@@ -860,7 +877,8 @@ class TestGetSteamShortcutPath:
|
||||
|
||||
|
||||
class TestEditSteamShortcut:
|
||||
def test_steam_dialog_has_no_executable_row(self, monkeypatch: Any) -> None:
|
||||
@pytest.mark.parametrize("store", ["steam", "gog", "egs"])
|
||||
def test_steam_dialog_has_no_executable_row(self, monkeypatch: Any, store: str) -> None:
|
||||
from portprotonqt.dialogs.base import AddGameDialog
|
||||
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
@@ -884,11 +902,15 @@ 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)
|
||||
@@ -977,11 +999,16 @@ class TestEditSteamShortcut:
|
||||
"", "", "", "", 0, 0, "egs",
|
||||
)
|
||||
manager.game_library_manager = MagicMock(games=[gog_game, egs_game])
|
||||
monkeypatch.setattr(context_menu, "AddGameDialog", lambda **_kwargs: dialog)
|
||||
make_dialog = MagicMock(return_value=dialog)
|
||||
monkeypatch.setattr(context_menu, "AddGameDialog", make_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
|
||||
@@ -1443,11 +1470,14 @@ 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,
|
||||
tmp_path: Path, monkeypatch: Any, edited_name: str, edited_executable: str,
|
||||
) -> 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"
|
||||
@@ -1463,12 +1493,15 @@ 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 = "Renamed"
|
||||
dialog.exeEdit.text.return_value = "/games/game.exe"
|
||||
dialog.nameEdit.text.return_value = edited_name
|
||||
dialog.exeEdit.text.return_value = edited_executable
|
||||
dialog.coverEdit.text.return_value = ""
|
||||
dialog.getDesktopEntryData.return_value = (source.read_text(), str(source))
|
||||
dialog.getDesktopEntryData.return_value = (
|
||||
source.read_text().replace("/games/game.exe", edited_executable), 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
|
||||
@@ -1482,9 +1515,66 @@ 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)),
|
||||
@@ -1492,17 +1582,29 @@ def test_edit_shortcut_removes_desktop_file_with_original_filename(
|
||||
(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]) -> None:
|
||||
expected: tuple[int, int], target: str) -> None:
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
|
||||
manager = object.__new__(ContextMenuManager)
|
||||
manager._batch_action = batch
|
||||
manager._check_portproton = lambda: True
|
||||
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)
|
||||
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)
|
||||
manager.signals = MagicMock()
|
||||
monkeypatch.setattr("portprotonqt.context_menu_manager.add_to_steam", lambda *args: result)
|
||||
manager.add_to_steam("Game", "game.exe", "cover")
|
||||
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")
|
||||
assert manager.signals.show_info_dialog.emit.call_count == expected[0]
|
||||
assert manager.signals.show_warning_dialog.emit.call_count == expected[1]
|
||||
|
||||
@@ -1553,6 +1655,20 @@ 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:
|
||||
@@ -1589,6 +1705,34 @@ 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:
|
||||
@@ -1882,7 +2026,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, edit_mode: bool,
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, edit_mode: bool,
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QHBoxLayout
|
||||
import portprotonqt.dialogs.base as module
|
||||
@@ -1890,7 +2034,8 @@ 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())
|
||||
monkeypatch.setattr(explorer_module, "FileExplorer", lambda *_args, **_kwargs: explorer)
|
||||
create_explorer = MagicMock(return_value=explorer)
|
||||
monkeypatch.setattr(explorer_module, "FileExplorer", create_explorer)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
|
||||
dialog = SimpleNamespace(
|
||||
last_exe_path=None, edit_mode=edit_mode, onExeSelected=MagicMock(),
|
||||
@@ -1900,6 +2045,17 @@ 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:
|
||||
@@ -2436,3 +2592,173 @@ 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"
|
||||
@@ -118,11 +118,16 @@ class TestDeleteCachedAppFiles:
|
||||
assert (tmp_path / "other.txt").exists()
|
||||
|
||||
|
||||
def test_matched_steam_game_keeps_cover_when_app_details_fail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.mark.parametrize("source", ["exe", "gog", "egs"])
|
||||
def test_matched_steam_game_keeps_cover_when_app_details_fail(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: str,
|
||||
) -> None:
|
||||
import portprotonqt.steam_api.api as api
|
||||
|
||||
monkeypatch.setattr("portprotonqt.config.cache.CACHE_DIR", tmp_path / "cache")
|
||||
executable = tmp_path / "Praest.exe"
|
||||
executable.touch()
|
||||
exec_line = str(executable) if source == "exe" else f"{source}://launch/graven"
|
||||
app = {"appid": 1371690, "normalized_name": "graven"}
|
||||
monkeypatch.setattr(api.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr("portprotonqt.steam_api.cache.get_exiftool_data", lambda _exe: {})
|
||||
@@ -133,8 +138,20 @@ def test_matched_steam_game_keeps_cover_when_app_details_fail(tmp_path: Path, mo
|
||||
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", str(executable), results.append)
|
||||
api.get_steam_game_info_async("GRAVEN", exec_line, results.append)
|
||||
assert len(results) == 1
|
||||
assert results[0]["appid"] == 1371690
|
||||
assert results[0]["steam_game"] == "true"
|
||||
assert results[0]["cover"] == "https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
|
||||
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]
|
||||
+128
-1
@@ -3,12 +3,15 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import orjson
|
||||
from PySide6.QtCore import QEventLoop, QThread, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from pytest import MonkeyPatch, mark
|
||||
from shiboken6 import delete
|
||||
|
||||
@@ -20,6 +23,7 @@ from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin
|
||||
from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin as GOGMixin
|
||||
import portprotonqt.main_window as main_window_module
|
||||
import portprotonqt.tabs.download_tab as download_tab_module
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
|
||||
@mark.parametrize("source", ("gog", "egs"))
|
||||
@@ -103,6 +107,93 @@ 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
|
||||
assert started.wait(3)
|
||||
window._stopWorkerThread("store_dlc_worker", None)
|
||||
assert not worker.isRunning()
|
||||
application.processEvents()
|
||||
assert window.store_dlc_worker is None
|
||||
window._on_store_dlcs_loaded.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_store_download_queues_reject_duplicates() -> None:
|
||||
active = {"app_id": "active", "title": "Active"}
|
||||
queued = {"app_id": "queued", "title": "Queued"}
|
||||
@@ -920,15 +1011,19 @@ def test_installed_filter_excludes_uninstalled_gog_games(
|
||||
get_launch_target=lambda app_id: f"/games/{app_id}/game.exe",
|
||||
)
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
|
||||
loaded_metadata = []
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.get_steam_game_info_async",
|
||||
lambda _name, _uri, callback: callback({}),
|
||||
lambda name, _uri, callback: (
|
||||
loaded_metadata.append(name) or callback({})
|
||||
),
|
||||
)
|
||||
results = []
|
||||
|
||||
MainWindow._load_gog_games_async(window, results.append)
|
||||
|
||||
assert [game[3] for game in results[0]] == ["installed"]
|
||||
assert loaded_metadata == ["Installed"]
|
||||
assert results[0][0][5] == "gog://launch/installed"
|
||||
|
||||
def test_installed_filter_disabled_includes_uninstalled_gog_games(
|
||||
@@ -1052,6 +1147,38 @@ def test_egs_library_uses_steam_description_when_epic_has_title_only(
|
||||
|
||||
assert results[0][0][1] == "Witch It — игра в прятки по сети."
|
||||
|
||||
|
||||
def test_installed_filter_skips_uninstalled_egs_metadata(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
library = [
|
||||
{"app_id": "installed", "title": "Installed"},
|
||||
{"app_id": "uninstalled", "title": "Uninstalled"},
|
||||
]
|
||||
installed_data = {"installed": {}}
|
||||
api = SimpleNamespace(
|
||||
load_library=lambda: library,
|
||||
load_installed=lambda: installed_data,
|
||||
is_game_installed=lambda app_id, data: app_id in data,
|
||||
get_launch_target=lambda app_id, _data: f"/games/{app_id}.exe",
|
||||
)
|
||||
loaded_metadata = []
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.get_steam_game_info_async",
|
||||
lambda name, _uri, callback: (
|
||||
loaded_metadata.append(name) or callback({})
|
||||
),
|
||||
)
|
||||
results = []
|
||||
|
||||
MainWindow._load_egs_games_async(
|
||||
cast(MainWindow, SimpleNamespace(egs_api=api)), results.append
|
||||
)
|
||||
|
||||
assert loaded_metadata == ["Installed"]
|
||||
assert [game[3] for game in results[0]] == ["installed"]
|
||||
|
||||
def test_egs_maintenance_uses_legendary_commands() -> None:
|
||||
start_operation = MagicMock()
|
||||
window = cast(MainWindow, SimpleNamespace(_start_egs_operation=start_operation))
|
||||
|
||||
Reference in new issue
Block a user