Compare commits
33
Commits
wine-aarch64
...
weblate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e103793e | ||
|
|
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
|
||||
+22
-2
@@ -7,8 +7,13 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: ЛКМ и кнопка A геймпада переключают выделение, Ctrl+клик позволяет выделять карточки напрямую
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
@@ -16,16 +21,28 @@
|
||||
|
||||
### 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
|
||||
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
|
||||
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
@@ -36,10 +53,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>
|
||||
|
||||
@@ -4012,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
|
||||
@@ -4193,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
|
||||
@@ -4219,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
|
||||
@@ -4237,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
|
||||
|
||||
+78
-112
@@ -2,7 +2,7 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
import shlex
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -11,17 +11,16 @@ from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
@@ -66,6 +65,8 @@ if APP_VERSION == "@APP" "_VERSION@":
|
||||
|
||||
__app_version__ = APP_VERSION
|
||||
|
||||
RESTART_STOP_TIMEOUT_SECONDS = 30
|
||||
|
||||
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
|
||||
|
||||
def get_version():
|
||||
@@ -82,103 +83,6 @@ def get_version():
|
||||
return __app_version__
|
||||
|
||||
|
||||
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
start_sh + ["cli", "--stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to execute PortProton stop command: %s", e)
|
||||
|
||||
|
||||
def get_portproton_tray_icon() -> QIcon:
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with minimal tray game controls."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
tray_icon.setToolTip(__app_name__)
|
||||
|
||||
from datetime import datetime
|
||||
from portprotonqt.time_utils import save_last_launch, save_playtime
|
||||
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
|
||||
start_time = datetime.now()
|
||||
|
||||
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
|
||||
tray_menu = QMenu()
|
||||
|
||||
def end_silent_run():
|
||||
monitor_timer.stop()
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
if elapsed > 0:
|
||||
save_playtime(exe_path, elapsed)
|
||||
tray_icon.hide()
|
||||
app.quit()
|
||||
|
||||
def stop_game() -> None:
|
||||
stop_portproton_game(start_sh, logger)
|
||||
end_silent_run()
|
||||
|
||||
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
def update_pause_action() -> list[psutil.Process]:
|
||||
try:
|
||||
children = psutil.Process(process.pid).children(recursive=True)
|
||||
paused = bool(children) and all(
|
||||
child.status() == psutil.STATUS_STOPPED for child in children
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
children = []
|
||||
paused = False
|
||||
pause_action.setEnabled(bool(children))
|
||||
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
return children
|
||||
|
||||
def toggle_pause() -> None:
|
||||
children = update_pause_action()
|
||||
if not children:
|
||||
return
|
||||
try:
|
||||
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
|
||||
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
|
||||
for child in children:
|
||||
try:
|
||||
child.send_signal(pause_signal)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
|
||||
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
|
||||
logger.warning("Failed to inspect game pause state: %s", error)
|
||||
update_pause_action()
|
||||
|
||||
pause_action = QAction(_("Pause Game"), tray_menu)
|
||||
pause_action.triggered.connect(toggle_pause)
|
||||
tray_menu.addAction(pause_action)
|
||||
tray_menu.aboutToShow.connect(update_pause_action)
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
tray_icon.setContextMenu(tray_menu)
|
||||
tray_icon.show()
|
||||
|
||||
monitor_timer = QTimer(app)
|
||||
monitor_timer.timeout.connect(close_when_game_exits)
|
||||
monitor_timer.start(1000)
|
||||
|
||||
|
||||
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
|
||||
"""Restore a PortProton prefix backup."""
|
||||
logger = get_logger(__name__)
|
||||
@@ -393,7 +297,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 +323,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 +339,11 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Remove old socket if it exists
|
||||
if not owns_instance:
|
||||
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
|
||||
return
|
||||
|
||||
# Only the lock owner can remove a stale socket.
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -548,10 +459,7 @@ def main():
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
@@ -576,11 +484,32 @@ def main():
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
restart_target = None
|
||||
|
||||
def handle_game_request(
|
||||
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
|
||||
) -> None:
|
||||
nonlocal restart_target
|
||||
if restart_target is not None:
|
||||
return
|
||||
if silent and (confirm or window.game_processes):
|
||||
message_box = QMessageBox(window)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(__app_name__)
|
||||
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
restart_target = target
|
||||
app.quit()
|
||||
return
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
run_silent_tray(app, start_sh, path)
|
||||
window.toggleGame(shlex.quote(path))
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
@@ -665,7 +594,7 @@ def main():
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
handle_game_request(target, True, confirm=True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
@@ -924,7 +853,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()
|
||||
@@ -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)
|
||||
|
||||
@@ -363,8 +363,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -392,8 +392,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -537,7 +537,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 +561,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()
|
||||
@@ -803,16 +803,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
|
||||
@@ -913,7 +918,9 @@ class ContextMenuManager:
|
||||
if not entry:
|
||||
continue
|
||||
exec_line = entry.get("Exec", entry.get("exec", ""))
|
||||
if script_name.lower() in exec_line.lower():
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
|
||||
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
|
||||
return file_path
|
||||
except OSError:
|
||||
continue
|
||||
@@ -1171,8 +1178,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 +1429,8 @@ class ContextMenuManager:
|
||||
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id,
|
||||
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
@@ -1536,6 +1544,27 @@ class ContextMenuManager:
|
||||
except OSError as error:
|
||||
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
|
||||
|
||||
def _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
game_dir = resolve_custom_data_dir(custom_root, exe_path)
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
metadata_lines = []
|
||||
if os.path.isfile(metadata_path):
|
||||
with open(metadata_path, encoding="utf-8") as metadata_file:
|
||||
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
|
||||
clean_name = " ".join(name.splitlines()).strip()
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
|
||||
temp_path = metadata_file.name
|
||||
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
|
||||
os.replace(temp_path, metadata_path)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1576,7 +1605,12 @@ class ContextMenuManager:
|
||||
_("Game name and executable path are required")
|
||||
)
|
||||
return
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
old_entry = parse_desktop_entry(old_path)
|
||||
original_name = old_entry.get("Name", game_name) if old_entry else game_name
|
||||
dialog.nameEdit.setText(original_name)
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
dialog.nameEdit.setText(new_name)
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1584,10 +1618,15 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
new_desktop_path = old_path
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
if exe_path == new_exe_path and os.path.isfile(old_path):
|
||||
with open(old_path, encoding="utf-8") as source:
|
||||
desktop_entry = source.read()
|
||||
if new_name != game_name:
|
||||
self._save_local_shortcut_name(new_exe_path, new_name)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
@@ -1606,13 +1645,6 @@ class ContextMenuManager:
|
||||
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
|
||||
if exe_path != new_exe_path:
|
||||
self._relocate_shortcut_statistics(exe_path, new_exe_path)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
_("Failed to delete old .desktop file: {error}"),
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1632,11 +1664,9 @@ class ContextMenuManager:
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
custom_folder = resolve_custom_data_dir(
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
|
||||
new_exe_path,
|
||||
)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
@@ -1727,12 +1757,18 @@ class ContextMenuManager:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
if not exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
|
||||
else:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
@@ -1769,11 +1805,7 @@ 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"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
from itertools import accumulate
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
|
||||
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -18,6 +18,19 @@ class CustomComboBox(QComboBox):
|
||||
super().__init__(parent)
|
||||
self.theme = theme
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
line_edit = self.lineEdit()
|
||||
if (
|
||||
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
|
||||
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
return True
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
self.showPopup()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
line_edit = self.lineEdit()
|
||||
if line_edit is None:
|
||||
|
||||
@@ -93,6 +93,9 @@ class DetailPageManager:
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
self._current_detail_source = ("game", dict(game_data))
|
||||
self._return_to_library_card_key = (
|
||||
game_data.get("name", ""), game_data.get("exec_line", "")
|
||||
)
|
||||
detail_page = QWidget()
|
||||
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
|
||||
@@ -1480,7 +1483,7 @@ class DetailPageManager:
|
||||
msg_box.setWindowTitle(_("Log Saved"))
|
||||
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1638,10 +1641,15 @@ class DetailPageManager:
|
||||
container = getattr(self.main_window, "gamesListWidget", None)
|
||||
if container is None:
|
||||
return
|
||||
manager = getattr(self.main_window, "game_library_manager", None)
|
||||
card_key = getattr(self, "_return_to_library_card_key", None)
|
||||
card = getattr(manager, "game_card_cache", {}).get(card_key)
|
||||
cards = [
|
||||
widget for widget in container.findChildren(QWidget)
|
||||
if widget.__class__.__name__ == "GameCard"
|
||||
]
|
||||
if card is not None and card.isVisible():
|
||||
cards = [card]
|
||||
if not cards:
|
||||
return
|
||||
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
|
||||
|
||||
@@ -30,6 +30,7 @@ from portprotonqt.config import (
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -364,6 +365,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 +432,19 @@ class AddGameDialog(DraggableDialog):
|
||||
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
|
||||
layout.addRow(cover_button_label, coverBrowseButton)
|
||||
|
||||
self.iconEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
|
||||
icon_label = QLabel(_("Custom Icon:"))
|
||||
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
icon_row = QHBoxLayout()
|
||||
icon_row.addWidget(self.iconEdit)
|
||||
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
|
||||
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
icon_browse.clicked.connect(self.browseIcon)
|
||||
icon_row.addWidget(icon_browse)
|
||||
layout.addRow(icon_label, icon_row)
|
||||
|
||||
self.add_to_steam_checkbox = QCheckBox()
|
||||
self.add_to_menu_checkbox = QCheckBox()
|
||||
self.add_to_desktop_checkbox = QCheckBox()
|
||||
@@ -561,10 +577,44 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
@@ -926,6 +976,8 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
|
||||
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
|
||||
if get_custom_icon_path(exe_path):
|
||||
return desktop_entry, desktop_path
|
||||
if not self.last_cover_path and file_icon_path:
|
||||
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
|
||||
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
|
||||
|
||||
@@ -607,7 +607,9 @@ class FileExplorer(DraggableDialog):
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
if isinstance(self.file_filter, str):
|
||||
if callable(self.file_filter):
|
||||
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
|
||||
elif isinstance(self.file_filter, str):
|
||||
files = [f for f in files if f.lower().endswith(self.file_filter)]
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
@@ -958,8 +958,8 @@ class ProtonManager(DraggableDialog):
|
||||
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.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
|
||||
|
||||
@@ -290,6 +290,11 @@ class ExeSettingsDialog(
|
||||
"""Resolve run-after executable relative to the main executable."""
|
||||
if not exe_path:
|
||||
return exe_path
|
||||
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
|
||||
return ",".join(
|
||||
self._resolve_run_after_exe_path(path.strip())
|
||||
for path in exe_path.split(",") if path.strip()
|
||||
)
|
||||
normalized = os.path.normpath(os.path.expanduser(exe_path))
|
||||
if os.path.isabs(normalized):
|
||||
return normalized
|
||||
@@ -300,6 +305,70 @@ class ExeSettingsDialog(
|
||||
return normalized
|
||||
return os.path.normpath(os.path.join(game_dir, normalized))
|
||||
|
||||
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
row = next(index for index in range(table.rowCount())
|
||||
if table.cellWidget(index, 1) is target.parentWidget())
|
||||
expanded = bool(target.property("ppqt_run_after_expanded"))
|
||||
target.setProperty("ppqt_run_after_expanded", not expanded)
|
||||
if expanded:
|
||||
while row + 1 < table.rowCount():
|
||||
item = table.item(row + 1, 0)
|
||||
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
|
||||
break
|
||||
table.removeRow(row + 1)
|
||||
return
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
if not target.text():
|
||||
entries = [""]
|
||||
for index, path in enumerate(entries):
|
||||
entry_row = row + index + 1
|
||||
table.insertRow(entry_row)
|
||||
item = QTableWidgetItem(path)
|
||||
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
|
||||
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
table.setItem(entry_row, 0, item)
|
||||
entry_edit = QLineEdit(path)
|
||||
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
entry_edit.installEventFilter(self)
|
||||
entry_edit.textChanged.connect(
|
||||
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
|
||||
)
|
||||
table.setCellWidget(entry_row, 0, entry_edit)
|
||||
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
|
||||
)
|
||||
table.setCellWidget(entry_row, 1, button)
|
||||
table.resizeRowsToContents()
|
||||
|
||||
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
|
||||
entries = target.text().split(",")
|
||||
if index < len(entries):
|
||||
entries[index] = value
|
||||
target.setText(",".join(entries))
|
||||
|
||||
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
entries.pop(index)
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setText(",".join(entries))
|
||||
if entries:
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _clear_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
for row in reversed(range(table.rowCount())):
|
||||
item = table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
|
||||
table.removeRow(row)
|
||||
target.setProperty("ppqt_run_after_expanded", False)
|
||||
target.clear()
|
||||
if target.isVisible():
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _get_setting_file_selector_path(self, current_path: str) -> str:
|
||||
"""Get initial path for setting file selectors."""
|
||||
initial_path = os.path.expanduser("~")
|
||||
@@ -403,7 +472,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, 230)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
@@ -672,8 +741,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -693,8 +762,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -951,10 +1020,12 @@ class ExeSettingsDialog(
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
text_layout.setContentsMargins(0, 0, 0, 0)
|
||||
text_layout.setSpacing(6)
|
||||
text_layout.addWidget(line_edit)
|
||||
container_layout = QVBoxLayout(text_container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.addWidget(line_edit)
|
||||
line_edit.hide()
|
||||
text_layout = QHBoxLayout()
|
||||
container_layout.addLayout(text_layout)
|
||||
|
||||
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -966,26 +1037,38 @@ class ExeSettingsDialog(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter="",
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
):
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = self._get_setting_file_selector_path(
|
||||
target_line_edit.text().strip()
|
||||
)
|
||||
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
file_filter=file_filter,
|
||||
initial_path=initial_path,
|
||||
initial_path=self._get_setting_file_selector_path(
|
||||
target_line_edit.text().rsplit(",", 1)[-1].strip()
|
||||
),
|
||||
)
|
||||
file_explorer.file_signal.file_selected.connect(
|
||||
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
|
||||
lambda path, target=target_line_edit: target.setText(
|
||||
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
|
||||
)
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
expand_button = AutoSizeButton("≡")
|
||||
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
expand_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
|
||||
)
|
||||
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
|
||||
)
|
||||
text_layout.addWidget(clear_button)
|
||||
text_layout.addWidget(expand_button)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
@@ -1108,6 +1191,8 @@ class ExeSettingsDialog(
|
||||
break
|
||||
|
||||
source_header = source_table.horizontalHeader()
|
||||
if source_header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents:
|
||||
source_table.resizeColumnToContents(1)
|
||||
target_header = self.favorites_table.horizontalHeader()
|
||||
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
@@ -1730,15 +1815,20 @@ class ExeSettingsDialog(
|
||||
item = table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
widget = table.cellWidget(row, 0)
|
||||
if widget is not None and not isinstance(widget, QLabel):
|
||||
continue
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
if text is None:
|
||||
text = item.text()
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
label = cast(QLabel | None, table.cellWidget(row, 0))
|
||||
if label is None:
|
||||
text = item.text()
|
||||
label = QLabel()
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setWordWrap(True)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
table.setCellWidget(row, 0, label)
|
||||
label.setAutoFillBackground(False)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
@@ -1753,7 +1843,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("/"))
|
||||
|
||||
+19
-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
|
||||
@@ -878,10 +879,10 @@ class GameCard(AnimatedCard):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.egs_visible, self.egsLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -1051,15 +1052,11 @@ class GameCard(AnimatedCard):
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -1293,14 +1290,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:
|
||||
|
||||
@@ -8,6 +8,7 @@ import io
|
||||
import pefile
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -49,6 +50,9 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
custom_icon = get_custom_icon_path(exe_path)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
|
||||
@@ -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-26 12:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -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,9 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательский значок:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -1008,36 +1008,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 +2396,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'
|
||||
|
||||
+57
-15
@@ -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
|
||||
@@ -1070,9 +1071,13 @@ class MainWindow(
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
for source in results
|
||||
}
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
@@ -1114,11 +1119,13 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
# Load all sources in parallel
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
getattr(self, f"_load_{source}_games_async")(callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
@@ -1130,6 +1137,11 @@ class MainWindow(
|
||||
library = [game for game in api.load_library() if game.get("app_id")]
|
||||
if self._upgrade_legacy_gog_library(api, library, callback):
|
||||
return
|
||||
if only_installed:
|
||||
library = [
|
||||
game for game in library
|
||||
if api.is_game_installed(str(game["app_id"]), installed)
|
||||
]
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
@@ -1193,6 +1205,23 @@ class MainWindow(
|
||||
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
|
||||
only_installed = game_config.get_only_installed()
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
installed_data = None
|
||||
installed_by_id = {}
|
||||
if only_installed:
|
||||
installed_data = self.egs_api.load_installed()
|
||||
installed_by_id = {
|
||||
str(game["app_id"]): self.egs_api.is_game_installed(
|
||||
str(game["app_id"]), installed_data
|
||||
)
|
||||
for game in library
|
||||
}
|
||||
library = [
|
||||
game for game in library
|
||||
if installed_by_id[str(game["app_id"])]
|
||||
]
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
@@ -1206,9 +1235,19 @@ class MainWindow(
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"egs", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
installed = (
|
||||
installed_by_id[app_id] if only_installed
|
||||
else self.egs_api.is_game_installed(app_id)
|
||||
)
|
||||
if not only_installed or installed:
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
target = None
|
||||
if installed:
|
||||
if only_installed:
|
||||
target = self.egs_api.get_launch_target(
|
||||
app_id, installed_data
|
||||
)
|
||||
else:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
description = str(game.get("description", ""))
|
||||
if not description or description == str(game.get("title", "")):
|
||||
description = str(steam_info.get("description", ""))
|
||||
@@ -1383,6 +1422,9 @@ class MainWindow(
|
||||
executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed)
|
||||
|
||||
def _generate_missing_portproton_icon(self, game_exe: str, icon_path: str, desktop_name: str) -> str:
|
||||
custom_icon = get_custom_icon_path(game_exe)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
if not self.portproton_location:
|
||||
return ""
|
||||
if not game_exe.lower().endswith(".exe") or not os.path.isfile(game_exe):
|
||||
@@ -2563,8 +2605,8 @@ class MainWindow(
|
||||
msg_box.setText(_("Prefix '{0}' was not found. Install recommended libraries?").format(prefix_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
env_vars["DISABLE_CP_DEFPFX"] = "1"
|
||||
@@ -2681,8 +2723,8 @@ class MainWindow(
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
|
||||
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
|
||||
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
return self._install_alt_i586_dependencies()
|
||||
@@ -2779,8 +2821,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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Steam API functions for PortProtonQt."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
@@ -15,6 +16,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
|
||||
from portprotonqt.steam_api.cache import (
|
||||
@@ -46,10 +48,12 @@ SGDB_CACHE_DURATION = 24 * 60 * 60
|
||||
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
|
||||
|
||||
|
||||
def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> None:
|
||||
def fetch_sgdb_cover_async(
|
||||
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
|
||||
) -> None:
|
||||
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
encoded = urllib.parse.quote(game_name, safe="")
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -66,6 +70,14 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
if text:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
text = text.replace(
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
1,
|
||||
)
|
||||
logger.debug("Got SGDB cover URL for %s: %s", game_name, text)
|
||||
callback(text)
|
||||
except Exception as e:
|
||||
@@ -74,7 +86,7 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
|
||||
cache_manager = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}.json"
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
|
||||
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
|
||||
process_response(str(cache_file))
|
||||
return
|
||||
@@ -182,6 +194,11 @@ def fetch_client_icon_async(appid: int | str, callback: Callable[[str], None]) -
|
||||
callback("")
|
||||
return
|
||||
|
||||
custom_icon = get_custom_icon_path(f"steam://rungameid/{appid_str}")
|
||||
if custom_icon:
|
||||
callback(custom_icon)
|
||||
return
|
||||
|
||||
cache_dir = CacheManager().cache_dir
|
||||
info_path = cache_dir / f"steamcmd_app_{appid_str}_{time.time_ns()}.json"
|
||||
url = f"https://api.steamcmd.net/v1/info/{appid_str}"
|
||||
@@ -224,7 +241,12 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
|
||||
callback(cached.get("tier", ""))
|
||||
return
|
||||
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
url = f"https://deckyloader.ru/protondb/{appid}.json"
|
||||
else:
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -623,6 +645,19 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.cache import get_exiftool_data
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
cache_manager = CacheManager()
|
||||
match_key = "steam_match_" + hashlib.sha256(
|
||||
orjson.dumps([desktop_name, exec_line])
|
||||
).hexdigest()
|
||||
cached_match = (
|
||||
cache_manager.load_json(match_key)
|
||||
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
|
||||
)
|
||||
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
|
||||
cached_match = None
|
||||
elif cached_match["appid"] <= 0:
|
||||
cached_match = None
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
|
||||
@@ -646,7 +681,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_store_uri:
|
||||
if cached_match is not None or is_store_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -685,9 +720,9 @@ def get_steam_game_info_async(
|
||||
data_and_index: tuple[list | None, dict | None]
|
||||
) -> None:
|
||||
steam_apps, steam_apps_index = data_and_index
|
||||
matching_app = None
|
||||
matching_app = cached_match
|
||||
|
||||
if not steam_apps or not steam_apps_index:
|
||||
if matching_app is None and (not steam_apps or not steam_apps_index):
|
||||
game_name = desktop_name or exe_name
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
@@ -716,9 +751,11 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
for candidate in candidates_ordered:
|
||||
if matching_app:
|
||||
break
|
||||
if not candidate:
|
||||
continue
|
||||
matching_app = search_app(candidate, steam_apps_index)
|
||||
matching_app = search_app(candidate, steam_apps_index or {})
|
||||
if matching_app:
|
||||
break
|
||||
|
||||
@@ -749,6 +786,8 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
appid = matching_app["appid"]
|
||||
if cached_match is None:
|
||||
cache_manager.save_json(match_key, matching_app)
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if not app_info:
|
||||
@@ -800,4 +839,7 @@ def get_steam_game_info_async(
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
if cached_match is not None:
|
||||
on_steam_apps_and_index((None, None))
|
||||
else:
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
@@ -7,13 +7,16 @@ import re
|
||||
import shlex
|
||||
import shutil
|
||||
import zlib
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
import vdf
|
||||
import websocket
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -33,6 +36,7 @@ from portprotonqt.steam_api.utils import (
|
||||
)
|
||||
from portprotonqt.steam_api.api import (
|
||||
get_steam_game_info_async,
|
||||
fetch_sgdb_cover_async,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -328,11 +332,12 @@ def _add_shortcut_via_vdf(
|
||||
|
||||
def _download_covers(
|
||||
appid: int,
|
||||
steam_appid: int,
|
||||
steam_appid: tuple[int | None, str],
|
||||
grid_dir: str,
|
||||
was_api_used: bool
|
||||
) -> None:
|
||||
"""Download cover images for the game."""
|
||||
source_appid, game_name = steam_appid
|
||||
cover_types = [
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
@@ -349,7 +354,7 @@ def _download_covers(
|
||||
with open(result_path, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
|
||||
ext = Path(steam_name).suffix.lstrip('.')
|
||||
ext = Path(result_path).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
|
||||
@@ -358,18 +363,54 @@ def _download_covers(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
|
||||
|
||||
def download_sgdb(index: int, cover_file: str, steam_name: str) -> None:
|
||||
image_type = ("grids", "heroes", "logos", "hgrids")[index]
|
||||
|
||||
def on_url(url: str) -> None:
|
||||
if not url.startswith((
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
)):
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
extension = Path(urlsplit(url).path).suffix.lower()
|
||||
if extension not in (".png", ".jpg", ".jpeg"):
|
||||
logger.warning("Unsupported SGDB artwork format: %s", extension)
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
destination = str(Path(cover_file).with_suffix(extension))
|
||||
downloader.download_async(
|
||||
url, destination, timeout=5,
|
||||
callback=partial(on_cover_download, steam_name=steam_name, index=index),
|
||||
)
|
||||
|
||||
fetch_sgdb_cover_async(game_name, on_url, image_type)
|
||||
|
||||
def on_steam_download(
|
||||
index: int, steam_name: str, cover_file: str, result: str | None
|
||||
) -> None:
|
||||
if result:
|
||||
on_cover_download(result, steam_name, index)
|
||||
else:
|
||||
download_sgdb(index, cover_file, steam_name)
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
|
||||
if not source_appid:
|
||||
download_sgdb(i, cover_file, steam_name)
|
||||
continue
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{source_appid}/{steam_name}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
|
||||
callback=partial(on_steam_download, i, steam_name, cover_file)
|
||||
)
|
||||
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
def add_to_steam(
|
||||
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
|
||||
and download Steam Grid covers with correct sizes and names.
|
||||
@@ -382,7 +423,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if not exe_path:
|
||||
return (False, error)
|
||||
|
||||
if not os.path.exists(exe_path):
|
||||
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
|
||||
if not is_store_uri and not os.path.exists(exe_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -396,7 +438,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
icon_path = _generate_icon(exe_path, portproton_dir)
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(exe_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else exe_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
@@ -471,10 +515,10 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
nonlocal steam_appid
|
||||
steam_appid = game_info.get("appid")
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
|
||||
return
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
|
||||
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
|
||||
|
||||
get_steam_game_info_async(game_name, exec_line, on_game_info)
|
||||
|
||||
@@ -487,10 +531,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
def _delete_cover_files(grid_dir: str, appid: int) -> None:
|
||||
"""Delete cover files for a game."""
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
|
||||
for suffix in ("", "p", "_hero", "_logo")
|
||||
for extension in (".jpg", ".png", ".jpeg")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
|
||||
@@ -397,6 +397,11 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
"""Rebuild auto-install cards after a live library layout change."""
|
||||
if not hasattr(self, "autoInstallContainer"):
|
||||
return
|
||||
if self.stackedWidget.currentIndex() != self.auto_install_tab_index:
|
||||
self._autoinstall_layout_pending = True
|
||||
self.autoInstallLoaded = False
|
||||
return
|
||||
self._autoinstall_layout_pending = False
|
||||
mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
@@ -487,6 +492,9 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
|
||||
def _start_autoinstall_load(self, force_refresh: bool = False) -> None:
|
||||
if getattr(self, "_autoinstall_layout_pending", False):
|
||||
self.refresh_autoinstall_layout()
|
||||
return
|
||||
if self.autoInstallLoaded or self.autoInstallLoading:
|
||||
return
|
||||
if not hasattr(self, "_on_autoinstall_games_loaded"):
|
||||
|
||||
@@ -226,6 +226,7 @@ class MainWindowControlHintsMixin:
|
||||
|
||||
keyboard_hints = [
|
||||
("key_enter", _("Select"), "confirm"),
|
||||
("key_space", _("Select multiple"), None),
|
||||
("key_backspace", _("Back"), "back"),
|
||||
("key_e", _("Add a shortcut"), "add_game"),
|
||||
("key_context", _("Menu"), "context_menu"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -97,7 +97,24 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_library_filter_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.filter_keys):
|
||||
return
|
||||
display_filter = self.filter_keys[index]
|
||||
selected = set(game_config.get_display_filter().split(","))
|
||||
key = self.filter_keys[index]
|
||||
if key in ("all", "favorites"):
|
||||
selected = {key}
|
||||
else:
|
||||
selected -= {"all", "favorites"}
|
||||
selected.symmetric_difference_update({key})
|
||||
display_filter = ",".join(key for key in self.filter_keys if key in selected) or "all"
|
||||
selected = set(display_filter.split(","))
|
||||
combo = self.gamesDisplayCombo
|
||||
combo.setCurrentIndex(self.filter_keys.index(display_filter.split(",")[0]))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
combo.setItemData(row, Qt.CheckState.Checked if source in selected
|
||||
else Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
|
||||
combo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
@@ -152,9 +169,17 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
card_orientation = "vertical"
|
||||
elif layout_mode in {"horizontal", "horizontal_top"}:
|
||||
card_orientation = "horizontal"
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
previous_mode = ui_config.get_library_layout_mode(theme_mode)
|
||||
previous_orientation = ui_config.get_horizontal_card_orientation("theme")
|
||||
ui_config.set_library_layout_mode(layout_mode)
|
||||
ui_config.set_horizontal_card_orientation(card_orientation)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
if (
|
||||
previous_mode == ui_config.get_library_layout_mode(theme_mode)
|
||||
and (previous_mode not in {"horizontal", "horizontal_top"}
|
||||
or previous_orientation == card_orientation)
|
||||
):
|
||||
return
|
||||
self.game_library_manager.rebuild_library_layout(
|
||||
ui_config.get_library_layout_mode(theme_mode)
|
||||
)
|
||||
@@ -348,11 +373,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
display_filter,
|
||||
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
|
||||
)
|
||||
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.setEditable(True)
|
||||
line_edit = self.gamesDisplayCombo.lineEdit()
|
||||
if line_edit is not None:
|
||||
line_edit.setReadOnly(True)
|
||||
line_edit.installEventFilter(self.gamesDisplayCombo)
|
||||
self.gamesDisplayCombo.setStyleSheet(
|
||||
self.gamesDisplayCombo.styleSheet() + self.theme.SOURCE_FILTER_STYLE
|
||||
)
|
||||
selected = set(display_filter.split(","))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
self.gamesDisplayCombo.setItemData(
|
||||
row, Qt.CheckState.Checked if source in selected else Qt.CheckState.Unchecked,
|
||||
Qt.ItemDataRole.CheckStateRole,
|
||||
)
|
||||
self.gamesDisplayCombo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
self.gamesDisplayCombo.activated.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
|
||||
@@ -498,9 +539,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader):
|
||||
if self.gog_api.auth_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
|
||||
) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if self.egs_api.user_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
|
||||
) and self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
@@ -538,8 +583,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for game in missing_games:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#3f424d"/></svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -727,6 +727,25 @@ COMBOBOX_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
SOURCE_FILTER_STYLE = f"""
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {color_surface};
|
||||
}}
|
||||
QListView::indicator {{
|
||||
width: {sourceFilterIndicatorSize}px;
|
||||
height: {sourceFilterIndicatorSize}px;
|
||||
border: {border_thin} {color_border_input};
|
||||
border-radius: {sourceFilterIndicatorRadius}px;
|
||||
background: {color_bg};
|
||||
image: none;
|
||||
}}
|
||||
QListView::indicator:checked {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_text};
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
}}
|
||||
"""
|
||||
|
||||
SCROLL_STYLE = f"""
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
|
||||
@@ -10,6 +10,8 @@ bootAnimationTimeoutMs = 30000
|
||||
cursorHideTimeoutMs = 5000
|
||||
startupLibraryLoadDelayMs = 500
|
||||
autoSizeButtonPadding = (10, 20)
|
||||
sourceFilterIndicatorSize = 18
|
||||
sourceFilterIndicatorRadius = 2
|
||||
favoriteLabelSize = 48, 48
|
||||
favoriteLabelIconSize = 32
|
||||
detailCompactCoverFrameSize = 128
|
||||
@@ -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
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
+79
-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)
|
||||
@@ -102,19 +58,21 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
|
||||
qt_app = MagicMock()
|
||||
qt_app.exec.return_value = 0
|
||||
monkeypatch.setattr(app, "QApplication", MagicMock(return_value=qt_app))
|
||||
lock = MagicMock()
|
||||
lock.tryLock.return_value = True
|
||||
monkeypatch.setattr(QtCore, "QLockFile", MagicMock(return_value=lock))
|
||||
monkeypatch.setattr(QtCore.QStandardPaths, "writableLocation", lambda _location: str(tmp_path))
|
||||
socket = MagicMock()
|
||||
socket.waitForConnected.return_value = False
|
||||
server = MagicMock()
|
||||
monkeypatch.setattr(QtNetwork, "QLocalSocket", MagicMock(return_value=socket))
|
||||
monkeypatch.setattr(QtNetwork, "QLocalServer", server)
|
||||
tray = MagicMock()
|
||||
window = MagicMock(side_effect=RuntimeError("main window reached"))
|
||||
monkeypatch.setattr(app, "run_silent_tray", tray)
|
||||
monkeypatch.setattr(main_window, "MainWindow", window)
|
||||
exe = tmp_path / "my game.exe"
|
||||
exe.touch()
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server,
|
||||
tray=tray, window=window, exe=exe)
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server, lock=lock,
|
||||
window=window, exe=exe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_uri", [False, True])
|
||||
@@ -128,7 +86,6 @@ def test_silent_exe_keeps_main_window_for_emulation(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
assert startup.window.call_args.kwargs["launch_exe"] == str(startup.exe)
|
||||
assert os.environ["MANGOHUD"] == "1"
|
||||
@@ -145,7 +102,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 +153,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,7 +229,6 @@ def test_other_launch_modes_keep_main_window(
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -653,3 +653,19 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
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)
|
||||
@@ -1567,3 +1567,93 @@ 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()
|
||||
@@ -451,6 +451,7 @@ def test_failed_protondb_completion_preserves_library_scroll(
|
||||
) -> None:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import api
|
||||
import portprotonqt.portproton_api as portproton_api
|
||||
|
||||
manager, _ = library_scene
|
||||
manager.rebuild_library_layout(scenario[0])
|
||||
@@ -459,6 +460,7 @@ def test_failed_protondb_completion_preserves_library_scroll(
|
||||
main.getColorPalette_from_pixmap = MagicMock()
|
||||
main.game_library_manager = manager
|
||||
main._get_portproton_desktop_files = lambda: set()
|
||||
monkeypatch.setattr(portproton_api, "get_user_conf_setting", lambda _key: None)
|
||||
monkeypatch.setattr(api, "load_protondb_status", lambda _appid: None)
|
||||
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
|
||||
responses: list[Callable[[str | None], None]] = []
|
||||
@@ -543,19 +545,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()
|
||||
|
||||
|
||||
|
||||
+265
-15
@@ -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
|
||||
@@ -430,7 +434,7 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
|
||||
manager.tray_icon.hide.assert_called_once_with()
|
||||
quit_app.assert_called_once_with()
|
||||
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, False)])
|
||||
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
|
||||
def test_source_ribbon_visibility(
|
||||
monkeypatch: MonkeyPatch,
|
||||
vertical_layout: bool,
|
||||
@@ -787,6 +791,9 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
|
||||
window.auto_size_slider.maximum.return_value = 250
|
||||
window.autoInstallLoading = False
|
||||
window._start_autoinstall_load = MagicMock()
|
||||
window.auto_install_tab_index = 1
|
||||
window.stackedWidget = MagicMock()
|
||||
window.stackedWidget.currentIndex.return_value = 1
|
||||
|
||||
window.refresh_autoinstall_layout()
|
||||
|
||||
@@ -1418,7 +1425,7 @@ def test_library_source_filter_stays_top_aligned_without_checkbox(
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
test_window = cast(Any, window)
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="")
|
||||
test_window.theme = SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE="")
|
||||
test_window._create_library_combo = lambda labels, _tooltip: QComboBox()
|
||||
test_window._set_combo_current_key = lambda *_args: None
|
||||
test_window._register_gamepad_tooltip = lambda *_args: None
|
||||
@@ -2257,10 +2264,12 @@ def test_steam_launch_plays_game_launch_sound(monkeypatch: MonkeyPatch) -> None:
|
||||
|
||||
assert launch_events == ["popen", "game_launch"]
|
||||
|
||||
@mark.parametrize("custom_name", [None, "Edited Local Game"])
|
||||
def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
tmp_config_dir: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: Any,
|
||||
custom_name: str | None,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "Game.exe"
|
||||
exe_path.write_text("", encoding="utf-8")
|
||||
@@ -2276,8 +2285,15 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
window.portproton_location = str(tmp_path)
|
||||
results: list[tuple | None] = []
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
if custom_name:
|
||||
from portprotonqt.config import get_custom_data_dir_name
|
||||
game_dir = custom_data_path / get_custom_data_dir_name(str(exe_path))
|
||||
game_dir.mkdir(parents=True)
|
||||
(game_dir / "metadata.txt").write_text(f"name={custom_name}\n", encoding="utf-8")
|
||||
|
||||
def fake_steam_info(_name: str, _exec_line: str, callback: Any) -> None:
|
||||
assert _name == "Test Game"
|
||||
callback({})
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -2297,10 +2313,9 @@ def test_process_portproton_desktop_calls_callback_without_asset_download(
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] is not None
|
||||
assert results[0][0] == "Test Game"
|
||||
assert results[0][0] == (custom_name or "Test Game")
|
||||
assert results[0][5] == f"portproton {exe_path}"
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
assert not custom_data_path.exists()
|
||||
assert custom_data_path.exists() == bool(custom_name)
|
||||
|
||||
def test_remove_empty_custom_data_dirs_keeps_non_empty_dirs(tmp_config_dir: Path) -> None:
|
||||
custom_data_path = tmp_config_dir.parent / "data" / "PortProtonQt" / "custom_data"
|
||||
@@ -2601,7 +2616,7 @@ def test_horizontal_layout_menu_maps_endless_modes(
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "theme")
|
||||
window = cast(Any, SimpleNamespace(
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE=""),
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=""),
|
||||
_create_library_combo=lambda labels, tooltip: QComboBox(),
|
||||
_set_combo_current_key=MagicMock(),
|
||||
_register_gamepad_tooltip=MagicMock(),
|
||||
@@ -2642,7 +2657,7 @@ def test_library_layout_control_rebuilds_both_libraries(
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.ui_config,
|
||||
"get_library_layout_mode",
|
||||
lambda theme_mode: "grid",
|
||||
MagicMock(side_effect=["list", "grid", "grid"]),
|
||||
)
|
||||
|
||||
LibraryMixin._on_library_layout_changed(window, 1)
|
||||
@@ -2873,15 +2888,22 @@ def test_library_refresh_updates_connected_gog_library(
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
|
||||
@mark.parametrize("display_filter, stores", [
|
||||
("all", ["gog", "egs"]),
|
||||
("gog,portproton", ["gog"]),
|
||||
("steam,portproton", []),
|
||||
])
|
||||
def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
display_filter: str,
|
||||
stores: list[str],
|
||||
) -> None:
|
||||
gog_auth_path = tmp_path / "gog_auth.json"
|
||||
egs_user_path = tmp_path / "egs_user.json"
|
||||
gog_auth_path.touch()
|
||||
egs_user_path.touch()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "all")
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter)
|
||||
monkeypatch.setattr(
|
||||
library_tab_module.QTimer,
|
||||
"singleShot",
|
||||
@@ -2903,11 +2925,7 @@ def test_library_refresh_updates_connected_stores_for_all_filter(
|
||||
|
||||
LibraryMixin.refreshGames(cast(Any, window))
|
||||
|
||||
assert calls == [
|
||||
"gog",
|
||||
"egs",
|
||||
("load", {"force_load": True}),
|
||||
]
|
||||
assert calls == [*stores, ("load", {"force_load": True})]
|
||||
|
||||
def test_library_refresh_skips_gog_for_portproton_filter(
|
||||
tmp_path: Path,
|
||||
@@ -3110,7 +3128,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 +3145,236 @@ 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
|
||||
)
|
||||
|
||||
|
||||
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog,portproton")
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
callbacks = {}
|
||||
loaded = MagicMock()
|
||||
window = cast(Any, SimpleNamespace(
|
||||
_loading_games=False, launch_exe=None, games_loaded=SimpleNamespace(emit=loaded),
|
||||
_load_gog_games_async=lambda callback: callbacks.update(gog=callback),
|
||||
_load_portproton_games_async=lambda callback: callbacks.update(portproton=callback),
|
||||
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
|
||||
))
|
||||
|
||||
MainWindow.loadGames(window, force_load=True)
|
||||
pp_game = ("PP", "", "", "", "", "pp.exe")
|
||||
gog_game = ("GOG", "", "", "", "", "gog://launch/1")
|
||||
callbacks["gog"]([gog_game])
|
||||
loaded.assert_not_called()
|
||||
callbacks["portproton"]([pp_game])
|
||||
|
||||
loaded.assert_called_once_with([pp_game, gog_game])
|
||||
window._load_steam_games_async.assert_not_called()
|
||||
window._load_egs_games_async.assert_not_called()
|
||||
|
||||
|
||||
@mark.parametrize("click_target", ["text", "arrow"])
|
||||
def test_library_source_filter_opens_across_entire_field(
|
||||
click_target: str, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
test_window = cast(Any, window)
|
||||
source_style = "QListView::indicator { image: none; }"
|
||||
test_window.theme = SimpleNamespace(
|
||||
CHECKBOX_STYLE="", SOURCE_FILTER_STYLE=source_style,
|
||||
COMBOBOX_STYLE="QComboBox { combobox-popup: 0; }", SCROLL_STYLE="",
|
||||
)
|
||||
test_window._register_gamepad_tooltip = lambda *_args: None
|
||||
test_window._delay_library_controls_hover_close = lambda: None
|
||||
test_window._position_library_controls_widget = lambda: None
|
||||
test_window._on_only_installed_changed = lambda _checked: None
|
||||
saved = MagicMock()
|
||||
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog")
|
||||
monkeypatch.setattr(game_config, "set_display_filter", saved)
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
controls = QWidget()
|
||||
LibraryMixin._add_library_filter_controls(window, QGridLayout(controls))
|
||||
controls.show()
|
||||
application.processEvents()
|
||||
combo = test_window.gamesDisplayCombo
|
||||
line_edit = combo.lineEdit()
|
||||
option = QStyleOptionComboBox()
|
||||
combo.initStyleOption(option)
|
||||
arrow = combo.style().subControlRect(
|
||||
QStyle.ComplexControl.CC_ComboBox, option, QStyle.SubControl.SC_ComboBoxArrow, combo
|
||||
)
|
||||
global_point = combo.mapToGlobal(
|
||||
line_edit.geometry().center() if click_target == "text" else arrow.center()
|
||||
)
|
||||
target = QApplication.widgetAt(global_point)
|
||||
assert isinstance(target, QWidget) and target is (line_edit if click_target == "text" else combo)
|
||||
assert source_style in combo.styleSheet()
|
||||
|
||||
QTest.mouseMove(target, target.mapFromGlobal(global_point))
|
||||
QTest.mouseClick(target, Qt.MouseButton.LeftButton, pos=target.mapFromGlobal(global_point))
|
||||
application.processEvents()
|
||||
assert combo.view().isVisible()
|
||||
index = combo.model().index(test_window.filter_keys.index("portproton"), 0)
|
||||
QTest.mouseMove(combo.view().viewport(), combo.view().visualRect(index).center())
|
||||
QTest.mouseClick(combo.view().viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=combo.view().visualRect(index).center())
|
||||
application.processEvents()
|
||||
saved.assert_called_once_with("gog,portproton")
|
||||
assert combo.currentText() == "GOG + PortProton"
|
||||
controls.close()
|
||||
|
||||
|
||||
@mark.parametrize("readonly", [True, False])
|
||||
def test_custom_combo_opens_readonly_field_on_mouse_release(readonly: bool) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
combo = CustomComboBox()
|
||||
combo.addItems(["GOG", "PortProton"])
|
||||
combo.setEditable(True)
|
||||
line_edit = combo.lineEdit()
|
||||
assert line_edit is not None
|
||||
line_edit.setReadOnly(readonly)
|
||||
line_edit.installEventFilter(combo)
|
||||
combo.show()
|
||||
application.processEvents()
|
||||
|
||||
QTest.mousePress(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
|
||||
assert not combo.view().isVisible()
|
||||
QTest.mouseRelease(line_edit, Qt.MouseButton.LeftButton, pos=line_edit.rect().center())
|
||||
application.processEvents()
|
||||
assert combo.view().isVisible() is readonly
|
||||
combo.hidePopup()
|
||||
combo.close()
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
+265
-18
@@ -69,16 +69,16 @@ def test_game_card_context_menu_does_not_block_gamepad_polling(monkeypatch) -> N
|
||||
assert app is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id"])
|
||||
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.touch()
|
||||
script_path = tmp_path / "game.sh"
|
||||
|
||||
monkeypatch.setattr(steam_shortcuts, "get_steam_home", lambda: steam_home)
|
||||
monkeypatch.setattr(
|
||||
steam_shortcuts,
|
||||
@@ -91,12 +91,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 +108,15 @@ 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
|
||||
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 +124,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 +171,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 +872,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 +897,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 +994,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 +1465,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 +1488,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 +1510,65 @@ 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"])
|
||||
@pytest.mark.parametrize("batch,result,expected", [
|
||||
(True, (True, "restart"), (0, 0)),
|
||||
(False, (True, "Game added to Steam via CEF API"), (0, 0)),
|
||||
@@ -1492,17 +1576,24 @@ 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)
|
||||
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 != "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 +1644,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 +1694,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:
|
||||
@@ -2436,3 +2569,117 @@ 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"]
|
||||
@@ -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]
|
||||
+118
-1
@@ -3,12 +3,15 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import orjson
|
||||
from PySide6.QtCore import QEventLoop, QThread, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from pytest import MonkeyPatch, mark
|
||||
from shiboken6 import delete
|
||||
|
||||
@@ -20,6 +23,7 @@ from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin
|
||||
from portprotonqt.tabs.download_tab import MainWindowDownloadTabMixin as GOGMixin
|
||||
import portprotonqt.main_window as main_window_module
|
||||
import portprotonqt.tabs.download_tab as download_tab_module
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
|
||||
@mark.parametrize("source", ("gog", "egs"))
|
||||
@@ -103,6 +107,83 @@ def test_store_dlc_selection_remains_available_during_download(
|
||||
assert window.store_dlc_worker is worker
|
||||
|
||||
|
||||
@mark.parametrize("source", ("gog", "egs"))
|
||||
@mark.parametrize("failed", (False, True))
|
||||
def test_dlc_worker_released_before_modal_and_retry(
|
||||
monkeypatch: MonkeyPatch, source: str, failed: bool
|
||||
) -> None:
|
||||
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
|
||||
loop = QEventLoop()
|
||||
observations = []
|
||||
|
||||
class Window(GOGMixin, QMainWindow):
|
||||
def _on_store_dlcs_loaded(self, source: str, game: dict, dlcs: list[dict]) -> None:
|
||||
observations.append((self.store_dlc_worker, QThread.currentThread()))
|
||||
dialog = download_tab_module.QDialog(self)
|
||||
QTimer.singleShot(0, dialog.reject)
|
||||
dialog.exec()
|
||||
loop.quit()
|
||||
|
||||
window: Any = Window()
|
||||
window.gog_api = cast(Any, object())
|
||||
window.egs_api = cast(Any, object())
|
||||
|
||||
def run(worker: download_tab_module.StoreDLCWorker) -> None:
|
||||
if failed:
|
||||
worker.failed.emit("failed")
|
||||
else:
|
||||
worker.loaded.emit([])
|
||||
|
||||
def warning(*args: Any) -> None:
|
||||
observations.append((window.store_dlc_worker, QThread.currentThread()))
|
||||
loop.quit()
|
||||
|
||||
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
|
||||
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
|
||||
timeout = QTimer()
|
||||
timeout.setSingleShot(True)
|
||||
timeout.timeout.connect(loop.quit)
|
||||
for _attempt in range(2):
|
||||
window._select_store_dlcs(source, {"app_id": "123", "title": "Game"})
|
||||
timeout.start(3000)
|
||||
loop.exec()
|
||||
timeout.stop()
|
||||
assert window.store_dlc_worker is None
|
||||
assert observations == [(None, application.thread())] * 2
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_dlc_worker_shutdown_suppresses_picker(monkeypatch: MonkeyPatch) -> None:
|
||||
application = download_tab_module.QApplication.instance() or download_tab_module.QApplication([])
|
||||
|
||||
class Window(GOGMixin, QMainWindow, MainWindowWorkersMixin):
|
||||
pass
|
||||
|
||||
window: Any = Window()
|
||||
window.gog_api = cast(Any, object())
|
||||
window._on_store_dlcs_loaded = MagicMock()
|
||||
warning = MagicMock()
|
||||
started = Event()
|
||||
|
||||
def run(worker: download_tab_module.StoreDLCWorker) -> None:
|
||||
started.set()
|
||||
while not worker.isInterruptionRequested():
|
||||
Event().wait(0.001)
|
||||
|
||||
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
|
||||
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
|
||||
window._select_store_dlcs("gog", {"app_id": "123", "title": "Game"})
|
||||
worker = window.store_dlc_worker
|
||||
assert started.wait(3)
|
||||
window._stopWorkerThread("store_dlc_worker", None)
|
||||
assert not worker.isRunning()
|
||||
application.processEvents()
|
||||
assert window.store_dlc_worker is None
|
||||
window._on_store_dlcs_loaded.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_store_download_queues_reject_duplicates() -> None:
|
||||
active = {"app_id": "active", "title": "Active"}
|
||||
queued = {"app_id": "queued", "title": "Queued"}
|
||||
@@ -920,15 +1001,19 @@ def test_installed_filter_excludes_uninstalled_gog_games(
|
||||
get_launch_target=lambda app_id: f"/games/{app_id}/game.exe",
|
||||
)
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
|
||||
loaded_metadata = []
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.get_steam_game_info_async",
|
||||
lambda _name, _uri, callback: callback({}),
|
||||
lambda name, _uri, callback: (
|
||||
loaded_metadata.append(name) or callback({})
|
||||
),
|
||||
)
|
||||
results = []
|
||||
|
||||
MainWindow._load_gog_games_async(window, results.append)
|
||||
|
||||
assert [game[3] for game in results[0]] == ["installed"]
|
||||
assert loaded_metadata == ["Installed"]
|
||||
assert results[0][0][5] == "gog://launch/installed"
|
||||
|
||||
def test_installed_filter_disabled_includes_uninstalled_gog_games(
|
||||
@@ -1052,6 +1137,38 @@ def test_egs_library_uses_steam_description_when_epic_has_title_only(
|
||||
|
||||
assert results[0][0][1] == "Witch It — игра в прятки по сети."
|
||||
|
||||
|
||||
def test_installed_filter_skips_uninstalled_egs_metadata(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
library = [
|
||||
{"app_id": "installed", "title": "Installed"},
|
||||
{"app_id": "uninstalled", "title": "Uninstalled"},
|
||||
]
|
||||
installed_data = {"installed": {}}
|
||||
api = SimpleNamespace(
|
||||
load_library=lambda: library,
|
||||
load_installed=lambda: installed_data,
|
||||
is_game_installed=lambda app_id, data: app_id in data,
|
||||
get_launch_target=lambda app_id, _data: f"/games/{app_id}.exe",
|
||||
)
|
||||
loaded_metadata = []
|
||||
monkeypatch.setattr(game_config, "get_only_installed", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.get_steam_game_info_async",
|
||||
lambda name, _uri, callback: (
|
||||
loaded_metadata.append(name) or callback({})
|
||||
),
|
||||
)
|
||||
results = []
|
||||
|
||||
MainWindow._load_egs_games_async(
|
||||
cast(MainWindow, SimpleNamespace(egs_api=api)), results.append
|
||||
)
|
||||
|
||||
assert loaded_metadata == ["Installed"]
|
||||
assert [game[3] for game in results[0]] == ["installed"]
|
||||
|
||||
def test_egs_maintenance_uses_legendary_commands() -> None:
|
||||
start_operation = MagicMock()
|
||||
window = cast(MainWindow, SimpleNamespace(_start_egs_operation=start_operation))
|
||||
|
||||
Reference in new issue
Block a user