Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83097b2d3a
|
No files matched your search
@@ -31,24 +31,9 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -89,24 +74,9 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
|
||||
@@ -24,24 +24,9 @@ jobs:
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install CI dependencies
|
||||
|
||||
@@ -83,24 +83,9 @@ jobs:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -213,24 +198,9 @@ jobs:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
@@ -22,7 +20,6 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
|
||||
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
|
||||
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
|
||||
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
|
||||
@@ -36,13 +33,9 @@
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
@@ -59,14 +52,12 @@
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
- При выключенной опции «Fix video in game» больше не блокируется создание DXGI Device Manager в Wine/Proton
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @iodnoorov
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1572,6 +1572,7 @@ pw_kill_autostart () {
|
||||
export -f pw_kill_autostart
|
||||
|
||||
stop_portproton () {
|
||||
pw_remove_session_env
|
||||
[[ "$int_xneur" == "1" ]] && xneur &
|
||||
|
||||
if [[ "$PW_USE_US_LAYOUT" == "1" ]] \
|
||||
@@ -2999,7 +3000,7 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
|
||||
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
|
||||
else unset WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER
|
||||
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
|
||||
fi
|
||||
|
||||
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
|
||||
@@ -3845,6 +3846,7 @@ start_portproton () {
|
||||
pw_mangohud_check
|
||||
pw_vkbasalt_check
|
||||
pw_lsfg_vk_check
|
||||
pw_save_session_env || print_warning "Could not save Wine session environment"
|
||||
}
|
||||
|
||||
pw_run () {
|
||||
@@ -3996,6 +3998,80 @@ pw_run () {
|
||||
}
|
||||
export -f pw_run
|
||||
|
||||
pw_save_session_env () {
|
||||
local session_dir="${PORT_DATA_PATH}/data/tmp/wine-session-$(id -u)"
|
||||
local session_tmp
|
||||
|
||||
( umask 077; mkdir -p "$session_dir" ) || return 1
|
||||
[[ ! -L "$session_dir" && -O "$session_dir" ]] || return 1
|
||||
[[ "$(stat -c %a "$session_dir")" == 700 ]] || return 1
|
||||
|
||||
session_tmp="$(mktemp "$session_dir/env.XXXXXX")" || return 1
|
||||
if ( export PW_TERM; export -p > "$session_tmp" ) \
|
||||
&& mv -fT "$session_tmp" "$session_dir/session.env" ; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -f "$session_tmp"
|
||||
return 1
|
||||
}
|
||||
|
||||
pw_remove_session_env () {
|
||||
local session_dir="${PORT_DATA_PATH}/data/tmp/wine-session-$(id -u)"
|
||||
[[ ! -L "$session_dir" && -O "$session_dir" ]] || return 0
|
||||
local session_env="$session_dir/session.env"
|
||||
[[ ! -L "$session_env" && -O "$session_env" ]] || return 0
|
||||
if grep -qsx "declare -x PW_START_PID=\"${PW_START_PID}\"" "$session_env" ; then
|
||||
rm -f "$session_env"
|
||||
fi
|
||||
}
|
||||
|
||||
pw_session_tool () {
|
||||
local tool="$1" session_pid session_env
|
||||
local session_dir="${PORT_DATA_PATH}/data/tmp/wine-session-$(id -u)"
|
||||
local session_wait_seconds="${2:-30}" wait_left
|
||||
wait_left="$session_wait_seconds"
|
||||
|
||||
case "$tool" in
|
||||
winefile|taskmgr|winecfg|regedit|cmd|uninstaller) ;;
|
||||
*) print_error "Unsupported Wine tool: $tool" ; return 1 ;;
|
||||
esac
|
||||
|
||||
session_env="$session_dir/session.env"
|
||||
while (( wait_left > 0 )) ; do
|
||||
if [[ ! -L "$session_dir" && -O "$session_dir" ]] \
|
||||
&& [[ "$(stat -c %a "$session_dir")" == 700 ]] \
|
||||
&& [[ ! -L "$session_env" && -O "$session_env" ]] \
|
||||
&& [[ "$(stat -c %a "$session_env")" == 600 ]] \
|
||||
&& session_pid="$(sed -n 's/^declare -x PW_START_PID="\([0-9]*\)"$/\1/p' "$session_env")" \
|
||||
&& [[ -n "$session_pid" ]] \
|
||||
&& kill -0 "$session_pid" 2>/dev/null ; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
(( --wait_left ))
|
||||
done
|
||||
|
||||
if (( wait_left == 0 )) ; then
|
||||
print_error "Wine session unavailable after ${session_wait_seconds} seconds"
|
||||
return 2
|
||||
fi
|
||||
|
||||
source "$session_env" || return 1
|
||||
PATH_TO_GAME="${WINEPREFIX}/drive_c"
|
||||
[[ -d "$PATH_TO_GAME" ]] || return 1
|
||||
|
||||
unset LAUNCH_PARAMETERS PW_RUN_AFTER_EXE PW_RUN_GAMESCOPE PW_USE_TERMINAL
|
||||
PW_TMPFS_PATH="$(mktemp -d "$session_dir/tool.XXXXXX")" || return 1
|
||||
export PW_TMPFS_PATH
|
||||
export PW_LOG_FILE="$PW_TMPFS_PATH/wine.log"
|
||||
|
||||
[[ "$tool" == cmd ]] && PW_USE_TERMINAL=1
|
||||
[[ "$tool" == winecfg || "$tool" == regedit ]] && export GST_PLUGIN_SYSTEM_PATH_1_0=""
|
||||
pw_run "$tool" || return 1
|
||||
}
|
||||
|
||||
pw_launch_wrapper () {
|
||||
if [[ -z "${PW_RUN_AFTER_EXE:-}" ]] ; then
|
||||
"$@"
|
||||
@@ -4012,25 +4088,6 @@ pw_launch_wrapper () {
|
||||
return
|
||||
fi
|
||||
|
||||
local -a wrapper_scripts=()
|
||||
local wrapper_script
|
||||
local wrapper_scripts_valid=1
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
|
||||
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
|
||||
wrapper_scripts_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
"${wrapper_script}"
|
||||
done
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a wrapper_cmd
|
||||
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
|
||||
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
|
||||
@@ -4212,22 +4269,9 @@ portwine_launch () {
|
||||
fi
|
||||
done
|
||||
else
|
||||
local -a run_after_exes=() run_after_environment=()
|
||||
local run_after_entry run_after_index
|
||||
local run_after_valid=1
|
||||
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
|
||||
run_after_exes=("${PW_RUN_AFTER_EXE}")
|
||||
else
|
||||
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
|
||||
fi
|
||||
for run_after_entry in "${run_after_exes[@]}" ; do
|
||||
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
|
||||
run_after_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ "${run_after_valid}" == 1 ]] \
|
||||
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
@@ -4251,7 +4295,6 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
|
||||
@@ -4270,30 +4313,10 @@ timeout /t ${wait_delay_int} /nobreak
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
EOF
|
||||
|
||||
local run_after_first_win="${run_after_exe_win}"
|
||||
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
|
||||
run_after_entry="${run_after_exes[run_after_index]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${run_after_entry//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
|
||||
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
|
||||
done
|
||||
|
||||
(
|
||||
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
|
||||
export "${run_after_environment[@]}"
|
||||
fi
|
||||
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
)
|
||||
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
|
||||
@@ -67,6 +67,21 @@ export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log"
|
||||
# shellcheck source=/dev/null
|
||||
source "$PORT_SCRIPTS_PATH/functions_helper"
|
||||
|
||||
if [[ "${1:-}" == "cli" && "${2:-}" == "--wine-session-tool" ]] ; then
|
||||
session_unavailable_status=2
|
||||
session_wait_seconds=30
|
||||
[[ -n "${4:-}" && -n "${5:-}" ]] && session_wait_seconds=1
|
||||
if pw_session_tool "${3:-}" "$session_wait_seconds" ; then
|
||||
exit 0
|
||||
else
|
||||
session_tool_status=$?
|
||||
fi
|
||||
if [[ "$session_tool_status" != "$session_unavailable_status" || -z "${4:-}" || -z "${5:-}" ]] ; then
|
||||
python_module dbus_tools notify -i error "Wine" "Failed to start Wine tool: ${3:-}" &>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
export PORT_WINE_TMP_PATH="${PORT_DATA_PATH}/data/tmp"
|
||||
create_new_dir "$PORT_WINE_TMP_PATH"
|
||||
rm -f "$PORT_WINE_TMP_PATH"/*.{exe,msi,tar}*
|
||||
@@ -280,11 +295,7 @@ case "$1" in
|
||||
--show-ppdb Show the content of .ppdb file for specified .exe file
|
||||
--backup-prefix Backup specified prefix to a file
|
||||
--restore-prefix Restore prefix from backup file
|
||||
--winefile Open wine file explorer, requires WINE version and prefix name
|
||||
--winecfg Open wine configuration, requires WINE version and prefix name
|
||||
--winecmd Open wine command prompt, requires WINE version and prefix name
|
||||
--winereg Open wine registry editor, requires WINE version and prefix name
|
||||
--wine_uninstaller Open wine uninstaller, requires WINE version and prefix name
|
||||
--wine-session-tool Open a Wine tool in the current session, or use optional WINE and prefix
|
||||
--winetricks-list-all List winetricks DLLs, fonts, and settings
|
||||
--winetricks-install Install winetricks items, requires WINE version and prefix name
|
||||
--clear_pfx Clear specified prefix, requires WINE version and prefix name
|
||||
@@ -300,7 +311,7 @@ Usage examples:
|
||||
portproton cli --del-user-conf PW_MANGOHUD
|
||||
portproton cli --backup-prefix DEFAULT /path/to/backup/directory
|
||||
portproton cli --restore-prefix /path/to/backup/file.ppack
|
||||
portproton cli --winecfg WINE_LG DEFAULT
|
||||
portproton cli --wine-session-tool winecfg WINE_LG DEFAULT
|
||||
portproton cli --winetricks-list-all WINE_LG DEFAULT
|
||||
portproton cli --winetricks-install WINE_LG DEFAULT --no-force corefonts
|
||||
portproton cli --mangohud-preview \"fps,frametime,cpu_temp,gpu_temp\"
|
||||
@@ -462,37 +473,21 @@ Usage examples:
|
||||
pw_unpack_prefix "$2"
|
||||
exit $?
|
||||
;;
|
||||
--winefile)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
--wine-session-tool)
|
||||
tool="$2"
|
||||
case "$tool" in
|
||||
winefile|taskmgr|winecfg|regedit|cmd|uninstaller) ;;
|
||||
*) print_error "Unsupported Wine tool: $tool" ; exit 1 ;;
|
||||
esac
|
||||
get_wine_and_pfx "$3" "$4"
|
||||
start_portproton
|
||||
pw_run winefile
|
||||
stop_portproton
|
||||
;;
|
||||
--winecfg)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
export GST_PLUGIN_SYSTEM_PATH_1_0=""
|
||||
pw_run winecfg
|
||||
stop_portproton
|
||||
;;
|
||||
--winecmd)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
|
||||
PW_USE_TERMINAL=1 pw_run cmd
|
||||
stop_portproton
|
||||
;;
|
||||
--winereg)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
export GST_PLUGIN_SYSTEM_PATH_1_0=""
|
||||
pw_run regedit
|
||||
stop_portproton
|
||||
;;
|
||||
--wine_uninstaller)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
pw_run uninstaller
|
||||
[[ "$tool" == winecfg || "$tool" == regedit ]] && export GST_PLUGIN_SYSTEM_PATH_1_0=""
|
||||
if [[ "$tool" == cmd ]] ; then
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
|
||||
PW_USE_TERMINAL=1 pw_run "$tool"
|
||||
else
|
||||
pw_run "$tool"
|
||||
fi
|
||||
stop_portproton
|
||||
;;
|
||||
--winetricks-list-all)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate shell completion files for bash, zsh, and fish
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -18,8 +17,8 @@ _portprotonqt_completions() {
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# All available options
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,14 +72,6 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
@@ -102,21 +93,13 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
|
||||
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
|
||||
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
|
||||
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
|
||||
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
|
||||
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
|
||||
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
|
||||
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
|
||||
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
|
||||
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
|
||||
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
|
||||
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
|
||||
complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
|
||||
@@ -140,38 +140,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
|
||||
import orjson
|
||||
|
||||
from portprotonqt.cli import edit_game, list_games
|
||||
|
||||
try:
|
||||
if parsed_args.edit_game:
|
||||
result = edit_game(parsed_args.edit_game, parsed_args.settings)
|
||||
elif parsed_args.game_info:
|
||||
games = list_games()
|
||||
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
|
||||
if result is None:
|
||||
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
|
||||
return 1
|
||||
else:
|
||||
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
|
||||
if parsed_args.fields:
|
||||
entries = result if isinstance(result, list) else [result]
|
||||
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
|
||||
result = selected if isinstance(result, list) else selected[0]
|
||||
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
|
||||
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
|
||||
except OSError as error:
|
||||
get_logger(__name__).error("Library command failed: %s", error)
|
||||
return 1
|
||||
except ValueError as error:
|
||||
get_logger(__name__).error("Invalid library command: %s", error)
|
||||
return 1
|
||||
except RuntimeError as error:
|
||||
get_logger(__name__).error("Failed to edit game settings: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
|
||||
@@ -2,7 +2,6 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -18,27 +17,6 @@ def parse_args():
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
library = parser.add_mutually_exclusive_group()
|
||||
library.add_argument(
|
||||
"--list-games", action="store_true",
|
||||
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
|
||||
)
|
||||
library.add_argument(
|
||||
"--game-info", metavar="ID",
|
||||
help="Print one game as JSON using its source-qualified ID from --list-games"
|
||||
)
|
||||
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
|
||||
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
|
||||
help="Set a game setting; repeat to change multiple values with --edit-game")
|
||||
parser.add_argument("--all", dest="all_games", action="store_true",
|
||||
help="Include uninstalled games from cached libraries in --list-games")
|
||||
parser.add_argument("--fields", metavar="FIELD,...",
|
||||
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
|
||||
formatting = parser.add_mutually_exclusive_group()
|
||||
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
|
||||
help="Format JSON with indentation (default)")
|
||||
formatting.add_argument("--raw", dest="pretty", action="store_false",
|
||||
help="Print compact JSON without indentation")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
@@ -128,276 +106,10 @@ def parse_args():
|
||||
args = parser.parse_args()
|
||||
launch_args = []
|
||||
|
||||
if args.settings and not args.edit_game:
|
||||
parser.error("--set requires --edit-game")
|
||||
if args.edit_game and not args.settings:
|
||||
parser.error("--edit-game requires at least one --set KEY=VALUE")
|
||||
if args.all_games and not args.list_games:
|
||||
parser.error("--all requires --list-games")
|
||||
if args.fields is not None:
|
||||
if not (args.list_games or args.game_info):
|
||||
parser.error("--fields requires --list-games or --game-info")
|
||||
args.fields = [field.strip() for field in args.fields.split(",")]
|
||||
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
|
||||
parser.error("--fields requires comma-separated field names without empty entries")
|
||||
args.launch_args = launch_args
|
||||
return args
|
||||
|
||||
|
||||
def _read_game_settings(path: Path) -> dict[str, str]:
|
||||
"""Read literal shell assignments without executing configuration code."""
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
settings: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return settings
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
get_logger(__name__).warning("Failed to read %s: %s", path, error)
|
||||
return settings
|
||||
for line in content.splitlines():
|
||||
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
value = match[2]
|
||||
if any(token in value for token in ("$", "`")):
|
||||
continue
|
||||
try:
|
||||
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) <= 1:
|
||||
settings[match[1]] = parts[0] if parts else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
|
||||
"""Read custom artwork and cached metadata without fetching remote resources."""
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.steam_api.api import get_cached_steam_game_info
|
||||
from portprotonqt.time_utils import (
|
||||
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
|
||||
)
|
||||
|
||||
metadata = get_cached_steam_game_info(name, exec_line)
|
||||
metadata.setdefault("name", name)
|
||||
metadata.setdefault("description", "")
|
||||
metadata.setdefault("cover", "")
|
||||
if not executable:
|
||||
return metadata
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
|
||||
metadata["custom_data_directory"] = str(folder)
|
||||
metadata_file = folder / "metadata.txt"
|
||||
if metadata_file.is_file():
|
||||
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
|
||||
metadata.update({key: value for key, value in translations.items() if value})
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover = folder / f"cover{extension}"
|
||||
if cover.is_file():
|
||||
metadata["cover"] = str(cover)
|
||||
break
|
||||
metadata["custom_icon"] = get_custom_icon_path(executable)
|
||||
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
|
||||
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
|
||||
return metadata
|
||||
|
||||
|
||||
def _get_game_runtime(
|
||||
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
|
||||
) -> dict:
|
||||
"""Export configured runtime values; never source PPDB files or launch Wine."""
|
||||
user_conf = location / "data/user.conf"
|
||||
ppdb = Path(f"{executable}.ppdb") if executable else None
|
||||
settings = _read_game_settings(ppdb) if ppdb else {}
|
||||
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
|
||||
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
|
||||
settings.update(overrides)
|
||||
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
|
||||
prefix = overrides.get("PW_PREFIX_NAME", prefix)
|
||||
resolved_runner = runner
|
||||
if runner == "PROTON_LG":
|
||||
resolved_runner = variables.get("PW_PROTON_LG_VER")
|
||||
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
|
||||
resolved_runner = variables.get("PW_WINE_LG_VER")
|
||||
runner_path = None
|
||||
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
|
||||
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
|
||||
location / "data/dist" / resolved_runner.upper()
|
||||
)
|
||||
elif resolved_runner == "USE_SYSTEM_WINE":
|
||||
wine = shutil.which("wine")
|
||||
runner_path = Path(wine).parent.parent if wine else None
|
||||
wine_directory = runner_path
|
||||
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
|
||||
wine_directory /= "files"
|
||||
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
|
||||
wine_directory / "bin-wow64"
|
||||
).is_dir() else "bin"
|
||||
prefix_path = location / "data/prefixes" / prefix if prefix else None
|
||||
return {
|
||||
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
|
||||
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
|
||||
"runner": resolved_runner,
|
||||
"runner_path": str(runner_path) if runner_path else None,
|
||||
"runner_installed": runner_path.is_dir() if runner_path else False,
|
||||
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
|
||||
"settings": settings, "user_config": str(user_conf),
|
||||
"ppdb_exists": ppdb.is_file() if ppdb else False,
|
||||
}
|
||||
|
||||
|
||||
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Export a cached GOG or Epic library using the existing store backends."""
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
api = GOGAPI() if source == "gog" else EGSAPI()
|
||||
installed = api.load_installed()
|
||||
games = []
|
||||
for record in api.load_library():
|
||||
app_id = str(record.get("app_id", ""))
|
||||
if not app_id:
|
||||
continue
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
|
||||
metadata = _get_game_metadata(name, target, target) if target else {}
|
||||
metadata.pop("name", None)
|
||||
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
|
||||
games.append({
|
||||
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
|
||||
**metadata,
|
||||
"id": f"{source}:{app_id}", "source": source, "name": name,
|
||||
"description": record.get("description") or metadata.get("description", ""),
|
||||
"cover": cover or record.get("cover") or metadata.get("cover", ""),
|
||||
"icon": icon, "installed": is_installed,
|
||||
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
|
||||
"executable": target, "directory": str(Path(target).parent) if target else None,
|
||||
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_steam_games() -> list[dict]:
|
||||
"""Export installed Steam games and their locally cached metadata."""
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import get_steam_installed_games
|
||||
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
|
||||
|
||||
games = []
|
||||
for name, appid, last_played, playtime in get_steam_installed_games():
|
||||
metadata = get_cached_full_steam_game_info(appid, name)
|
||||
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"steam:{appid}", "source": "steam", "name": name,
|
||||
"cover": cover or metadata.get("cover", ""),
|
||||
"installed": True, "exec": f"steam://rungameid/{appid}",
|
||||
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Return local PortProton shortcuts without launching games or downloading metadata."""
|
||||
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
|
||||
|
||||
if not location:
|
||||
return []
|
||||
games = []
|
||||
for desktop_path in sorted(Path(location).glob("*.desktop")):
|
||||
entry = parse_desktop_entry(str(desktop_path))
|
||||
if entry is None:
|
||||
continue
|
||||
exec_line = entry.get("Exec", "")
|
||||
executable = extract_exec_target_path(exec_line)
|
||||
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
|
||||
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"portproton:{desktop_path.name}", "source": "portproton",
|
||||
"installed": bool(executable and Path(executable).is_file()),
|
||||
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
|
||||
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
|
||||
"desktop_file": str(desktop_path),
|
||||
"exec": exec_line,
|
||||
"executable": executable,
|
||||
"directory": str(Path(executable).parent) if executable else None,
|
||||
"icon": icon,
|
||||
"ppdb": f"{executable}.ppdb" if executable else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def list_games() -> list[dict]:
|
||||
"""Return locally available library data from all supported game sources."""
|
||||
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
|
||||
|
||||
location = get_portproton_location()
|
||||
scripts = get_portproton_scripts_path()
|
||||
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
|
||||
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
|
||||
games = _list_portproton_games(location, variables, overrides)
|
||||
games.extend(_list_steam_games())
|
||||
for source in ("gog", "egs"):
|
||||
games.extend(_list_store_games(source, location, variables, overrides))
|
||||
return games
|
||||
|
||||
|
||||
def edit_game(game_id: str, changes: list[str]) -> dict:
|
||||
"""Update a game's PPDB through PortProton and verify the saved settings."""
|
||||
import subprocess
|
||||
|
||||
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
|
||||
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
|
||||
|
||||
game = next((game for game in list_games() if game["id"] == game_id), None)
|
||||
if game is None:
|
||||
raise ValueError(f"Game not found: {game_id}")
|
||||
executable = game.get("executable")
|
||||
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
|
||||
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
|
||||
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
|
||||
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
|
||||
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
|
||||
values: dict[str, str] = {}
|
||||
for change in changes:
|
||||
key, separator, value = change.partition("=")
|
||||
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
|
||||
raise ValueError(f"Unknown or invalid game setting: {key}")
|
||||
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
|
||||
raise ValueError(f"Unsupported shell syntax in setting: {key}")
|
||||
if key in toggles and value not in {"0", "1"}:
|
||||
raise ValueError(f"{key} must be 0 or 1")
|
||||
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
|
||||
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
|
||||
values[key] = value
|
||||
if not values:
|
||||
raise ValueError("At least one setting is required")
|
||||
command = get_portproton_start_command()
|
||||
if not command:
|
||||
raise ValueError("PortProton start.sh was not found")
|
||||
result = subprocess.run(
|
||||
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
|
||||
capture_output=True, text=True, errors="replace", check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
|
||||
saved = _read_game_settings(Path(f"{executable}.ppdb"))
|
||||
if any(saved.get(key) != value for key, value in values.items()):
|
||||
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
|
||||
return next(game for game in list_games() if game["id"] == game_id)
|
||||
|
||||
|
||||
def add_steam_compat_tool(force_install: bool = False) -> bool:
|
||||
"""
|
||||
Add PortProtonQt as a Steam compatibility tool.
|
||||
|
||||
@@ -607,9 +607,7 @@ class FileExplorer(DraggableDialog):
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
if callable(self.file_filter):
|
||||
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
|
||||
elif isinstance(self.file_filter, str):
|
||||
if isinstance(self.file_filter, str):
|
||||
files = [f for f in files if f.lower().endswith(self.file_filter)]
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
@@ -352,9 +352,14 @@ class ProtonManager(DraggableDialog):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
|
||||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if hasattr(self, 'tab_widget'):
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -674,7 +679,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
|
||||
@@ -290,11 +290,6 @@ class ExeSettingsDialog(
|
||||
"""Resolve run-after executable relative to the main executable."""
|
||||
if not exe_path:
|
||||
return exe_path
|
||||
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
|
||||
return ",".join(
|
||||
self._resolve_run_after_exe_path(path.strip())
|
||||
for path in exe_path.split(",") if path.strip()
|
||||
)
|
||||
normalized = os.path.normpath(os.path.expanduser(exe_path))
|
||||
if os.path.isabs(normalized):
|
||||
return normalized
|
||||
@@ -305,70 +300,6 @@ class ExeSettingsDialog(
|
||||
return normalized
|
||||
return os.path.normpath(os.path.join(game_dir, normalized))
|
||||
|
||||
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
row = next(index for index in range(table.rowCount())
|
||||
if table.cellWidget(index, 1) is target.parentWidget())
|
||||
expanded = bool(target.property("ppqt_run_after_expanded"))
|
||||
target.setProperty("ppqt_run_after_expanded", not expanded)
|
||||
if expanded:
|
||||
while row + 1 < table.rowCount():
|
||||
item = table.item(row + 1, 0)
|
||||
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
|
||||
break
|
||||
table.removeRow(row + 1)
|
||||
return
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
if not target.text():
|
||||
entries = [""]
|
||||
for index, path in enumerate(entries):
|
||||
entry_row = row + index + 1
|
||||
table.insertRow(entry_row)
|
||||
item = QTableWidgetItem(path)
|
||||
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
|
||||
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
table.setItem(entry_row, 0, item)
|
||||
entry_edit = QLineEdit(path)
|
||||
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
entry_edit.installEventFilter(self)
|
||||
entry_edit.textChanged.connect(
|
||||
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
|
||||
)
|
||||
table.setCellWidget(entry_row, 0, entry_edit)
|
||||
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
|
||||
)
|
||||
table.setCellWidget(entry_row, 1, button)
|
||||
table.resizeRowsToContents()
|
||||
|
||||
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
|
||||
entries = target.text().split(",")
|
||||
if index < len(entries):
|
||||
entries[index] = value
|
||||
target.setText(",".join(entries))
|
||||
|
||||
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
entries.pop(index)
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setText(",".join(entries))
|
||||
if entries:
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _clear_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
for row in reversed(range(table.rowCount())):
|
||||
item = table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
|
||||
table.removeRow(row)
|
||||
target.setProperty("ppqt_run_after_expanded", False)
|
||||
target.clear()
|
||||
if target.isVisible():
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _get_setting_file_selector_path(self, current_path: str) -> str:
|
||||
"""Get initial path for setting file selectors."""
|
||||
initial_path = os.path.expanduser("~")
|
||||
@@ -472,7 +403,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, 230)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
@@ -1020,12 +951,10 @@ class ExeSettingsDialog(
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
container_layout = QVBoxLayout(text_container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.addWidget(line_edit)
|
||||
line_edit.hide()
|
||||
text_layout = QHBoxLayout()
|
||||
container_layout.addLayout(text_layout)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
text_layout.setContentsMargins(0, 0, 0, 0)
|
||||
text_layout.setSpacing(6)
|
||||
text_layout.addWidget(line_edit)
|
||||
|
||||
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -1037,38 +966,26 @@ class ExeSettingsDialog(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
file_filter="",
|
||||
):
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = self._get_setting_file_selector_path(
|
||||
target_line_edit.text().strip()
|
||||
)
|
||||
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
file_filter=file_filter,
|
||||
initial_path=self._get_setting_file_selector_path(
|
||||
target_line_edit.text().rsplit(",", 1)[-1].strip()
|
||||
),
|
||||
initial_path=initial_path,
|
||||
)
|
||||
file_explorer.file_signal.file_selected.connect(
|
||||
lambda path, target=target_line_edit: target.setText(
|
||||
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
|
||||
)
|
||||
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
expand_button = AutoSizeButton("≡")
|
||||
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
expand_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
|
||||
)
|
||||
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
|
||||
)
|
||||
text_layout.addWidget(clear_button)
|
||||
text_layout.addWidget(expand_button)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
@@ -1815,20 +1732,15 @@ class ExeSettingsDialog(
|
||||
item = table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
widget = table.cellWidget(row, 0)
|
||||
if widget is not None and not isinstance(widget, QLabel):
|
||||
continue
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
if text is None:
|
||||
text = item.text()
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
label = cast(QLabel | None, table.cellWidget(row, 0))
|
||||
if label is None:
|
||||
text = item.text()
|
||||
label = QLabel()
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setWordWrap(True)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
table.setCellWidget(row, 0, label)
|
||||
label.setAutoFillBackground(False)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
@@ -1843,6 +1755,7 @@ class ExeSettingsDialog(
|
||||
label.setPalette(palette)
|
||||
if label.foregroundRole() != QPalette.ColorRole.Text:
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
name, separator, description = text.partition("\n")
|
||||
formatted_text = escape(name)
|
||||
if separator:
|
||||
|
||||
@@ -498,7 +498,7 @@ def load_pixmap_async(
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
|
||||
@@ -86,8 +86,9 @@ class MouseEmulationThread(QThread):
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self._device = ui
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
@@ -213,6 +214,10 @@ class InputManager(
|
||||
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
self.nav_timer = QTimer(self)
|
||||
@@ -260,8 +265,6 @@ class InputManager(
|
||||
|
||||
def _on_mouse_emulation_finished(self, success: bool):
|
||||
"""Handle mouse emulation thread completion."""
|
||||
if not self.mouse_emulation_enabled:
|
||||
return
|
||||
if success and self.mouse_emulation_thread:
|
||||
device = self.mouse_emulation_thread.get_device()
|
||||
if device:
|
||||
@@ -278,22 +281,6 @@ class InputManager(
|
||||
self.emulation_active = self.mouse_emulation_enabled
|
||||
if not self.emulation_active:
|
||||
self.emulation_triggered = False
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
return
|
||||
if self.emulation_triggered:
|
||||
if self.ui is None:
|
||||
self._async_enable_mouse_emulation()
|
||||
elif self.ui is not None:
|
||||
try:
|
||||
self.ui.close()
|
||||
except Exception as e:
|
||||
logger.error("EMUL: Error closing virtual mouse: %s", e)
|
||||
self.ui = None
|
||||
self.mouse_emulation_thread = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
|
||||
"""Common navigation logic for game cards in a container."""
|
||||
@@ -427,10 +414,8 @@ class InputManager(
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread:
|
||||
if thread and thread.isRunning():
|
||||
thread.wait()
|
||||
if self.ui is None:
|
||||
self.ui = thread.get_device()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
|
||||
@@ -476,14 +476,7 @@ class SettingsInputMixin(InputMixin):
|
||||
if table != self.settings_dialog.advanced_table:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
if isinstance(entry_widget, QLineEdit):
|
||||
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QPushButton):
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if isinstance(cell_widget, QComboBox):
|
||||
line_edit = cell_widget.lineEdit()
|
||||
if cell_widget.isEditable() and line_edit is not None:
|
||||
@@ -499,33 +492,15 @@ class SettingsInputMixin(InputMixin):
|
||||
and cell_widget.property("ppqt_run_after_exe_widget")
|
||||
):
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
|
||||
if line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
if button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
|
||||
"""Move focus between inline line edit and button in settings cell."""
|
||||
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
for row in range(table.rowCount()):
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
delete_button = table.cellWidget(row, 1)
|
||||
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
|
||||
continue
|
||||
if focused not in (entry_widget, delete_button):
|
||||
continue
|
||||
widget = delete_button if focused == entry_widget else entry_widget
|
||||
if not widget.isEnabled():
|
||||
return False
|
||||
table.setCurrentCell(row, 1)
|
||||
widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if table.currentColumn() != 1:
|
||||
return False
|
||||
|
||||
@@ -533,14 +508,15 @@ class SettingsInputMixin(InputMixin):
|
||||
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
|
||||
return False
|
||||
|
||||
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
|
||||
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
focused = QApplication.focusWidget()
|
||||
if focused in widgets:
|
||||
index = widgets.index(focused) + (1 if value > 0 else -1)
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value > 0 and focused == line_edit and button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focus_first_row_in_current_settings_table(self):
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"PO-Revision-Date: 2026-10-17 23:27+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -32,8 +32,6 @@ msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
|
||||
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
@@ -151,8 +149,9 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar vários"
|
||||
msgstr "Selecionar Tudo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
@@ -482,13 +481,9 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
|
||||
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
|
||||
"caracteres cirílicos:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Não lembrar novamente"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
@@ -969,19 +964,19 @@ msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Verificado: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Verificando os atalhos existentes: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Adicionando: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar atalho: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
@@ -998,8 +993,9 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Ícone personalizado:"
|
||||
msgstr "Capa Personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
@@ -1013,28 +1009,29 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Selecionar pasta"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Processando jogos…"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Processados: {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "Nenhum jogo encontrado na pasta selecionada."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Selecionar jogos"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
@@ -2404,7 +2401,7 @@ msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Desativar aviso de caminho em cirílico"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-10-26 12:27+0000\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -996,8 +996,9 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательский значок:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Chinese (Simplified Han script) <https://"
|
||||
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid ""
|
||||
@@ -29,7 +29,7 @@ msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
@@ -143,8 +143,9 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "多选"
|
||||
msgstr "全选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
@@ -155,8 +156,9 @@ msgstr "停止游戏"
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "修复"
|
||||
msgstr "已配对"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "从桌面移除"
|
||||
@@ -174,7 +176,7 @@ msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Import"
|
||||
msgstr "导入"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
@@ -258,9 +260,9 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "未找到可执行文件"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
@@ -454,14 +456,14 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "另一个 Epic 操作正在运行中"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
@@ -473,12 +475,9 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
|
||||
"PortProton 移动到不包含西里尔字母字符的路径:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "不再提示"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
@@ -931,19 +930,19 @@ msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "正在扫描:{folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "正在检查现有快捷方式:{path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "正在添加:{name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建快捷方式失败:{name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
@@ -980,22 +979,21 @@ msgstr "图像无效"
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "正在处理游戏…"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"已处理:{completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "在所选文件夹中未找到任何游戏。"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
@@ -1015,10 +1013,10 @@ msgid "No cover selected"
|
||||
msgstr "未选择封面"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "兼容性报告"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr "程序已停止。已自动收集兼容性详细信息。"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
@@ -1244,7 +1242,7 @@ msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "编辑 user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
@@ -1270,8 +1268,9 @@ msgstr "编辑 PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载全局游戏设置失败"
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
@@ -1282,8 +1281,9 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开文件失败:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
@@ -1303,8 +1303,9 @@ msgstr "默认"
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "下载 Wine/Proton…"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
@@ -2103,11 +2104,13 @@ msgstr "全部清除"
|
||||
msgid "Now Downloading"
|
||||
msgstr "正在下载"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "请稍候…"
|
||||
msgstr "警告"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "磁盘: "
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
@@ -2121,10 +2124,10 @@ msgid "Store"
|
||||
msgstr "存储"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr "将链接复制到剪贴板"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr "退出 GOG 账号失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
@@ -2146,27 +2149,27 @@ msgid "Log out"
|
||||
msgstr "注销"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "打开登录页面"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr "未连接 GOG 账号"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "退出 Epic 账号失败"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "将授权码复制到剪贴板"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Epic 授权中…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
@@ -2180,33 +2183,33 @@ msgstr "已连接"
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "选择安装文件夹"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "另一个 GOG 操作正在运行中"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr "未找到 GOG 安装"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "所选文件夹不是此 GOG 游戏"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
@@ -2218,11 +2221,11 @@ msgstr "正在下载… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr "磁盘:{0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr "ETA: {0}"
|
||||
msgstr ",预计剩余时间:{}秒"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
@@ -2276,7 +2279,7 @@ msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "网格"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
@@ -2286,17 +2289,21 @@ msgstr "浅色"
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平块"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平卡片"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平无限块"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平无限卡片"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Layout"
|
||||
@@ -2324,7 +2331,7 @@ msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "账号"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
@@ -2352,7 +2359,7 @@ msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "连接手柄时自动全屏"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "禁用开机动画"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "关闭时最小化到系统托盘"
|
||||
@@ -2367,22 +2374,22 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "隐藏操作提示"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "强制使用英语"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "游戏崩溃后显示兼容性报告"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "禁用西里尔字母路径警告"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "禁用 ALT Linux i586 依赖项检查"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
@@ -2391,7 +2398,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "禁用 Steam Proton 扫描"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
@@ -2480,7 +2487,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr "AppImage 已集成于:{path}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
@@ -2628,12 +2635,13 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "删除主题“{0}”?"
|
||||
msgstr "选择主题:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "删除主题失败。"
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
@@ -1869,11 +1869,11 @@ def build_command(args: argparse.Namespace) -> list[str]:
|
||||
"""Build the command list to execute in the PTY."""
|
||||
if args.e:
|
||||
if len(args.e) == 1:
|
||||
command = args.e[0]
|
||||
# functions_helper is bash-only, user shell may be zsh or fish
|
||||
if _needs_portproton_functions(command):
|
||||
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
|
||||
return [default_shell(), "-lc", command]
|
||||
return [
|
||||
default_shell(),
|
||||
"-lc",
|
||||
_with_portproton_functions(args.e[0]),
|
||||
]
|
||||
return args.e
|
||||
return [default_shell()]
|
||||
|
||||
|
||||
@@ -160,11 +160,12 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
tools_grid.setSpacing(6)
|
||||
|
||||
tools = [
|
||||
("--winecfg", _("Wine Configuration")),
|
||||
("--winereg", _("Registry Editor")),
|
||||
("--winefile", _("File Explorer")),
|
||||
("--winecmd", _("Command Prompt")),
|
||||
("--wine_uninstaller", _("Uninstaller")),
|
||||
("winecfg", _("Wine Configuration")),
|
||||
("regedit", _("Registry Editor")),
|
||||
("winefile", _("File Explorer")),
|
||||
("cmd", _("Command Prompt")),
|
||||
("uninstaller", _("Uninstaller")),
|
||||
("taskmgr", _("Task Manager")),
|
||||
]
|
||||
|
||||
for i, (tool_cmd, tool_name) in enumerate(tools):
|
||||
@@ -237,7 +238,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
env_vars = os.environ.copy()
|
||||
self._check_missing_prefix_by_name_before_launch(prefix, env_vars)
|
||||
start_sh = self.start_sh
|
||||
cmd = start_sh + ["cli", cli_arg, wine, prefix]
|
||||
cmd = start_sh + ["cli", "--wine-session-tool", cli_arg, wine, prefix]
|
||||
|
||||
# Show progress bar before launch
|
||||
self.wine_progress_bar.setVisible(True)
|
||||
@@ -269,16 +270,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _check_wine_process(self, cli_arg):
|
||||
"""Check if target .exe process started."""
|
||||
exe_map = {
|
||||
"--winecfg": "winecfg.exe",
|
||||
"--winereg": "regedit.exe",
|
||||
"--winefile": "winefile.exe",
|
||||
"--winecmd": "cmd.exe",
|
||||
"--wine_uninstaller": "uninstaller.exe",
|
||||
}
|
||||
target_exe = exe_map.get(cli_arg, "")
|
||||
if not target_exe:
|
||||
return
|
||||
target_exe = f"{cli_arg}.exe"
|
||||
|
||||
# Check processes via psutil
|
||||
for proc in psutil.process_iter(attrs=["name"]):
|
||||
|
||||
@@ -370,5 +370,3 @@ WINETRICKS_CHECK_COLUMN_WIDTH = 80
|
||||
WINETRICKS_ROW_HEIGHT = 36
|
||||
|
||||
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
|
||||
|
||||
RUN_AFTER_VALUE_WIDTH = 230
|
||||
@@ -3,6 +3,7 @@ import subprocess
|
||||
import signal
|
||||
import psutil
|
||||
import os
|
||||
from PySide6.QtCore import QProcess
|
||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox
|
||||
from PySide6.QtGui import QIcon, QAction
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -45,6 +46,39 @@ def _restart_args(args: list[str]) -> list[str]:
|
||||
return restart_args
|
||||
|
||||
|
||||
def create_wine_tools_menu(parent: QMenu, start_sh: list[str]) -> QMenu:
|
||||
"""Create tools that join the running game's Wine session."""
|
||||
menu = QMenu("Wine", parent)
|
||||
tools = (
|
||||
("winefile", _("File Explorer")),
|
||||
("taskmgr", _("Task Manager")),
|
||||
("winecfg", _("Wine Configuration")),
|
||||
("regedit", _("Registry Editor")),
|
||||
("cmd", _("Command Prompt")),
|
||||
("uninstaller", _("Uninstaller")),
|
||||
)
|
||||
for tool, title in tools:
|
||||
action = menu.addAction(title)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, name=tool: launch_session_wine_tool(start_sh, name)
|
||||
)
|
||||
return menu
|
||||
|
||||
|
||||
def launch_session_wine_tool(start_sh: list[str], tool: str) -> None:
|
||||
"""Start a Wine tool without running the game's startup or cleanup."""
|
||||
if not start_sh:
|
||||
logger.error("Cannot launch Wine tool: PortProton command is missing")
|
||||
QMessageBox.warning(None, _("Error"), _("Failed to start process."))
|
||||
return
|
||||
started, _pid = QProcess.startDetached(
|
||||
start_sh[0], start_sh[1:] + ["cli", "--wine-session-tool", tool]
|
||||
)
|
||||
if not started:
|
||||
logger.error("Failed to start Wine tool %s", tool)
|
||||
QMessageBox.warning(None, _("Error"), _("Failed to start process."))
|
||||
|
||||
|
||||
class TrayManager:
|
||||
"""Tray management module for PortProtonQt.
|
||||
|
||||
@@ -83,8 +117,12 @@ class TrayManager:
|
||||
self.pause_game_action.setEnabled(False)
|
||||
self.pause_game_action.triggered.connect(self.toggle_game_pause)
|
||||
|
||||
self.wine_tools_menu = create_wine_tools_menu(self.tray_menu, self.main_window.start_sh)
|
||||
self.wine_tools_menu.setEnabled(False)
|
||||
|
||||
self.tray_menu.addAction(self.pause_game_action)
|
||||
self.tray_menu.addAction(self.stop_game_action)
|
||||
self.tray_menu.addMenu(self.wine_tools_menu)
|
||||
self.tray_menu.addSeparator()
|
||||
self.tray_menu.addSeparator()
|
||||
|
||||
@@ -103,6 +141,7 @@ class TrayManager:
|
||||
|
||||
self.tray_menu.addAction(self.pause_game_action)
|
||||
self.tray_menu.addAction(self.stop_game_action)
|
||||
self.tray_menu.addMenu(self.wine_tools_menu)
|
||||
self.update_game_actions()
|
||||
if self.minimal_mode:
|
||||
return
|
||||
@@ -151,6 +190,7 @@ class TrayManager:
|
||||
target_exe = getattr(self.main_window, "target_exe", None)
|
||||
has_running_game = bool(game_processes or target_exe)
|
||||
self.stop_game_action.setEnabled(has_running_game)
|
||||
self.wine_tools_menu.setEnabled(has_running_game)
|
||||
self.pause_game_action.setEnabled(bool(processes))
|
||||
paused = self._are_processes_paused(processes)
|
||||
self.pause_game_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
|
||||
@@ -177,9 +177,6 @@ def test_app_restarts_installed_appimage_after_integration(
|
||||
lambda: SimpleNamespace(
|
||||
debug_level="NOTSET",
|
||||
reinstall_steam_compat_tool=True,
|
||||
list_games=False,
|
||||
game_info=None,
|
||||
edit_game=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -408,428 +408,3 @@ class TestParseResolution:
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_resolution("") is None
|
||||
|
||||
|
||||
def test_list_games_json_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
shortcut = tmp_path / "game.desktop"
|
||||
shortcut.write_text(
|
||||
'[Desktop Entry]\nName=Test Game\n'
|
||||
f'Exec=portprotonqt --silent "{executable}"\nIcon=test-icon\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(portproton, "get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {
|
||||
"name": "Test Game", "description": "Description", "cover": "cover.png",
|
||||
"appid": 123, "custom_icon": "",
|
||||
})
|
||||
games = _list_portproton_games(str(tmp_path), {}, {})
|
||||
assert len(games) == 1
|
||||
assert games[0]["description"] == "Description"
|
||||
assert games[0]["cover"] == "cover.png"
|
||||
assert games[0]["appid"] == 123
|
||||
assert not {"metadata", "desktop_entry", "custom_icon"} & games[0].keys()
|
||||
assert {key: games[0][key] for key in (
|
||||
"id", "name", "desktop_file", "exec", "executable", "directory", "icon", "ppdb"
|
||||
)} == {
|
||||
"id": "portproton:game.desktop", "name": "Test Game",
|
||||
"desktop_file": str(shortcut),
|
||||
"exec": f'portprotonqt --silent "{executable}"',
|
||||
"executable": str(executable), "directory": str(tmp_path),
|
||||
"icon": "test-icon", "ppdb": f"{executable}.ppdb",
|
||||
}
|
||||
|
||||
|
||||
def test_list_games_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: None)
|
||||
assert _list_portproton_games(None, {}, {}) == []
|
||||
|
||||
|
||||
def test_parse_list_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games"])
|
||||
assert parse_args().list_games
|
||||
|
||||
|
||||
def test_export_custom_artwork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _get_game_metadata
|
||||
from portprotonqt.config.portproton import resolve_custom_data_dir
|
||||
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_steam_game_info", lambda *args: {
|
||||
"name": "Cached", "description": "Cached description", "appid": "123",
|
||||
})
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_playtime_for_exe", lambda *args: 42)
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_last_launch_timestamp", lambda *args: 100)
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
folder = Path(resolve_custom_data_dir(str(tmp_path / "PortProtonQt/custom_data"), executable))
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "metadata.txt").write_text("name_en=Custom\ndescription_en=Description\n")
|
||||
(folder / "cover.png").touch()
|
||||
(folder / "icon.png").touch()
|
||||
metadata = _get_game_metadata("Original", "launch", executable)
|
||||
assert metadata["name"] == "Custom"
|
||||
assert metadata["description"] == "Description"
|
||||
assert metadata["cover"] == str(folder / "cover.png")
|
||||
assert metadata["custom_icon"] == str(folder / "icon.png")
|
||||
assert metadata["appid"] == "123"
|
||||
assert metadata["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_export_runtime(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
Path(f"{executable}.ppdb").write_text(
|
||||
'export PW_WINE_USE="PROTON_LG"\nexport PW_PREFIX_NAME="GAME"\n'
|
||||
)
|
||||
runner = tmp_path / "data/dist/PROTON_TEST"
|
||||
runner.mkdir(parents=True)
|
||||
runtime = _get_game_runtime(tmp_path, executable, {"PW_PROTON_LG_VER": "PROTON_TEST"}, {})
|
||||
assert runtime["runner"] == "PROTON_TEST"
|
||||
assert "runner_version" not in runtime
|
||||
assert runtime["runner_path"] == str(runner)
|
||||
assert runtime["runner_installed"]
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/GAME")
|
||||
|
||||
|
||||
def test_settings_do_not_execute_shell(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
marker = tmp_path / "should-not-exist"
|
||||
settings = tmp_path / "settings"
|
||||
settings.write_text(
|
||||
f'export PW_WINE_USE="$(touch {marker})"\n'
|
||||
'export PW_PREFIX_NAME="Game prefix"\n'
|
||||
'check_variables PW_VULKAN_USE "1"\n'
|
||||
)
|
||||
assert _read_game_settings(settings) == {"PW_PREFIX_NAME": "Game prefix", "PW_VULKAN_USE": "1"}
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_list_games_exits_before_gui(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(list_games=True, game_info=None, edit_game=None, pretty=True, fields=None, all_games=True))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{"name": "Игра"}])
|
||||
gui = MagicMock(side_effect=AssertionError("GUI must not start"))
|
||||
monkeypatch.setattr(app, "QApplication", gui)
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
assert orjson.loads(output) == [{"name": "Игра"}]
|
||||
assert output == '[\n {\n "name": "Игра"\n }\n]\n'
|
||||
gui.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("external", [False, True])
|
||||
def test_runtime_wine_layout(tmp_path: Path, external: bool) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
runner = tmp_path / "External Wine" if external else tmp_path / "data/dist/PROTON_TEST"
|
||||
(runner / "files/bin-wow64").mkdir(parents=True)
|
||||
selected = str(runner) if external else "PROTON_TEST"
|
||||
runtime = _get_game_runtime(tmp_path, None, {}, {
|
||||
"PW_WINE_USE": selected, "PW_USE_WOW64": "1", "PW_PREFIX_NAME": "CUSTOM",
|
||||
})
|
||||
assert runtime["wine_path"] == str(runner / "files/bin-wow64/wine")
|
||||
assert runtime["wineserver_path"] == str(runner / "files/bin-wow64/wineserver")
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/CUSTOM")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,backend", [("gog", "gog_api.GOGAPI"), ("egs", "egs_api.EGSAPI")])
|
||||
def test_list_store_games(source: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_store_games
|
||||
|
||||
api = MagicMock()
|
||||
api.load_library.return_value = [
|
||||
{"app_id": "123", "title": "Installed", "description": "Details", "cover": "store.jpg"},
|
||||
{"app_id": "456", "title": "Owned"},
|
||||
]
|
||||
api.load_installed.return_value = {"123": {}}
|
||||
api.is_game_installed.side_effect = lambda app_id, installed: app_id in installed
|
||||
api.get_launch_target.return_value = "/games/game.exe"
|
||||
monkeypatch.setattr(f"portprotonqt.{backend}", lambda: api)
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda source, app_id, name: (name, "custom.jpg"))
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {"custom_icon": "icon.png"})
|
||||
games = _list_store_games(source, None, {}, {})
|
||||
assert [game["id"] for game in games] == [f"{source}:123", f"{source}:456"]
|
||||
assert all(game["source"] == source for game in games)
|
||||
assert games[0]["description"] == "Details"
|
||||
assert games[0]["cover"] == "custom.jpg"
|
||||
assert games[0]["icon"] == "icon.png"
|
||||
assert games[0]["exec"] == f"{source}://launch/123"
|
||||
assert games[1]["exec"] == f"{source}://install/456"
|
||||
assert games[1]["executable"] is None
|
||||
api.get_launch_target.assert_called_once_with("123")
|
||||
|
||||
|
||||
def test_list_steam_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_steam_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.steam_api.get_steam_installed_games", lambda: [("Game", 123, 100, 42)])
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_full_steam_game_info",
|
||||
lambda *args: {"description": "Details", "cover": "cached.jpg"})
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda *args: ("Custom", "custom.jpg"))
|
||||
game = _list_steam_games()[0]
|
||||
assert game["id"] == "steam:123"
|
||||
assert game["source"] == "steam"
|
||||
assert game["name"] == "Custom"
|
||||
assert game["cover"] == "custom.jpg"
|
||||
assert game["exec"] == "steam://rungameid/123"
|
||||
assert game["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_list_all_sources_without_portproton(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import list_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._list_steam_games", lambda: [{"source": "steam"}])
|
||||
monkeypatch.setattr("portprotonqt.cli._list_store_games", lambda source, *args: [{"source": source}])
|
||||
assert [game["source"] for game in list_games()] == ["steam", "gog", "egs"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_flag,pretty", [("--pretty", True), ("--raw", False)])
|
||||
def test_json_format_args(format_flag: str, pretty: bool, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--game-info", "steam:123", format_flag])
|
||||
args = parse_args()
|
||||
assert args.game_info == "steam:123"
|
||||
assert args.pretty is pretty
|
||||
|
||||
|
||||
@pytest.mark.parametrize("game_id", ["steam:123", "gog:123", "missing"])
|
||||
def test_game_info_output(game_id: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(
|
||||
list_games=False, game_info=game_id, edit_game=None, pretty=False, fields=None,
|
||||
))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "steam:123", "source": "steam"}, {"id": "gog:123", "source": "gog"},
|
||||
])
|
||||
code = app.main()
|
||||
output = capsys.readouterr().out
|
||||
if game_id == "missing":
|
||||
assert code == 1
|
||||
assert output == ""
|
||||
else:
|
||||
assert code == 0
|
||||
assert orjson.loads(output)["id"] == game_id
|
||||
assert len(output.splitlines()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--pretty", "--raw"], ["--list-games", "--game-info", "steam:123"], ["--game-info"],
|
||||
])
|
||||
def test_library_conflicting_args(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editable_game(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
executable.touch()
|
||||
ppdb = Path(f"{executable}.ppdb")
|
||||
ppdb.write_text('# Keep this comment\nexport PW_MANGOHUD="0"\nexport PW_WINDOWS_VER="10"\n')
|
||||
game = {"id": "portproton:game.desktop", "source": "portproton", "executable": str(executable)}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{**game, "runtime": {"settings": _read_game_settings(ppdb)}},
|
||||
])
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
functions = "\n".join(
|
||||
name + helper.split(name, 1)[1].split("\n}\n", 1)[0] + "\n}"
|
||||
for name in ("set_several_variables () {", "edit_db_from_gui () {")
|
||||
)
|
||||
script = tmp_path / "start.sh"
|
||||
script.write_text(
|
||||
'#!/usr/bin/env bash\nset -e\n' + functions + '\n'
|
||||
'print_info () { printf "%s\\n" "$*"; }\n'
|
||||
'print_error () { printf "%s\\n" "$*" >&2; }\n'
|
||||
'PORTWINE_DB_FILE="${3}.ppdb"\n'
|
||||
'set_several_variables "${@:4}"\nedit_db_from_gui $keys_all\n'
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command",
|
||||
lambda: ["bash", str(script)])
|
||||
return {**game, "ppdb": ppdb, "script": script}
|
||||
|
||||
|
||||
def test_edit_game_roundtrip(editable_game: dict) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
result = edit_game(editable_game["id"], [
|
||||
"PW_MANGOHUD=1", 'LAUNCH_PARAMETERS=--option=value "quoted"',
|
||||
"PW_PREFIX_NAME=GAME", "WINEDLLOVERRIDES=d3d11=n,b;dxgi=n",
|
||||
])
|
||||
settings = result["runtime"]["settings"]
|
||||
assert settings["PW_MANGOHUD"] == "1"
|
||||
assert settings["LAUNCH_PARAMETERS"] == '--option=value "quoted"'
|
||||
assert settings["WINEDLLOVERRIDES"] == "d3d11=n,b;dxgi=n"
|
||||
assert settings["PW_PREFIX_NAME"] == "GAME"
|
||||
assert settings["PW_WINDOWS_VER"] == "10"
|
||||
assert editable_game["ppdb"].read_text().startswith("# Keep this comment\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
"PW_MANGOHUD=2", "NOT_A_SETTING=1", "PW_MANGOHUD", "PW_PREFIX_NAME=../other",
|
||||
"PW_PREFIX_NAME=", "PW_MANGOHUD=$(touch /tmp/forbidden)", "PW_MANGOHUD=1\ntrue",
|
||||
"LAUNCH_PARAMETERS=a|b", "LAUNCH_PARAMETERS=`id`",
|
||||
])
|
||||
def test_edit_game_rejects_invalid_values(editable_game: dict, change: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
before = editable_game["ppdb"].read_bytes()
|
||||
with pytest.raises(ValueError):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1", change])
|
||||
run.assert_not_called()
|
||||
assert editable_game["ppdb"].read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["portproton", "gog", "egs"])
|
||||
def test_edit_game_sources(editable_game: dict, source: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{**editable_game, "source": source}])
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
assert 'export PW_MANGOHUD="1"' in editable_game["ppdb"].read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["missing", "steam", "uninstalled", "no-script", "failed", "not-saved"])
|
||||
def test_edit_game_errors(editable_game: dict, mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
if mode == "missing":
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
elif mode in {"steam", "uninstalled"}:
|
||||
game = {**editable_game, "source": "steam"} if mode == "steam" else {**editable_game, "executable": None}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [game])
|
||||
elif mode == "no-script":
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command", lambda: None)
|
||||
else:
|
||||
editable_game["script"].write_text("exit 4\n" if mode == "failed" else "exit 0\n")
|
||||
expected = RuntimeError if mode in {"failed", "not-saved"} else ValueError
|
||||
with pytest.raises(expected):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--set", "PW_MANGOHUD=1"], ["--edit-game", "gog:123"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--list-games"],
|
||||
])
|
||||
def test_edit_game_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [False, True])
|
||||
def test_edit_game_cli_output(failure: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", "--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--raw",
|
||||
])
|
||||
edit = MagicMock(return_value={"id": "gog:123"})
|
||||
if failure:
|
||||
edit.side_effect = RuntimeError("write failed")
|
||||
monkeypatch.setattr("portprotonqt.cli.edit_game", edit)
|
||||
assert app.main() == (1 if failure else 0)
|
||||
output = capsys.readouterr().out
|
||||
assert output == "" if failure else orjson.loads(output) == {"id": "gog:123"}
|
||||
edit.assert_called_once_with("gog:123", ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("single", [False, True])
|
||||
@pytest.mark.parametrize("pretty", [False, True])
|
||||
def test_library_selected_fields(single: bool, pretty: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
command = ["--game-info", "gog:123"] if single else ["--list-games"]
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", *command, "--fields", "executable, icon,name,source",
|
||||
"--pretty" if pretty else "--raw",
|
||||
])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "gog:123", "name": "Game", "icon": "icon.png", "source": "gog",
|
||||
"executable": "/games/game.exe", "description": "Not selected", "installed": True},
|
||||
{"id": "steam:456", "name": "Steam Game", "source": "steam", "installed": True},
|
||||
])
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
expected = {"executable": "/games/game.exe", "icon": "icon.png", "name": "Game", "source": "gog"}
|
||||
assert orjson.loads(output) == (expected if single else [
|
||||
expected, {"executable": None, "icon": None, "name": "Steam Game", "source": "steam"},
|
||||
])
|
||||
assert (len(output.splitlines()) > 1) is pretty
|
||||
|
||||
|
||||
def test_library_selected_fields_empty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games", "--fields", "name"])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
assert app.main() == 0
|
||||
assert capsys.readouterr().out == "[]\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--fields", "name"], ["--list-games", "--fields", ""],
|
||||
["--list-games", "--fields", "name,,icon"], ["--list-games", "--fields", "name,"],
|
||||
["--list-games", "--fields", "name;icon"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--fields", "name"],
|
||||
])
|
||||
def test_library_selected_fields_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_all", [False, True])
|
||||
def test_list_games_installed_filter(include_all: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
arguments = ["portprotonqt", "--list-games", "--fields", "id", "--raw"]
|
||||
if include_all:
|
||||
arguments.append("--all")
|
||||
monkeypatch.setattr(sys, "argv", arguments)
|
||||
games = [
|
||||
{"id": "portproton:missing.desktop", "installed": False},
|
||||
{"id": "steam:123", "installed": True},
|
||||
{"id": "gog:456", "installed": False},
|
||||
{"id": "egs:789", "installed": True},
|
||||
]
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: games)
|
||||
assert app.main() == 0
|
||||
expected = [game for game in games if include_all or game["installed"]]
|
||||
assert orjson.loads(capsys.readouterr().out) == [{"id": game["id"]} for game in expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [["--all"], ["--game-info", "gog:123", "--all"]])
|
||||
def test_all_requires_list_games(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
+1
-46
@@ -1,4 +1,3 @@
|
||||
import argparse
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -7,53 +6,9 @@ from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.scripts_utils.easyterm import (
|
||||
CONF_FALLBACK_SHELL,
|
||||
TerminalWidget,
|
||||
build_command,
|
||||
)
|
||||
from portprotonqt.scripts_utils.easyterm import TerminalWidget
|
||||
|
||||
PROCESS_TIMEOUT_SECONDS = 30
|
||||
HELPER_PATH = "/usr/share/portproton/scripts/functions_helper"
|
||||
USER_SHELL = "/usr/bin/zsh"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_shell(monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm.default_shell", lambda: USER_SHELL
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm._portproton_functions_helper_path",
|
||||
lambda: HELPER_PATH,
|
||||
)
|
||||
return USER_SHELL
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_portproton_functions(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["pw_run Game"]))
|
||||
|
||||
assert command == [
|
||||
CONF_FALLBACK_SHELL,
|
||||
"-lc",
|
||||
f"source {HELPER_PATH}; pw_run Game",
|
||||
]
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_exported_functions(
|
||||
user_shell: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("BASH_FUNC_launch_game%%", "() { :; }")
|
||||
|
||||
command = build_command(argparse.Namespace(e=["launch_game"]))
|
||||
|
||||
assert command[0] == CONF_FALLBACK_SHELL
|
||||
|
||||
|
||||
def test_build_command_keeps_user_shell_for_plain_commands(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["ls -l"]))
|
||||
|
||||
assert command == [user_shell, "-lc", "ls -l"]
|
||||
|
||||
|
||||
def test_terminal_keeps_failed_command_output(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for themed file explorer icons."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
@@ -30,30 +29,6 @@ from portprotonqt.dialogs.file_explorer import (
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_executable_filter_keeps_exe_and_linux_executables(tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QMimeDatabase
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
for name in ("Game.EXE", "launcher", "notes.txt"):
|
||||
(tmp_path / name).touch()
|
||||
(tmp_path / "launcher").chmod(0o755)
|
||||
(tmp_path / "folder").mkdir()
|
||||
explorer = SimpleNamespace(
|
||||
file_list=QListWidget(), current_path=str(tmp_path), directory_only=False,
|
||||
thumbnail_cache={}, pending_thumbnails={}, deferred_thumbnails={}, file_items={},
|
||||
path_history={}, path_label=QLabel(), mime_db=QMimeDatabase(),
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
_get_list_icon=lambda name: None, _get_file_type_icon=lambda path: None,
|
||||
schedule_thumbnail_loading=lambda: None,
|
||||
)
|
||||
|
||||
FileExplorer.update_file_list(cast(FileExplorer, explorer))
|
||||
|
||||
assert [explorer.file_list.item(i).text() for i in range(explorer.file_list.count())] == [
|
||||
"../", "folder/", "Game.EXE", "launcher",
|
||||
]
|
||||
|
||||
|
||||
def test_gamepad_opens_file_explorer_context_menu(monkeypatch: MonkeyPatch) -> None:
|
||||
explorer = FileExplorer.__new__(FileExplorer)
|
||||
QDialog.__init__(explorer)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
from pytest import MonkeyPatch, raises
|
||||
|
||||
import portprotonqt.game_card as game_card_module
|
||||
import portprotonqt.image_utils as image_utils
|
||||
@@ -608,16 +608,9 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
|
||||
]
|
||||
|
||||
|
||||
@mark.parametrize("url, filename", [
|
||||
("https://example.org/game.jpg", "game.jpg"),
|
||||
("https://cdn2.steamgriddb.com/grid/unique-cover.png", "unique-cover.png"),
|
||||
("https://deckyloader.ru/sgdb-cdn/grid/unique-cover.png", "unique-cover.png"),
|
||||
])
|
||||
def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
url: str,
|
||||
filename: str,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "game.exe"
|
||||
exe_path.write_bytes(b"MZ")
|
||||
@@ -633,8 +626,6 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
timeout: int,
|
||||
callback: Callable[[str | None], None],
|
||||
) -> None:
|
||||
assert _url == url
|
||||
assert Path(_path).name == filename
|
||||
callbacks.append(callback)
|
||||
|
||||
monkeypatch.setattr("portprotonqt.icon_extractor.CACHE_DIR", tmp_path / "cache" / "PortProtonQt")
|
||||
@@ -651,13 +642,13 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
)
|
||||
|
||||
image_utils.load_pixmap_async(
|
||||
url, 100, 150,
|
||||
"https://example.org/game.jpg", 100, 150,
|
||||
lambda pixmap: loaded_paths.append(cast(FakePixmap, pixmap).path),
|
||||
app_name="game", fallback_exe=str(exe_path),
|
||||
fallback_icon_path=str(fallback_path),
|
||||
)
|
||||
callbacks[0](None)
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / "game.jpg"
|
||||
downloaded_path.write_bytes(b"cover")
|
||||
callbacks[0](str(downloaded_path))
|
||||
|
||||
|
||||
@@ -1632,75 +1632,3 @@ def test_confirm_on_open_combo_activates_highlighted_row() -> None:
|
||||
assert not combo.view().isVisible()
|
||||
combo.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@mark.parametrize("direction", [-1, 1])
|
||||
@mark.parametrize("column", [0, 1])
|
||||
def test_run_after_dpad_reaches_delete_from_entry(direction: int, column: int) -> None:
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
table = QTableWidget(1, 2)
|
||||
entry = QLineEdit("game.exe")
|
||||
remove = QPushButton("delete")
|
||||
table.setCellWidget(0, 0, entry)
|
||||
table.setCellWidget(0, 1, remove)
|
||||
manager = SimpleNamespace(settings_dialog=SimpleNamespace(advanced_table=table))
|
||||
table.show()
|
||||
table.activateWindow()
|
||||
table.setCurrentCell(0, column)
|
||||
entry.setFocus()
|
||||
app.processEvents()
|
||||
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, direction)
|
||||
assert QApplication.focusWidget() == remove
|
||||
assert SettingsInputMixin._move_settings_inline_button_focus(cast(SettingsInputMixin, manager), table, -direction)
|
||||
assert QApplication.focusWidget() == entry
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
def test_mouse_device_follows_emulation_toggle(monkeypatch: MonkeyPatch) -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.emulation_triggered = False
|
||||
manager.mouse_emulation_thread = None
|
||||
manager.ui = None
|
||||
create = MagicMock()
|
||||
monkeypatch.setattr(manager, "_async_enable_mouse_emulation", create)
|
||||
|
||||
manager._update_emulation_flag()
|
||||
create.assert_not_called()
|
||||
manager.emulation_triggered = True
|
||||
manager._update_emulation_flag()
|
||||
create.assert_called_once_with()
|
||||
|
||||
device = MagicMock()
|
||||
manager.ui = device
|
||||
manager.emulation_triggered = False
|
||||
manager._update_emulation_flag()
|
||||
device.close.assert_called_once_with()
|
||||
assert manager.ui is None
|
||||
assert manager.mouse_emulation_enabled
|
||||
manager.emulation_triggered = True
|
||||
manager._update_emulation_flag()
|
||||
assert create.call_count == 2
|
||||
|
||||
|
||||
def test_mouse_shutdown_collects_pending_device() -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
device = MagicMock()
|
||||
thread = MagicMock()
|
||||
thread.get_device.return_value = device
|
||||
manager.mouse_emulation_thread = thread
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.ui = None
|
||||
|
||||
manager.disable_mouse_emulation()
|
||||
manager._on_mouse_emulation_finished(True)
|
||||
|
||||
thread.wait.assert_called_once_with()
|
||||
device.close.assert_called_once_with()
|
||||
assert manager.ui is None
|
||||
assert manager.mouse_emulation_thread is None
|
||||
+101
-4
@@ -42,7 +42,7 @@ from portprotonqt.game_library_manager import FullLibraryTile, GameLibraryManage
|
||||
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
|
||||
from portprotonqt.tray_manager import TrayManager, create_wine_tools_menu, launch_session_wine_tool
|
||||
from portprotonqt.themes.standart.styles.constants import GAME_CARD_ANIMATION
|
||||
from portprotonqt.portproton_api import remove_empty_custom_data_dirs
|
||||
import portprotonqt.tabs.autoinstall_tab as autoinstall_tab_module
|
||||
@@ -374,18 +374,71 @@ def test_minimal_tray_contains_game_actions() -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
manager = TrayManager.__new__(TrayManager)
|
||||
manager.tray_menu = QMenu()
|
||||
manager.pause_game_action = QAction("Pause Game", manager.tray_menu)
|
||||
manager.stop_game_action = QAction("Stop Game", manager.tray_menu)
|
||||
owner = QWidget()
|
||||
manager.pause_game_action = QAction("Pause Game", owner)
|
||||
manager.stop_game_action = QAction("Stop Game", owner)
|
||||
manager.wine_tools_menu = create_wine_tools_menu(manager.tray_menu, ["portproton"])
|
||||
manager.minimal_mode = True
|
||||
manager.update_game_actions = MagicMock()
|
||||
|
||||
manager.refresh_tray_menu()
|
||||
manager.refresh_tray_menu()
|
||||
|
||||
assert manager.tray_menu.actions() == [
|
||||
manager.pause_game_action,
|
||||
manager.stop_game_action,
|
||||
manager.wine_tools_menu.menuAction(),
|
||||
]
|
||||
manager.update_game_actions.assert_called_once_with()
|
||||
assert manager.update_game_actions.call_count == 2
|
||||
|
||||
|
||||
@mark.parametrize("tool_index,tool", list(enumerate(
|
||||
("winefile", "taskmgr", "winecfg", "regedit", "cmd", "uninstaller")
|
||||
)))
|
||||
def test_wine_tools_menu_launches_session_tool(
|
||||
monkeypatch: MonkeyPatch, tool_index: int, tool: str,
|
||||
) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
parent = QMenu()
|
||||
start = MagicMock(return_value=(True, 123))
|
||||
monkeypatch.setattr("portprotonqt.tray_manager.QProcess.startDetached", start)
|
||||
menu = create_wine_tools_menu(parent, ["bash", "/path with spaces/start.sh"])
|
||||
|
||||
assert len(menu.actions()) == 6
|
||||
menu.actions()[tool_index].trigger()
|
||||
|
||||
start.assert_called_once_with(
|
||||
"bash", ["/path with spaces/start.sh", "cli", "--wine-session-tool", tool]
|
||||
)
|
||||
|
||||
|
||||
def test_wine_tool_start_failure_is_reported(monkeypatch: MonkeyPatch) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
warning = MagicMock()
|
||||
monkeypatch.setattr("portprotonqt.tray_manager.QMessageBox.warning", warning)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.tray_manager.QProcess.startDetached", lambda *args: (False, 0)
|
||||
)
|
||||
|
||||
launch_session_wine_tool(["portproton"], "winefile")
|
||||
|
||||
warning.assert_called_once()
|
||||
|
||||
|
||||
@mark.parametrize("running", [False, True])
|
||||
def test_wine_tools_menu_requires_running_game(running: bool) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
manager = TrayManager.__new__(TrayManager)
|
||||
manager.main_window = SimpleNamespace(game_processes=[], target_exe="game.exe" if running else None)
|
||||
manager.stop_game_action = QAction()
|
||||
manager.pause_game_action = QAction()
|
||||
manager.wine_tools_menu = QMenu()
|
||||
manager._game_process_tree = MagicMock(return_value=[])
|
||||
|
||||
manager.update_game_actions()
|
||||
|
||||
assert manager.wine_tools_menu.isEnabled() is running
|
||||
|
||||
|
||||
def test_tray_pauses_game_process_tree(monkeypatch: MonkeyPatch) -> None:
|
||||
child = MagicMock(pid=12)
|
||||
@@ -3378,3 +3431,47 @@ def test_custom_combo_opens_readonly_field_on_mouse_release(readonly: bool) -> N
|
||||
assert combo.view().isVisible() is readonly
|
||||
combo.hidePopup()
|
||||
combo.close()
|
||||
|
||||
|
||||
@mark.parametrize("tool", ["winecfg", "regedit", "winefile", "cmd", "uninstaller", "taskmgr"])
|
||||
@mark.parametrize("running", [False, True])
|
||||
def test_wine_settings_tools_use_unified_command(
|
||||
monkeypatch: MonkeyPatch, tool: str, running: bool,
|
||||
) -> None:
|
||||
process = MagicMock()
|
||||
monkeypatch.setattr("portprotonqt.tabs.wine_tab.QProcess", MagicMock(return_value=process))
|
||||
window = MagicMock()
|
||||
window.game_processes = [MagicMock()] if running else []
|
||||
window.target_exe = "game.exe" if running else None
|
||||
window.start_sh = ["bash", "/path with spaces/start.sh"]
|
||||
window.wineCombo.currentText.return_value = "WINE_LG"
|
||||
window.prefixCombo.currentText.return_value = "my prefix"
|
||||
|
||||
WineMixin.launch_generic_tool(window, tool)
|
||||
|
||||
process.start.assert_called_once_with(
|
||||
"bash", ["/path with spaces/start.sh", "cli", "--wine-session-tool", tool, "WINE_LG", "MY_PREFIX"],
|
||||
)
|
||||
window._start_wine_process_monitor.assert_called_once_with(tool)
|
||||
|
||||
|
||||
|
||||
def test_silent_tray_includes_wine_tools_menu(monkeypatch: MonkeyPatch) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
manager = TrayManager.__new__(TrayManager)
|
||||
manager.tray_menu = QMenu()
|
||||
owner = QWidget()
|
||||
manager.pause_game_action = QAction("Pause Game", owner)
|
||||
manager.stop_game_action = QAction("Stop Game", owner)
|
||||
manager.wine_tools_menu = create_wine_tools_menu(manager.tray_menu, ["portproton"])
|
||||
manager.update_game_actions = MagicMock()
|
||||
start = MagicMock(return_value=(True, 123))
|
||||
monkeypatch.setattr("portprotonqt.tray_manager.QProcess.startDetached", start)
|
||||
|
||||
manager.set_minimal_mode()
|
||||
|
||||
tools = manager.tray_menu.actions()[2].menu()
|
||||
assert isinstance(tools, QMenu)
|
||||
assert len(tools.actions()) == 6
|
||||
tools.actions()[1].trigger()
|
||||
start.assert_called_once_with("portproton", ["cli", "--wine-session-tool", "taskmgr"])
|
||||
@@ -42,46 +42,21 @@ gui_config () {
|
||||
assert ppdb.read_text() == expected
|
||||
|
||||
|
||||
@mark.parametrize("enabled", ["0", "1", ""])
|
||||
@mark.parametrize("inherited", [None, "0", "1"])
|
||||
def test_video_fix_controls_dxgi_device_manager(
|
||||
tmp_path: Path, enabled: str, inherited: str | None,
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
block = ' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]' + helper.split(
|
||||
' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]', 1,
|
||||
)[1].split(" fi", 1)[0] + " fi\n"
|
||||
env = {"PATH": os.defpath, "PW_FIX_VIDEO_IN_GAME": enabled}
|
||||
if inherited is not None:
|
||||
env["WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER"] = inherited
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", block
|
||||
+ 'printf "%s" "${WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER-unset}"'],
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == ("1" if enabled == "1" else "unset")
|
||||
|
||||
|
||||
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
|
||||
@mark.parametrize("copies", [1, 3])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
condition = helper.split(' local -a run_after_exes=', 1)[1].split(
|
||||
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 1)[1].split(
|
||||
" then", 1,
|
||||
)[0]
|
||||
condition = ' local -a run_after_exes=' + condition
|
||||
main_exe = tmp_path / "Game.exe"
|
||||
second_exe = tmp_path / second_name
|
||||
main_exe.touch()
|
||||
second_exe.touch()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'check_run_after() {\n' + condition
|
||||
+ "then return 0; else return 1; fi; }; check_run_after"],
|
||||
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
|
||||
+ "then exit 0; else exit 1; fi"],
|
||||
env={**os.environ, "PW_EXE_FILE": str(main_exe),
|
||||
"PW_RUN_AFTER_EXE": ",".join([str(second_exe)] * copies)},
|
||||
"PW_RUN_AFTER_EXE": str(second_exe)},
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -137,9 +112,7 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
|
||||
)[0]
|
||||
main_path = "D:\\Games\\Test Game\\Test Game.exe"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'pw_run() { env; }\nlaunch() {\n'
|
||||
'local -a run_after_exes=(unused) run_after_environment=()\n'
|
||||
'cat > "$run_after_bat" <<EOF\n' + block + '\n}; launch'],
|
||||
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
|
||||
env={**os.environ, "run_after_bat": str(tmp_path / "launch.bat"),
|
||||
"pw_exe_file_win": main_path, "run_after_exe_win": trainer_path,
|
||||
"wait_delay_int": "3", "LAUNCH_PARAMETERS": ""},
|
||||
@@ -147,7 +120,6 @@ def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path:
|
||||
)
|
||||
assert f"PW_RUN_AFTER_MAIN_PATH={main_path}" in result.stdout
|
||||
assert f"PW_RUN_AFTER_SECOND_PATH={trainer_path}" in result.stdout
|
||||
assert "PW_RUN_AFTER_EXE=\n" in result.stdout
|
||||
batch = (tmp_path / "launch.bat").read_text(encoding="ascii")
|
||||
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in batch
|
||||
|
||||
@@ -262,67 +234,139 @@ def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback(
|
||||
assert "Company Name: Sony" in metadata
|
||||
|
||||
|
||||
def test_run_after_batch_preserves_duplicate_entries(tmp_path: Path) -> None:
|
||||
def test_wine_session_missing_returns_distinct_status(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()
|
||||
definition = helper.split("pw_session_tool () {", 1)[1].split("pw_launch_wrapper () {", 1)[0]
|
||||
script = "pw_session_tool () {" + definition + '''
|
||||
print_error () { echo "$@" >&2; }
|
||||
sleep () { return 0; }
|
||||
pw_session_tool taskmgr 1
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], cwd=tmp_path, capture_output=True, text=True, check=False,
|
||||
env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)},
|
||||
)
|
||||
|
||||
assert result.returncode == 2
|
||||
|
||||
|
||||
@mark.parametrize("tool", ["winecfg", "regedit", "winefile", "cmd", "uninstaller", "taskmgr"])
|
||||
def test_wine_session_tool_standalone_uses_selected_wine_and_prefix(tmp_path: Path, tool: str) -> None:
|
||||
start = Path("build-aux/share/portproton/scripts/start.sh").read_text()
|
||||
block = start.split(" --wine-session-tool)\n", 1)[1].split(" --winetricks-list-all)", 1)[0]
|
||||
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"
|
||||
get_wine_and_pfx () { printf '%s\\n' "$@"; }
|
||||
start_portproton () { mkdir -p "$PORT_DATA_PATH/data/prefixes/$PW_PREFIX_NAME/drive_c"; echo start; }
|
||||
pw_run () { printf '%s\\n' "$@"; }
|
||||
stop_portproton () { echo stop; }
|
||||
''' + 'case "$1" in --wine-session-tool)\n' + block + '\nesac\n'
|
||||
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"},
|
||||
["bash", "-c", script, "bash", "--wine-session-tool", tool, "WINE_LG", "MY_PREFIX"],
|
||||
cwd=tmp_path, capture_output=True, text=True, check=False,
|
||||
env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path), "PW_PREFIX_NAME": "MY_PREFIX"},
|
||||
)
|
||||
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
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == ["WINE_LG", "MY_PREFIX", "start", tool, "stop"]
|
||||
|
||||
|
||||
def test_run_after_wrapper_runs_script_list_with_duplicates(tmp_path: Path) -> None:
|
||||
@mark.parametrize("tool", ["winefile", "taskmgr", "winecfg", "regedit", "cmd", "uninstaller"])
|
||||
@mark.parametrize("tool_exit_code", [0, 2])
|
||||
def test_wine_session_tools_reuse_prefix_without_game_options(
|
||||
tmp_path: Path, tool: str, tool_exit_code: int,
|
||||
) -> 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)
|
||||
definitions = helper.split("pw_save_session_env () {", 1)[1].split("pw_launch_wrapper () {", 1)[0]
|
||||
script = "pw_save_session_env () {" + definitions + '''
|
||||
print_error () { echo "$@" >&2; }
|
||||
pw_run () {
|
||||
printf '%s\\n' "$1" "$WINEPREFIX" "${PW_USE_TERMINAL:-0}" "${LAUNCH_PARAMETERS:-}" \\
|
||||
"${PW_RUN_AFTER_EXE:-}" "${PW_RUN_GAMESCOPE:-}" "$PW_TMPFS_PATH"
|
||||
return "$TOOL_EXIT_CODE"
|
||||
}
|
||||
PW_TERM="test-terminal"
|
||||
export PW_START_PID="$$" WINEPREFIX="$PORT_DATA_PATH/prefix with spaces"
|
||||
export LAUNCH_PARAMETERS="--game-only" PW_RUN_AFTER_EXE=trainer.exe PW_RUN_GAMESCOPE=gamescope
|
||||
pw_save_session_env || exit 2
|
||||
pw_session_tool "$1"
|
||||
'''
|
||||
(tmp_path / "prefix with spaces/drive_c").mkdir(parents=True)
|
||||
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,
|
||||
["bash", "-c", script, "bash", tool], capture_output=True, text=True,
|
||||
env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path),
|
||||
"TOOL_EXIT_CODE": str(tool_exit_code)}, check=False,
|
||||
)
|
||||
assert result.stdout.splitlines() == ["first", "second", "first", "game"]
|
||||
|
||||
assert result.returncode == int(tool_exit_code != 0), result.stderr
|
||||
lines = result.stdout.splitlines()
|
||||
assert lines[:6] == [tool, str(tmp_path / "prefix with spaces"), "1" if tool == "cmd" else "0", "", "", ""]
|
||||
assert Path(lines[6]).is_dir()
|
||||
assert Path(lines[6]).stat().st_mode & 0o777 == 0o700
|
||||
session = next((tmp_path / "data/tmp").glob("wine-session-*/session.env"))
|
||||
assert session.stat().st_mode & 0o777 == 0o600
|
||||
assert 'declare -x PW_TERM="test-terminal"' in session.read_text()
|
||||
|
||||
|
||||
def test_run_after_wrapper_preserves_command_with_comma(tmp_path: Path) -> None:
|
||||
@mark.parametrize("unsafe_directory", [False, True])
|
||||
def test_wine_session_save_does_not_follow_symlinks(tmp_path: Path, unsafe_directory: bool) -> 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]
|
||||
definition = helper.split("pw_save_session_env () {", 1)[1].split("pw_remove_session_env () {", 1)[0]
|
||||
session_dir = tmp_path / "data/tmp" / f"wine-session-{os.getuid()}"
|
||||
session_dir.parent.mkdir(parents=True)
|
||||
victim = tmp_path / "victim"
|
||||
victim.write_text("keep this")
|
||||
if unsafe_directory:
|
||||
target = tmp_path / "other-directory"
|
||||
target.mkdir(mode=0o700)
|
||||
session_dir.symlink_to(target, target_is_directory=True)
|
||||
else:
|
||||
session_dir.mkdir(mode=0o700)
|
||||
(session_dir / "session.env").symlink_to(victim)
|
||||
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,
|
||||
["bash", "-c", "pw_save_session_env () {" + definition + "\npw_save_session_env"],
|
||||
capture_output=True, text=True,
|
||||
env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, check=False,
|
||||
)
|
||||
assert result.stdout.splitlines() == ["first,second", "game"]
|
||||
|
||||
assert victim.read_text() == "keep this"
|
||||
assert result.returncode == (1 if unsafe_directory else 0), result.stderr
|
||||
if not unsafe_directory:
|
||||
assert not (session_dir / "session.env").is_symlink()
|
||||
|
||||
|
||||
def test_wine_session_tool_rejects_unknown_tool_before_reading_environment(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("pw_session_tool () {", 1)[1].split("pw_launch_wrapper () {", 1)[0]
|
||||
script = "pw_session_tool () {" + definition + '''
|
||||
print_error () { echo "$@" >&2; }
|
||||
sleep () { exit 99; }
|
||||
pw_session_tool '../../other-command'
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True,
|
||||
env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Unsupported Wine tool" in result.stderr
|
||||
|
||||
|
||||
def test_wine_session_cleanup_preserves_newer_session(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definitions = helper.split("pw_save_session_env () {", 1)[1].split("pw_session_tool () {", 1)[0]
|
||||
script = "pw_save_session_env () {" + definitions + '''
|
||||
export PW_START_PID="$$"
|
||||
pw_save_session_env || exit 2
|
||||
PW_START_PID=99999999
|
||||
pw_remove_session_env
|
||||
[[ -f "$PORT_DATA_PATH/data/tmp/wine-session-$(id -u)/session.env" ]] || exit 3
|
||||
PW_START_PID="$$"
|
||||
pw_remove_session_env
|
||||
[[ ! -e "$PORT_DATA_PATH/data/tmp/wine-session-$(id -u)/session.env" ]]
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True,
|
||||
env={"PATH": os.defpath, "PORT_DATA_PATH": str(tmp_path)}, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -219,74 +219,3 @@ def test_disabled_steam_proton_scanning_keeps_local_versions(
|
||||
assert manager.get_installed_versions() == [("LocalWine", str(local_wine))]
|
||||
compat.assert_not_called()
|
||||
home.assert_not_called()
|
||||
|
||||
|
||||
def test_metadata_error_preserves_local_versions(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtWidgets import QLabel, QStackedWidget, QTabWidget
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
local_version = tmp_path / "GE-Proton"
|
||||
local_version.mkdir()
|
||||
manager = SimpleNamespace(
|
||||
content_stack=QStackedWidget(),
|
||||
tab_widget=QTabWidget(),
|
||||
theme=SimpleNamespace(GETWINE_WINDOW_STYLE=""),
|
||||
)
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.tab_widget.addTab(QWidget(), "Loading")
|
||||
|
||||
def create_installed_tab() -> None:
|
||||
assert local_version.is_dir()
|
||||
manager.tab_widget.addTab(QLabel(local_version.name), "Installed")
|
||||
|
||||
manager.create_installed_tab = create_installed_tab
|
||||
monkeypatch.setattr(module, "_", lambda text: text)
|
||||
module.ProtonManager.on_wine_data_load_error(cast(Any, manager), "ConnectionError: " + "x" * 2000)
|
||||
|
||||
assert manager.content_stack.currentIndex() == 1
|
||||
assert manager.tab_widget.count() == 1
|
||||
assert manager.tab_widget.currentIndex() == 0
|
||||
assert manager.tab_widget.widget(0).text() == local_version.name
|
||||
manager.tab_widget.deleteLater()
|
||||
manager.content_stack.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_installed_sizes_update_after_metadata_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QEventLoop, QTimer
|
||||
from PySide6.QtWidgets import QPushButton, QTableWidget
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
version = tmp_path / "GE-Proton"
|
||||
version.mkdir()
|
||||
(version / "wine").write_bytes(b"x" * 2048)
|
||||
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **kwargs: QPushButton(text))
|
||||
monkeypatch.setattr(module.ProtonManager, "get_installed_versions", lambda self: [("GE-Proton", str(version))])
|
||||
manager = module.ProtonManager(portproton_location=str(tmp_path), theme=ThemeWrapper(load_theme("standart")))
|
||||
manager.on_wine_data_load_error("Offline")
|
||||
workers = list(manager._installed_size_tables)
|
||||
loop = QEventLoop()
|
||||
workers[0].finished.connect(loop.quit)
|
||||
QTimer.singleShot(3000, loop.quit)
|
||||
loop.exec()
|
||||
app.processEvents()
|
||||
try:
|
||||
installed_tab = manager.tab_widget.widget(0)
|
||||
assert installed_tab is not None
|
||||
table = installed_tab.findChild(QTableWidget)
|
||||
assert table is not None
|
||||
size_item = table.item(0, 2)
|
||||
assert size_item is not None
|
||||
assert size_item.text() == "2.0 KiB"
|
||||
finally:
|
||||
from shiboken6 import isValid
|
||||
for worker in workers:
|
||||
if isValid(worker):
|
||||
worker.wait()
|
||||
manager.close()
|
||||
app.processEvents()
|
||||
@@ -3,7 +3,7 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
|
||||
@@ -458,22 +458,6 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
|
||||
table.selectRow(1)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert isinstance(table.cellWidget(1, 0), QLabel)
|
||||
label = table.cellWidget(1, 0)
|
||||
ExeSettingsDialog._set_favorite_text_cells(
|
||||
cast(ExeSettingsDialog, dialog), 1, "key", "Updated", "New <description>",
|
||||
)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert table.cellWidget(1, 0) is label
|
||||
assert isinstance(label, QLabel)
|
||||
assert label.text() == theme.SETTINGS_SELECTED_TEXT_HTML.format(
|
||||
text="Updated<br>" + theme.SETTINGS_SELECTED_DESCRIPTION_HTML.format(
|
||||
description="New <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()
|
||||
|
||||
|
||||
@@ -941,93 +925,3 @@ def test_settings_close_waits_for_pending_save(monkeypatch) -> None:
|
||||
dialog.reject()
|
||||
assert dialog.close_after_save
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_run_after_normalization_preserves_duplicates(tmp_path: Path) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
dialog = SimpleNamespace(exe_path=str(tmp_path / "Game.exe"))
|
||||
dialog._resolve_run_after_exe_path = lambda value: ExeSettingsDialog._resolve_run_after_exe_path(cast(ExeSettingsDialog, dialog), value)
|
||||
value = "EVE.exe,EVE.exe,Trainer.exe"
|
||||
assert dialog._resolve_run_after_exe_path(value) == ",".join(
|
||||
str(tmp_path / name) for name in value.split(",")
|
||||
)
|
||||
|
||||
|
||||
def test_run_after_inline_entries_preserve_duplicates(tmp_path: Path) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
owner = cast(Any, QWidget())
|
||||
owner.advanced_table = QTableWidget(2, 3)
|
||||
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
|
||||
container = QWidget()
|
||||
target = QLineEdit(container)
|
||||
owner.advanced_table.setCellWidget(0, 1, container)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.touch()
|
||||
target.setText(f"{exe},{exe}")
|
||||
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
|
||||
assert owner.advanced_table.rowCount() == 4
|
||||
item = owner.advanced_table.item(1, 0)
|
||||
assert item is not None and item.text() == str(exe)
|
||||
item = owner.advanced_table.item(2, 0)
|
||||
assert item is not None and item.text() == str(exe)
|
||||
ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
|
||||
assert owner.advanced_table.rowCount() == 2
|
||||
item = owner.advanced_table.item(1, 0)
|
||||
assert item is not None and item.text() == "next setting"
|
||||
owner.advanced_table.deleteLater()
|
||||
|
||||
|
||||
def test_run_after_entry_paint_preserves_text_and_height() -> None:
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
table = QTableWidget(1, 2)
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
table.setItem(0, 0, QTableWidgetItem("Game.exe"))
|
||||
entry = QLineEdit("Game.exe")
|
||||
table.setCellWidget(0, 0, entry)
|
||||
table.resizeRowsToContents()
|
||||
table.show()
|
||||
QApplication.processEvents()
|
||||
height = table.rowHeight(0)
|
||||
|
||||
for _ in range(2):
|
||||
ExeSettingsDialog.eventFilter(
|
||||
cast(ExeSettingsDialog, SimpleNamespace()),
|
||||
table.viewport(), QEvent(QEvent.Type.Paint),
|
||||
)
|
||||
|
||||
assert entry.text() == "Game.exe"
|
||||
item = table.item(0, 0)
|
||||
assert item is not None and item.text() == "Game.exe"
|
||||
assert table.rowHeight(0) == height
|
||||
assert entry.height() >= entry.sizeHint().height()
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
def test_run_after_removing_only_entry_clears_field(tmp_path: Path) -> None:
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
owner = cast(Any, QWidget())
|
||||
owner.advanced_table = QTableWidget(2, 3)
|
||||
owner.theme = SimpleNamespace(ACTION_BUTTON_STYLE="", LINE_EDIT_STYLE="")
|
||||
owner._toggle_run_after_entries = lambda target: ExeSettingsDialog._toggle_run_after_entries(cast(ExeSettingsDialog, owner), target)
|
||||
container = QWidget()
|
||||
target = QLineEdit(container)
|
||||
owner.advanced_table.setCellWidget(0, 1, container)
|
||||
owner.advanced_table.setItem(1, 0, QTableWidgetItem("next setting"))
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.touch()
|
||||
target.setText(str(exe))
|
||||
owner._toggle_run_after_entries(target)
|
||||
ExeSettingsDialog._remove_run_after_entry(cast(ExeSettingsDialog, owner), target, 0)
|
||||
assert target.text() == ""
|
||||
assert owner.advanced_table.rowCount() == 2
|
||||
owner.advanced_table.deleteLater()
|
||||
Reference in new issue
Block a user