Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b60a79ae3
|
No files matched your search
@@ -31,11 +31,6 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
@@ -74,11 +69,6 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
|
||||
@@ -22,25 +22,37 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install CI dependencies
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs npm
|
||||
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Install project and check dependencies
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Activate .venv & run pre-commit
|
||||
shell: bash
|
||||
run: |
|
||||
source build-aux/PKGBUILD-git
|
||||
pacman -S --noconfirm --needed "${depends[@]}" python-qrcode libpulse mesa pre-commit pyright python-pytest python-pytest-mock
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- name: Run pre-commit
|
||||
run: pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
@@ -80,12 +80,24 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -195,12 +207,24 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:061679925e5d5028f2df9395d441ce6c4b9accf0157ff3486c06b6962e014ef1
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -266,7 +290,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
|
||||
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
|
||||
@@ -9,9 +9,6 @@ jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
env:
|
||||
UV_PYTHON_INSTALL_MIRROR: https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/
|
||||
UV_DEFAULT_INDEX: https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: pyright
|
||||
entry: uv run pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
@@ -62,7 +62,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: pytest tests/ -x -q --tb=line
|
||||
entry: uv run pytest tests/ -x -q --tb=line
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^(tests/|portprotonqt/)
|
||||
|
||||
@@ -419,7 +419,7 @@ tests/
|
||||
├── test_cli.py # normalize_launch_path, URL/resolution parsing
|
||||
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
|
||||
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
|
||||
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, and focus regressions
|
||||
├── test_input_manager.py # Gamepad input navigation and focus regressions
|
||||
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
|
||||
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
|
||||
├── test_main_window.py # Main window data processing and callback regressions
|
||||
@@ -433,10 +433,9 @@ tests/
|
||||
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
|
||||
├── test_file_explorer.py # File explorer themed icon state regressions
|
||||
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
|
||||
├── test_exe_metadata.py # PE version strings, shell output, and ExifTool fallback
|
||||
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
|
||||
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
|
||||
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
|
||||
├── test_shortcuts.py # Desktop shortcut creation, paths with spaces, .desktop entry
|
||||
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
|
||||
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
|
||||
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
|
||||
@@ -1058,8 +1057,6 @@ PortProtonQt/
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use the existing `orjson` dependency instead of standard-library `json` for faster JSON parsing and serialization. For formatted output, use `orjson.OPT_INDENT_2`.
|
||||
|
||||
### Core
|
||||
|
||||
| Package | Purpose | License |
|
||||
|
||||
@@ -7,39 +7,13 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: ЛКМ и кнопка A геймпада переключают выделение, Ctrl+клик позволяет выделять карточки напрямую
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
|
||||
|
||||
### Changed
|
||||
|
||||
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
|
||||
- После добавления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог об успешном добавлении
|
||||
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
|
||||
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
|
||||
- Настройки exe и `user.conf` применяются автоматически при изменении
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
|
||||
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
|
||||
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
|
||||
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
|
||||
### Contributors
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -943,13 +943,13 @@ get_and_set_reg_file () {
|
||||
esac
|
||||
name_block=${name_block//\\/\\\\\\\\}
|
||||
if [[ -n $name_for_new_block ]] ; then
|
||||
find_block=$(grep -n "\[$name_block\]" "${WINEPREFIX}/$name_for_new_block.reg")
|
||||
find_block=$(grep -n "\[$name_block\]" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg")
|
||||
else
|
||||
find_block=$(grep -n "\[$name_block\]" "${WINEPREFIX}/"*.reg)
|
||||
find_block=$(grep -n "\[$name_block\]" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/"*.reg)
|
||||
fi
|
||||
if [[ -n $find_block ]] ; then
|
||||
if [[ -n $name_for_new_block ]] ; then
|
||||
find_file="${WINEPREFIX}/$name_for_new_block.reg"
|
||||
find_file="${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg"
|
||||
find_line=${find_block//:*/}
|
||||
else
|
||||
find_file=${find_block//:*/}
|
||||
@@ -974,8 +974,8 @@ get_and_set_reg_file () {
|
||||
if [[ $name_add_or_del == --add ]] ; then
|
||||
if [[ -z $find_block ]] ; then
|
||||
if [[ -n $name_for_new_block ]] ; then
|
||||
sed -i '$a\\n'\["$name_block"\] "${WINEPREFIX}/$name_for_new_block.reg"
|
||||
find_file="${WINEPREFIX}/$name_for_new_block.reg"
|
||||
sed -i '$a\\n'\["$name_block"\] "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg"
|
||||
find_file="${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/$name_for_new_block.reg"
|
||||
find_line=$(wc -l "$find_file" | awk -F" " '{print $1}')
|
||||
find_line=$(( find_line - 1 ))
|
||||
else
|
||||
@@ -1199,23 +1199,6 @@ init_wine_ver () {
|
||||
if [[ -d "${WINEDIR}"/share/espeak-ng-data ]] ; then
|
||||
export ESPEAK_DATA_PATH="${WINEDIR}"/share/
|
||||
fi
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
if [[ -x "${STEAM_COMPAT_TOOL_PATHS%%:*}/proton" ]] ; then
|
||||
PROTON_RUNNER="${STEAM_COMPAT_TOOL_PATHS%%:*}/proton"
|
||||
elif [[ "${PW_WINE_USE:-}" = /* && -x "${PW_WINE_USE}/proton" ]] ; then
|
||||
PROTON_RUNNER="${PW_WINE_USE}/proton"
|
||||
elif [[ -x "${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton" ]] ; then
|
||||
PROTON_RUNNER="${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton"
|
||||
elif [[ -x "${WINEDIR}/proton" ]] ; then
|
||||
PROTON_RUNNER="${WINEDIR}/proton"
|
||||
elif [[ -x "${WINEDIR%/files}/proton" ]] ; then
|
||||
PROTON_RUNNER="${WINEDIR%/files}/proton"
|
||||
else
|
||||
fatal "$PW_WINE_USE not found!"
|
||||
fi
|
||||
export PROTON_RUNNER
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1874,10 +1857,6 @@ check_dirs_and_files_in_pfx () {
|
||||
}
|
||||
|
||||
pw_init_db () {
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ -n "${PW_DEFAULT_WINE_USE:-}" ]] && export PW_WINE_USE="${PW_DEFAULT_WINE_USE}"
|
||||
[[ -n "${PW_DEFAULT_PREFIX_NAME:-}" ]] && export PW_PREFIX_NAME="${PW_DEFAULT_PREFIX_NAME}"
|
||||
[[ -n "${PW_DEFAULT_VULKAN_USE:-}" ]] && export PW_VULKAN_USE="${PW_DEFAULT_VULKAN_USE}"
|
||||
@@ -2293,14 +2272,9 @@ manage_user_conf_value () {
|
||||
|
||||
use_exiftool () {
|
||||
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|
||||
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
printf '%s\n' "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if [[ "${1,,}" == *.exe ]] \
|
||||
&& python_module exe_metadata "$1" >> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if ! command -v exiftool &>/dev/null \
|
||||
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
@@ -2454,8 +2428,9 @@ pw_specialk_set_ini_value () {
|
||||
[[ -f "$ini_file" ]] && sed -i '/./,$!d' "$ini_file"
|
||||
|
||||
if grep -q "^\[$section\]" "$ini_file" ; then
|
||||
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" | grep -q "^$key=" ; then
|
||||
sed -i "/^\[$section\]/,/^\[/{s/^$key=.*/$key=$value/}" "$ini_file"
|
||||
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" \
|
||||
| grep -q "^[[:space:]]*$key[[:space:]]*=" ; then
|
||||
sed -i "/^\[$section\]/,/^\[/{s/^[[:space:]]*$key[[:space:]]*=.*/$key=$value/}" "$ini_file"
|
||||
else
|
||||
sed -i "/^\[$section\]/a $key=$value" "$ini_file"
|
||||
fi
|
||||
@@ -2548,6 +2523,171 @@ pw_specialk_sync_files () {
|
||||
|
||||
}
|
||||
|
||||
pw_reshade_sync_files () {
|
||||
local root="${PW_PLUGINS_PATH}/reshade_windows" runtime detect arch dll highest ogl vulkan source
|
||||
local manifest_reg package installed installed_arch installed_version extract_dir reshade_exe reshade_font font_win screenshot_path backup
|
||||
local PATH_TO_GAME="${PATH_TO_GAME:-}"
|
||||
runtime="$root/reshade/${PW_RESHADE_VER}_Addon"
|
||||
reshade_exe="${PW_RESHADE_EXE:-$PW_EXE_FILE}"
|
||||
if [[ ! -f "$reshade_exe" ]] ; then
|
||||
print_warning "ReShade executable not found: $reshade_exe. Using $PW_EXE_FILE."
|
||||
reshade_exe="$PW_EXE_FILE"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 ]] ; then
|
||||
detect=$(python_module graphics_detector "$reshade_exe")
|
||||
file "$reshade_exe" | grep -qE 'x86-64|PE32\+' && arch=64 || arch=32
|
||||
highest=$(echo "$detect" | grep '^HIGHEST_DX=' | cut -d= -f2)
|
||||
ogl=$(echo "$detect" | grep '^USES_OGL=' | cut -d= -f2)
|
||||
vulkan=$(echo "$detect" | grep '^USES_VULKAN=' | cut -d= -f2)
|
||||
source=$(echo "$detect" | grep '^SOURCE=' | cut -d= -f2-)
|
||||
[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] && vulkan=false
|
||||
fi
|
||||
if [[ -n "$PW_RESHADE_EXE" ]] ; then
|
||||
PATH_TO_GAME="$(dirname "$(realpath "$reshade_exe")")"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 ]] \
|
||||
&& [[ ! -f "$runtime/ReShade32.dll" || ! -f "$runtime/ReShade32.json" \
|
||||
|| ! -f "$runtime/ReShade64.dll" || ! -f "$runtime/ReShade64.json" ]] ; then
|
||||
try_download "https://reshade.me/downloads/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
|
||||
"${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
|
||||
create_new_dir "$runtime"
|
||||
unpack "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
|
||||
"$runtime" silent || fatal "Failed to extract ReShade."
|
||||
try_remove_file "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3dcompiler_47.dll" ]] ; then
|
||||
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/d3dcompiler_47.dll" "$root/d3dcompiler_47.dll"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3d8.dll" ]] ; then
|
||||
try_download "https://github.com/crosire/d3d8to9/releases/download/v${PW_D3D8TO9_VER}/d3d8.dll" "$root/d3d8.dll"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -d "$root/shaders/Shaders" ]] ; then
|
||||
try_remove_dir "$root/shaders"
|
||||
create_new_dir "$root/shaders/Shaders"
|
||||
create_new_dir "$root/shaders/Textures"
|
||||
for package in reshade_shaders sweetfx_shaders immerse_shaders astrayfx_shaders prod80_shaders retroarch_shaders ; do
|
||||
extract_dir="${PORT_DATA_PATH}/data/tmp/$package"
|
||||
if [[ "$package" == immerse_shaders ]] ; then
|
||||
try_download "https://github.com/martymcmodding/iMMERSE/archive/refs/heads/main.tar.gz" \
|
||||
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
else
|
||||
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/${package}.tar.gz" \
|
||||
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
fi
|
||||
try_remove_dir "$extract_dir"
|
||||
create_new_dir "$extract_dir"
|
||||
unpack "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" "$extract_dir" \
|
||||
silent || fatal "Failed to extract $package."
|
||||
find "$extract_dir" -type d -iname Shaders -exec cp -rf '{}/.' "$root/shaders/Shaders/" \;
|
||||
find "$extract_dir" -type d -iname Textures -exec cp -rf '{}/.' "$root/shaders/Textures/" \;
|
||||
try_remove_dir "$extract_dir"
|
||||
try_remove_file "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
done
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && "$vulkan" == true ]] ; then
|
||||
dll="Vulkan${arch}"
|
||||
elif [[ "$PW_USE_RESHADE" == 1 ]] ; then
|
||||
case "$highest" in
|
||||
"DirectX 10"|"DirectX 11"|"DirectX 12") dll=dxgi.dll ;;
|
||||
"DirectX 8"|"DirectX 9") dll=d3d9.dll ;;
|
||||
"DirectDraw") return 0 ;;
|
||||
*) [[ "$ogl" == true ]] && dll=opengl32.dll || dll=dxgi.dll ;;
|
||||
esac
|
||||
fi
|
||||
case "$PW_RESHADE_API" in
|
||||
vulkan) vulkan=true; dll="Vulkan${arch}" ;;
|
||||
dxgi.dll|d3d9.dll|opengl32.dll) vulkan=false; dll="$PW_RESHADE_API" ;;
|
||||
esac
|
||||
if [[ -f "$PATH_TO_GAME/reshade_version" ]] \
|
||||
&& { [[ "$PW_USE_RESHADE" != 1 ]] \
|
||||
|| [[ "$(<"$PATH_TO_GAME/reshade_version")" != "${PW_RESHADE_VER}_Addon"$'\n'"$dll" ]]; } ; then
|
||||
installed_version=$(sed -n '1p' "$PATH_TO_GAME/reshade_version")
|
||||
installed=$(sed -n '2p' "$PATH_TO_GAME/reshade_version")
|
||||
if [[ "$installed" == Vulkan32 || "$installed" == Vulkan64 ]] ; then
|
||||
installed_arch=${installed#Vulkan}
|
||||
manifest_reg="Z:${root//\//\\}\\reshade\\${installed_version}\\ReShade${installed_arch}.json"
|
||||
manifest_reg=${manifest_reg//\\/\\\\\\\\}
|
||||
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" user
|
||||
if [[ "$installed_arch" == 32 ]] ; then
|
||||
get_and_set_reg_file --delete 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" system
|
||||
else
|
||||
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" system
|
||||
fi
|
||||
fi
|
||||
try_remove_file "$PATH_TO_GAME/$installed"
|
||||
if [[ -L "$PATH_TO_GAME/${installed}.b" \
|
||||
&& $(readlink -f "$PATH_TO_GAME/${installed}.b") == "$root/reshade/"* ]] ; then
|
||||
unlink "$PATH_TO_GAME/${installed}.b"
|
||||
fi
|
||||
[[ -e "$PATH_TO_GAME/${installed}.b" ]] \
|
||||
&& mv -f "$PATH_TO_GAME/${installed}.b" "$PATH_TO_GAME/$installed"
|
||||
[[ -L "$PATH_TO_GAME/d3dcompiler_47.dll" ]] && unlink "$PATH_TO_GAME/d3dcompiler_47.dll"
|
||||
[[ -L "$PATH_TO_GAME/d3d8.dll" ]] && unlink "$PATH_TO_GAME/d3d8.dll"
|
||||
[[ -e "$PATH_TO_GAME/d3d8.dll.b" ]] \
|
||||
&& mv -f "$PATH_TO_GAME/d3d8.dll.b" "$PATH_TO_GAME/d3d8.dll"
|
||||
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
|
||||
try_remove_file "$PATH_TO_GAME/ReShade.ini"
|
||||
try_remove_file "$PATH_TO_GAME/ReShadePreset.ini"
|
||||
try_remove_file "$PATH_TO_GAME/ReShade.log"
|
||||
try_remove_file "$PATH_TO_GAME/reshade_version"
|
||||
fi
|
||||
[[ "$PW_USE_RESHADE" != 1 ]] && return 0
|
||||
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
|
||||
try_force_link_dir "$root/shaders" "$PATH_TO_GAME/ReShade_shaders"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "EffectSearchPaths" '.\\ReShade_shaders\\Shaders'
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "TextureSearchPaths" '.\\ReShade_shaders\\Textures'
|
||||
if [[ -n "$PW_RESHADE_FONT" ]] ; then
|
||||
reshade_font=$(fc-match --format='%{file}' "$PW_RESHADE_FONT")
|
||||
else
|
||||
reshade_font=/usr/share/fonts/ttf/dejavu/DejaVuSans.ttf
|
||||
fi
|
||||
if [[ -f "$reshade_font" ]] ; then
|
||||
font_win="Z:${reshade_font//\//\\\\}"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "Font" "$font_win"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "EditorFont" "$font_win"
|
||||
elif [[ -n "$PW_RESHADE_FONT" ]] ; then
|
||||
print_warning "ReShade font not found: $PW_RESHADE_FONT"
|
||||
fi
|
||||
screenshot_path='.\\'
|
||||
if [[ -n "$PW_RESHADE_SCREENSHOT_PATH" ]] ; then
|
||||
create_new_dir "$PW_RESHADE_SCREENSHOT_PATH"
|
||||
screenshot_path="Z:${PW_RESHADE_SCREENSHOT_PATH//\//\\\\}"
|
||||
fi
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyScreenshot" "$PW_RESHADE_SCREENSHOT_KEY"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyOverlay" "$PW_RESHADE_OVERLAY_KEY"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "SCREENSHOT" "SavePath" "$screenshot_path"
|
||||
if [[ "$vulkan" == true ]] ; then
|
||||
manifest_reg="Z:${runtime//\//\\}\\ReShade${arch}.json"
|
||||
manifest_reg=${manifest_reg//\\/\\\\\\\\}
|
||||
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 user
|
||||
if [[ "$arch" == 32 ]] ; then
|
||||
get_and_set_reg_file --add 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
|
||||
else
|
||||
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
|
||||
fi
|
||||
var_winedlloverride_update "vulkan-1=n,b"
|
||||
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
|
||||
return 0
|
||||
fi
|
||||
[[ ! -f "$PATH_TO_GAME/reshade_version" \
|
||||
&& -e "$PATH_TO_GAME/$dll" && ! -e "$PATH_TO_GAME/${dll}.b" \
|
||||
&& ( ! -L "$PATH_TO_GAME/$dll" \
|
||||
|| $(readlink -f "$PATH_TO_GAME/$dll") != "$root/reshade/"* ) ]] \
|
||||
&& mv -f "$PATH_TO_GAME/$dll" "$PATH_TO_GAME/${dll}.b"
|
||||
try_force_link_file "$runtime/ReShade${arch}.dll" "$PATH_TO_GAME/$dll"
|
||||
try_force_link_file "$root/d3dcompiler_47.dll" "$PATH_TO_GAME/d3dcompiler_47.dll"
|
||||
var_winedlloverride_update "d3dcompiler_47=n;${dll%.*}=n,b"
|
||||
if [[ "$highest" == "DirectX 8" && "$dll" == d3d9.dll ]] ; then
|
||||
[[ -e "$PATH_TO_GAME/d3d8.dll" && ! -e "$PATH_TO_GAME/d3d8.dll.b" ]] && mv -f "$PATH_TO_GAME/d3d8.dll" "$PATH_TO_GAME/d3d8.dll.b"
|
||||
try_force_link_file "$root/d3d8.dll" "$PATH_TO_GAME/d3d8.dll"
|
||||
var_winedlloverride_update "d3d8=n,b"
|
||||
fi
|
||||
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
|
||||
}
|
||||
|
||||
pw_prepare_gamescope_launch () {
|
||||
[[ "$PW_USE_NATIVE_WAYLAND" == "1" ]] && export PW_GAMESCOPE="0"
|
||||
|
||||
@@ -2666,28 +2806,16 @@ pw_enable_gamemode_basic () {
|
||||
}
|
||||
|
||||
start_portproton () {
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
PATH_TO_GAME="$(dirname "$(realpath "${PW_EXE_FILE}")")"
|
||||
export PATH_TO_GAME
|
||||
export PORTWINE_DB_FILE="${PW_EXE_FILE}.ppdb"
|
||||
[[ -f "${PORTWINE_DB_FILE}" ]] && source "${PORTWINE_DB_FILE}"
|
||||
unset PW_PREFIX_NAME
|
||||
export WINEPREFIX="${STEAM_COMPAT_DATA_PATH}/pfx"
|
||||
fi
|
||||
|
||||
pw_check_and_download_plugins
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
export PW_USE_RUNTIME="0"
|
||||
export PW_USE_SUPPLIED_DXVK_VKD3D="1"
|
||||
elif [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
|
||||
if [[ "${PW_DISABLE_RUNTIME_DOWNLOAD}" == "1" ]] ; then
|
||||
try_remove_dir "${PORT_WINE_TMP_PATH}/libs${PW_LIBS_VER}"
|
||||
pw_check_and_download_dxvk_and_vkd3d
|
||||
else
|
||||
pw_check_and_download_runtime
|
||||
pw_check_and_download_dxvk_and_vkd3d
|
||||
fi
|
||||
|
||||
pw_check_and_download_dxvk_and_vkd3d
|
||||
|
||||
pw_skip_get_info
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
|
||||
[[ -z "${PW_USE_NVAPI_AND_DLSS:-}" ]] && export PW_USE_NVAPI_AND_DLSS="1"
|
||||
@@ -2702,9 +2830,7 @@ start_portproton () {
|
||||
export PW_LOCALE_SELECT="disabled"
|
||||
fi
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
|
||||
WINEPREFIX="$(readlink -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}")"
|
||||
fi
|
||||
WINEPREFIX="$(readlink -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}")"
|
||||
export WINEPREFIX
|
||||
|
||||
pw_get_pfx () {
|
||||
@@ -2741,7 +2867,7 @@ start_portproton () {
|
||||
return 0
|
||||
}
|
||||
|
||||
[[ "${PW_STEAMPLAY:-}" != "1" ]] && pw_get_pfx
|
||||
pw_get_pfx
|
||||
|
||||
var_winedlloverride_update "${PW_MUST_WINEDLLOVERRIDES}"
|
||||
pw_list_install_dll_in_prefix
|
||||
@@ -2857,55 +2983,53 @@ start_portproton () {
|
||||
print_info "The prefix has been updated."
|
||||
}
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
|
||||
if [[ ! -d "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/drive_c/windows ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/userdef.reg ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/system.reg ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/user.reg ]]
|
||||
then
|
||||
pw_clear_pfx
|
||||
check_dirs_and_files_in_pfx
|
||||
if [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
|
||||
&& [[ "${DISABLE_CP_DEFPFX}" != 1 ]] ; then
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
|
||||
&& [[ "${DISABLE_CP_DEFPFX}" == 1 ]] ; then
|
||||
pw_wineboot -i
|
||||
elif [[ "${DISABLE_CP_DEFPFX}" == "1" ]]; then
|
||||
pw_wineboot -i
|
||||
else
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
fi
|
||||
get_and_set_reg_file --add 'Control Panel\Desktop' 'LogPixels' 'REG_DWORD' "96" "user"
|
||||
if [[ ! -d "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/drive_c/windows ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/userdef.reg ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/system.reg ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}"/user.reg ]]
|
||||
then
|
||||
pw_clear_pfx
|
||||
check_dirs_and_files_in_pfx
|
||||
if [[ "${PW_PREFIX_NAME}" == "DEFAULT" ]] ; then
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
|
||||
&& [[ "${DISABLE_CP_DEFPFX}" != 1 ]] ; then
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
elif [[ "${PW_CHECK_AUTOINSTALL}" == 1 ]] \
|
||||
&& [[ "${DISABLE_CP_DEFPFX}" == 1 ]] ; then
|
||||
pw_wineboot -i
|
||||
elif [[ "${DISABLE_CP_DEFPFX}" == "1" ]]; then
|
||||
pw_wineboot -i
|
||||
else
|
||||
check_dirs_and_files_in_pfx
|
||||
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c/windows/system32/shell32.dll" ]] \
|
||||
|| ! grep "${PW_WINE_USE}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" &>/dev/null
|
||||
then
|
||||
pw_wineboot -r
|
||||
fi
|
||||
unpack "${PW_PLUGINS_PATH}/default_pfx.tar.xz" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/" silent
|
||||
pw_wineboot -r
|
||||
echo "based_on_default" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
fi
|
||||
|
||||
if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] \
|
||||
&& grep -q "based_on" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
then export PW_BASE_PFX="$(awk -F'based_on_' '$2 != "" {print $2}' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")"
|
||||
else export PW_BASE_PFX="none"
|
||||
get_and_set_reg_file --add 'Control Panel\Desktop' 'LogPixels' 'REG_DWORD' "96" "user"
|
||||
else
|
||||
check_dirs_and_files_in_pfx
|
||||
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" ]] \
|
||||
|| [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c/windows/system32/shell32.dll" ]] \
|
||||
|| ! grep "${PW_WINE_USE}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver" &>/dev/null
|
||||
then
|
||||
pw_wineboot -r
|
||||
fi
|
||||
|
||||
rm -f "${PORT_DATA_PATH}"/data/prefixes/*/drive_c/users/*/Desktop/*.lnk
|
||||
|
||||
echo "${PW_WINE_USE}" > "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver"
|
||||
fi
|
||||
|
||||
if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] \
|
||||
&& grep -q "based_on" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
then export PW_BASE_PFX="$(awk -F'based_on_' '$2 != "" {print $2}' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")"
|
||||
else export PW_BASE_PFX="none"
|
||||
fi
|
||||
|
||||
rm -f "${PORT_DATA_PATH}"/data/prefixes/*/drive_c/users/*/Desktop/*.lnk
|
||||
|
||||
echo "${PW_WINE_USE}" > "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/.wine_ver"
|
||||
|
||||
pw_set_sync_runtime_vars
|
||||
|
||||
# export WINE_SIMULATE_ASYNC_READ=1
|
||||
@@ -2999,6 +3123,7 @@ start_portproton () {
|
||||
|
||||
pw_optiscaler_sync_files
|
||||
pw_specialk_sync_files
|
||||
pw_reshade_sync_files
|
||||
|
||||
if [[ "${PW_USE_RAY_TRACING}" == "1" ]] ; then
|
||||
var_vkd3d_config_update dxr
|
||||
@@ -3010,13 +3135,8 @@ start_portproton () {
|
||||
fi
|
||||
|
||||
if [[ "${PW_USE_NVAPI_AND_DLSS}" == "1" ]] ; then
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
export PROTON_FORCE_NVAPI="1"
|
||||
export PROTON_HIDE_NVIDIA_GPU="0"
|
||||
else
|
||||
export DXVK_ENABLE_NVAPI="1"
|
||||
fi
|
||||
if [[ "${PW_STEAMPLAY:-}" != "1" ]] && echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
|
||||
export DXVK_ENABLE_NVAPI="1"
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
|
||||
FIND_NVNGX="$(dirname $(find /usr/* -type f -name "nvngx.dll" 2>/dev/null | head -n 1 | awk '{print $1}'))"
|
||||
if [[ -n "$FIND_NVNGX" ]] ; then
|
||||
try_copy_file_with_checksums "${FIND_NVNGX}/nvngx.dll" "${WINEPREFIX}/drive_c/windows/system32/nvngx.dll"
|
||||
@@ -3025,7 +3145,7 @@ start_portproton () {
|
||||
export NVIDIA_WINE_DLL_DIR="${FIND_NVNGX}"
|
||||
fi
|
||||
fi
|
||||
elif [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
|
||||
else
|
||||
export DXVK_ENABLE_NVAPI="0"
|
||||
fi
|
||||
|
||||
@@ -3039,11 +3159,9 @@ start_portproton () {
|
||||
else export WINE_ALLOW_XIM="0"
|
||||
fi
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
|
||||
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
|
||||
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
|
||||
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
|
||||
fi
|
||||
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
|
||||
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
|
||||
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
|
||||
fi
|
||||
|
||||
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
|
||||
@@ -3146,17 +3264,16 @@ start_portproton () {
|
||||
export PW_USE_US_LAYOUT="0"
|
||||
fi
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
|
||||
D3D_EXTRAS_LIBS="d3dcompiler_33 d3dcompiler_34 d3dcompiler_35 d3dcompiler_36 d3dcompiler_37
|
||||
d3dcompiler_38 d3dcompiler_39 d3dcompiler_40 d3dcompiler_41 d3dcompiler_42 d3dcompiler_43 d3dcompiler_46
|
||||
d3dcompiler_47 d3dx10_33 d3dx10_34 d3dx10_35 d3dx10_36 d3dx10_37 d3dx10_38 d3dx10_39 d3dx10_40 d3dx10_41
|
||||
d3dx10_42 d3dx10_43 d3dx10 d3dx11_42 d3dx11_43 d3dx9_24 d3dx9_25 d3dx9_26 d3dx9_27 d3dx9_28 d3dx9_29 d3dx9_30
|
||||
d3dx9_31 d3dx9_32 d3dx9_33 d3dx9_34 d3dx9_35 d3dx9_36 d3dx9_37 d3dx9_38 d3dx9_39 d3dx9_40 d3dx9_41 d3dx9_42 d3dx9_43"
|
||||
if [[ "${PW_USE_D3D_EXTRAS}" == "1" ]] ; then
|
||||
if [[ ! -f "${WINEPREFIX}/winetricks.log" ]] \
|
||||
|| [[ -z "$(grep d3dx9 "${WINEPREFIX}/winetricks.log")" ]]
|
||||
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] \
|
||||
|| [[ -z "$(grep d3dx9 "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")" ]]
|
||||
then
|
||||
echo "d3dx9" >> "${WINEPREFIX}/winetricks.log"
|
||||
echo "d3dx9" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
fi
|
||||
# try link d3d extras libs
|
||||
PATH_TO_D3DEXTRAS="${PW_PLUGINS_PATH}/d3d_extras"
|
||||
@@ -3164,8 +3281,8 @@ start_portproton () {
|
||||
try_force_link_file "${PATH_TO_D3DEXTRAS}/x64/${d3d_extras_from_plugins}.dll" "${WINEPREFIX}/drive_c/windows/system32/${d3d_extras_from_plugins}.dll"
|
||||
try_force_link_file "${PATH_TO_D3DEXTRAS}/x32/${d3d_extras_from_plugins}.dll" "${WINEPREFIX}/drive_c/windows/syswow64/${d3d_extras_from_plugins}.dll"
|
||||
var_winedlloverride_update "${d3d_extras_from_plugins}=n"
|
||||
if [[ -z "$(grep "${d3d_extras_from_plugins}" "${WINEPREFIX}/winetricks.log")" ]] ; then
|
||||
echo "${d3d_extras_from_plugins}" >> "${WINEPREFIX}/winetricks.log"
|
||||
if [[ -z "$(grep "${d3d_extras_from_plugins}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log")" ]] ; then
|
||||
echo "${d3d_extras_from_plugins}" >> "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
fi
|
||||
done
|
||||
else
|
||||
@@ -3665,7 +3782,6 @@ start_portproton () {
|
||||
else
|
||||
var_winedlloverride_update "wineopenxr,vrclient,vrclient_x64,openvr_api_dxvk="
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${PW_USE_SHADER_CACHE}" == "1" ]] ; then
|
||||
create_new_dir "${PW_VULKAN_DIR}/gl_shader_cache"
|
||||
@@ -3739,65 +3855,59 @@ start_portproton () {
|
||||
fi
|
||||
export LSFGVK_EXPERIMENTAL_PRESENT_MODE="$MESA_VK_WSI_PRESENT_MODE"
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then
|
||||
#run_winetricks_from_db
|
||||
if [[ -n "${PW_MUST_HAVE_DLL}" ]] ; then
|
||||
PW_DLL_INSTALL="$(echo "${PW_MUST_HAVE_DLL} ${PW_DLL_INSTALL}" | awk '{ for(i=1;i<=NF;i++){a[$i]++} }END{ for(i in a){printf("%s ",i)} }' )"
|
||||
export PW_DLL_INSTALL
|
||||
fi
|
||||
#run_winetricks_from_db
|
||||
if [[ -n "${PW_MUST_HAVE_DLL}" ]] ; then
|
||||
PW_DLL_INSTALL="$(echo "${PW_MUST_HAVE_DLL} ${PW_DLL_INSTALL}" | awk '{ for(i=1;i<=NF;i++){a[$i]++} }END{ for(i in a){printf("%s ",i)} }' )"
|
||||
export PW_DLL_INSTALL
|
||||
fi
|
||||
|
||||
if [[ -n "${PW_DLL_INSTALL}" ]] ; then
|
||||
export PW_DLL_NEED_INSTALL=""
|
||||
export USE_WT_FROM_DB=0
|
||||
if [[ ! -f "${WINEPREFIX}/winetricks.log" ]] ; then
|
||||
touch "${WINEPREFIX}/winetricks.log"
|
||||
if [[ -n "${PW_DLL_INSTALL}" ]] ; then
|
||||
export PW_DLL_NEED_INSTALL=""
|
||||
export USE_WT_FROM_DB=0
|
||||
if [[ ! -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" ]] ; then
|
||||
touch "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
fi
|
||||
for need_install_dll_to_pfx in ${PW_DLL_INSTALL} ; do
|
||||
if [[ "${need_install_dll_to_pfx}" == vcrun201[5-9] ]] \
|
||||
|| [[ "${need_install_dll_to_pfx}" == vcrun2022 ]] ; then
|
||||
need_install_dll_to_pfx="vcrun2022"
|
||||
sed -i '/vcrun2015/d' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
sed -i '/vcrun2017/d' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
sed -i '/vcrun2019/d' "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log"
|
||||
fi
|
||||
for need_install_dll_to_pfx in ${PW_DLL_INSTALL} ; do
|
||||
if [[ "${need_install_dll_to_pfx}" == vcrun201[5-9] ]] \
|
||||
|| [[ "${need_install_dll_to_pfx}" == vcrun2022 ]] ; then
|
||||
need_install_dll_to_pfx="vcrun2022"
|
||||
sed -i '/vcrun2015/d' "${WINEPREFIX}/winetricks.log"
|
||||
sed -i '/vcrun2017/d' "${WINEPREFIX}/winetricks.log"
|
||||
sed -i '/vcrun2019/d' "${WINEPREFIX}/winetricks.log"
|
||||
grep "${need_install_dll_to_pfx}" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/winetricks.log" &>/dev/null
|
||||
if [[ "$?" == "1" ]] ; then
|
||||
if [[ -z "${PW_DLL_NEED_INSTALL}" ]]
|
||||
then export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx}"
|
||||
else export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx} ${PW_DLL_NEED_INSTALL}"
|
||||
fi
|
||||
grep "${need_install_dll_to_pfx}" "${WINEPREFIX}/winetricks.log" &>/dev/null
|
||||
if [[ "$?" == "1" ]] ; then
|
||||
if [[ -z "${PW_DLL_NEED_INSTALL}" ]]
|
||||
then export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx}"
|
||||
else export PW_DLL_NEED_INSTALL="${need_install_dll_to_pfx} ${PW_DLL_NEED_INSTALL}"
|
||||
fi
|
||||
export USE_WT_FROM_DB=1
|
||||
fi
|
||||
done
|
||||
if [[ "${USE_WT_FROM_DB}" == "1" ]] ; then
|
||||
update_winetricks
|
||||
echo "START WINETRICKS..." >> "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
echo "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}" >> "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
print_info "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}"
|
||||
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
|
||||
"${PORT_WINE_TMP_PATH}/winetricks" -q -f ${PW_DLL_NEED_INSTALL} | tee -a "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
wait_wineserver
|
||||
kill_portwine
|
||||
export USE_WT_FROM_DB=1
|
||||
fi
|
||||
done
|
||||
if [[ "${USE_WT_FROM_DB}" == "1" ]] ; then
|
||||
update_winetricks
|
||||
echo "START WINETRICKS..." >> "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
echo "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}" >> "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
print_info "Try to install DLL in prefix: ${PW_DLL_NEED_INSTALL}"
|
||||
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
|
||||
"${PORT_WINE_TMP_PATH}/winetricks" -q -f ${PW_DLL_NEED_INSTALL} | tee -a "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
wait_wineserver
|
||||
kill_portwine
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "${WINEPREFIX}/system.reg" ]] \
|
||||
&& ! grep -iq "Microsoft Windows $PW_WINDOWS_VER" "${WINEPREFIX}/system.reg"
|
||||
if [[ -f "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/system.reg" ]] \
|
||||
&& ! grep -iq "Microsoft Windows $PW_WINDOWS_VER" "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/system.reg"
|
||||
then
|
||||
if [[ -n $PW_WINDOWS_VER ]] \
|
||||
&& [[ $(echo "$PW_WINDOWS_VER" | sed 's/.*/\L&/') == "xp" ]]
|
||||
then
|
||||
export PW_WINDOWS_VER="xp64"
|
||||
fi
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
"${PROTON_RUNNER}" run winecfg -v "$(echo "win${PW_WINDOWS_VER}" | sed 's/.*/\L&/')"
|
||||
else
|
||||
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}:${WINE_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
|
||||
"${WINELOADER}" winecfg -v "$(echo "win${PW_WINDOWS_VER}" | sed 's/.*/\L&/')"
|
||||
wait_wineserver
|
||||
kill_portwine
|
||||
fi
|
||||
${pw_runtime} LD_LIBRARY_PATH="${PW_LD_LIBRARY_PATH}:${WINE_LIBRARY_PATH}" GST_PLUGIN_SYSTEM_PATH_1_0="" \
|
||||
"${WINELOADER}" winecfg -v $(echo "win${PW_WINDOWS_VER}" | sed 's/.*/\L&/')
|
||||
wait_wineserver
|
||||
kill_portwine
|
||||
echo "Set to win${PW_WINDOWS_VER}"
|
||||
fi
|
||||
|
||||
@@ -4081,54 +4191,154 @@ pw_launch_wrapper () {
|
||||
}
|
||||
export -f pw_launch_wrapper
|
||||
|
||||
steamplay_launch () {
|
||||
pw_check_and_download_plugins
|
||||
|
||||
if [[ -n "${PW_EXE_FILE:-}" ]]; then
|
||||
local proton_runner
|
||||
local steamplay_ld_library_path
|
||||
cd "$(dirname "${PW_EXE_FILE}")"
|
||||
export PATH_TO_GAME="$(pwd)"
|
||||
export PORTWINE_DB_FILE="${PW_EXE_FILE}.ppdb"
|
||||
[[ -f "${PORTWINE_DB_FILE}" ]] && source "${PORTWINE_DB_FILE}"
|
||||
export PW_USE_SUPPLIED_DXVK_VKD3D="1"
|
||||
|
||||
steamplay_ld_library_path="${LD_LIBRARY_PATH}:${STEAM_RUNTIME_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
|
||||
[[ -d "${HOME}/.local/share/Steam/ubuntu12_32" ]] && steamplay_ld_library_path="${steamplay_ld_library_path}:${HOME}/.local/share/Steam/ubuntu12_32"
|
||||
[[ -d "${HOME}/.local/share/Steam/ubuntu12_64" ]] && steamplay_ld_library_path="${steamplay_ld_library_path}:${HOME}/.local/share/Steam/ubuntu12_64"
|
||||
|
||||
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
|
||||
unset MANGOHUD_CONFIG
|
||||
export MANGOHUD_CONFIGFILE="$PATH_TO_GAME/MangoHud.conf"
|
||||
print_info "Custom MangoHud.conf in use: $MANGOHUD_CONFIGFILE"
|
||||
elif [[ "${PW_MANGOHUD_USER_CONF}" == 1 ]] ; then
|
||||
pw_select_mangohud_user_conf
|
||||
fi
|
||||
|
||||
[[ "${PW_VKBASALT_USER_CONF}" == 1 ]] && pw_select_vkbasalt_user_conf
|
||||
|
||||
pw_optiscaler_sync_files
|
||||
pw_specialk_sync_files
|
||||
PORT_WINE_PREFIX="$STEAM_COMPAT_DATA_PATH/pfx"
|
||||
|
||||
pw_prepare_gamescope_launch
|
||||
|
||||
if check_gamescope_session ; then
|
||||
export PW_GAMEMODERUN_SLR=""
|
||||
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
|
||||
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
|
||||
then
|
||||
pw_enable_gamemode_basic
|
||||
fi
|
||||
|
||||
pw_enable_idle_inhibit
|
||||
|
||||
pw_set_sync_runtime_vars
|
||||
|
||||
# Mangohud, VkBasalt, LSFG
|
||||
if [[ "$PW_USE_SYSTEM_VK_LAYERS" == "1" ]] ; then
|
||||
unset PW_VK_LAYER_PATH
|
||||
else
|
||||
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
|
||||
fi
|
||||
|
||||
pw_mangohud_check
|
||||
pw_vkbasalt_check
|
||||
pw_lsfg_vk_check
|
||||
|
||||
export VK_ADD_IMPLICIT_LAYER_PATH="${PW_VK_LAYER_PATH}"
|
||||
export VK_ADD_LAYER_PATH="${PW_VK_LAYER_PATH}"
|
||||
export VK_INSTANCE_LAYERS="${PW_VK_INSTANCE_LAYERS}"
|
||||
|
||||
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
|
||||
|
||||
if [[ "${PW_WINE_USE,,}" != *proton* ]] ; then
|
||||
fatal "Steamplay don\`t work with ${PW_WINE_USE}!"
|
||||
else
|
||||
init_wine_ver
|
||||
fi
|
||||
|
||||
check_proton_runner () {
|
||||
if [[ -x "${STEAM_COMPAT_TOOL_PATHS%%:*}/proton" ]] ; then
|
||||
proton_runner="${STEAM_COMPAT_TOOL_PATHS%%:*}/proton"
|
||||
elif [[ "${PW_WINE_USE:-}" = /* ]] && [[ -x "${PW_WINE_USE}/proton" ]] ; then
|
||||
proton_runner="${PW_WINE_USE}/proton"
|
||||
elif [[ -x "${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton" ]] ; then
|
||||
proton_runner="${PORT_DATA_PATH}/data/dist/${PW_WINE_USE^^}/proton"
|
||||
export PW_WINE_USE="${PW_WINE_USE^^}"
|
||||
elif [[ -x "${WINEDIR}/proton" ]] ; then
|
||||
proton_runner="${WINEDIR}/proton"
|
||||
elif [[ -x "${WINEDIR%/files}/proton" ]] ; then
|
||||
proton_runner="${WINEDIR%/files}/proton"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if ! check_proton_runner ; then
|
||||
gui_proton_downloader "${PW_WINE_USE}"
|
||||
init_wine_ver
|
||||
if ! check_proton_runner ; then
|
||||
fatal "$PW_WINE_USE not found!"
|
||||
fi
|
||||
fi
|
||||
|
||||
pw_reshade_sync_files
|
||||
|
||||
# Winetricks
|
||||
if [[ -n "${PW_DLL_INSTALL:-}" ]] ; then
|
||||
update_winetricks
|
||||
PATH="${PATH}:${PW_PLUGINS_PATH}/portable/bin" LD_LIBRARY_PATH="${steamplay_ld_library_path}" \
|
||||
"${PORT_WINE_TMP_PATH}/winetricks" -q ${PW_DLL_INSTALL}
|
||||
fi
|
||||
|
||||
# Virtual Desktop
|
||||
if [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
|
||||
if [[ "${PW_SCREEN_RESOLUTION:-}" != *x* ]] ; then
|
||||
if [[ -f "${PW_TMPFS_PATH}/xrandr.tmp" ]] ; then
|
||||
PW_SCREEN_RESOLUTION="$(sed -rn 's/^.*primary.* ([0-9]+x[0-9]+).*$/\1/p' "${PW_TMPFS_PATH}/xrandr.tmp")"
|
||||
fi
|
||||
[[ "${PW_SCREEN_RESOLUTION:-}" != *x* ]] && PW_SCREEN_RESOLUTION="1920x1080"
|
||||
fi
|
||||
"${proton_runner}" "run" reg add "HKEY_CURRENT_USER\\Software\\Wine\\Explorer\\Desktops" /v "PortProton" /d "${PW_SCREEN_RESOLUTION}" /f &>/dev/null
|
||||
"${proton_runner}" "run" reg add "HKEY_CURRENT_USER\\Software\\Wine\\Explorer" /v "Desktop" /d "PortProton" /f &>/dev/null
|
||||
print_info "Steam Play virtual desktop enabled in registry: ${PW_SCREEN_RESOLUTION}"
|
||||
else
|
||||
"${proton_runner}" "run" reg delete "HKEY_CURRENT_USER\\Software\\Wine\\Explorer" /v "Desktop" /f &>/dev/null
|
||||
"${proton_runner}" "run" reg delete "HKEY_CURRENT_USER\\Software\\Wine\\Explorer\\Desktops" /v "PortProton" /f &>/dev/null
|
||||
print_info "Steam Play virtual desktop disabled in registry"
|
||||
fi
|
||||
|
||||
print_info "$proton_runner in use."
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
pw_launch_wrapper \
|
||||
${PW_RUN_GAMESCOPE} \
|
||||
env \
|
||||
LD_LIBRARY_PATH="${steamplay_ld_library_path}" \
|
||||
LD_PRELOAD="${LD_PRELOAD:+$LD_PRELOAD:}${PW_LD_PRELOAD}" \
|
||||
${PW_ADD_VAR_SLR} \
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
|
||||
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
|
||||
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
|
||||
debug_timer --end -s "PW_TIME_IN_GAME"
|
||||
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
|
||||
search_desktop_file
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
portwine_launch () {
|
||||
start_portproton
|
||||
unset PW_VD_TMP
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
if [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
|
||||
[[ "${PW_SCREEN_RESOLUTION:-}" != *x* ]] && PW_SCREEN_RESOLUTION="1920x1080"
|
||||
get_and_set_reg_file --add 'Software\Wine\Explorer\Desktops' 'PortProton' 'REG_SZ' "${PW_SCREEN_RESOLUTION}" "user"
|
||||
get_and_set_reg_file --add 'Software\Wine\Explorer' 'Desktop' 'REG_SZ' "PortProton" "user"
|
||||
else
|
||||
get_and_set_reg_file --delete 'Software\Wine\Explorer' 'Desktop' '' '' "user"
|
||||
get_and_set_reg_file --delete 'Software\Wine\Explorer\Desktops' 'PortProton' '' '' "user"
|
||||
fi
|
||||
elif [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
|
||||
if [[ "${PW_VIRTUAL_DESKTOP}" == "1" ]] ; then
|
||||
PW_VD_TMP=(explorer "/desktop=PortProton,${PW_SCREEN_RESOLUTION}")
|
||||
fi
|
||||
|
||||
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] \
|
||||
&& [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]]
|
||||
then
|
||||
printf -v PROTON_REMOTE_DEBUG_CMD '%q' "${PW_RUN_AFTER_EXE}"
|
||||
export PROTON_REMOTE_DEBUG_CMD
|
||||
export PRESSURE_VESSEL_FILESYSTEMS_RW="${PRESSURE_VESSEL_FILESYSTEMS_RW:+$PRESSURE_VESSEL_FILESYSTEMS_RW:}$(dirname "${PW_RUN_AFTER_EXE}")"
|
||||
fi
|
||||
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
local steamplay_ld_library_path
|
||||
cd "${PATH_TO_GAME}" || fatal "${PATH_TO_GAME} not found"
|
||||
steamplay_ld_library_path="${LD_LIBRARY_PATH}:${STEAM_RUNTIME_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
|
||||
${PW_RUN_GAMESCOPE} \
|
||||
env \
|
||||
LD_LIBRARY_PATH="${steamplay_ld_library_path}" \
|
||||
LD_PRELOAD="${LD_PRELOAD:+$LD_PRELOAD:}${PW_LD_PRELOAD}" \
|
||||
VK_ADD_IMPLICIT_LAYER_PATH="${PW_VK_LAYER_PATH}" \
|
||||
VK_ADD_LAYER_PATH="${PW_VK_LAYER_PATH}" \
|
||||
VK_INSTANCE_LAYERS="${PW_VK_INSTANCE_LAYERS}" \
|
||||
${PW_ADD_VAR_SLR} \
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_INHIBIT_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${PROTON_RUNNER}" waitforexitandrun "${PW_EXE_FILE}" "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
if [[ -n "${LAUNCH_URI}" ]] ; then
|
||||
export WINEFSYNC="1"
|
||||
pw_run start /high /b "${LAUNCH_URI}" &
|
||||
@@ -4289,7 +4499,9 @@ gui_edit_db () {
|
||||
KEY_EDIT_DB_GUI=$RANDOM
|
||||
DISABLE_EDIT_DB_LIST=""
|
||||
PW_EDIT_DB_LIST=(PW_MANGOHUD PW_MANGOHUD_USER_CONF PW_VKBASALT PW_VKBASALT_USER_CONF
|
||||
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK
|
||||
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK PW_USE_RESHADE
|
||||
PW_RESHADE_EXE PW_RESHADE_API PW_RESHADE_FONT PW_RESHADE_SCREENSHOT_PATH
|
||||
PW_RESHADE_SCREENSHOT_KEY PW_RESHADE_OVERLAY_KEY
|
||||
PW_USE_ESYNC PW_USE_FSYNC PW_USE_NTSYNC PW_USE_RAY_TRACING PW_USE_NVAPI_AND_DLSS PW_USE_OPTISCALER
|
||||
PW_USE_LS_FRAME_GEN PW_WINE_FULLSCREEN_FSR PW_HIDE_NVIDIA_GPU PW_VIRTUAL_DESKTOP PW_USE_TERMINAL
|
||||
PW_USE_GAMEMODE PW_USE_INHIBIT_SLEEP PW_USE_D3D_EXTRAS PW_FIX_VIDEO_IN_GAME PW_REDUCE_PULSE_LATENCY
|
||||
@@ -4323,6 +4535,10 @@ gui_edit_db () {
|
||||
DISABLE_EDIT_DB_LIST+=" PW_VKBASALT PW_VKBASALT_USER_CONF PW_USE_RAY_TRACING PW_USE_OBS_VKCAPTURE PW_USE_GALLIUM_ZINK PW_USE_WINED3D_VULKAN"
|
||||
fi
|
||||
[[ "${PW_USE_SPECIALK}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_DGVOODOO2"
|
||||
[[ "${PW_USE_RESHADE}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"
|
||||
if [[ "${PW_USE_OPTISCALER}" == "1" || "${PW_USE_SPECIALK}" == "1" ]] ; then
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"
|
||||
fi
|
||||
|
||||
[[ ! -e "/dev/ntsync" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_NTSYNC"
|
||||
|
||||
|
||||
@@ -198,11 +198,10 @@ try_remove_file "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
[[ ! -f "$PORT_WINE_TMP_PATH/statistics" ]] && touch "$PORT_WINE_TMP_PATH/statistics"
|
||||
|
||||
if [[ -n "${STEAM_COMPAT_DATA_PATH:-}" ]]; then
|
||||
export PW_STEAMPLAY="1"
|
||||
export WINEPREFIX="${STEAM_COMPAT_DATA_PATH}/pfx"
|
||||
else
|
||||
unset WINEPREFIX
|
||||
steamplay_launch "${@:2}"
|
||||
exit
|
||||
fi
|
||||
unset WINEPREFIX
|
||||
|
||||
# choose mirror
|
||||
if [[ -z "$MIRROR" ]] \
|
||||
@@ -581,11 +580,7 @@ Usage examples:
|
||||
exit 0
|
||||
;;
|
||||
--launch)
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
portwine_launch "${@:3}"
|
||||
else
|
||||
portwine_launch
|
||||
fi
|
||||
portwine_launch
|
||||
stop_portproton
|
||||
;;
|
||||
--stop)
|
||||
@@ -599,11 +594,7 @@ Usage examples:
|
||||
;;
|
||||
*)
|
||||
if [[ -f "$PW_EXE_FILE" ]] ; then
|
||||
if [[ "${PW_STEAMPLAY:-}" == "1" ]] ; then
|
||||
portwine_launch "${@:2}"
|
||||
else
|
||||
portwine_launch
|
||||
fi
|
||||
portwine_launch
|
||||
stop_portproton
|
||||
else
|
||||
fatal "File not found: $PW_EXE_FILE"
|
||||
|
||||
@@ -16,6 +16,8 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
|
||||
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
|
||||
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
|
||||
export PW_SPECIALK_VER="SK_26_5_15"
|
||||
export PW_RESHADE_VER="6.8.0"
|
||||
export PW_D3D8TO9_VER="1.15.1"
|
||||
export PW_GALLIUM_NINE_VER="0.10"
|
||||
export DOTPFX_VER="48v9"
|
||||
export DEFPFX_VER="v1"
|
||||
@@ -33,6 +35,13 @@ check_variables PROTON_USE_XALIA "0"
|
||||
check_variables PW_USE_RUNTIME "1"
|
||||
check_variables PW_USE_D7VK "0"
|
||||
check_variables PW_USE_SPECIALK "0"
|
||||
check_variables PW_USE_RESHADE "0"
|
||||
check_variables PW_RESHADE_API "auto"
|
||||
check_variables PW_RESHADE_EXE ""
|
||||
check_variables PW_RESHADE_FONT ""
|
||||
check_variables PW_RESHADE_SCREENSHOT_PATH ""
|
||||
check_variables PW_RESHADE_SCREENSHOT_KEY "44,0,0,0"
|
||||
check_variables PW_RESHADE_OVERLAY_KEY "36,0,0,0"
|
||||
check_variables PW_USE_ESYNC "0"
|
||||
check_variables PW_USE_FSYNC "0"
|
||||
check_variables PW_USE_NTSYNC "0"
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops cold war",
|
||||
"status": "Running",
|
||||
"status": "Broken",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
@@ -1286,7 +1286,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "nikke goddess of victory",
|
||||
"status": "Running",
|
||||
"status": "Broken",
|
||||
"slug": "nikke"
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
+796
-20228
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+10
-205
@@ -384,16 +384,6 @@
|
||||
"normalized_name": "automobilista 2",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 8729,
|
||||
"normalized_name": "autonauts",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 8640,
|
||||
"normalized_name": "avatar frontiers of pandora",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3568,
|
||||
"normalized_name": "avowed",
|
||||
@@ -414,11 +404,6 @@
|
||||
"normalized_name": "ayasa shadows of silence",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 43802,
|
||||
"normalized_name": "ball x pit",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2352,
|
||||
"normalized_name": "biomutant",
|
||||
@@ -444,11 +429,6 @@
|
||||
"normalized_name": "bad day l.a.",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116179,
|
||||
"normalized_name": "baldo the guardian owls",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 27439,
|
||||
"normalized_name": "baldur's gate dark alliance ii",
|
||||
@@ -554,11 +534,6 @@
|
||||
"normalized_name": "battlefield v",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6953,
|
||||
"normalized_name": "beacon pines",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3003,
|
||||
"normalized_name": "bendy and the dark revival",
|
||||
@@ -944,11 +919,6 @@
|
||||
"normalized_name": "call of juarez",
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 128972,
|
||||
"normalized_name": "captain contraption's chocolate factory",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2554,
|
||||
"normalized_name": "carx street",
|
||||
@@ -1019,16 +989,6 @@
|
||||
"normalized_name": "clair obscur expedition 33",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1389,
|
||||
"normalized_name": "clicker heroes",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 95679,
|
||||
"normalized_name": "clive barker's hellraiser revival",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 137209,
|
||||
"normalized_name": "clive barker's jericho",
|
||||
@@ -1639,11 +1599,6 @@
|
||||
"normalized_name": "doomsday × attack on titan",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 22403,
|
||||
"normalized_name": "down in bermuda",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 137816,
|
||||
"normalized_name": "downtown run",
|
||||
@@ -1834,11 +1789,6 @@
|
||||
"normalized_name": "everspace 2",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 32237,
|
||||
"normalized_name": "echo weaver",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 135061,
|
||||
"normalized_name": "eclipse",
|
||||
@@ -1879,11 +1829,6 @@
|
||||
"normalized_name": "enclave",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142352,
|
||||
"normalized_name": "end of abyss",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1499,
|
||||
"normalized_name": "enshrouded",
|
||||
@@ -1954,21 +1899,6 @@
|
||||
"normalized_name": "expeditions a mudrunner game",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144749,
|
||||
"normalized_name": "eye of the beholder",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144750,
|
||||
"normalized_name": "eye of the beholder ii the legend of darkmoon",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144751,
|
||||
"normalized_name": "eye of the beholder iii assault on myth drannor",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2102,
|
||||
"normalized_name": "f.e.a.r.",
|
||||
@@ -1984,11 +1914,6 @@
|
||||
"normalized_name": "f.e.a.r. perseus mandate",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 6674,
|
||||
"normalized_name": "fatal frame / project zero maiden of black water",
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 122228,
|
||||
"normalized_name": "final fantasy xvi",
|
||||
@@ -1999,11 +1924,6 @@
|
||||
"normalized_name": "foundry",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6470,
|
||||
"normalized_name": "fpv kamikaze drone",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 25751,
|
||||
"normalized_name": "front mission 1st remake",
|
||||
@@ -2019,11 +1939,6 @@
|
||||
"normalized_name": "ftl faster than light",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 57304,
|
||||
"normalized_name": "futa runner big dick",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2088,
|
||||
"normalized_name": "fable anniversary",
|
||||
@@ -2062,7 +1977,7 @@
|
||||
{
|
||||
"id": 3730,
|
||||
"normalized_name": "fallout tactics brotherhood of steel",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 1586,
|
||||
@@ -2097,7 +2012,7 @@
|
||||
{
|
||||
"id": 1509,
|
||||
"normalized_name": "far cry 4",
|
||||
"overall_rating": "silver"
|
||||
"overall_rating": "broken"
|
||||
},
|
||||
{
|
||||
"id": 112335,
|
||||
@@ -2134,11 +2049,6 @@
|
||||
"normalized_name": "fearmonium",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142741,
|
||||
"normalized_name": "femboy futa house",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3557,
|
||||
"normalized_name": "final fantasy iii (3d remake)",
|
||||
@@ -2487,7 +2397,7 @@
|
||||
{
|
||||
"id": 134441,
|
||||
"normalized_name": "hard truck 2 king of the road",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 2141,
|
||||
@@ -2520,13 +2430,8 @@
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116886,
|
||||
"id": 135348,
|
||||
"normalized_name": "headquarters world war ii",
|
||||
"overall_rating": null
|
||||
},
|
||||
{
|
||||
"id": 95965,
|
||||
"normalized_name": "hearts of iron iv",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
@@ -2574,11 +2479,6 @@
|
||||
"normalized_name": "heroes of science and fiction",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1930,
|
||||
"normalized_name": "heroes of might & magic iii hd",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3035,
|
||||
"normalized_name": "hi fi rush",
|
||||
@@ -2879,11 +2779,6 @@
|
||||
"normalized_name": "kiborg",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144768,
|
||||
"normalized_name": "kamikaze strike fpv drone",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 118598,
|
||||
"normalized_name": "kane & lynch 2 dog days",
|
||||
@@ -3259,11 +3154,6 @@
|
||||
"normalized_name": "maid of the dead",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 117468,
|
||||
"normalized_name": "maneater",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3619,
|
||||
"normalized_name": "manhunt",
|
||||
@@ -3454,11 +3344,6 @@
|
||||
"normalized_name": "mirror's edge catalyst",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 73042,
|
||||
"normalized_name": "miss!don't affect my rank",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 71287,
|
||||
"normalized_name": "modern warships",
|
||||
@@ -3504,11 +3389,6 @@
|
||||
"normalized_name": "mudrunner",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2747,
|
||||
"normalized_name": "mutant year zero road to eden",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2061,
|
||||
"normalized_name": "my summer car",
|
||||
@@ -3804,11 +3684,6 @@
|
||||
"normalized_name": "overgrowth",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 121097,
|
||||
"normalized_name": "overlord",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 23742,
|
||||
"normalized_name": "overthrown",
|
||||
@@ -3994,11 +3869,6 @@
|
||||
"normalized_name": "praetorians hd remaster",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 5719,
|
||||
"normalized_name": "praey for the gods",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 83824,
|
||||
"normalized_name": "prehistorik",
|
||||
@@ -4014,11 +3884,6 @@
|
||||
"normalized_name": "prey",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 7107,
|
||||
"normalized_name": "prince of persia the lost crown",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3646,
|
||||
"normalized_name": "prince of persia the sands of time",
|
||||
@@ -4075,9 +3940,9 @@
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 28860,
|
||||
"id": 134787,
|
||||
"normalized_name": "quartet",
|
||||
"overall_rating": null
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 8765,
|
||||
@@ -4479,11 +4344,6 @@
|
||||
"normalized_name": "star wars zero company",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1673,
|
||||
"normalized_name": "star wars battlefront ii",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116330,
|
||||
"normalized_name": "star wars empire at war gold pack",
|
||||
@@ -4664,11 +4524,6 @@
|
||||
"normalized_name": "shadow gambit the cursed crew",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 9068,
|
||||
"normalized_name": "shadow man",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1745,
|
||||
"normalized_name": "shadow of the tomb raider",
|
||||
@@ -4759,11 +4614,6 @@
|
||||
"normalized_name": "sleeping dogs",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3137,
|
||||
"normalized_name": "sniper elite",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1624,
|
||||
"normalized_name": "sniper elite 3",
|
||||
@@ -4804,11 +4654,6 @@
|
||||
"normalized_name": "sonic heroes",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2065,
|
||||
"normalized_name": "sonic mania",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 4357,
|
||||
"normalized_name": "sorcerer king rivals",
|
||||
@@ -5049,11 +4894,6 @@
|
||||
"normalized_name": "stoneshard",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144782,
|
||||
"normalized_name": "stranger things 3 the game",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 142281,
|
||||
"normalized_name": "stranglehold",
|
||||
@@ -5199,11 +5039,6 @@
|
||||
"normalized_name": "tron 2.0",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 3256,
|
||||
"normalized_name": "tails of iron",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 2892,
|
||||
"normalized_name": "tainted grail the fall of avalon",
|
||||
@@ -5294,11 +5129,6 @@
|
||||
"normalized_name": "the alters",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144742,
|
||||
"normalized_name": "the amazing spider man (2012)",
|
||||
"overall_rating": "bronze"
|
||||
},
|
||||
{
|
||||
"id": 1873,
|
||||
"normalized_name": "the ascent",
|
||||
@@ -5334,11 +5164,6 @@
|
||||
"normalized_name": "the callisto protocol",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 144752,
|
||||
"normalized_name": "the chronicles of riddick assault on dark athena",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 6140,
|
||||
"normalized_name": "the crust",
|
||||
@@ -5429,11 +5254,6 @@
|
||||
"normalized_name": "the gold river project",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 95161,
|
||||
"normalized_name": "the imperial gatekeeper",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 122565,
|
||||
"normalized_name": "the invincible",
|
||||
@@ -5447,7 +5267,7 @@
|
||||
{
|
||||
"id": 1898,
|
||||
"normalized_name": "the last remnant",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "silver"
|
||||
},
|
||||
{
|
||||
"id": 117639,
|
||||
@@ -5854,11 +5674,6 @@
|
||||
"normalized_name": "tropico 2 pirate cove",
|
||||
"overall_rating": "gold"
|
||||
},
|
||||
{
|
||||
"id": 131874,
|
||||
"normalized_name": "tropico 5",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 87300,
|
||||
"normalized_name": "truck driver",
|
||||
@@ -5982,7 +5797,7 @@
|
||||
{
|
||||
"id": 143163,
|
||||
"normalized_name": "vk play",
|
||||
"overall_rating": "platinum"
|
||||
"overall_rating": "bronze"
|
||||
},
|
||||
{
|
||||
"id": 2997,
|
||||
@@ -6229,11 +6044,6 @@
|
||||
"normalized_name": "whiskerwood",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 107512,
|
||||
"normalized_name": "wild west pioneers",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 116847,
|
||||
"normalized_name": "will rock",
|
||||
@@ -6257,7 +6067,7 @@
|
||||
{
|
||||
"id": 138835,
|
||||
"normalized_name": "witcher 3 wild hunt",
|
||||
"overall_rating": "gold"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 6964,
|
||||
@@ -6299,11 +6109,6 @@
|
||||
"normalized_name": "world of tanks",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 1335,
|
||||
"normalized_name": "world of tanks blitz",
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 131549,
|
||||
"normalized_name": "world of warcraft",
|
||||
@@ -6472,7 +6277,7 @@
|
||||
{
|
||||
"id": 10633,
|
||||
"normalized_name": "калибр",
|
||||
"overall_rating": "silver"
|
||||
"overall_rating": "platinum"
|
||||
},
|
||||
{
|
||||
"id": 138882,
|
||||
|
||||
Binary file not shown.
@@ -4,7 +4,6 @@ import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -168,8 +167,7 @@ class GameCardAnimations:
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
if not isinstance(QApplication.focusWidget(), QLineEdit):
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
+9
-18
@@ -313,13 +313,6 @@ def main():
|
||||
|
||||
# Launch game via PortProton without GUI
|
||||
env_vars = os.environ.copy()
|
||||
from portprotonqt.steam_api import get_steam_runtime_library_paths
|
||||
steam_runtime_paths = get_steam_runtime_library_paths()
|
||||
if steam_runtime_paths:
|
||||
runtime_path = env_vars.get("STEAM_RUNTIME_LIBRARY_PATH", "")
|
||||
env_vars["STEAM_RUNTIME_LIBRARY_PATH"] = os.pathsep.join(
|
||||
path for path in [runtime_path, *steam_runtime_paths] if path
|
||||
)
|
||||
cmd = start_sh + [exe_path] + parsed_args.launch_args
|
||||
try:
|
||||
subprocess.run(cmd, env=env_vars)
|
||||
@@ -814,14 +807,13 @@ def main():
|
||||
return
|
||||
QMessageBox.warning(window, _("Error"), _("Failed to update AppImage."))
|
||||
|
||||
if not is_silent_launch:
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
|
||||
class GOGDLUpdateWorker(QThread):
|
||||
"""Update gogdl without blocking the UI thread."""
|
||||
@@ -908,15 +900,14 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
if start_sh:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
elif not start_sh:
|
||||
else:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Cache configuration and management."""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -117,7 +118,7 @@ class CacheManager:
|
||||
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
|
||||
try:
|
||||
if pretty:
|
||||
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
|
||||
content = json.dumps(data, indent=2).encode("utf-8")
|
||||
else:
|
||||
content = orjson.dumps(data)
|
||||
with _CACHE_FILE_LOCK:
|
||||
|
||||
@@ -519,15 +519,6 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
def get_disable_cyrillic_path_check(self) -> bool:
|
||||
"""Get Cyrillic launch path check setting."""
|
||||
return self._get_bool("disable_cyrillic_path_check", False)
|
||||
|
||||
def set_disable_cyrillic_path_check(self, enabled: bool) -> None:
|
||||
"""Set Cyrillic launch path check setting."""
|
||||
validate_bool(enabled, "disable_cyrillic_path_check")
|
||||
self._save_value("disable_cyrillic_path_check", enabled, "bool")
|
||||
|
||||
def get_disable_alt_i586_dependency_check(self) -> bool:
|
||||
"""Get ALT i586 dependency check setting."""
|
||||
return self._get_bool("disable_alt_i586_dependency_check", False)
|
||||
|
||||
@@ -4,8 +4,8 @@ import shutil
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
@@ -68,14 +68,6 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.game_library_manager = game_library_manager
|
||||
self._batch_action = False
|
||||
self.card_selection_mode = False
|
||||
self._card_actions: list[QAction] = []
|
||||
self._card_action_menus: list[QMenu] = []
|
||||
self._card_actions_stopping = False
|
||||
self._card_action_timer = QTimer(parent)
|
||||
self._card_action_timer.setSingleShot(True)
|
||||
self._card_action_timer.timeout.connect(self._run_next_card_action)
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.signals = ContextMenuSignals()
|
||||
self.signals.show_warning_dialog.connect(
|
||||
@@ -457,92 +449,7 @@ class ContextMenuManager:
|
||||
return self.theme_manager.get_colored_icon_path(icon_name, color)
|
||||
return None
|
||||
|
||||
def clear_card_selection(self) -> None:
|
||||
self.card_selection_mode = False
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if card.batch_selected:
|
||||
card.batch_selected = False
|
||||
card.update()
|
||||
|
||||
def start_card_selection(self, game_card: "GameCard") -> None:
|
||||
self.clear_card_selection()
|
||||
self.card_selection_mode = True
|
||||
game_card.batch_selected = True
|
||||
game_card.update()
|
||||
game_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _start_card_actions(self, actions: list[QAction], menus: list[QMenu]) -> None:
|
||||
if self._batch_action or self._card_actions_stopping:
|
||||
return
|
||||
self.clear_card_selection()
|
||||
self._batch_action = True
|
||||
self._card_actions = list(reversed(actions))
|
||||
self._card_action_menus = menus
|
||||
for menu in menus:
|
||||
menu.setParent(self.parent, menu.windowFlags())
|
||||
self._run_next_card_action()
|
||||
|
||||
def _run_next_card_action(self) -> None:
|
||||
if not self._batch_action:
|
||||
return
|
||||
jobs = [getattr(self.parent, name, None) for name in (
|
||||
"egs_process", "gog_process", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker",
|
||||
)]
|
||||
jobs.extend(getattr(self.parent, "gog_support_workers", []))
|
||||
for job in jobs:
|
||||
if job is None or not isinstance(job, (QProcess, QThread)):
|
||||
continue
|
||||
job.finished.connect(lambda *args: self._card_action_timer.start() if not self._card_actions_stopping else None)
|
||||
if ((isinstance(job, QProcess) and job.state() == QProcess.ProcessState.NotRunning)
|
||||
or (isinstance(job, QThread) and not job.isRunning())):
|
||||
self._card_action_timer.start()
|
||||
return
|
||||
if self._card_actions_stopping or not self._card_actions:
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
self._card_actions = []
|
||||
return
|
||||
self._card_actions.pop().trigger()
|
||||
self._card_action_timer.start()
|
||||
|
||||
def stop_card_actions(self) -> None:
|
||||
self._card_actions_stopping = True
|
||||
self._card_action_timer.stop()
|
||||
self._card_actions = []
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
|
||||
def _show_card_selection_menu(self, menu: QMenu, game_card: "GameCard", pos: QPoint) -> None:
|
||||
grouped: dict[str, list[QAction]] = {}
|
||||
menus: list[QMenu] = []
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if not card.batch_selected:
|
||||
continue
|
||||
card_menu = self.show_context_menu(card, pos, build_only=True)
|
||||
if card_menu is None:
|
||||
continue
|
||||
card_menu.setParent(menu, card_menu.windowFlags())
|
||||
menus.append(card_menu)
|
||||
for action in card_menu.actions():
|
||||
if not action.isSeparator() and action.isEnabled():
|
||||
grouped.setdefault(action.text(), []).append(action)
|
||||
for text, actions in grouped.items():
|
||||
action = menu.addAction(actions[0].icon(), text)
|
||||
action.setEnabled(not self._batch_action)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
|
||||
)
|
||||
menu.addSeparator()
|
||||
cancel_action = menu.addAction(_("Cancel"))
|
||||
cancel_action.triggered.connect(self.clear_card_selection)
|
||||
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint, build_only: bool = False):
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
@@ -556,16 +463,6 @@ class ContextMenuManager:
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
if not build_only:
|
||||
if self.card_selection_mode or game_card.batch_selected:
|
||||
self._show_card_selection_menu(menu, game_card, pos)
|
||||
return
|
||||
self.clear_card_selection()
|
||||
selection_action = menu.addAction(_("Select multiple"))
|
||||
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
|
||||
menu.setActiveAction(selection_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
if game_card.game_source == "portproton":
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -574,8 +471,6 @@ class ContextMenuManager:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
if build_only:
|
||||
return menu
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
@@ -814,9 +709,6 @@ class ContextMenuManager:
|
||||
else self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if build_only:
|
||||
return menu
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
@@ -1162,9 +1054,6 @@ class ContextMenuManager:
|
||||
self.game_library_manager.remove_game_incremental(game_name, card_exec_line or resolved_exec_line)
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
if self._batch_action:
|
||||
self._delete_game_without_confirm(game_name, exec_line)
|
||||
return
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Confirm Deletion"))
|
||||
@@ -1734,13 +1623,16 @@ class ContextMenuManager:
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1762,8 +1654,6 @@ class ContextMenuManager:
|
||||
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
"""Base dialogs for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
import tempfile
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
import psutil
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
THEMED_LAUNCH_ICON_NAMES,
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
get_portproton_location,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -38,7 +28,6 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
@@ -136,229 +125,9 @@ class GameLaunchDialog(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class GameFolderScanWorker(QThread):
|
||||
"""Find executable files recursively and report scanned directory counts."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, folder: str, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.folder = folder
|
||||
self.games: list[tuple[str, str]] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
MAX_SCAN_DEPTH = 5
|
||||
MAX_SCAN_DIRECTORIES = 400
|
||||
GAME_SUBDIRECTORIES = frozenset((
|
||||
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
|
||||
))
|
||||
|
||||
def run(self) -> None:
|
||||
for directory, paths in self._find_executables(self.folder).items():
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
executable = max(paths, key=lambda path: self._executable_score(path, directory))
|
||||
self.games.append((self._game_name(executable, directory), executable))
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
self.games.sort(key=lambda game: game[1].casefold())
|
||||
|
||||
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
|
||||
name = Path(executable).stem.casefold()
|
||||
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
|
||||
key = re.sub(r"[\W_]", "", name)
|
||||
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
|
||||
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
|
||||
depth = len(Path(executable).relative_to(directory).parts)
|
||||
return match - int(launcher), -depth, os.path.getsize(executable)
|
||||
|
||||
def _executable_name(self, executable: str) -> str:
|
||||
try:
|
||||
with pefile.PE(executable, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", [])]
|
||||
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for table in tables for key, value in table.entries.items()}
|
||||
except pefile.PEFormatError:
|
||||
return ""
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", executable, error)
|
||||
return ""
|
||||
for field in ("ProductName", "FileDescription"):
|
||||
value = metadata.get(field, "").strip()
|
||||
if value and not re.fullmatch(
|
||||
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _game_name(self, executable: str, directory: str) -> str:
|
||||
name = ""
|
||||
for info in sorted(Path(directory).glob("goggame-*.info")):
|
||||
try:
|
||||
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
|
||||
if (isinstance(data, dict) and isinstance(data.get("name"), str)
|
||||
and data.get("gameId") == data.get("rootGameId")):
|
||||
name = data["name"]
|
||||
break
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read game metadata %s: %s", info, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid game metadata %s: %s", info, error)
|
||||
if not name:
|
||||
name = self._executable_name(executable) or Path(directory).name
|
||||
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
|
||||
name = name.replace(".", " ")
|
||||
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
|
||||
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
|
||||
|
||||
def _record_error(self, error: OSError) -> None:
|
||||
logger.warning("Failed to scan game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _is_runtime_directory(self, path: str) -> bool:
|
||||
name = os.path.basename(path).casefold()
|
||||
parent = os.path.basename(os.path.dirname(path)).casefold()
|
||||
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
|
||||
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
|
||||
return True
|
||||
if name == "windows" and parent == "drive_c":
|
||||
return True
|
||||
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
|
||||
return True
|
||||
if "proton" in name or name.startswith("wine"):
|
||||
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
|
||||
return False
|
||||
|
||||
def _find_executables(self, folder: str) -> dict[str, list[str]]:
|
||||
games: dict[str, list[str]] = {}
|
||||
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
|
||||
visited: dict[str, int] = {}
|
||||
completed, total = 0, len(pending)
|
||||
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
|
||||
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
|
||||
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
|
||||
while pending:
|
||||
if self.isInterruptionRequested():
|
||||
return {}
|
||||
root, depth, branch = pending.popleft()
|
||||
label = _("Scanning: {folder}").format(folder=root)
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
|
||||
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
|
||||
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
|
||||
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
|
||||
directory = root
|
||||
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
|
||||
directory = os.path.dirname(directory)
|
||||
if files:
|
||||
games.setdefault(directory, []).extend(files)
|
||||
for entry in entries_list:
|
||||
name = entry.name.casefold()
|
||||
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
|
||||
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
|
||||
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
|
||||
continue
|
||||
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
|
||||
continue
|
||||
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
|
||||
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
|
||||
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
|
||||
break
|
||||
visited[owner] = visited.get(owner, 0) + 1
|
||||
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
|
||||
pending.append((entry.path, next_depth, owner))
|
||||
total += 1
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
completed += 1
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
return games
|
||||
|
||||
|
||||
class GameFolderImportWorker(QThread):
|
||||
"""Create selected shortcuts without overwriting existing entries."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.games = games
|
||||
self.added = 0
|
||||
self.skipped = 0
|
||||
self.errors: list[str] = []
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
raise FileNotFoundError("PortProton directory not found")
|
||||
existing = set()
|
||||
with os.scandir(location) as entries:
|
||||
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
|
||||
for index, path in enumerate(paths):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
|
||||
entry = parse_desktop_entry(path)
|
||||
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
|
||||
if target:
|
||||
existing.add(os.path.realpath(target))
|
||||
for index, (name, executable) in enumerate(self.games):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
self._import_game(name, executable, existing)
|
||||
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
|
||||
target = os.path.realpath(executable)
|
||||
if target in existing:
|
||||
self.skipped += 1
|
||||
return
|
||||
created = False
|
||||
desktop_path = ""
|
||||
try:
|
||||
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
|
||||
result = create_desktop_file(executable, name)
|
||||
if result is None:
|
||||
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
|
||||
content, desktop_path, icon_path = result
|
||||
if os.path.exists(desktop_path):
|
||||
self.skipped += 1
|
||||
return
|
||||
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
|
||||
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
|
||||
with open(desktop_path, "x", encoding="utf-8") as desktop:
|
||||
created = True
|
||||
desktop.write(content)
|
||||
os.chmod(desktop_path, 0o755)
|
||||
existing.add(target)
|
||||
self.added += 1
|
||||
except FileExistsError:
|
||||
self.skipped += 1
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import '%s': %s", executable, error)
|
||||
self.errors.append(f"{name}: {error}")
|
||||
if created:
|
||||
try:
|
||||
os.unlink(desktop_path)
|
||||
except OSError as cleanup_error:
|
||||
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
|
||||
|
||||
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
gamesAdded = Signal()
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
|
||||
super().__init__(parent)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
@@ -366,9 +135,6 @@ class AddGameDialog(DraggableDialog):
|
||||
self.edit_mode = edit_mode
|
||||
self.last_exe_path = exe_path
|
||||
self.last_cover_path = cover_path
|
||||
refresh_games = getattr(parent, "refreshGames", None)
|
||||
if callable(refresh_games):
|
||||
self.gamesAdded.connect(refresh_games)
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
self.setWindowTitle(_("Edit Shortcut") if edit_mode else _("Add a shortcut"))
|
||||
@@ -572,12 +338,6 @@ class AddGameDialog(DraggableDialog):
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
folder_button.setObjectName("gamepadAddButton")
|
||||
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
|
||||
file_explorer.button_layout.insertWidget(0, folder_button)
|
||||
|
||||
parent_widget = self.parentWidget()
|
||||
if parent_widget:
|
||||
@@ -591,195 +351,6 @@ class AddGameDialog(DraggableDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in browseExe: {e}")
|
||||
|
||||
def _select_game_folder(self, explorer: "FileExplorer") -> None:
|
||||
folder = explorer.current_path
|
||||
item = explorer.file_list.currentItem()
|
||||
if item and item.text() != "../":
|
||||
selected = os.path.normpath(os.path.join(folder, item.text()))
|
||||
if os.path.isdir(selected):
|
||||
folder = selected
|
||||
self._game_folder_root = folder
|
||||
explorer.accept()
|
||||
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
|
||||
|
||||
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
|
||||
owner = cast(Any, self.parentWidget() or self)
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
owner._gameFolderWorkers = workers
|
||||
workers.append(worker)
|
||||
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
progress.setValue(0)
|
||||
progress.canceled.connect(worker.requestInterruption)
|
||||
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
completed, total, current = state
|
||||
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
|
||||
completed=completed, total=total, current=current,
|
||||
))
|
||||
progress.setRange(0, max(total, 1))
|
||||
progress.setValue(completed)
|
||||
|
||||
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
owner = self.parentWidget() or self
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
cancelled = progress.wasCanceled()
|
||||
progress.close()
|
||||
progress.deleteLater()
|
||||
worker.deleteLater()
|
||||
if worker not in workers:
|
||||
return
|
||||
workers.remove(worker)
|
||||
if isinstance(worker, GameFolderImportWorker):
|
||||
if worker.added:
|
||||
self.gamesAdded.emit()
|
||||
if cancelled:
|
||||
return
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
self.reject()
|
||||
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
if worker.games:
|
||||
self._show_folder_games(worker.games)
|
||||
else:
|
||||
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
|
||||
|
||||
def _create_folder_game_row(
|
||||
self, layout: QVBoxLayout, game: tuple[str, str],
|
||||
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
|
||||
name, executable = game
|
||||
content = layout.parentWidget()
|
||||
row = QHBoxLayout()
|
||||
checkbox = QCheckBox(content)
|
||||
checkbox.setObjectName("gameFolderExecutable")
|
||||
checkbox.setAccessibleName(executable)
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox.setChecked(True)
|
||||
title = CustomLineEdit(name, content, theme=self.theme)
|
||||
title.setAccessibleName(_("Name:"))
|
||||
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
path = CustomLineEdit(executable, content, theme=self.theme)
|
||||
path.setObjectName("gameFolderPath")
|
||||
path.setAccessibleName(_("Path to Executable:"))
|
||||
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
browse.setAutoDefault(False)
|
||||
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
browse.clicked.connect(partial(self._browse_folder_exe, path))
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(path)
|
||||
path_row.addWidget(browse)
|
||||
details = QVBoxLayout()
|
||||
details.addWidget(title)
|
||||
details.addLayout(path_row)
|
||||
row.addWidget(checkbox)
|
||||
row.addLayout(details)
|
||||
layout.addLayout(row)
|
||||
QWidget.setTabOrder(checkbox, title)
|
||||
QWidget.setTabOrder(title, path)
|
||||
QWidget.setTabOrder(path, browse)
|
||||
return title, path, checkbox
|
||||
|
||||
def _browse_folder_exe(self, field: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(
|
||||
field.window(), theme=self.theme, file_filter=(".exe",),
|
||||
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(field.setText)
|
||||
explorer.exec()
|
||||
field.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
explorer.deleteLater()
|
||||
|
||||
def _accept_folder_games(
|
||||
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
for title, path, checkbox in rows:
|
||||
if not checkbox.isChecked():
|
||||
continue
|
||||
if not title.text().strip():
|
||||
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
|
||||
title.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
executable = os.path.expanduser(path.text().strip())
|
||||
if not os.path.isfile(executable):
|
||||
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
|
||||
path.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
path.setText(executable)
|
||||
dialog.accept()
|
||||
|
||||
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
|
||||
if focused is not None and scroll.isAncestorOf(focused):
|
||||
scroll.ensureWidgetVisible(focused)
|
||||
|
||||
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
|
||||
dialog = cast(Any, DraggableDialog(self))
|
||||
dialog.setWindowTitle(_("Select games"))
|
||||
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
|
||||
layout = QVBoxLayout(dialog)
|
||||
scroll = QScrollArea(dialog)
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget(scroll)
|
||||
rows_layout = QVBoxLayout(content)
|
||||
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
|
||||
rows_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
buttons = QHBoxLayout()
|
||||
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
|
||||
apply_button.setObjectName("gamepadAddButton")
|
||||
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
|
||||
for button in (apply_button, cancel_button):
|
||||
button.setAutoDefault(False)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttons.addWidget(button)
|
||||
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
|
||||
cancel_button.clicked.connect(dialog.reject)
|
||||
layout.addLayout(buttons)
|
||||
main_window = self.parentWidget()
|
||||
input_manager = getattr(main_window, "input_manager", None)
|
||||
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
|
||||
layout.addWidget(hints)
|
||||
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
|
||||
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
|
||||
dialog.finished.connect(dialog.keyboard.hide)
|
||||
if rows:
|
||||
QTimer.singleShot(0, rows[0][2].setFocus)
|
||||
app = cast(QApplication, QApplication.instance())
|
||||
scroll_focus = partial(self._scroll_folder_focus, scroll)
|
||||
app.focusChanged.connect(scroll_focus)
|
||||
try:
|
||||
accepted = dialog.exec() == QDialog.DialogCode.Accepted
|
||||
finally:
|
||||
app.focusChanged.disconnect(scroll_focus)
|
||||
if accepted:
|
||||
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
|
||||
if checkbox.isChecked()]
|
||||
if selected:
|
||||
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
|
||||
dialog.deleteLater()
|
||||
|
||||
def onExeSelected(self, file_path):
|
||||
"""Handle file selection in FileExplorer."""
|
||||
self.exeEdit.setText(file_path)
|
||||
|
||||
@@ -3,14 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
import orjson
|
||||
|
||||
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
|
||||
)
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -20,12 +14,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.config import CACHE_DIR, proxy_config, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CompatibilityReportDialog(DraggableDialog):
|
||||
@@ -80,11 +70,6 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
def _create_buttons(self, theme) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.addStretch()
|
||||
self.ppdb_button = AutoSizeButton(_("Download") + " PPDB")
|
||||
self.ppdb_button.setStyleSheet(theme.ACTION_BUTTON_STYLE)
|
||||
self.ppdb_button.hide()
|
||||
self.ppdb_button.clicked.connect(self._download_ppdb)
|
||||
layout.addWidget(self.ppdb_button)
|
||||
close_button = AutoSizeButton(_("Close"))
|
||||
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@@ -92,81 +77,6 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
layout.addWidget(close_button)
|
||||
return layout
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
if ui_config.get_auto_download_ppdb() or not os.path.isfile(executable):
|
||||
return
|
||||
self.ppdb_path = executable + ".ppdb"
|
||||
self.ppdb_network = QNetworkAccessManager(self)
|
||||
self.finished.connect(self.ppdb_network.deleteLater)
|
||||
self.ppdb_network.setTransferTimeout(
|
||||
QNetworkRequest.TransferTimeoutConstant.DefaultTransferTimeoutConstant.value
|
||||
)
|
||||
cache = QNetworkDiskCache(self.ppdb_network)
|
||||
cache.setCacheDirectory(str(CACHE_DIR / "ppdb_lookup"))
|
||||
self.ppdb_network.setCache(cache)
|
||||
proxies = proxy_config.get_proxy() or {}
|
||||
proxy_url = QUrl(proxies.get("https") or proxies.get("http") or "")
|
||||
if proxy_url.host():
|
||||
proxy_type = (QNetworkProxy.ProxyType.Socks5Proxy
|
||||
if proxy_url.scheme().startswith("socks")
|
||||
else QNetworkProxy.ProxyType.HttpProxy)
|
||||
self.ppdb_network.setProxy(QNetworkProxy(
|
||||
proxy_type, proxy_url.host(), max(proxy_url.port(), 0),
|
||||
proxy_url.userName(), proxy_url.password(),
|
||||
))
|
||||
url = QUrl("https://linux-gaming.ru/api/lookup/exe/")
|
||||
url.setPath(url.path() + os.path.basename(executable))
|
||||
reply = self.ppdb_network.get(QNetworkRequest(url))
|
||||
reply.finished.connect(lambda: self._ppdb_lookup_finished(reply))
|
||||
|
||||
def _ppdb_lookup_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB lookup failed: %s", reply.errorString())
|
||||
return
|
||||
try:
|
||||
metadata = orjson.loads(bytes(reply.readAll().data()))
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid PPDB metadata: %s", error)
|
||||
return
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
url = metadata.get("ppdb_url")
|
||||
if not isinstance(url, str):
|
||||
return
|
||||
self.ppdb_url = QUrl(url)
|
||||
if self.ppdb_url.scheme() in {"https", "http"} and self.ppdb_url.host():
|
||||
self.ppdb_button.show()
|
||||
|
||||
def _download_ppdb(self) -> None:
|
||||
self.ppdb_button.setEnabled(False)
|
||||
self.ppdb_button.setText(_("Downloading…"))
|
||||
request = QNetworkRequest(self.ppdb_url)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.CacheLoadControlAttribute,
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
|
||||
)
|
||||
reply = self.ppdb_network.get(request)
|
||||
reply.finished.connect(lambda: self._ppdb_download_finished(reply))
|
||||
|
||||
def _ppdb_download_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
self.ppdb_button.setText(_("Download") + " PPDB")
|
||||
self.ppdb_button.setEnabled(True)
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB download failed: %s", reply.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
destination = QSaveFile(self.ppdb_path)
|
||||
data = reply.readAll()
|
||||
if (data.isEmpty() or not destination.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
or destination.write(data) != data.size() or not destination.commit()):
|
||||
logger.warning("Failed to save PPDB: %s", destination.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
self.ppdb_button.setText(_("Downloading Complete"))
|
||||
self.ppdb_button.setEnabled(False)
|
||||
|
||||
def _show_context_menu(
|
||||
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
|
||||
) -> None:
|
||||
|
||||
@@ -25,12 +25,7 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
|
||||
dialog_actions = []
|
||||
|
||||
if context == 'folder_import':
|
||||
dialog_actions = [
|
||||
("add_game", _("Apply")),
|
||||
("back", _("Close")),
|
||||
]
|
||||
elif context == 'file_explorer':
|
||||
if context == 'file_explorer':
|
||||
dialog_actions = [
|
||||
("confirm", _("Open")),
|
||||
("add_game", _("Select Dir")),
|
||||
@@ -50,9 +45,10 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
elif context == 'settings':
|
||||
dialog_actions = [
|
||||
("confirm", _("Toggle")),
|
||||
("add_game", _("Save")),
|
||||
("prev_dir", _("Search")),
|
||||
("context_menu", _("Menu")),
|
||||
("back", _("Close")),
|
||||
("back", _("Cancel")),
|
||||
("prev_tab", _("Prev Tab")),
|
||||
("next_tab", _("Next Tab")),
|
||||
]
|
||||
|
||||
@@ -59,7 +59,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.main_window = None
|
||||
parent_widget = self.parent()
|
||||
while parent_widget:
|
||||
if hasattr(parent_widget, 'get_button_icon'):
|
||||
if hasattr(parent_widget, 'input_manager'):
|
||||
self.main_window = parent_widget
|
||||
break
|
||||
parent_widget = parent_widget.parent()
|
||||
@@ -69,14 +69,6 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.enable_proton_manager_mode()
|
||||
if self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_hints()
|
||||
|
||||
@staticmethod
|
||||
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
|
||||
@@ -286,6 +278,18 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||||
|
||||
if self.input_manager and self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
|
||||
@@ -29,8 +29,6 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSlider,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
@@ -57,6 +55,7 @@ from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update
|
||||
from portprotonqt.dialogs.proton_manager import show_proton_manager
|
||||
from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin
|
||||
from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_reshade import RESHADE_ENV_KEYS, ReShadeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -87,6 +86,8 @@ DEFAULT_WINE_SETTING_KEYS = {
|
||||
'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME',
|
||||
'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE',
|
||||
}
|
||||
|
||||
|
||||
def _normalize_prefix_directories(prefixes_dir):
|
||||
if not os.path.isdir(prefixes_dir):
|
||||
return
|
||||
@@ -151,6 +152,7 @@ class ExeSettingsDialog(
|
||||
DraggableDialog,
|
||||
MangoHudSettingsMixin,
|
||||
GamescopeSettingsMixin,
|
||||
ReShadeSettingsMixin,
|
||||
VkBasaltSettingsMixin,
|
||||
):
|
||||
"""Dialog for configuring executable-specific settings."""
|
||||
@@ -200,12 +202,6 @@ class ExeSettingsDialog(
|
||||
_normalize_prefix_directories(prefixes_dir)
|
||||
self.prefix_options = get_available_prefix_options(self.portproton_path)
|
||||
|
||||
self.saving = False
|
||||
self.settings_loading = True
|
||||
self.close_after_save = False
|
||||
self.save_timer = QTimer(self)
|
||||
self.save_timer.setSingleShot(True)
|
||||
self.save_timer.timeout.connect(self.apply_changes)
|
||||
self.current_settings = {}
|
||||
self.value_widgets = {}
|
||||
self.original_values = {}
|
||||
@@ -215,6 +211,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_settings_by_key = {}
|
||||
self.init_mangohud_state()
|
||||
self.init_gamescope_state()
|
||||
self.init_reshade_state()
|
||||
self.init_vkbasalt_state()
|
||||
self.blocked_keys = set()
|
||||
self.numa_nodes = {}
|
||||
@@ -340,6 +337,8 @@ class ExeSettingsDialog(
|
||||
self.mangohud_tab_layout = QVBoxLayout(self.mangohud_tab)
|
||||
self.vkbasalt_tab = QWidget()
|
||||
self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab)
|
||||
self.reshade_tab = QWidget()
|
||||
self.reshade_tab_layout = QVBoxLayout(self.reshade_tab)
|
||||
self.gamescope_tab = QWidget()
|
||||
self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab)
|
||||
|
||||
@@ -347,6 +346,7 @@ class ExeSettingsDialog(
|
||||
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
|
||||
self.tab_widget.addTab(self.mangohud_tab, "MangoHud")
|
||||
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
|
||||
self.tab_widget.addTab(self.reshade_tab, "ReShade")
|
||||
if self.gamescope_available:
|
||||
self.tab_widget.addTab(self.gamescope_tab, "Gamescope")
|
||||
|
||||
@@ -470,6 +470,7 @@ class ExeSettingsDialog(
|
||||
|
||||
self.setup_mangohud_tab()
|
||||
self.setup_vkbasalt_tab()
|
||||
self.setup_reshade_tab()
|
||||
if self.gamescope_available:
|
||||
self.setup_gamescope_tab()
|
||||
|
||||
@@ -480,6 +481,7 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip.setStyleSheet(self.theme.TOOLTIP_STYLE)
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
self.gamepad_tooltip.setParent(self)
|
||||
self.gamepad_tooltip.setWindowFlags(Qt.WindowType.ToolTip)
|
||||
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
self.gamepad_tooltip_timer = QTimer(self)
|
||||
@@ -487,20 +489,25 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip_timer.timeout.connect(lambda: self.gamepad_tooltip.setVisible(False))
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.cancel_button = AutoSizeButton(_("Close"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.open_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clear_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.apply_button.installEventFilter(self)
|
||||
self.cancel_button.installEventFilter(self)
|
||||
self.open_ppdb_button.installEventFilter(self)
|
||||
self.clear_ppdb_button.installEventFilter(self)
|
||||
button_layout.addWidget(self.apply_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
button_layout.addWidget(self.open_ppdb_button)
|
||||
button_layout.addWidget(self.clear_ppdb_button)
|
||||
self.main_layout.addLayout(button_layout)
|
||||
|
||||
self.apply_button.clicked.connect(self.apply_changes)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.open_ppdb_button.clicked.connect(self.open_ppdb_file)
|
||||
self.clear_ppdb_button.clicked.connect(self.clear_ppdb_file)
|
||||
@@ -508,7 +515,6 @@ class ExeSettingsDialog(
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load available toggles and current settings."""
|
||||
self.settings_loading = True
|
||||
self.settings_container.setCurrentIndex(0)
|
||||
self.advanced_container.setCurrentIndex(0)
|
||||
|
||||
@@ -560,6 +566,7 @@ class ExeSettingsDialog(
|
||||
or key in MANGOHUD_ENV_KEYS
|
||||
or key in GAMESCOPE_ENV_KEYS
|
||||
or key in VKBASALT_ENV_KEYS
|
||||
or key in RESHADE_ENV_KEYS
|
||||
or key in TOGGLE_BOOL_KEYS
|
||||
):
|
||||
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
|
||||
@@ -580,11 +587,6 @@ class ExeSettingsDialog(
|
||||
"PW_USE_GALLIUM_NINE",
|
||||
"PW_USE_SUPPLIED_DXVK_VKD3D",
|
||||
"PW_USE_INHIBIT_SLEEP",
|
||||
"PW_FIX_VIDEO_IN_GAME",
|
||||
"PW_USE_D7VK",
|
||||
"PW_USE_WINE_DXGI",
|
||||
"PW_USE_WINED3D_VULKAN",
|
||||
"PW_USE_GALLIUM_ZINK",
|
||||
})
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
for key in self.toggle_settings:
|
||||
@@ -599,6 +601,8 @@ class ExeSettingsDialog(
|
||||
self.current_settings[key] = ''
|
||||
for key in VKBASALT_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
for key in RESHADE_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
elif not self.user_conf:
|
||||
self.current_settings.setdefault('PW_MANGOHUD', '0')
|
||||
self.current_settings.setdefault('PW_VKBASALT', '0')
|
||||
@@ -627,14 +631,13 @@ class ExeSettingsDialog(
|
||||
self.populate_advanced()
|
||||
self.populate_mangohud()
|
||||
self.populate_vkbasalt()
|
||||
self.populate_reshade()
|
||||
if self.gamescope_available:
|
||||
self.populate_gamescope()
|
||||
self.populate_favorites(select_tab=True)
|
||||
|
||||
self.settings_container.setCurrentIndex(1)
|
||||
self.advanced_container.setCurrentIndex(1)
|
||||
self.settings_loading = False
|
||||
self._connect_autosave()
|
||||
|
||||
def open_ppdb_file(self):
|
||||
"""Open the PPDB file for the current executable."""
|
||||
@@ -721,8 +724,8 @@ class ExeSettingsDialog(
|
||||
self.user_conf_changes = [
|
||||
change for change in self.user_conf_changes if change.split('=', 1)[1]
|
||||
]
|
||||
self.saving = True
|
||||
self.close_after_user_conf_changes = False
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def populate_table(self):
|
||||
@@ -869,19 +872,13 @@ class ExeSettingsDialog(
|
||||
prefix_line_edit = combo.lineEdit()
|
||||
if prefix_line_edit is not None:
|
||||
prefix_line_edit.setPlaceholderText(_("Enter prefix name"))
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
|
||||
current_raw = current.get(setting['key'], '') if self.user_conf else (
|
||||
current.get(setting['key'], setting['default'])
|
||||
@@ -903,9 +900,7 @@ class ExeSettingsDialog(
|
||||
current_val = reverse_map[current_raw]
|
||||
|
||||
current_val_text = current_val if isinstance(current_val, str) else ''
|
||||
if setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and current_val_text == _('Download Wine/Proton...'):
|
||||
current_val_text = setting['options'][0]
|
||||
if current_val_text and combo.findText(current_val_text) < 0:
|
||||
if current_val_text and current_val_text not in setting['options']:
|
||||
combo.addItem(current_val_text)
|
||||
combo.setCurrentText(current_val_text)
|
||||
|
||||
@@ -1218,8 +1213,8 @@ class ExeSettingsDialog(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.setEditable(source_widget.isEditable())
|
||||
combo.setInsertPolicy(source_widget.insertPolicy())
|
||||
combo.setModel(source_widget.model())
|
||||
for index in range(source_widget.count()):
|
||||
combo.addItem(source_widget.itemText(index), source_widget.itemData(index))
|
||||
combo.setCurrentText(source_widget.currentText())
|
||||
combo.setEnabled(source_widget.isEnabled())
|
||||
settings_combo_style = getattr(self.theme, "SETTINGS_TABLE_COMBOBOX_STYLE", "")
|
||||
@@ -1229,14 +1224,9 @@ class ExeSettingsDialog(
|
||||
+ self.theme.SCROLL_STYLE
|
||||
)
|
||||
combo.currentTextChanged.connect(source_widget.setCurrentText)
|
||||
source_widget.currentTextChanged.connect(combo.setCurrentText)
|
||||
combo.activated.connect(source_widget.activated.emit)
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
combo.highlighted.connect(
|
||||
lambda row_idx, c=combo: self._on_combo_highlighted(row_idx, c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
self.favorites_table.setCellWidget(row, 1, combo)
|
||||
|
||||
def _add_favorite_line_edit(self, row: int, source_widget: QLineEdit) -> None:
|
||||
@@ -1272,7 +1262,9 @@ class ExeSettingsDialog(
|
||||
|
||||
def _on_combo_highlighted(self, row: int, combo: QComboBox) -> None:
|
||||
view = combo.view()
|
||||
view.installEventFilter(self)
|
||||
if not self.isVisible() or not combo.isVisible() or not view.isVisible():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
index = view.model().index(row, 0)
|
||||
if not index.isValid():
|
||||
@@ -1288,31 +1280,25 @@ class ExeSettingsDialog(
|
||||
if text_width <= item_rect.intersected(view.viewport().rect()).width():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.setWordWrap(False)
|
||||
w = min(500, self.gamepad_tooltip.sizeHint().width())
|
||||
self.gamepad_tooltip.setWordWrap(True)
|
||||
h = min(300, self.gamepad_tooltip.heightForWidth(w))
|
||||
text_rect = fm.boundingRect(0, 0, 480, 1000, Qt.TextFlag.TextWordWrap, text)
|
||||
w = min(500, text_rect.width() + 25)
|
||||
h = min(300, text_rect.height() + 25)
|
||||
item_center_y = view.viewport().mapToGlobal(item_rect.center()).y()
|
||||
combo_right_x = combo.mapToGlobal(combo.rect().topRight()).x()
|
||||
pos = QPoint(combo_right_x + 4, item_center_y - h // 2)
|
||||
screen = combo.screen()
|
||||
screen = QGuiApplication.screenAt(pos) or QGuiApplication.primaryScreen()
|
||||
if screen:
|
||||
ar = screen.availableGeometry().intersected(self.rect().translated(self.mapToGlobal(QPoint())))
|
||||
w, h = min(w, ar.width()), min(h, ar.height())
|
||||
ar = screen.availableGeometry()
|
||||
if pos.x() + w > ar.right():
|
||||
pos.setX(combo.mapToGlobal(combo.rect().topLeft()).x() - w)
|
||||
if pos.x() < ar.left():
|
||||
pos.setX(ar.left())
|
||||
pos.setX(ar.right() - w)
|
||||
if pos.y() + h > ar.bottom():
|
||||
pos.setY(ar.bottom() - h)
|
||||
if pos.y() < ar.top():
|
||||
pos.setY(ar.top())
|
||||
self.gamepad_tooltip.hide()
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.setFixedSize(w, h)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(pos))
|
||||
self.gamepad_tooltip.move(pos)
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
self.gamepad_tooltip.raise_()
|
||||
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
|
||||
|
||||
def _open_wine_manager_from_combo(self, index: int, combo: QComboBox, key: str) -> None:
|
||||
@@ -1344,7 +1330,6 @@ class ExeSettingsDialog(
|
||||
) if setting['key'] == 'PW_WINE_USE')
|
||||
if self.user_conf:
|
||||
options = [_('Default')] + options
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(options)
|
||||
if current_text and current_text not in options:
|
||||
@@ -1352,7 +1337,6 @@ class ExeSettingsDialog(
|
||||
combo.insertSeparator(combo.count())
|
||||
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
|
||||
combo.setCurrentText(current_text)
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _select_checkbox_row(self, widget: QCheckBox) -> bool:
|
||||
for (row, column), checkbox in self.value_widgets.items():
|
||||
@@ -1420,61 +1404,11 @@ class ExeSettingsDialog(
|
||||
|
||||
self._filter_mangohud_settings(search_text)
|
||||
self._filter_vkbasalt_settings(search_text)
|
||||
self._filter_reshade_settings(search_text)
|
||||
self._filter_gamescope_settings(search_text)
|
||||
|
||||
def _connect_autosave(self) -> None:
|
||||
for widget in self.findChildren(QWidget):
|
||||
if widget.property("ppqt_autosave") or widget is self.search_edit:
|
||||
continue
|
||||
if isinstance(widget, QLineEdit):
|
||||
if isinstance(widget.parent(), QComboBox):
|
||||
continue
|
||||
widget.textChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QComboBox):
|
||||
widget.currentTextChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.toggled.connect(self._schedule_save)
|
||||
elif isinstance(widget, QSlider):
|
||||
widget.valueChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QPushButton):
|
||||
widget.clicked.connect(self._schedule_save)
|
||||
else:
|
||||
continue
|
||||
widget.setProperty("ppqt_autosave", True)
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if not self.settings_loading:
|
||||
self.save_timer.start(0)
|
||||
|
||||
def _on_save_error(self, error: QProcess.ProcessError) -> None:
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
logger.error("Failed to start settings save process: %s", cast(QProcess, self.sender()).errorString())
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
|
||||
def _finish_save(self) -> None:
|
||||
for key, value in self.saved_values.items():
|
||||
if self.current_settings.get(key) == self.saved_current_settings.get(key):
|
||||
self.current_settings[key] = value
|
||||
self.original_values.update(self.saved_values)
|
||||
self.original_display_values.update(self.saved_display_values)
|
||||
self.mangohud_original_values.update(self.saved_values)
|
||||
self.vkbasalt_original_values.update(self.saved_values)
|
||||
self.gamescope_original_values.update(self.saved_values)
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
self.saving = False
|
||||
self.apply_changes()
|
||||
if self.close_after_save and not self.saving:
|
||||
self.close()
|
||||
|
||||
def apply_changes(self):
|
||||
"""Apply changes by collecting diffs from both main and advanced tabs."""
|
||||
if self.settings_loading or self.saving:
|
||||
return
|
||||
self.save_timer.stop()
|
||||
changes = []
|
||||
mangohud_enabled = False
|
||||
|
||||
@@ -1506,8 +1440,6 @@ class ExeSettingsDialog(
|
||||
for key, widget in self.advanced_widgets.items():
|
||||
orig_val = self.original_display_values.get(key, '')
|
||||
if isinstance(widget, QComboBox):
|
||||
if widget.currentData() == 'download_wine':
|
||||
continue
|
||||
new_val = widget.currentText()
|
||||
if self.user_conf and new_val == _('Default'):
|
||||
new_val = ''
|
||||
@@ -1547,6 +1479,7 @@ class ExeSettingsDialog(
|
||||
if self.gamescope_available:
|
||||
gamescope_changes = self._collect_gamescope_changes()
|
||||
vkbasalt_changes = self._collect_vkbasalt_changes()
|
||||
reshade_changes = self._collect_reshade_changes()
|
||||
if self.user_conf:
|
||||
if self.current_settings.get('PW_MANGOHUD') != '1':
|
||||
mangohud_changes = [
|
||||
@@ -1563,7 +1496,14 @@ class ExeSettingsDialog(
|
||||
change for change in gamescope_changes
|
||||
if change.startswith('PW_GAMESCOPE=')
|
||||
]
|
||||
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
|
||||
if self.current_settings.get('PW_USE_RESHADE') != '1':
|
||||
reshade_changes = [
|
||||
change for change in reshade_changes
|
||||
if change.startswith('PW_USE_RESHADE=')
|
||||
]
|
||||
specialized_changes = (
|
||||
mangohud_changes + vkbasalt_changes + reshade_changes + gamescope_changes
|
||||
)
|
||||
if self.user_conf:
|
||||
defaults = ('', '0', '0.00', 'Home')
|
||||
specialized_changes = [
|
||||
@@ -1592,42 +1532,26 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
return
|
||||
|
||||
self.saving = True
|
||||
self.clear_ppdb_button.setEnabled(False)
|
||||
self.saved_current_settings = self.current_settings.copy()
|
||||
self.saved_values = dict(change.split('=', 1) for change in changes)
|
||||
self.saved_display_values = {
|
||||
key: widget.currentText() if isinstance(widget, QComboBox) else widget.text().strip()
|
||||
for key, widget in self.advanced_widgets.items()
|
||||
if isinstance(widget, (QComboBox, QLineEdit))
|
||||
}
|
||||
for key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
if key in self.saved_display_values:
|
||||
self.saved_display_values[key] = re.sub(
|
||||
r"[ \t]", "_", self.saved_display_values[key].strip()
|
||||
).upper()
|
||||
if 'PW_RUN_AFTER_EXE' in self.saved_values:
|
||||
self.saved_display_values['PW_RUN_AFTER_EXE'] = self.saved_values['PW_RUN_AFTER_EXE']
|
||||
if self.user_conf:
|
||||
self.user_conf_changes = changes
|
||||
self.close_after_user_conf_changes = True
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
return
|
||||
|
||||
self.save_process = QProcess(self)
|
||||
process = self.save_process
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_edit_db_finished)
|
||||
process.errorOccurred.connect(self._on_save_error)
|
||||
process_args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
args = self._get_process_args(process_args)
|
||||
process.start(args[0], args[1:])
|
||||
self.apply_button.setEnabled(False)
|
||||
|
||||
def _apply_next_user_conf_change(self) -> None:
|
||||
if not self.user_conf_changes:
|
||||
self.apply_button.setEnabled(True)
|
||||
if self.close_after_user_conf_changes:
|
||||
self._finish_save()
|
||||
self.close()
|
||||
else:
|
||||
self.saving = False
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
@@ -1638,45 +1562,33 @@ class ExeSettingsDialog(
|
||||
command.append(value)
|
||||
self.user_conf_process = QProcess(self)
|
||||
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
|
||||
self.user_conf_process.errorOccurred.connect(self._on_save_error)
|
||||
args = self._get_process_args(command)
|
||||
self.user_conf_process.start(args[0], args[1:])
|
||||
|
||||
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
|
||||
'utf-8', 'ignore'
|
||||
)
|
||||
self.apply_button.setEnabled(True)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error("Failed to update user.conf: %s", error_output)
|
||||
return
|
||||
self.user_conf_process.deleteLater()
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def on_edit_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --edit-db output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
process.deleteLater()
|
||||
self.apply_button.setEnabled(True)
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error(f"Failed to apply changes: {error_output}")
|
||||
else:
|
||||
self._finish_save()
|
||||
self.load_current_settings()
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if hasattr(self, 'save_timer'):
|
||||
self.apply_changes()
|
||||
if self.saving:
|
||||
self.close_after_save = True
|
||||
event.ignore()
|
||||
return
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
app.removeEventFilter(self)
|
||||
@@ -1756,8 +1668,7 @@ class ExeSettingsDialog(
|
||||
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
|
||||
label.setAutoFillBackground(selected)
|
||||
label.setPalette(palette)
|
||||
if label.foregroundRole() != QPalette.ColorRole.Text:
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
name, separator, description = text.partition("\n")
|
||||
formatted_text = escape(name)
|
||||
@@ -1774,11 +1685,7 @@ class ExeSettingsDialog(
|
||||
if label.text() != formatted_text:
|
||||
label.setText(formatted_text)
|
||||
width = max(1, table.columnWidth(0))
|
||||
height_key = [width, formatted_text, label.font().toString()]
|
||||
if label.property("ppqt_height_key") != height_key:
|
||||
height = label.heightForWidth(width) + table.fontMetrics().height()
|
||||
label.setProperty("ppqt_height_key", height_key)
|
||||
table.setRowHeight(row, height)
|
||||
table.setRowHeight(row, label.heightForWidth(width) + table.fontMetrics().height())
|
||||
for row in range(table.rowCount()):
|
||||
widget = table.cellWidget(row, 1)
|
||||
if widget is None:
|
||||
@@ -1792,11 +1699,13 @@ class ExeSettingsDialog(
|
||||
)
|
||||
return False
|
||||
|
||||
if isinstance(obj, QWidget) and self._handle_reshade_key_button_event(obj, event):
|
||||
return True
|
||||
if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event):
|
||||
self._schedule_save()
|
||||
return True
|
||||
|
||||
action_buttons = (
|
||||
getattr(self, "apply_button", None),
|
||||
getattr(self, "cancel_button", None),
|
||||
getattr(self, "open_ppdb_button", None),
|
||||
getattr(self, "clear_ppdb_button", None),
|
||||
@@ -1874,9 +1783,8 @@ class ExeSettingsDialog(
|
||||
y = available_rect.top()
|
||||
|
||||
self.gamepad_tooltip.setFixedSize(required_width, required_height)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(QPoint(x, y)))
|
||||
self.gamepad_tooltip.move(x, y)
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
self.gamepad_tooltip.raise_()
|
||||
self.gamepad_tooltip_timer.start(tooltip_timeout_ms)
|
||||
|
||||
if anchor_global_pos is not None:
|
||||
@@ -1906,11 +1814,6 @@ class ExeSettingsDialog(
|
||||
super().hideEvent(event)
|
||||
|
||||
def reject(self):
|
||||
if hasattr(self, 'save_timer'):
|
||||
self.apply_changes()
|
||||
if self.saving:
|
||||
self.close_after_save = True
|
||||
return
|
||||
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
|
||||
self.keyboard.hide()
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""ReShade settings support for executable settings dialog."""
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
RESHADE_ENV_KEYS = [
|
||||
'PW_RESHADE_EXE',
|
||||
'PW_RESHADE_API',
|
||||
'PW_RESHADE_FONT',
|
||||
'PW_RESHADE_SCREENSHOT_PATH',
|
||||
'PW_RESHADE_SCREENSHOT_KEY',
|
||||
'PW_RESHADE_OVERLAY_KEY',
|
||||
]
|
||||
RESHADE_API_OPTIONS = {
|
||||
"Auto": "auto",
|
||||
"DXGI": "dxgi.dll",
|
||||
"DirectX 8/9": "d3d9.dll",
|
||||
"OpenGL": "opengl32.dll",
|
||||
"Vulkan": "vulkan",
|
||||
}
|
||||
RESHADE_SPECIAL_KEYS = {
|
||||
Qt.Key.Key_Cancel: 3,
|
||||
Qt.Key.Key_Backspace: 8,
|
||||
Qt.Key.Key_Tab: 9,
|
||||
Qt.Key.Key_Clear: 12,
|
||||
Qt.Key.Key_Return: 13,
|
||||
Qt.Key.Key_Enter: 13,
|
||||
Qt.Key.Key_Pause: 19,
|
||||
Qt.Key.Key_CapsLock: 20,
|
||||
Qt.Key.Key_Escape: 27,
|
||||
Qt.Key.Key_Space: 32,
|
||||
Qt.Key.Key_PageUp: 33,
|
||||
Qt.Key.Key_PageDown: 34,
|
||||
Qt.Key.Key_End: 35,
|
||||
Qt.Key.Key_Home: 36,
|
||||
Qt.Key.Key_Left: 37,
|
||||
Qt.Key.Key_Up: 38,
|
||||
Qt.Key.Key_Right: 39,
|
||||
Qt.Key.Key_Down: 40,
|
||||
Qt.Key.Key_Print: 44,
|
||||
Qt.Key.Key_Insert: 45,
|
||||
Qt.Key.Key_Delete: 46,
|
||||
Qt.Key.Key_Help: 47,
|
||||
Qt.Key.Key_Meta: 91,
|
||||
Qt.Key.Key_Menu: 93,
|
||||
Qt.Key.Key_Sleep: 95,
|
||||
Qt.Key.Key_NumLock: 144,
|
||||
Qt.Key.Key_ScrollLock: 145,
|
||||
Qt.Key.Key_Semicolon: 186,
|
||||
Qt.Key.Key_Equal: 187,
|
||||
Qt.Key.Key_Comma: 188,
|
||||
Qt.Key.Key_Minus: 189,
|
||||
Qt.Key.Key_Period: 190,
|
||||
Qt.Key.Key_Slash: 191,
|
||||
Qt.Key.Key_QuoteLeft: 192,
|
||||
Qt.Key.Key_BracketLeft: 219,
|
||||
Qt.Key.Key_Backslash: 220,
|
||||
Qt.Key.Key_BracketRight: 221,
|
||||
Qt.Key.Key_Apostrophe: 222,
|
||||
Qt.Key.Key_Back: 166,
|
||||
Qt.Key.Key_Forward: 167,
|
||||
Qt.Key.Key_Refresh: 168,
|
||||
Qt.Key.Key_Stop: 169,
|
||||
Qt.Key.Key_Search: 170,
|
||||
Qt.Key.Key_Favorites: 171,
|
||||
Qt.Key.Key_HomePage: 172,
|
||||
Qt.Key.Key_VolumeMute: 173,
|
||||
Qt.Key.Key_VolumeDown: 174,
|
||||
Qt.Key.Key_VolumeUp: 175,
|
||||
Qt.Key.Key_MediaNext: 176,
|
||||
Qt.Key.Key_MediaPrevious: 177,
|
||||
Qt.Key.Key_MediaStop: 178,
|
||||
Qt.Key.Key_MediaPlay: 179,
|
||||
Qt.Key.Key_LaunchMail: 180,
|
||||
Qt.Key.Key_LaunchMedia: 181,
|
||||
Qt.Key.Key_Launch0: 182,
|
||||
Qt.Key.Key_Launch1: 183,
|
||||
}
|
||||
RESHADE_KEYPAD_KEYS = {
|
||||
Qt.Key.Key_Asterisk: 106,
|
||||
Qt.Key.Key_Plus: 107,
|
||||
Qt.Key.Key_Comma: 108,
|
||||
Qt.Key.Key_Minus: 109,
|
||||
Qt.Key.Key_Period: 110,
|
||||
Qt.Key.Key_Slash: 111,
|
||||
}
|
||||
RESHADE_FIRST_FUNCTION_KEY = 112
|
||||
RESHADE_LAST_FUNCTION_KEY = 135
|
||||
RESHADE_FIRST_NUMPAD_KEY = 96
|
||||
|
||||
|
||||
class ReShadeSettingsMixin:
|
||||
"""Mixin with ReShade settings UI and serialization logic."""
|
||||
|
||||
theme: Any
|
||||
exe_path: str | None
|
||||
current_settings: dict[str, str]
|
||||
original_values: dict[str, str]
|
||||
reshade_tab: QWidget
|
||||
reshade_tab_layout: QVBoxLayout
|
||||
|
||||
def init_reshade_state(self) -> None:
|
||||
self.reshade_original_values = {}
|
||||
self.reshade_key_target = None
|
||||
|
||||
def setup_reshade_tab(self) -> None:
|
||||
"""Create ReShade tab widgets."""
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
scroll.setStyleSheet(self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing)
|
||||
|
||||
actions = QGroupBox(_("Actions"))
|
||||
actions.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
actions_layout = QGridLayout(actions)
|
||||
self.reshade_enable_button = self._create_reshade_button("")
|
||||
self.reshade_enable_button.clicked.connect(self.toggle_reshade_enable)
|
||||
actions_layout.addWidget(self.reshade_enable_button, 0, 0)
|
||||
layout.addWidget(actions)
|
||||
|
||||
self._add_reshade_settings_group(layout)
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(container)
|
||||
self.reshade_tab_layout.addWidget(scroll)
|
||||
|
||||
def _add_reshade_settings_group(self, parent_layout: QVBoxLayout) -> None:
|
||||
self.reshade_settings_group = QGroupBox(_("Settings"))
|
||||
self.reshade_settings_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
settings_layout = QGridLayout(self.reshade_settings_group)
|
||||
self.reshade_exe_edit = self._add_reshade_file_row(
|
||||
settings_layout, 0, "EXE", ".exe"
|
||||
)
|
||||
self.reshade_api_combo = CustomComboBox(theme=self.theme)
|
||||
self.reshade_api_combo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
self.reshade_api_combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
self.reshade_api_combo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.reshade_api_combo.addItems(list(RESHADE_API_OPTIONS))
|
||||
settings_layout.addWidget(QLabel("API"), 1, 0)
|
||||
settings_layout.addWidget(self.reshade_api_combo, 1, 1)
|
||||
self.reshade_font_combo = CustomComboBox(theme=self.theme)
|
||||
self.reshade_font_combo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
self.reshade_font_combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
self.reshade_font_combo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.reshade_font_combo.addItem(_("Default"), "")
|
||||
settings_layout.addWidget(QLabel(_("Font")), 2, 0)
|
||||
settings_layout.addWidget(self.reshade_font_combo, 2, 1)
|
||||
self.reshade_screenshot_edit = self._add_reshade_directory_row(
|
||||
settings_layout, 3, _("Screenshots")
|
||||
)
|
||||
self.reshade_screenshot_key_button = self._create_reshade_key_button(
|
||||
"screenshot"
|
||||
)
|
||||
settings_layout.addWidget(QLabel(_("Screenshot key")), 4, 0)
|
||||
settings_layout.addWidget(self.reshade_screenshot_key_button, 4, 1)
|
||||
self.reshade_overlay_key_button = self._create_reshade_key_button("overlay")
|
||||
settings_layout.addWidget(QLabel(_("Overlay key")), 5, 0)
|
||||
settings_layout.addWidget(self.reshade_overlay_key_button, 5, 1)
|
||||
parent_layout.addWidget(self.reshade_settings_group)
|
||||
self._load_reshade_fonts()
|
||||
|
||||
def _create_reshade_button(self, label: str) -> QPushButton:
|
||||
button = QPushButton(label)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
return button
|
||||
|
||||
def _add_reshade_file_row(
|
||||
self, layout: QGridLayout, row: int, label: str, file_filter: str
|
||||
) -> QLineEdit:
|
||||
line_edit = QLineEdit()
|
||||
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
button = AutoSizeButton(
|
||||
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
|
||||
)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False: self._select_reshade_file(line_edit, file_filter)
|
||||
)
|
||||
field_layout = QHBoxLayout()
|
||||
field_layout.addWidget(line_edit)
|
||||
field_layout.addWidget(button)
|
||||
layout.addWidget(QLabel(label), row, 0)
|
||||
layout.addLayout(field_layout, row, 1)
|
||||
return line_edit
|
||||
|
||||
def _select_reshade_file(self, line_edit: QLineEdit, file_filter: str) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
current_path = line_edit.text().strip()
|
||||
initial_path = os.path.dirname(current_path) if os.path.isfile(current_path) else None
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, file_filter=file_filter, initial_path=initial_path
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(line_edit.setText)
|
||||
explorer.exec()
|
||||
|
||||
def _add_reshade_directory_row(
|
||||
self, layout: QGridLayout, row: int, label: str
|
||||
) -> QLineEdit:
|
||||
line_edit = QLineEdit()
|
||||
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
button = AutoSizeButton(
|
||||
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
|
||||
)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False: self._select_reshade_directory(line_edit)
|
||||
)
|
||||
field_layout = QHBoxLayout()
|
||||
field_layout.addWidget(line_edit)
|
||||
field_layout.addWidget(button)
|
||||
layout.addWidget(QLabel(label), row, 0)
|
||||
layout.addLayout(field_layout, row, 1)
|
||||
return line_edit
|
||||
|
||||
def _select_reshade_directory(self, line_edit: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = line_edit.text().strip() or os.path.dirname(self.exe_path or "")
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=initial_path, directory_only=True
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(line_edit.setText)
|
||||
explorer.exec()
|
||||
|
||||
def _create_reshade_key_button(self, target: str) -> QPushButton:
|
||||
button = self._create_reshade_button("")
|
||||
button.clicked.connect(lambda _checked=False: self._capture_reshade_key(target))
|
||||
button.installEventFilter(cast(QWidget, self))
|
||||
return button
|
||||
|
||||
def _capture_reshade_key(self, target: str) -> None:
|
||||
self.reshade_key_target = target
|
||||
button = self._get_reshade_key_button(target)
|
||||
button.setText(_("Press a button to choose"))
|
||||
button.grabKeyboard()
|
||||
|
||||
def _get_reshade_key_button(self, target: str) -> QPushButton:
|
||||
if target == "screenshot":
|
||||
return self.reshade_screenshot_key_button
|
||||
return self.reshade_overlay_key_button
|
||||
|
||||
def _handle_reshade_key_button_event(self, obj: QWidget, event: QEvent) -> bool:
|
||||
target = self.reshade_key_target
|
||||
if target is None or obj is not self._get_reshade_key_button(target):
|
||||
return False
|
||||
if event.type() != QEvent.Type.KeyPress:
|
||||
return False
|
||||
key_event = cast(QKeyEvent, event)
|
||||
key_value = self._reshade_virtual_key(
|
||||
key_event.key(), key_event.modifiers()
|
||||
)
|
||||
if key_value is None:
|
||||
return True
|
||||
modifiers = key_event.modifiers()
|
||||
value = ",".join(str(item) for item in (
|
||||
key_value,
|
||||
int(bool(modifiers & Qt.KeyboardModifier.ControlModifier)),
|
||||
int(bool(modifiers & Qt.KeyboardModifier.ShiftModifier)),
|
||||
int(bool(modifiers & Qt.KeyboardModifier.AltModifier)),
|
||||
))
|
||||
self.current_settings[f'PW_RESHADE_{target.upper()}_KEY'] = value
|
||||
button = self._get_reshade_key_button(target)
|
||||
button.setText(QKeySequence(key_event.keyCombination()).toString())
|
||||
button.releaseKeyboard()
|
||||
self.reshade_key_target = None
|
||||
return True
|
||||
|
||||
def _reshade_virtual_key(
|
||||
self, key: int, modifiers: Qt.KeyboardModifier
|
||||
) -> int | None:
|
||||
is_keypad = bool(modifiers & Qt.KeyboardModifier.KeypadModifier)
|
||||
if is_keypad and Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
|
||||
return RESHADE_FIRST_NUMPAD_KEY + key - Qt.Key.Key_0.value
|
||||
if is_keypad:
|
||||
try:
|
||||
keypad_key = RESHADE_KEYPAD_KEYS.get(Qt.Key(key))
|
||||
except ValueError:
|
||||
keypad_key = None
|
||||
if keypad_key is not None:
|
||||
return keypad_key
|
||||
if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
|
||||
return key
|
||||
if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value:
|
||||
return key
|
||||
if Qt.Key.Key_F1.value <= key <= Qt.Key.Key_F24.value:
|
||||
return RESHADE_FIRST_FUNCTION_KEY + key - Qt.Key.Key_F1.value
|
||||
try:
|
||||
return RESHADE_SPECIAL_KEYS.get(Qt.Key(key))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _format_reshade_key(self, value: str) -> str:
|
||||
try:
|
||||
key, control, shift, alt = (int(item) for item in value.split(","))
|
||||
except ValueError:
|
||||
return value
|
||||
qt_key = next(
|
||||
(item for item, code in RESHADE_SPECIAL_KEYS.items() if code == key), None
|
||||
)
|
||||
if qt_key is not None:
|
||||
name = QKeySequence(qt_key).toString()
|
||||
elif RESHADE_FIRST_FUNCTION_KEY <= key <= RESHADE_LAST_FUNCTION_KEY:
|
||||
name = f"F{key - RESHADE_FIRST_FUNCTION_KEY + 1}"
|
||||
else:
|
||||
name = chr(key)
|
||||
modifiers = [
|
||||
label for active, label in (
|
||||
(control, "Ctrl"), (shift, "Shift"), (alt, "Alt")
|
||||
) if active
|
||||
]
|
||||
return "+".join([*modifiers, name])
|
||||
|
||||
def _load_reshade_fonts(self) -> None:
|
||||
selected_family = self.current_settings.get('PW_RESHADE_FONT', '')
|
||||
font_options = cast(Any, self)._get_mangohud_font_options()
|
||||
families = {name for name, _path in font_options}
|
||||
self.reshade_font_combo.clear()
|
||||
self.reshade_font_combo.addItem(_("Default"), "")
|
||||
for family in sorted(families, key=str.casefold):
|
||||
self.reshade_font_combo.addItem(family, family)
|
||||
self._select_reshade_font(selected_family)
|
||||
|
||||
def _select_reshade_font(self, family: str) -> None:
|
||||
for index in range(self.reshade_font_combo.count()):
|
||||
if self.reshade_font_combo.itemData(index) == family:
|
||||
self.reshade_font_combo.setCurrentIndex(index)
|
||||
return
|
||||
if family:
|
||||
self.reshade_font_combo.addItem(family, family)
|
||||
self.reshade_font_combo.setCurrentIndex(self.reshade_font_combo.count() - 1)
|
||||
|
||||
def populate_reshade(self) -> None:
|
||||
"""Populate ReShade tab from current settings."""
|
||||
reshade_exe = self.current_settings.get('PW_RESHADE_EXE', '') or self.exe_path or ''
|
||||
self.reshade_exe_edit.setText(reshade_exe)
|
||||
api_value = self.current_settings.get('PW_RESHADE_API', 'auto')
|
||||
api_label = next(
|
||||
(label for label, value in RESHADE_API_OPTIONS.items() if value == api_value),
|
||||
"Auto",
|
||||
)
|
||||
self.reshade_api_combo.setCurrentText(api_label)
|
||||
font_family = self.current_settings.get('PW_RESHADE_FONT', '')
|
||||
self._select_reshade_font(font_family)
|
||||
screenshot_path = self.current_settings.get('PW_RESHADE_SCREENSHOT_PATH', '')
|
||||
screenshot_key = self.current_settings.get(
|
||||
'PW_RESHADE_SCREENSHOT_KEY', '44,0,0,0'
|
||||
)
|
||||
overlay_key = self.current_settings.get(
|
||||
'PW_RESHADE_OVERLAY_KEY', '36,0,0,0'
|
||||
)
|
||||
self.reshade_screenshot_edit.setText(screenshot_path)
|
||||
self.reshade_screenshot_key_button.setText(
|
||||
self._format_reshade_key(screenshot_key)
|
||||
)
|
||||
self.reshade_overlay_key_button.setText(self._format_reshade_key(overlay_key))
|
||||
self.current_settings['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
|
||||
self.current_settings['PW_RESHADE_OVERLAY_KEY'] = overlay_key
|
||||
self.reshade_original_values = {
|
||||
key: self.current_settings.get(key, '') for key in RESHADE_ENV_KEYS
|
||||
}
|
||||
self.reshade_original_values['PW_RESHADE_EXE'] = reshade_exe
|
||||
self.reshade_original_values['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
|
||||
self.reshade_original_values['PW_RESHADE_OVERLAY_KEY'] = overlay_key
|
||||
self._update_reshade_widgets()
|
||||
|
||||
def toggle_reshade_enable(self) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.current_settings['PW_USE_RESHADE'] = '0' if enabled else '1'
|
||||
self._update_reshade_widgets()
|
||||
|
||||
def _update_reshade_widgets(self) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.reshade_enable_button.setText(
|
||||
_("Disable {0}").format("ReShade")
|
||||
if enabled else _("Enable {0}").format("ReShade")
|
||||
)
|
||||
self.reshade_enable_button.setStyleSheet(
|
||||
self.theme.ACTION_BUTTON_ACTIVE_STYLE if enabled else self.theme.ACTION_BUTTON_STYLE
|
||||
)
|
||||
self.reshade_settings_group.setVisible(enabled)
|
||||
|
||||
def _collect_reshade_changes(self) -> list[str]:
|
||||
changes = []
|
||||
enabled = '1' if self.current_settings.get('PW_USE_RESHADE') == '1' else '0'
|
||||
original_enabled = '1' if self.original_values.get('PW_USE_RESHADE') == '1' else '0'
|
||||
if enabled != original_enabled:
|
||||
changes.append(f"PW_USE_RESHADE={enabled}")
|
||||
values = {
|
||||
'PW_RESHADE_EXE': self.reshade_exe_edit.text().strip(),
|
||||
'PW_RESHADE_API': RESHADE_API_OPTIONS[self.reshade_api_combo.currentText()],
|
||||
'PW_RESHADE_FONT': str(self.reshade_font_combo.currentData() or ''),
|
||||
'PW_RESHADE_SCREENSHOT_PATH': self.reshade_screenshot_edit.text().strip(),
|
||||
'PW_RESHADE_SCREENSHOT_KEY': self.current_settings['PW_RESHADE_SCREENSHOT_KEY'],
|
||||
'PW_RESHADE_OVERLAY_KEY': self.current_settings['PW_RESHADE_OVERLAY_KEY'],
|
||||
}
|
||||
for key, value in values.items():
|
||||
if value != self.reshade_original_values.get(key, ''):
|
||||
changes.append(f"{key}={value}")
|
||||
return changes
|
||||
|
||||
def _filter_reshade_settings(self, search_text: str) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.reshade_settings_group.setVisible(
|
||||
enabled and (
|
||||
not search_text
|
||||
or search_text in "exe api font screenshot overlay"
|
||||
or search_text in _("Settings").lower()
|
||||
)
|
||||
)
|
||||
@@ -286,10 +286,6 @@ class AnimatedCard(QFrame):
|
||||
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
|
||||
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
|
||||
self.animations.paint_border(painter)
|
||||
if getattr(self, "batch_selected", False):
|
||||
painter.setPen(QPen(QColor(self.theme.color_accent),
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"]))
|
||||
painter.drawRect(self.rect())
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
@@ -379,7 +375,6 @@ class GameCard(AnimatedCard):
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.batch_selected = False
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
@@ -1261,12 +1256,6 @@ class GameCard(AnimatedCard):
|
||||
super().showEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
if self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode is True:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
return
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
if self._get_missing_executable_path() and self.context_menu_manager:
|
||||
self.context_menu_manager.handle_missing_executable(self)
|
||||
return
|
||||
@@ -1293,11 +1282,6 @@ class GameCard(AnimatedCard):
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
event.accept()
|
||||
return
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
@@ -161,7 +160,11 @@ class ButtonInputMixin(InputMixin):
|
||||
def _route_button_to_text_input(
|
||||
self, active: QWidget, focused: QWidget | None, button_code: int
|
||||
) -> bool:
|
||||
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
keyboard = (
|
||||
getattr(active, 'keyboard', None)
|
||||
if isinstance(active, AddGameDialog)
|
||||
else getattr(self._parent, 'keyboard', None)
|
||||
)
|
||||
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
@@ -243,11 +246,6 @@ class ButtonInputMixin(InputMixin):
|
||||
return False
|
||||
|
||||
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
|
||||
if button_code in BUTTONS['add_game'] and isinstance(active, QDialog):
|
||||
action = active.findChild(QPushButton, "gamepadAddButton")
|
||||
if action is not None and action.isVisible() and action.isEnabled():
|
||||
action.click()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
||||
active.reject()
|
||||
return True
|
||||
|
||||
@@ -317,7 +317,10 @@ class DpadInputMixin(InputMixin):
|
||||
|
||||
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
keyboard = getattr(active_window, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
else:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if value == 0:
|
||||
self._reset_dpad_repeat()
|
||||
|
||||
@@ -141,10 +141,6 @@ class FileExplorerInputMixin(InputMixin):
|
||||
self.file_explorer.show_folder_context_menu(item_rect.center())
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self._handle_active_window_button(self.file_explorer, button_code):
|
||||
return
|
||||
if not self.file_explorer.directory_only:
|
||||
return
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ class KeyboardInputMixin(InputMixin):
|
||||
keyboard.stop_backspace_repeat()
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Show:
|
||||
if isinstance(obj, QMenu):
|
||||
SoundManager().play("open")
|
||||
@@ -158,7 +162,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
return False
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
|
||||
native_key = self._native_scan_to_input_key(event)
|
||||
|
||||
@@ -184,7 +184,10 @@ class SettingsInputMixin(InputMixin):
|
||||
return
|
||||
|
||||
# 4. Global Shortcuts
|
||||
if button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
if button_code in BUTTONS['add_game']: # X: Apply
|
||||
self.settings_dialog.apply_changes()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
focused = QApplication.focusWidget()
|
||||
focused_combo = None
|
||||
if isinstance(focused, QTableWidget) and self.settings_dialog:
|
||||
@@ -339,14 +342,12 @@ class SettingsInputMixin(InputMixin):
|
||||
combo_tables = (
|
||||
self.settings_dialog.advanced_table,
|
||||
self.settings_dialog.settings_table,
|
||||
getattr(self.settings_dialog, "favorites_table", None),
|
||||
)
|
||||
if self.settings_dialog and table in combo_tables and table.currentRow() >= 0:
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
view = cell_widget.view()
|
||||
idx = view.currentIndex().row()
|
||||
idx = cell_widget.currentIndex()
|
||||
step = -1 if value < 0 else 1
|
||||
model = cell_widget.model()
|
||||
end = -1 if step < 0 else cell_widget.count()
|
||||
@@ -354,8 +355,7 @@ class SettingsInputMixin(InputMixin):
|
||||
row for row in range(idx + step, end, step)
|
||||
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
), idx)
|
||||
view.setCurrentIndex(model.index(new_idx, 0))
|
||||
view.scrollTo(model.index(new_idx, 0), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
|
||||
@@ -22,12 +22,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.5\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -148,9 +142,6 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -303,6 +294,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -476,16 +471,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -781,6 +766,9 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
@@ -793,6 +781,9 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
@@ -895,22 +886,6 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -935,30 +910,6 @@ msgstr ""
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
@@ -977,13 +928,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1007,6 +952,9 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1148,6 +1096,9 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1235,6 +1186,9 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -2163,6 +2117,9 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2235,9 +2192,6 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2301,9 +2255,6 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2524,10 +2475,16 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Spiel fortsetzen"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Spiel anhalten"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
@@ -153,10 +147,6 @@ msgstr "Die Datei existiert bereits: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Datei konnte nicht umbenannt werden: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Alle auswählen"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Von PortProton löschen"
|
||||
|
||||
@@ -316,6 +306,10 @@ msgstr ""
|
||||
"'{game_name}' wurde zu Steam hinzugefügt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' wurde zu Steam hinzugefügt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Fehler beim Hinzufügen von '{game_name}' zu Steam: {error}"
|
||||
@@ -489,16 +483,6 @@ msgstr "Eine weitere Epic-Operation läuft"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Wine-Prefix nicht gefunden"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
@@ -853,6 +837,9 @@ msgstr "Min."
|
||||
msgid "sec."
|
||||
msgstr "Sek."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Spiel anhalten"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Exit"
|
||||
|
||||
@@ -865,6 +852,9 @@ msgstr "Favoriten"
|
||||
msgid "Recent Games"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Spiel fortsetzen"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Spiel konnte nicht pausiert werden"
|
||||
|
||||
@@ -968,22 +958,6 @@ msgstr "AppImage aktualisieren..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Start {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Ordner konnte nicht erstellt werden: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Titel:"
|
||||
|
||||
@@ -1008,33 +982,6 @@ msgstr "Titelbild-Vorschau:"
|
||||
msgid "Apply"
|
||||
msgstr "Anwenden"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Ordner löschen"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Ungültiges Bild"
|
||||
|
||||
@@ -1055,14 +1002,8 @@ msgstr ""
|
||||
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
|
||||
"erfasst."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
@@ -1085,6 +1026,9 @@ msgstr "Vorheriger Tab"
|
||||
msgid "Next Tab"
|
||||
msgstr "Nächster Tab"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Suchen"
|
||||
|
||||
@@ -1232,6 +1176,9 @@ msgstr "WINE/Proton konnte nicht entfernt werden in {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton erfolgreich entfernt."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Alle ausgewählten WINE/Proton-Versionen wurden heruntergeladen!"
|
||||
|
||||
@@ -1319,6 +1266,9 @@ msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Datei konnte nicht geöffnet werden:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "PPDB-Zurücksetzung bestätigen"
|
||||
|
||||
@@ -2265,6 +2215,9 @@ msgstr "Fehlgeschlagen"
|
||||
msgid "Repaired"
|
||||
msgstr "Repariert"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Schnellstart"
|
||||
|
||||
@@ -2339,9 +2292,6 @@ msgstr ""
|
||||
"Sollen '{count}' Titelbidler ohne ausführbaren Dateien wirklich gelöscht "
|
||||
"werden? Dabei werden Benutzerdaten und .desktop-Dateien gelöscht."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton-Einstellungen"
|
||||
|
||||
@@ -2405,9 +2355,6 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
|
||||
|
||||
@@ -2632,9 +2579,15 @@ msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Herunterladen des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Herunterladen… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Theme auswählen:"
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Reanudar el juego"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar el juego"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
@@ -155,10 +149,6 @@ msgstr "El archivo ya existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "No se pudo cambiar el nombre del archivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Eliminar de PortProton"
|
||||
|
||||
@@ -318,6 +308,10 @@ msgstr ""
|
||||
"Se ha añadido «{game_name}» a Steam. Reinicia Steam para que los cambios "
|
||||
"surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "«{game_name}» se ha añadido a Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "No se ha podido añadir «{game_name}» a Steam: {error}"
|
||||
@@ -494,16 +488,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefijo no encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
@@ -873,6 +857,9 @@ msgstr "min"
|
||||
msgid "sec."
|
||||
msgstr "s"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar el juego"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Salir"
|
||||
|
||||
@@ -885,6 +872,9 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Reanudar el juego"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Error al pausar el juego"
|
||||
|
||||
@@ -987,22 +977,6 @@ msgstr "Actualizando AppImage…"
|
||||
msgid "Launching {0}"
|
||||
msgstr "Comenzando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "No se ha podido crear la carpeta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nombre:"
|
||||
|
||||
@@ -1027,33 +1001,6 @@ msgstr "Vista previa de la portada:"
|
||||
msgid "Apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagen no válida"
|
||||
|
||||
@@ -1072,14 +1019,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1103,6 +1043,9 @@ msgstr "Pestaña anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Pestaña siguiente"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
@@ -1248,6 +1191,9 @@ msgstr "No se pudo eliminar WINE/Proton en {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1338,6 +1284,9 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "No se ha podido abrir la carpeta: {error}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar que PPDB está vacío"
|
||||
|
||||
@@ -2277,6 +2226,10 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2354,9 +2307,6 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar «{game_name}»? Esto eliminará el "
|
||||
"archivo .desktop y los datos personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2420,9 +2370,6 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2645,9 +2592,15 @@ msgstr "Error al cargar los temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Error al descargar los temas"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Descargando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-10-21 00:34+0500\n"
|
||||
"POT-Creation-Date: 2026-10-11 01:04+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -18,12 +18,6 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -144,9 +138,6 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -299,6 +290,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -472,16 +467,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -777,6 +762,9 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
@@ -789,6 +777,9 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
@@ -891,22 +882,6 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -931,30 +906,6 @@ msgstr ""
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
@@ -973,13 +924,7 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1003,6 +948,9 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1144,6 +1092,9 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1231,6 +1182,9 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -2159,6 +2113,9 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2231,9 +2188,6 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2297,9 +2251,6 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2521,10 +2472,16 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-10-17 23:27+0000\n"
|
||||
"PO-Revision-Date: 2026-09-24 06:40+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -18,15 +18,9 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Retomar jogo"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar jogo"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
@@ -153,10 +147,6 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar Tudo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
|
||||
@@ -316,6 +306,10 @@ msgstr ""
|
||||
"'{game_name}' foi adicionado à Steam. Reinicie a Steam para aplicar as "
|
||||
"alterações."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' foi adicionado à Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Falha ao adicionar '{game_name}' à Steam: {error}"
|
||||
@@ -488,16 +482,6 @@ msgstr "Outra operação da Epic está em andamento"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo do Wine não encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -855,6 +839,9 @@ msgstr "min."
|
||||
msgid "sec."
|
||||
msgstr "s."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar jogo"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Sair"
|
||||
|
||||
@@ -867,6 +854,9 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Retomar jogo"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Falha ao pausar o jogo"
|
||||
|
||||
@@ -969,22 +959,6 @@ msgstr "Atualizando AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
@@ -1009,33 +983,6 @@ msgstr "Pré-visualização da Capa:"
|
||||
msgid "Apply"
|
||||
msgstr "Aplicar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
@@ -1056,14 +1003,8 @@ msgstr ""
|
||||
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
|
||||
"automaticamente."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -1086,6 +1027,9 @@ msgstr "Aba Anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Próxima Aba"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
@@ -1233,6 +1177,9 @@ msgstr "Falha ao remover WINE/Proton em {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton removido(s) com sucesso."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Todos os WINE/Proton selecionados foram baixados!"
|
||||
|
||||
@@ -1271,14 +1218,15 @@ msgstr "O download ou a extração foi cancelado."
|
||||
msgid "System WINE"
|
||||
msgstr "WINE do Sistema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Configurações globais dos jogos"
|
||||
msgstr "Salvar Configurações"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Configurações do Exe"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr "Editar o arquivo user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Pesquisar configurações…"
|
||||
@@ -1304,8 +1252,9 @@ msgstr "Editar PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Falha ao carregar as configurações globais dos jogos."
|
||||
msgstr "Falha ao carregar temas"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "O caminho do executável não está disponível."
|
||||
@@ -1316,8 +1265,12 @@ msgstr "O arquivo PPDB não existe em: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Falha ao abrir o arquivo:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar Limpeza do PPDB"
|
||||
@@ -1339,8 +1292,9 @@ msgstr "Padrão"
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Baixar Wine/Proton..."
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Digite o nome do prefixo"
|
||||
@@ -2181,7 +2135,7 @@ msgid "GOG account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
msgid "Log out"
|
||||
msgstr "Sair da sessão"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de login"
|
||||
@@ -2263,6 +2217,9 @@ msgstr "Falhou"
|
||||
msgid "Repaired"
|
||||
msgstr "Reparado"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
|
||||
@@ -2302,14 +2259,16 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Tema"
|
||||
msgstr "Temas"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr "Grade"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Lista"
|
||||
msgstr "Claro"
|
||||
|
||||
msgid "Vertical"
|
||||
msgstr "Vertical"
|
||||
@@ -2337,9 +2296,6 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir {count} cartões sem executável? Isso "
|
||||
"removerá os arquivos .desktop e os dados personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
@@ -2370,14 +2326,15 @@ msgstr "Tipo de Controle:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Iniciar Aplicativo em Tela Cheia"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Ocultar o cursor em tela cheia"
|
||||
msgstr "Forçar janelas em tela cheia"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Tela Cheia Automática ao Conectar o Controle"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Desativar animação de inicialização"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Minimizar para a bandeja ao fechar"
|
||||
@@ -2403,11 +2360,8 @@ msgstr "Ativar sons da interface"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
@@ -2416,7 +2370,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Desativar a busca por instalações do Steam Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
@@ -2628,9 +2582,15 @@ msgstr "Falha ao carregar temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Falha ao baixar o tema"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Baixando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Selecionar Tema:"
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-10-15 19:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-10-09 11:06+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -19,15 +19,9 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Возобновить игру"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Приостановить игру"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
@@ -153,10 +147,6 @@ msgstr "Файл уже существует: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Не удалось переименовать файл: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Выбрать всё"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
@@ -314,6 +304,10 @@ msgstr ""
|
||||
"'{game_name}' добавлен(а) в Steam. Пожалуйста, перезапустите Steam, чтобы"
|
||||
" изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' добавлен(а) в Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
@@ -486,16 +480,6 @@ msgstr "Другая операция Epic уже выполняется"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Префикс Wine не найден"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
@@ -763,14 +747,14 @@ msgstr ""
|
||||
"run --user --scope --slice=app.slice"
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr "Ограничение ядер ЦП"
|
||||
msgstr "Ограничение ядер CPU"
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
"Ограничение количества ядер процессора полезно для игр на Unity "
|
||||
"(рекомендуется установить значение равным 8)"
|
||||
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
|
||||
"установить значение равным 8)"
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr "Версия OpenGL"
|
||||
@@ -854,6 +838,9 @@ msgstr "мин."
|
||||
msgid "sec."
|
||||
msgstr "сек."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Приостановить игру"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Выход"
|
||||
|
||||
@@ -866,6 +853,9 @@ msgstr "Избранное"
|
||||
msgid "Recent Games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Возобновить игру"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Не удалось приостановить игру"
|
||||
|
||||
@@ -968,22 +958,6 @@ msgstr "Обновление AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Запуск {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Не удалось создать папку: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
|
||||
@@ -1008,33 +982,6 @@ msgstr "Предпросмотр обложки:"
|
||||
msgid "Apply"
|
||||
msgstr "Применить"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Удалить Папку"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Недавние"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Недопустимое изображение"
|
||||
|
||||
@@ -1055,14 +1002,8 @@ msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1085,6 +1026,9 @@ msgstr "Предыдущая вкладка"
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
@@ -1232,6 +1176,9 @@ msgstr "Не удалось удалить WINE/Proton в {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "Успешно удалено {} WINE/Proton."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Все выбранные WINE/Proton успешно загружены!"
|
||||
|
||||
@@ -1318,6 +1265,9 @@ msgstr "Не удалось открыть файл PPDB:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
|
||||
@@ -1597,7 +1547,7 @@ msgid "Failed to save custom preset."
|
||||
msgstr "Ошибка сохранения пользовательских настроек."
|
||||
|
||||
msgid "Overlay position"
|
||||
msgstr "Положение MangoHud"
|
||||
msgstr "Позиция MangoHud"
|
||||
|
||||
msgid "Device battery"
|
||||
msgstr "Батарея устройства"
|
||||
@@ -1612,7 +1562,7 @@ msgid "FCAT screen edge"
|
||||
msgstr "Край области FCAT"
|
||||
|
||||
msgid "Table columns"
|
||||
msgstr "Столбцов таблицы"
|
||||
msgstr "Кол-во колонок"
|
||||
|
||||
msgid "Network (tx/rx kb/s)"
|
||||
msgstr "Сеть (tx/rx kb/s)"
|
||||
@@ -1981,8 +1931,9 @@ msgid ""
|
||||
"Select one or more FPS presets. The values are saved into FPS_LIMIT and "
|
||||
"MangoHud will also show the active FPS limit."
|
||||
msgstr ""
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения будут "
|
||||
"сохранены в FPS_LIMIT, а также MangoHud отобразит активное ограничение FPS."
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения "
|
||||
"будут сохранены в FPS_LIMIT и MangoHud также отобразит активное "
|
||||
"ограничение FPS."
|
||||
|
||||
msgid "Additional comma-separated MangoHud options not covered by the GUI."
|
||||
msgstr "Дополнительные параметры MangoHud (через запятую), отсутствующие в GUI."
|
||||
@@ -2256,6 +2207,9 @@ msgstr "Ошибка"
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2330,9 +2284,6 @@ msgstr ""
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
|
||||
@@ -2396,9 +2347,6 @@ msgstr "Включить звуки интерфейса"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Отключить проверку зависимостей ALT Linux i586"
|
||||
|
||||
@@ -2619,9 +2567,15 @@ msgstr "Не удалось загрузить темы"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Не удалось скачать тему"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Скачивание… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
|
||||
@@ -21,14 +21,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
@@ -149,10 +141,6 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "全选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
|
||||
@@ -310,6 +298,10 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "无法将\"{game_name}\"添加到 Steam:{error}"
|
||||
@@ -484,16 +476,6 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "未找到前缀"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -820,6 +802,10 @@ msgstr "分钟。"
|
||||
msgid "sec."
|
||||
msgstr "秒。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "退出"
|
||||
|
||||
@@ -832,6 +818,10 @@ msgstr "收藏夹"
|
||||
msgid "Recent Games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to pause game"
|
||||
msgstr "停止游戏失败"
|
||||
@@ -935,22 +925,6 @@ msgstr "正在更新 AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
|
||||
@@ -975,34 +949,6 @@ msgstr "封面预览:"
|
||||
msgid "Apply"
|
||||
msgstr "应用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
@@ -1021,15 +967,9 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
@@ -1052,6 +992,9 @@ msgstr "上一标签页"
|
||||
msgid "Next Tab"
|
||||
msgstr "下一标签页"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
@@ -1199,6 +1142,9 @@ msgstr "移除位于 {path} 的 WINE/Proton 失败:{error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "已成功移除 {} 个 WINE/Proton。"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "所有选定的 WINE/Proton 已下载完成!"
|
||||
|
||||
@@ -1288,6 +1234,9 @@ msgstr "打开 PPDB 文件失败:\n"
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
|
||||
@@ -2238,6 +2187,10 @@ msgstr "详细"
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
@@ -2320,9 +2273,6 @@ msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
@@ -2388,9 +2338,6 @@ msgstr "启用界面音效"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2613,9 +2560,15 @@ msgstr "加载主题失败"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "下载主题失败"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "正在下载… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "选择主题:"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from babel import Locale
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -85,11 +85,8 @@ def get_system_locale():
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
try:
|
||||
loc = locale.getlocale()[0]
|
||||
return str(Locale.parse(loc)) if loc else 'en'
|
||||
except (UnknownLocaleError, ValueError):
|
||||
return 'en'
|
||||
loc = locale.getlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
def get_steam_language():
|
||||
try:
|
||||
|
||||
+18
-58
@@ -7,7 +7,6 @@ import shlex
|
||||
import subprocess
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
@@ -116,7 +115,7 @@ class MainWindow(
|
||||
QMainWindow,
|
||||
):
|
||||
games_loaded = Signal(list)
|
||||
compatibility_report_ready = Signal(str, str)
|
||||
compatibility_report_ready = Signal(str)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -2494,12 +2493,11 @@ class MainWindow(
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
|
||||
return
|
||||
self.compatibility_report_ready.emit(report, launch.executable)
|
||||
self.compatibility_report_ready.emit(report)
|
||||
|
||||
def _show_compatibility_report(self, report: str, executable: str) -> None:
|
||||
def _show_compatibility_report(self, report: str) -> None:
|
||||
logger.info("Compatibility report dialog opened")
|
||||
dialog = CompatibilityReportDialog(self, self.theme, report)
|
||||
dialog.check_ppdb(executable)
|
||||
dialog.exec()
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
@@ -2766,23 +2764,22 @@ class MainWindow(
|
||||
|
||||
def _delete_egs_game(self, app_id: str) -> None:
|
||||
game = self.egs_api.load_installed().get(app_id, {})
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game.get("title") or app_id
|
||||
)
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game.get("title") or app_id
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._start_egs_operation(
|
||||
app_id, ["uninstall", app_id, "-y"], _("Delete")
|
||||
)
|
||||
@@ -2873,37 +2870,6 @@ class MainWindow(
|
||||
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
|
||||
return self.egs_api.is_eos_overlay_enabled(prefix_path)
|
||||
|
||||
def _warn_cyrillic_launch_paths(self, game_path: str) -> bool:
|
||||
if ui_config.get_disable_cyrillic_path_check():
|
||||
return False
|
||||
paths = [game_path, *(self.start_sh or [])[:1]]
|
||||
affected_paths = []
|
||||
for path in paths:
|
||||
for candidate in (os.path.abspath(path), os.path.realpath(path)):
|
||||
if candidate in affected_paths:
|
||||
continue
|
||||
if any("CYRILLIC" in unicodedata.name(char, "") for char in candidate):
|
||||
affected_paths.append(candidate)
|
||||
if not affected_paths:
|
||||
return False
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Warning)
|
||||
message_box.setWindowTitle(_("Warning"))
|
||||
message_box.setText(
|
||||
_("Cyrillic characters were found in the following paths. "
|
||||
"Wine may work unreliably. Move the game or PortProton "
|
||||
"to a path without Cyrillic characters:\n\n")
|
||||
+ "\n".join(affected_paths)
|
||||
)
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Close)
|
||||
disable_button = message_box.addButton(
|
||||
_("Don't remind again"), QMessageBox.ButtonRole.ActionRole
|
||||
)
|
||||
message_box.exec()
|
||||
if message_box.clickedButton() is disable_button:
|
||||
ui_config.set_disable_cyrillic_path_check(True)
|
||||
return True
|
||||
|
||||
def _launch_egs_game(self, app_id: str, button=None) -> None:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
if not target:
|
||||
@@ -2931,7 +2897,6 @@ class MainWindow(
|
||||
"launch", app_id, "--json", "--wrapper", self.start_sh[0],
|
||||
"--no-wine", "--skip-version-check",
|
||||
])
|
||||
self._warn_cyrillic_launch_paths(target)
|
||||
process = subprocess.Popen(
|
||||
command, env=self.egs_api.get_environment(), shell=False,
|
||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
||||
@@ -2993,9 +2958,6 @@ class MainWindow(
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
self._finish_silent_launch()
|
||||
return
|
||||
self._warn_cyrillic_launch_paths(
|
||||
self.gog_api.get_launch_target(app_id) or install_path
|
||||
)
|
||||
needs_setup = getattr(self.gog_api, "needs_support_setup", lambda _app_id: False)
|
||||
if needs_setup(app_id):
|
||||
self._start_gog_support_setup(app_id, button)
|
||||
@@ -3101,8 +3063,6 @@ class MainWindow(
|
||||
else:
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
if file_to_check.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS):
|
||||
self._warn_cyrillic_launch_paths(file_to_check)
|
||||
|
||||
if update_button:
|
||||
try:
|
||||
|
||||
@@ -118,6 +118,7 @@ install_data(
|
||||
'dialogs/settings_dialog.py',
|
||||
'dialogs/settings_mangohud.py',
|
||||
'dialogs/settings_gamescope.py',
|
||||
'dialogs/settings_reshade.py',
|
||||
'dialogs/settings_vkbasalt.py',
|
||||
'dialogs/proton_manager.py',
|
||||
'dialogs/prefix_backup.py',
|
||||
@@ -186,7 +187,6 @@ install_data(
|
||||
['scripts_utils/__init__.py',
|
||||
'scripts_utils/dbus_tools.py',
|
||||
'scripts_utils/easyterm.py',
|
||||
'scripts_utils/exe_metadata.py',
|
||||
'scripts_utils/graphics_detector.py',
|
||||
'scripts_utils/idle_inhibit.py',
|
||||
'scripts_utils/json_tools.py',
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
@@ -169,20 +168,17 @@ async def request_deepin_wm_switch() -> bool:
|
||||
|
||||
|
||||
async def request_idle_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
"""Request idle inhibit through screensaver, portal or logind D-Bus API."""
|
||||
bus = None
|
||||
"""Request idle inhibit through screensaver or portal D-Bus API."""
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
kind, payload = await _request_screensaver_inhibit(bus, application, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Screensaver inhibit failed: %s", error)
|
||||
kind, payload = await _request_portal_inhibit(bus, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Session idle inhibit failed: %s", error)
|
||||
if bus is not None:
|
||||
bus.disconnect()
|
||||
return await _request_logind_inhibit(application, reason)
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
return bus, kind, payload
|
||||
|
||||
@@ -215,33 +211,8 @@ async def _request_portal_inhibit(bus: MessageBus, reason: str) -> tuple[str, An
|
||||
return "portal", body[0]
|
||||
|
||||
|
||||
async def _request_logind_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SYSTEM, negotiate_unix_fd=True).connect())
|
||||
try:
|
||||
reply = await dbus_call(bus.call(Message(
|
||||
destination="org.freedesktop.login1",
|
||||
path="/org/freedesktop/login1",
|
||||
interface="org.freedesktop.login1.Manager",
|
||||
member="Inhibit",
|
||||
signature="ssss",
|
||||
body=["sleep:idle", application, reason, "block"],
|
||||
)))
|
||||
if reply.message_type.name == "ERROR":
|
||||
raise DBusError(reply.error_name, reply.body[0])
|
||||
return bus, "logind", reply.unix_fds[reply.body[0]]
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
|
||||
async def release_idle_inhibit(bus: MessageBus, kind: str, payload: Any) -> None:
|
||||
"""Release idle inhibit requested through request_idle_inhibit."""
|
||||
if kind == "logind":
|
||||
try:
|
||||
os.close(payload)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
return
|
||||
with contextlib.suppress(DBusError, asyncio.TimeoutError):
|
||||
if kind == "screensaver":
|
||||
iface, cookie = payload
|
||||
|
||||
@@ -1026,7 +1026,11 @@ class TerminalWidget(QPlainTextEdit):
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
except ValueError:
|
||||
max_fd = 256
|
||||
os.closerange(3, max_fd)
|
||||
for fd in range(3, max_fd):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if self.cwd:
|
||||
os.chdir(self.cwd)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""Read PE version metadata for PortProton shell scripts."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pefile
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def read_metadata(path: str) -> dict[str, str] | None:
|
||||
"""Read version strings without starting an external metadata tool."""
|
||||
try:
|
||||
with pefile.PE(path, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
return {
|
||||
key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", []) for key, value in table.entries.items()
|
||||
}
|
||||
except pefile.PEFormatError as error:
|
||||
logger.debug("Unsupported PE file %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
sys.stderr.write("Usage: python -m portprotonqt.scripts_utils.exe_metadata FILE\n")
|
||||
return 2
|
||||
metadata = read_metadata(sys.argv[1])
|
||||
if metadata is None:
|
||||
return 1
|
||||
sys.stdout.write(f"File Name: {Path(sys.argv[1]).name}\n")
|
||||
for key, value in metadata.items():
|
||||
label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
|
||||
sys.stdout.write(f"{label}: {' '.join(value.splitlines())}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -149,6 +149,7 @@ def get_toggle_settings():
|
||||
'PW_USE_SPECIALK': _("Enable SpecialK (injection library for fixing graphics, latency and more)"),
|
||||
# Upscalers and Frame Generation
|
||||
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
|
||||
'PW_USE_RESHADE': _("Enable ReShade post-processing"),
|
||||
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
|
||||
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
|
||||
# Ray Tracing
|
||||
|
||||
@@ -4,7 +4,6 @@ from portprotonqt.steam_api.utils import (
|
||||
safe_vdf_load,
|
||||
decode_text,
|
||||
get_steam_home,
|
||||
get_steam_runtime_library_paths,
|
||||
get_steam_launch_commands,
|
||||
get_steam_compatibilitytools_dir,
|
||||
get_steam_compat_tool,
|
||||
@@ -63,7 +62,6 @@ __all__ = [
|
||||
"safe_vdf_load",
|
||||
"decode_text",
|
||||
"get_steam_home",
|
||||
"get_steam_runtime_library_paths",
|
||||
"get_steam_launch_commands",
|
||||
"get_steam_compatibilitytools_dir",
|
||||
"get_steam_compat_tool",
|
||||
|
||||
@@ -752,18 +752,15 @@ def get_steam_game_info_async(
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if not app_info:
|
||||
cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if not cover and not has_custom_data:
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
def on_anticheat_info_no_info(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
appid=appid,
|
||||
appid="",
|
||||
name=decode_text(game_name),
|
||||
description="",
|
||||
cover=cover,
|
||||
cover="",
|
||||
controller_support="",
|
||||
protondb_tier="",
|
||||
steam_game="true",
|
||||
steam_game="false",
|
||||
anticheat_status=anticheat_info.get("status", ""),
|
||||
anticheat_slug=anticheat_info.get("slug", ""),
|
||||
)
|
||||
|
||||
@@ -179,7 +179,7 @@ def build_index(steam_apps: list) -> dict:
|
||||
for app in steam_apps:
|
||||
normalized = app.get("normalized_name", "")
|
||||
if normalized:
|
||||
steam_apps_index.setdefault(normalized, app)
|
||||
steam_apps_index[normalized] = app
|
||||
return steam_apps_index
|
||||
|
||||
|
||||
|
||||
@@ -89,17 +89,6 @@ def get_steam_home() -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_steam_runtime_library_paths() -> list[str]:
|
||||
"""Return runtime library directories from all Steam installations."""
|
||||
runtime_paths = []
|
||||
for steam_path in _iter_existing_steam_data_dirs():
|
||||
for runtime_dir in ("ubuntu12_32", "ubuntu12_64"):
|
||||
library_path = steam_path / runtime_dir
|
||||
if library_path.is_dir():
|
||||
runtime_paths.append(str(library_path))
|
||||
return runtime_paths
|
||||
|
||||
|
||||
def get_steam_launch_commands(appid: str) -> list[list[str]]:
|
||||
"""Return Steam launch commands based on detected Steam data dir."""
|
||||
steam_home = get_steam_home()
|
||||
|
||||
@@ -59,7 +59,6 @@ def _format_download_size(size_mib: float) -> str:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
|
||||
_MainWindowTypingBase = QMainWindow
|
||||
else:
|
||||
@@ -205,35 +204,6 @@ class GOGRepairWorker(QThread):
|
||||
self.error = str(error)
|
||||
|
||||
|
||||
class GOGDeleteWorker(QThread):
|
||||
"""Delete a GOG installation outside the UI thread."""
|
||||
|
||||
def __init__(self, api: GOGAPI, game: dict, menu_manager: "ContextMenuManager") -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.game = game
|
||||
self.menu_manager = menu_manager
|
||||
self.error = ""
|
||||
|
||||
def run(self) -> None:
|
||||
app_id = str(self.game["app_id"])
|
||||
try:
|
||||
install_path = self.api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
raise FileNotFoundError(f"GOG installation not found: {app_id}")
|
||||
shutil.rmtree(install_path)
|
||||
manifest_path = self.api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.api.remove_installed_game(app_id)
|
||||
self.menu_manager.remove_gog_shortcuts(str(self.game["title"]))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to delete GOG game %s", app_id)
|
||||
self.error = str(error)
|
||||
|
||||
|
||||
class StoreDLCWorker(QThread):
|
||||
"""Read owned, installable store DLC outside the UI thread."""
|
||||
|
||||
@@ -354,7 +324,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
self.gog_process = None
|
||||
self.egs_process = None
|
||||
self.gog_repair_worker = None
|
||||
self.gog_delete_worker = None
|
||||
self.egs_install_importing = False
|
||||
self.store_download_queue = []
|
||||
self.gog_download_output = ""
|
||||
@@ -1082,41 +1051,38 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
if getattr(self, "gog_delete_worker", None) is not None:
|
||||
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
|
||||
return
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
)
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
worker = GOGDeleteWorker(self.gog_api, game, self.context_menu_manager)
|
||||
worker.finished.connect(self._on_gog_delete_finished)
|
||||
self.gog_delete_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_delete_finished(self) -> None:
|
||||
worker = self.gog_delete_worker
|
||||
if worker is None:
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.gog_delete_worker = None
|
||||
if worker.error:
|
||||
self.gogAccountStatus.setText(worker.error)
|
||||
else:
|
||||
self.loadGames(force_load=True)
|
||||
worker.deleteLater()
|
||||
try:
|
||||
shutil.rmtree(install_path)
|
||||
except OSError as error:
|
||||
logger.error("Failed to delete GOG game %s: %s", app_id, error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.gog_api.remove_installed_game(app_id)
|
||||
self.context_menu_manager.remove_gog_shortcuts(str(game["title"]))
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _start_gog_download(
|
||||
self, game: dict, install_path: Path, command: list[str], action: str
|
||||
@@ -1138,7 +1104,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._gog_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_gog_download_output)
|
||||
process.finished.connect(self._on_gog_download_finished)
|
||||
self.gog_process = process
|
||||
@@ -1172,7 +1137,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(self._on_egs_download_finished)
|
||||
self.egs_process = process
|
||||
@@ -1206,7 +1170,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._on_egs_operation_finished(
|
||||
@@ -1222,12 +1185,6 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _handle_store_process_error(self, process: QProcess, error: QProcess.ProcessError) -> None:
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
logger.error("Failed to start store operation: %s", process.errorString())
|
||||
process.finished.emit(os.EX_UNAVAILABLE, QProcess.ExitStatus.CrashExit)
|
||||
|
||||
def _cancel_gog_download(self) -> None:
|
||||
process = self.gog_process or self.egs_process
|
||||
if process is None:
|
||||
|
||||
@@ -13,7 +13,6 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -43,7 +42,6 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
|
||||
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
|
||||
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
|
||||
from portprotonqt.tabs.theme_store_workers import THEME_ARCHIVE_EXTENSIONS, ThemeArchiveInstallWorker
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -606,21 +604,18 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile().lower()
|
||||
if path.endswith(
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
if any(worker.isRunning() for worker in getattr(self, "_themeArchiveWorkers", [])):
|
||||
event.ignore()
|
||||
return
|
||||
wine_archives = []
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile()
|
||||
path_lower = path.lower()
|
||||
if path_lower.endswith(THEME_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
if path_lower.endswith(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
wine_archives.append(path)
|
||||
continue
|
||||
if path_lower.endswith(BACKUP_EXTENSION):
|
||||
@@ -636,55 +631,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if wine_archives:
|
||||
self._install_dropped_theme_archives(wine_archives)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _install_dropped_theme_archives(self, paths: list[str]) -> None:
|
||||
progress = QProgressDialog(_("Installing…"), "", 0, 0, self)
|
||||
progress.setWindowTitle(_("Theme"))
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setCancelButton(None)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
worker = ThemeArchiveInstallWorker(paths)
|
||||
workers = getattr(self, "_themeArchiveWorkers", [])
|
||||
self._themeArchiveWorkers = workers
|
||||
workers.append(worker)
|
||||
worker.installed.connect(self._on_dropped_theme_installed)
|
||||
worker.failed.connect(progress.close)
|
||||
worker.failed.connect(self._on_dropped_theme_failed)
|
||||
worker.wine_archives.connect(progress.close)
|
||||
worker.wine_archives.connect(self._on_dropped_wine_archives)
|
||||
worker.finished.connect(progress.close)
|
||||
worker.finished.connect(progress.deleteLater)
|
||||
worker.finished.connect(lambda: workers.remove(worker))
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _on_dropped_theme_installed(self, theme_names: list[str]) -> None:
|
||||
for theme_name in theme_names:
|
||||
self.theme_manager.invalidate_theme(theme_name)
|
||||
variant = ui_config.get_theme_variant()
|
||||
theme_name = ui_config.resolve_theme(theme_names[0], variant)
|
||||
if theme_name not in theme_names:
|
||||
theme_name = theme_names[0]
|
||||
self._refresh_theme_combo(theme_name)
|
||||
self._apply_theme_and_restart(theme_name, variant)
|
||||
|
||||
def _on_dropped_theme_failed(self, message: str) -> None:
|
||||
logger.warning("Failed to install dropped theme: %s", message)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to install theme"))
|
||||
|
||||
def _on_dropped_wine_archives(self, paths: list[str]) -> None:
|
||||
wine_paths = [path for path in paths if path.lower().endswith(WINE_ARCHIVE_EXTENSIONS)]
|
||||
if len(wine_paths) != len(paths):
|
||||
self._on_dropped_theme_failed("No themes found in archive")
|
||||
if wine_paths:
|
||||
show_proton_manager(
|
||||
self, self.portproton_location,
|
||||
input_manager=self.input_manager, local_archives=wine_paths,
|
||||
self,
|
||||
self.portproton_location,
|
||||
input_manager=self.input_manager,
|
||||
local_archives=wine_archives,
|
||||
)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def openAddGameDialog(self, exe_path=None):
|
||||
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
|
||||
|
||||
@@ -508,33 +508,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
crash_reports_enabled_layout.addStretch()
|
||||
uiForm.addRow(crash_reports_enabled_layout)
|
||||
|
||||
self.disableCyrillicPathCheckCheckBox = QCheckBox()
|
||||
self.disableCyrillicPathCheckCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.disableCyrillicPathCheckCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
disable_cyrillic_path_check_title = QLabel(
|
||||
_("Disable Cyrillic path warning")
|
||||
)
|
||||
disable_cyrillic_path_check_title.setSizePolicy(
|
||||
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
disable_cyrillic_path_check_title.setStyleSheet(
|
||||
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
|
||||
)
|
||||
disable_cyrillic_path_check_title.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.disableCyrillicPathCheckCheckBox.setChecked(
|
||||
ui_config.get_disable_cyrillic_path_check()
|
||||
)
|
||||
disable_cyrillic_path_check_layout = QHBoxLayout()
|
||||
disable_cyrillic_path_check_layout.setContentsMargins(0, 0, 0, 0)
|
||||
disable_cyrillic_path_check_layout.addWidget(
|
||||
self.disableCyrillicPathCheckCheckBox
|
||||
)
|
||||
disable_cyrillic_path_check_layout.addWidget(
|
||||
disable_cyrillic_path_check_title
|
||||
)
|
||||
disable_cyrillic_path_check_layout.addStretch()
|
||||
uiForm.addRow(disable_cyrillic_path_check_layout)
|
||||
|
||||
self.disableAltDependencyCheckCheckBox: QCheckBox | None = None
|
||||
if self._is_alt_x86_64():
|
||||
self.disableAltDependencyCheckCheckBox = QCheckBox()
|
||||
@@ -1222,9 +1195,6 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
ui_config.set_crash_reports_enabled(
|
||||
self.crashReportsEnabledCheckBox.isChecked()
|
||||
)
|
||||
ui_config.set_disable_cyrillic_path_check(
|
||||
self.disableCyrillicPathCheckCheckBox.isChecked()
|
||||
)
|
||||
if self.disableAltDependencyCheckCheckBox is not None:
|
||||
ui_config.set_disable_alt_i586_dependency_check(
|
||||
self.disableAltDependencyCheckCheckBox.isChecked()
|
||||
|
||||
@@ -21,7 +21,6 @@ THEME_STORE_IMAGE_WORKERS = 8
|
||||
THEME_STORE_CARD_BATCH_SIZE = 20
|
||||
THEME_STORE_VOTES_ICON = "★"
|
||||
THEME_STORE_DOWNLOADS_ICON = "⇩"
|
||||
THEME_ARCHIVE_EXTENSIONS = (".zip", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".tar.bz2")
|
||||
|
||||
|
||||
def _theme_store_download_url(theme_id: int) -> str:
|
||||
@@ -127,40 +126,6 @@ def _install_theme_archive(archive_path: str) -> list[str]:
|
||||
return installed_names
|
||||
|
||||
|
||||
class ThemeArchiveInstallWorker(QThread):
|
||||
installed = Signal(list)
|
||||
failed = Signal(str)
|
||||
wine_archives = Signal(list)
|
||||
|
||||
def __init__(self, paths: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.paths = paths
|
||||
|
||||
def run(self) -> None:
|
||||
wine_paths = []
|
||||
for path in self.paths:
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = archive.namelist()
|
||||
else:
|
||||
with tarfile.open(path) as archive:
|
||||
names = archive.getnames()
|
||||
if not any(name.rstrip("/").split("/")[-1] == "styles.py" for name in names):
|
||||
wine_paths.append(path)
|
||||
continue
|
||||
installed = _install_theme_archive(path)
|
||||
if not installed:
|
||||
raise ValueError("No themes found in archive")
|
||||
self.installed.emit(installed)
|
||||
except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
|
||||
self.failed.emit(str(error))
|
||||
if wine_paths and not self.isInterruptionRequested():
|
||||
self.wine_archives.emit(wine_paths)
|
||||
|
||||
|
||||
class ThemeStoreListWorker(QThread):
|
||||
loaded = Signal(list)
|
||||
failed = Signal(str)
|
||||
|
||||
@@ -5,17 +5,6 @@ logger = get_logger(__name__)
|
||||
|
||||
class MainWindowWorkersMixin:
|
||||
def _stopBackgroundWorkers(self) -> None:
|
||||
context_menu_manager = getattr(self, "context_menu_manager", None)
|
||||
if context_menu_manager is not None:
|
||||
context_menu_manager.stop_card_actions()
|
||||
self.store_download_queue = []
|
||||
for process_name in ("gog_process", "egs_process"):
|
||||
process = getattr(self, process_name, None)
|
||||
if process is not None:
|
||||
process.terminate()
|
||||
if not process.waitForFinished():
|
||||
process.kill()
|
||||
process.waitForFinished()
|
||||
worker_timeouts = {
|
||||
"networkWorker": 3000,
|
||||
"bluetoothWorker": 12000,
|
||||
@@ -33,7 +22,6 @@ class MainWindowWorkersMixin:
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
"gog_auth_worker": None,
|
||||
"gog_account_worker": None,
|
||||
@@ -50,8 +38,6 @@ class MainWindowWorkersMixin:
|
||||
"_listWorkerPool",
|
||||
"_imageWorkerPool",
|
||||
"_detailImageWorkerPool",
|
||||
"_themeArchiveWorkers",
|
||||
"_gameFolderWorkers",
|
||||
):
|
||||
self._stopWorkerThreads(workers_name)
|
||||
downloader = getattr(self, "downloader", None)
|
||||
|
||||
@@ -366,5 +366,3 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
WINETRICKS_CHECK_COLUMN_WIDTH = 80
|
||||
WINETRICKS_ROW_HEIGHT = 36
|
||||
|
||||
GAME_FOLDER_IMPORT_DIALOG_SIZE = (800, 600)
|
||||
@@ -141,14 +141,6 @@ def test_crash_reports_config_defaults_to_enabled(tmp_path: Path):
|
||||
assert config.get_crash_reports_enabled() is False
|
||||
|
||||
|
||||
def test_cyrillic_path_check_config_defaults_to_enabled(tmp_path: Path):
|
||||
config = UIConfig(config_file=tmp_path / "test.conf")
|
||||
|
||||
assert config.get_disable_cyrillic_path_check() is False
|
||||
config.set_disable_cyrillic_path_check(True)
|
||||
assert config.get_disable_cyrillic_path_check() is True
|
||||
|
||||
|
||||
def test_alt_dependency_check_config_defaults_to_enabled(tmp_path: Path):
|
||||
config = UIConfig(config_file=tmp_path / "test.conf")
|
||||
|
||||
@@ -196,11 +188,6 @@ def test_horizontal_card_orientation_uses_theme_until_overridden(tmp_path: Path)
|
||||
@pytest.mark.parametrize("system_locale,force_english,expected", [
|
||||
("ru_RU", False, "ru_RU"),
|
||||
(None, False, "en"),
|
||||
("C", False, "en"),
|
||||
("c", False, "en"),
|
||||
("C.UTF-8", False, "en"),
|
||||
("POSIX", False, "en"),
|
||||
("unknown", False, "en"),
|
||||
("ru_RU", True, "en"),
|
||||
])
|
||||
def test_system_locale(
|
||||
|
||||
@@ -11,6 +11,10 @@ class TestCacheManagerInit:
|
||||
CacheManager(cache_dir=cache_dir)
|
||||
assert cache_dir.exists()
|
||||
|
||||
def test_default_cache_dir(self):
|
||||
mgr = CacheManager()
|
||||
assert mgr.cache_dir.exists()
|
||||
|
||||
|
||||
class TestGetCacheFilePath:
|
||||
def test_sanitizes_name(self, tmp_path: Path):
|
||||
@@ -52,8 +56,7 @@ class TestSaveLoadJson:
|
||||
|
||||
def test_pretty_print(self, tmp_path: Path):
|
||||
mgr = CacheManager(cache_dir=tmp_path)
|
||||
assert mgr.save_json("test", {"a": 1}, pretty=True)
|
||||
assert mgr._get_cache_file_path("test").read_text() == '{\n "a": 1\n}'
|
||||
mgr.save_json("test", {"a": 1}, pretty=True)
|
||||
loaded = mgr.load_json("test")
|
||||
assert loaded == {"a": 1}
|
||||
|
||||
|
||||
+8
-32
@@ -16,6 +16,8 @@ from portprotonqt.cli import (
|
||||
parse_portprotonqt_theme_url,
|
||||
parse_resolution,
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
PP_EXTENSIONS,
|
||||
PREFIX_BACKUP_EXTENSION,
|
||||
parse_args,
|
||||
)
|
||||
|
||||
@@ -150,38 +152,6 @@ def test_silent_exe_uses_existing_instance(
|
||||
startup.qt_app.exec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_silent_launch_skips_startup_workers(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, silent: bool,
|
||||
) -> None:
|
||||
from PySide6.QtCore import QThread
|
||||
from portprotonqt import appimage_updater
|
||||
|
||||
startup = silent_startup
|
||||
window = MagicMock()
|
||||
startup.window.side_effect = None
|
||||
startup.window.return_value = window
|
||||
monkeypatch.setattr(startup.app, "QTimer", MagicMock())
|
||||
monkeypatch.setattr(QThread, "start", MagicMock())
|
||||
update_worker = MagicMock()
|
||||
monkeypatch.setattr(appimage_updater, "AppImageUpdateWorker", update_worker)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
|
||||
monkeypatch.setattr(startup.app.display_config, "set_fullscreen", MagicMock())
|
||||
flags = ["--silent"] if silent else []
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(startup.exe)])
|
||||
|
||||
with pytest.raises(SystemExit) as result:
|
||||
startup.app.main()
|
||||
|
||||
assert result.value.code == 0
|
||||
assert window.silent_launch_mode is silent
|
||||
assert ("initialCommandWorker" in vars(window)) is (not silent)
|
||||
assert update_worker.called is (not silent)
|
||||
assert ("appimageUpdateWorker" in vars(window)) is (not silent)
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags,target", [
|
||||
([], "exe"),
|
||||
(["--silent", "--log"], "exe"),
|
||||
@@ -290,6 +260,9 @@ class TestIsPrefixBackupFile:
|
||||
def test_nonexistent_file(self):
|
||||
assert is_prefix_backup_file("/nonexistent/backup.ppack") is False
|
||||
|
||||
def test_ppack_extension_constant(self):
|
||||
assert PREFIX_BACKUP_EXTENSION == ".ppack"
|
||||
|
||||
|
||||
class TestIsAutoinstallFile:
|
||||
def test_ppai_file(self, tmp_path: Path):
|
||||
@@ -302,6 +275,9 @@ class TestIsAutoinstallFile:
|
||||
txt.touch()
|
||||
assert is_autoinstall_file(str(txt)) is False
|
||||
|
||||
def test_ppai_extension_constant(self):
|
||||
assert PP_EXTENSIONS == (".ppack", ".ppai")
|
||||
|
||||
|
||||
class TestParsePortprotonUrl:
|
||||
def test_https_url(self):
|
||||
|
||||
@@ -590,144 +590,3 @@ def test_non_actionable_findings_do_not_create_fixes() -> None:
|
||||
)
|
||||
|
||||
assert suggestions == []
|
||||
|
||||
|
||||
@mark.parametrize("case", [
|
||||
(True, False, False), (True, True, False), (False, True, True), (False, False, True),
|
||||
])
|
||||
def test_report_ppdb_lookup_conditions(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, case: tuple[bool, bool, bool],
|
||||
) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from portprotonqt.dialogs import compatibility_report as dialog_module
|
||||
|
||||
automatic, existing, lookup = case
|
||||
executable = tmp_path / "game.exe"
|
||||
executable.touch()
|
||||
if existing:
|
||||
executable.with_suffix(".exe.ppdb").touch()
|
||||
monkeypatch.setattr(dialog_module.ui_config, "get_auto_download_ppdb", lambda: automatic)
|
||||
network = MagicMock()
|
||||
monkeypatch.setattr(dialog_module, "QNetworkAccessManager", network)
|
||||
monkeypatch.setattr(dialog_module, "QNetworkDiskCache", MagicMock())
|
||||
monkeypatch.setattr(dialog_module.proxy_config, "get_proxy", lambda: {})
|
||||
dialog = MagicMock()
|
||||
|
||||
dialog_module.CompatibilityReportDialog.check_ppdb(dialog, str(executable))
|
||||
|
||||
assert network.called is lookup
|
||||
if lookup:
|
||||
assert network.return_value.get.call_args.args[0].url().toString().endswith("/game.exe")
|
||||
|
||||
|
||||
@mark.parametrize("metadata,visible", [
|
||||
(b'{"ppdb_url":"https://linux-gaming.ru/ppdb/download"}', True),
|
||||
(b'{"ppdb_url":""}', False), (b'{}', False), (b'[]', False),
|
||||
(b'invalid', False), (b'{"ppdb_url":"file:///tmp/settings"}', False),
|
||||
])
|
||||
def test_report_ppdb_button_requires_download_url(metadata: bytes, visible: bool) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QByteArray
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
|
||||
dialog = MagicMock()
|
||||
reply = MagicMock()
|
||||
reply.error.return_value = QNetworkReply.NetworkError.NoError
|
||||
reply.readAll.return_value = QByteArray(metadata)
|
||||
|
||||
CompatibilityReportDialog._ppdb_lookup_finished(dialog, reply)
|
||||
|
||||
assert dialog.ppdb_button.show.called is visible
|
||||
reply.deleteLater.assert_called_once()
|
||||
|
||||
|
||||
@mark.parametrize("existing,failed", [
|
||||
(False, False), (True, False), (False, True), (True, True),
|
||||
])
|
||||
def test_report_ppdb_download_replaces_existing_file_only_on_success(
|
||||
tmp_path: Path, existing: bool, failed: bool,
|
||||
) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QByteArray
|
||||
from PySide6.QtNetwork import QNetworkReply
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
|
||||
path = tmp_path / "game.exe.ppdb"
|
||||
if existing:
|
||||
path.write_bytes(b"user settings")
|
||||
dialog = MagicMock(ppdb_path=str(path))
|
||||
reply = MagicMock()
|
||||
reply.error.return_value = (QNetworkReply.NetworkError.ConnectionRefusedError
|
||||
if failed else QNetworkReply.NetworkError.NoError)
|
||||
reply.readAll.return_value = QByteArray(b"downloaded settings")
|
||||
|
||||
CompatibilityReportDialog._ppdb_download_finished(dialog, reply)
|
||||
|
||||
if failed:
|
||||
if existing:
|
||||
assert path.read_bytes() == b"user settings"
|
||||
else:
|
||||
assert not path.exists()
|
||||
dialog.ppdb_button.setEnabled.assert_called_with(True)
|
||||
else:
|
||||
assert path.read_bytes() == b"downloaded settings"
|
||||
dialog.ppdb_button.setEnabled.assert_called_with(False)
|
||||
|
||||
|
||||
def test_report_close_releases_pending_ppdb_network(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QCoreApplication, QEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from shiboken6 import isValid
|
||||
from portprotonqt.dialogs import compatibility_report as dialog_module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
theme = SimpleNamespace(
|
||||
COMPATIBILITY_REPORT_DIALOG={
|
||||
"minimum_width": 400, "minimum_height": 300, "margin": 10, "spacing": 10,
|
||||
},
|
||||
MAIN_WINDOW_STYLE="", COMPATIBILITY_REPORT_DIALOG_STYLE="", SCROLL_STYLE="",
|
||||
ACTION_BUTTON_STYLE="", ACTION_BUTTON_ACTIVE_STYLE="",
|
||||
)
|
||||
executable = tmp_path / "game.exe"
|
||||
executable.touch()
|
||||
monkeypatch.setattr(dialog_module, "CACHE_DIR", tmp_path / "cache")
|
||||
monkeypatch.setattr(dialog_module.ui_config, "get_auto_download_ppdb", lambda: False)
|
||||
monkeypatch.setattr(dialog_module.proxy_config, "get_proxy", lambda: {})
|
||||
get = MagicMock()
|
||||
monkeypatch.setattr(dialog_module.QNetworkAccessManager, "get", get)
|
||||
dialog = dialog_module.CompatibilityReportDialog(None, theme, "report")
|
||||
dialog.check_ppdb(str(executable))
|
||||
manager = dialog.ppdb_network
|
||||
assert manager.parent() is dialog
|
||||
assert dialog.ppdb_button.isHidden()
|
||||
|
||||
dialog.reject()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
assert not isValid(manager)
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_report_ppdb_redownload_bypasses_cache(tmp_path: Path) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtNetwork import QNetworkRequest
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
|
||||
path = tmp_path / "game.exe.ppdb"
|
||||
path.write_bytes(b"old settings")
|
||||
dialog = MagicMock(ppdb_path=str(path), ppdb_url=QUrl("https://linux-gaming.ru/ppdb"))
|
||||
|
||||
CompatibilityReportDialog._download_ppdb(dialog)
|
||||
|
||||
request = dialog.ppdb_network.get.call_args.args[0]
|
||||
assert request.url() == dialog.ppdb_url
|
||||
assert request.attribute(QNetworkRequest.Attribute.CacheLoadControlAttribute) == (
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork
|
||||
)
|
||||
dialog.ppdb_button.setEnabled.assert_called_with(False)
|
||||
assert path.read_bytes() == b"old settings"
|
||||
+74
-67
@@ -1,13 +1,27 @@
|
||||
"""Tests for scripts_utils/dbus_tools.py — D-Bus helpers, notifications, power profiles."""
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from dbus_fast import BusType, DBusError, Message, Variant
|
||||
from dbus_fast import DBusError, Message, Variant
|
||||
|
||||
from portprotonqt.scripts_utils.dbus_tools import (
|
||||
ACTIVE_PROFILE,
|
||||
DBUS_TIMEOUT,
|
||||
NOTIFICATIONS_BUS_NAME,
|
||||
NOTIFICATIONS_BUS_PATH,
|
||||
PORTAL_BUS_NAME,
|
||||
PORTAL_BUS_PATH,
|
||||
PORTAL_IDLE_FLAG,
|
||||
PORTAL_INTERFACE,
|
||||
PORTAL_NOTIFICATION_INTERFACE,
|
||||
PORTAL_REQUEST_INTERFACE,
|
||||
POWER_PROFILE_ENDPOINTS,
|
||||
PROFILES,
|
||||
PROPERTIES_INTERFACE,
|
||||
SCREENSAVER_BUS_NAME,
|
||||
SCREENSAVER_BUS_PATH,
|
||||
NotificationRequest,
|
||||
_active_power_profile,
|
||||
_bus_call,
|
||||
@@ -34,6 +48,21 @@ DUMMY_MSG = Message(
|
||||
)
|
||||
|
||||
|
||||
class TestNotificationRequest:
|
||||
def test_construction(self):
|
||||
req = NotificationRequest("app", "icon", "title", "body", 5000)
|
||||
assert req.app == "app"
|
||||
assert req.icon == "icon"
|
||||
assert req.title == "title"
|
||||
assert req.body == "body"
|
||||
assert req.timeout == 5000
|
||||
|
||||
def test_frozen(self):
|
||||
req = NotificationRequest("a", "i", "t", "b", 1)
|
||||
with pytest.raises(AttributeError):
|
||||
req.app = "x" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSerializeIcon:
|
||||
def test_file_path_with_slash(self):
|
||||
result = _serialize_icon("/usr/share/icon.png")
|
||||
@@ -184,12 +213,11 @@ class TestDbusCall:
|
||||
result = _run_async(dbus_call(coro()))
|
||||
assert result == "ok"
|
||||
|
||||
def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def slow() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
monkeypatch.setattr("portprotonqt.scripts_utils.dbus_tools.DBUS_TIMEOUT", 0.01)
|
||||
def test_timeout(self):
|
||||
async def slow():
|
||||
await asyncio.sleep(100)
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
_run_async(dbus_call(slow()))
|
||||
_run_async(asyncio.wait_for(slow(), timeout=0.01))
|
||||
|
||||
|
||||
class TestBusCall:
|
||||
@@ -337,66 +365,6 @@ class TestRequestIdleInhibit:
|
||||
assert bus is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_available", [True, False])
|
||||
def test_idle_inhibit_falls_back_to_logind(session_available: bool) -> None:
|
||||
session = MagicMock()
|
||||
system = MagicMock()
|
||||
session.connect = AsyncMock(return_value=session)
|
||||
if not session_available:
|
||||
session.connect.side_effect = OSError("no session bus")
|
||||
system.connect = AsyncMock(return_value=system)
|
||||
system.call = AsyncMock(return_value=Message.new_method_return(
|
||||
Message(destination="a.b", path="/a", member="m", serial=1),
|
||||
signature="h", body=[0], unix_fds=[42],
|
||||
))
|
||||
with (
|
||||
patch("portprotonqt.scripts_utils.dbus_tools.MessageBus", side_effect=[session, system]) as factory,
|
||||
patch("portprotonqt.scripts_utils.dbus_tools._request_screensaver_inhibit", new_callable=AsyncMock) as screensaver,
|
||||
patch("portprotonqt.scripts_utils.dbus_tools._request_portal_inhibit", new_callable=AsyncMock) as portal,
|
||||
):
|
||||
screensaver.side_effect = RuntimeError("no screensaver")
|
||||
portal.side_effect = RuntimeError("no portal")
|
||||
bus, kind, payload = _run_async(request_idle_inhibit("app", "reason"))
|
||||
|
||||
assert (bus, kind, payload) == (system, "logind", 42)
|
||||
factory.assert_called_with(bus_type=BusType.SYSTEM, negotiate_unix_fd=True)
|
||||
assert session.disconnect.call_count == int(session_available)
|
||||
message = system.call.call_args.args[0]
|
||||
assert message.destination == "org.freedesktop.login1"
|
||||
assert message.path == "/org/freedesktop/login1"
|
||||
assert message.interface == "org.freedesktop.login1.Manager"
|
||||
assert message.member == "Inhibit"
|
||||
assert message.signature == "ssss"
|
||||
assert message.body == ["sleep:idle", "app", "reason", "block"]
|
||||
system.disconnect.assert_not_called()
|
||||
|
||||
|
||||
def test_idle_inhibit_disconnects_when_logind_fails() -> None:
|
||||
session = MagicMock()
|
||||
session.connect = AsyncMock(side_effect=OSError("no session bus"))
|
||||
system = MagicMock()
|
||||
system.connect = AsyncMock(return_value=system)
|
||||
system.call = AsyncMock(return_value=Message.new_error(
|
||||
Message(destination="a.b", path="/a", member="m", serial=1),
|
||||
"org.freedesktop.DBus.Error.AccessDenied", "denied",
|
||||
))
|
||||
with patch("portprotonqt.scripts_utils.dbus_tools.MessageBus", side_effect=[session, system]):
|
||||
with pytest.raises(DBusError, match="denied"):
|
||||
_run_async(request_idle_inhibit("app", "reason"))
|
||||
system.disconnect.assert_called_once()
|
||||
|
||||
|
||||
def test_release_logind_closes_inhibitor() -> None:
|
||||
bus = MagicMock()
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
_run_async(release_idle_inhibit(bus, "logind", write_fd))
|
||||
assert os.read(read_fd, 1) == b""
|
||||
bus.disconnect.assert_called_once()
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
|
||||
|
||||
class TestReleaseIdleInhibit:
|
||||
def test_release_screensaver(self):
|
||||
async def run():
|
||||
@@ -615,3 +583,42 @@ class TestMain:
|
||||
with patch("sys.argv", ["dbus_tools"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
|
||||
class TestConstants:
|
||||
def test_power_profile_endpoints_count(self):
|
||||
assert len(POWER_PROFILE_ENDPOINTS) == 2
|
||||
|
||||
def test_power_profile_endpoints_format(self):
|
||||
for name, path in POWER_PROFILE_ENDPOINTS:
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(path, str)
|
||||
assert path.startswith("/")
|
||||
|
||||
def test_bus_timeout(self):
|
||||
assert DBUS_TIMEOUT == 10
|
||||
|
||||
def test_portal_idle_flag(self):
|
||||
assert PORTAL_IDLE_FLAG == 8
|
||||
|
||||
def test_screensaver_bus(self):
|
||||
assert SCREENSAVER_BUS_NAME == "org.freedesktop.ScreenSaver"
|
||||
assert SCREENSAVER_BUS_PATH == "/org/freedesktop/ScreenSaver"
|
||||
|
||||
def test_portal_bus(self):
|
||||
assert PORTAL_BUS_NAME == "org.freedesktop.portal.Desktop"
|
||||
assert PORTAL_BUS_PATH == "/org/freedesktop/portal/desktop"
|
||||
|
||||
def test_notification_bus(self):
|
||||
assert NOTIFICATIONS_BUS_NAME == "org.freedesktop.Notifications"
|
||||
assert NOTIFICATIONS_BUS_PATH == "/org/freedesktop/Notifications"
|
||||
|
||||
def test_interfaces(self):
|
||||
assert PORTAL_INTERFACE == "org.freedesktop.portal.Inhibit"
|
||||
assert PORTAL_NOTIFICATION_INTERFACE == "org.freedesktop.portal.Notification"
|
||||
assert PORTAL_REQUEST_INTERFACE == "org.freedesktop.portal.Request"
|
||||
assert PROPERTIES_INTERFACE == "org.freedesktop.DBus.Properties"
|
||||
|
||||
def test_profile_constants(self):
|
||||
assert ACTIVE_PROFILE == "ActiveProfile"
|
||||
assert PROFILES == "Profiles"
|
||||
@@ -12,9 +12,6 @@ from PySide6.QtWidgets import QApplication
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
THREAD_TIMEOUT_MS = 30000
|
||||
SYNC_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def application() -> QCoreApplication:
|
||||
@@ -79,7 +76,7 @@ def test_missing_destination_still_downloads_with_a_worker(
|
||||
|
||||
assert worker is not None
|
||||
assert worker in downloader._active_threads
|
||||
assert worker.wait(THREAD_TIMEOUT_MS)
|
||||
assert worker.wait(5000)
|
||||
assert results == []
|
||||
QApplication.processEvents()
|
||||
assert results == [str(target)]
|
||||
@@ -127,11 +124,11 @@ def test_download_started_from_a_worker_cleans_up_on_the_owner_thread(
|
||||
|
||||
caller = threading.Thread(target=start_download)
|
||||
caller.start()
|
||||
caller.join(timeout=SYNC_TIMEOUT_SECONDS)
|
||||
caller.join(timeout=5)
|
||||
assert not caller.is_alive()
|
||||
worker = workers[0]
|
||||
assert worker is not None
|
||||
assert worker.wait(THREAD_TIMEOUT_MS)
|
||||
assert worker.wait(5000)
|
||||
QApplication.processEvents()
|
||||
|
||||
assert results == [(path, QThread.currentThread())]
|
||||
@@ -149,13 +146,13 @@ def test_shutdown_waits_for_an_active_download(
|
||||
|
||||
def download(*_args: Any) -> str:
|
||||
entered.set()
|
||||
assert release.wait(SYNC_TIMEOUT_SECONDS)
|
||||
assert release.wait(5)
|
||||
return path
|
||||
|
||||
monkeypatch.setattr("portprotonqt.downloader.download_with_cache", download)
|
||||
worker = downloader.download_async("https://example.com/data.json", path)
|
||||
assert worker is not None
|
||||
assert entered.wait(SYNC_TIMEOUT_SECONDS)
|
||||
assert entered.wait(5)
|
||||
owner = cast(Any, MainWindowWorkersMixin())
|
||||
owner.downloader = downloader
|
||||
owner.compatibility_workers = []
|
||||
@@ -174,4 +171,4 @@ def test_shutdown_waits_for_an_active_download(
|
||||
finally:
|
||||
release.set()
|
||||
release_timer.join()
|
||||
worker.wait(THREAD_TIMEOUT_MS)
|
||||
worker.wait(5000)
|
||||
@@ -8,8 +8,6 @@ from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.scripts_utils.easyterm import TerminalWidget
|
||||
|
||||
PROCESS_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
def test_terminal_keeps_failed_command_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -27,10 +25,9 @@ def test_terminal_keeps_failed_command_output(
|
||||
False,
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + PROCESS_TIMEOUT_SECONDS
|
||||
while terminal.exit_code is None and time.monotonic() < deadline:
|
||||
deadline = time.monotonic() + 5
|
||||
while terminal.child_pid is not None and time.monotonic() < deadline:
|
||||
application.processEvents()
|
||||
terminal._check_child()
|
||||
time.sleep(0.01)
|
||||
|
||||
assert terminal.exit_code == 7
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Tests for PE metadata consumed by PortProton shell scripts."""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from portprotonqt.scripts_utils import exe_metadata
|
||||
|
||||
|
||||
def test_reads_version_strings_and_closes_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
path = tmp_path / "Game.exe"
|
||||
pe = MagicMock()
|
||||
table = SimpleNamespace(entries={b"ProductName": b"Game", b"CompanyName": b"Sony"})
|
||||
pe.__enter__.return_value.FileInfo = [[SimpleNamespace(StringTable=[table])]]
|
||||
constructor = MagicMock(return_value=pe)
|
||||
monkeypatch.setattr(exe_metadata.pefile, "PE", constructor)
|
||||
|
||||
assert exe_metadata.read_metadata(str(path)) == {"ProductName": "Game", "CompanyName": "Sony"}
|
||||
constructor.assert_called_once_with(str(path), fast_load=True)
|
||||
pe.__enter__.return_value.parse_data_directories.assert_called_once_with(
|
||||
directories=[exe_metadata.pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]],
|
||||
)
|
||||
pe.__exit__.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [exe_metadata.pefile.PEFormatError("invalid"), OSError("unreadable")])
|
||||
def test_unreadable_pe_allows_exiftool_fallback(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None:
|
||||
monkeypatch.setattr(exe_metadata.pefile, "PE", MagicMock(side_effect=error))
|
||||
assert exe_metadata.read_metadata("Game.exe") is None
|
||||
|
||||
|
||||
def test_cli_preserves_shell_field_labels(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
monkeypatch.setattr(exe_metadata.sys, "argv", ["exe_metadata", "/Games/Game.exe"])
|
||||
monkeypatch.setattr(exe_metadata, "read_metadata", lambda _path: {
|
||||
"ProductName": "Game", "FileDescription": "Description\r\nextra", "CompanyName": "Sony",
|
||||
})
|
||||
assert exe_metadata.main() == 0
|
||||
assert capsys.readouterr().out == (
|
||||
"File Name: Game.exe\nProduct Name: Game\nFile Description: Description extra\nCompany Name: Sony\n"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_returns_failure_for_unsupported_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(exe_metadata.sys, "argv", ["exe_metadata", "Game.exe"])
|
||||
monkeypatch.setattr(exe_metadata, "read_metadata", lambda _path: None)
|
||||
assert exe_metadata.main() == 1
|
||||
+6
-146
@@ -6,9 +6,8 @@ from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QStringListModel, Qt
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
from shiboken6 import delete
|
||||
|
||||
import portprotonqt.input_manager as input_manager
|
||||
import portprotonqt.input_manager.buttons as input_buttons
|
||||
@@ -36,34 +35,6 @@ HIDDEN_CARD_X = 20
|
||||
NEXT_CARD_X = 40
|
||||
|
||||
|
||||
@mark.parametrize("event_type", [QEvent.Type.Destroy, QEvent.Type.ChildRemoved, QEvent.Type.DeferredDelete])
|
||||
def test_keyboard_filter_ignores_deleted_layout(event_type: QEvent.Type) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
layout = QVBoxLayout()
|
||||
delete(layout)
|
||||
|
||||
assert manager.eventFilter(layout, QEvent(event_type)) is False
|
||||
assert app is not None
|
||||
|
||||
|
||||
@mark.parametrize("event_type", [QEvent.Type.Destroy, QEvent.Type.ChildRemoved, QEvent.Type.DeferredDelete])
|
||||
def test_keyboard_filter_teardown_does_not_lookup_application(
|
||||
monkeypatch: MonkeyPatch, event_type: QEvent.Type,
|
||||
) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
watched = QObject()
|
||||
lookup = MagicMock(side_effect=AssertionError("Application lookup during teardown"))
|
||||
monkeypatch.setattr(QApplication, "instance", lookup)
|
||||
|
||||
assert manager.eventFilter(watched, QEvent(event_type)) is False
|
||||
lookup.assert_not_called()
|
||||
assert app is not None
|
||||
|
||||
|
||||
@mark.parametrize("initial", [True, False])
|
||||
def test_gamepad_auto_fullscreen_shows_boot_on_hotplug(monkeypatch: MonkeyPatch, initial: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -675,10 +646,7 @@ def test_confirm_button_opens_combo_popup() -> None:
|
||||
assert app is not None
|
||||
|
||||
|
||||
@mark.parametrize("table_name", ["advanced_table", "favorites_table"])
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
monkeypatch: MonkeyPatch, table_name: str,
|
||||
) -> None:
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: MonkeyPatch) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
@@ -704,7 +672,7 @@ def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
cast(ExeSettingsDialog, settings), index, combo, "PW_WINE_USE",
|
||||
))
|
||||
manager = SimpleNamespace(
|
||||
settings_dialog=SimpleNamespace(advanced_table=None, settings_table=None, favorites_table=None),
|
||||
settings_dialog=SimpleNamespace(advanced_table=table, settings_table=None, favorites_table=None),
|
||||
_is_current_settings_tool_tab=lambda: False,
|
||||
_handle_table_action_navigation=lambda *_args: False,
|
||||
_get_dialog_action_buttons=lambda _dialog: [],
|
||||
@@ -712,13 +680,13 @@ def test_settings_gamepad_skips_separator_and_activates_download(
|
||||
_get_open_settings_combo=lambda: combo,
|
||||
_handle_common_ui_elements=lambda _code: False,
|
||||
)
|
||||
setattr(manager.settings_dialog, table_name, table)
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
app.processEvents()
|
||||
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, 1, 0)
|
||||
assert combo.view().currentIndex().row() == 2
|
||||
assert combo.currentText() == "Proton"
|
||||
assert highlighted[-1] == 2
|
||||
assert combo.currentData() == "download_wine"
|
||||
SettingsInputMixin.handle_settings_button(
|
||||
cast(SettingsInputMixin, manager), next(iter(BUTTONS["confirm"])), 1,
|
||||
)
|
||||
@@ -757,7 +725,7 @@ def test_settings_combo_navigation_with_keyboard_events_intercepted() -> None:
|
||||
app.processEvents()
|
||||
for direction, expected in ((1, 1), (1, 2), (1, 2), (-1, 1), (-1, 0), (-1, 0)):
|
||||
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, direction, 0)
|
||||
assert combo.currentIndex() == 0
|
||||
assert combo.currentIndex() == expected
|
||||
assert combo.view().currentIndex().row() == expected
|
||||
assert combo.view().isVisible()
|
||||
finally:
|
||||
@@ -1459,111 +1427,3 @@ def test_settings_tool_gamepad_reaches_action_buttons(tool: str, monkeypatch: Mo
|
||||
manager.handle_settings_dpad(PAD_DPAD_Y, -1, 0)
|
||||
assert QApplication.focusWidget() is control
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_confirm_in_dialog_uses_its_own_keyboard() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
field = QLineEdit(dialog)
|
||||
shown_for = []
|
||||
dialog.keyboard = SimpleNamespace(show_for_widget=shown_for.append)
|
||||
main_keyboard = MagicMock()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=main_keyboard))
|
||||
confirm = next(iter(input_manager.BUTTONS["confirm"]))
|
||||
|
||||
assert manager._route_button_to_text_input(dialog, field, confirm)
|
||||
assert shown_for == [field]
|
||||
main_keyboard.show_for_widget.assert_not_called()
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_dpad_stays_on_active_dialog_keyboard(monkeypatch: MonkeyPatch) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
field = QLineEdit(dialog)
|
||||
key = QPushButton("Key", dialog)
|
||||
key.move(0, 40)
|
||||
down = MagicMock(side_effect=key.setFocus)
|
||||
dialog.keyboard = SimpleNamespace(isVisible=lambda: True, move_focus_left=MagicMock(), move_focus_right=MagicMock(),
|
||||
move_focus_up=MagicMock(), move_focus_down=down)
|
||||
dialog.show()
|
||||
dialog.activateWindow()
|
||||
app.processEvents()
|
||||
field.setFocus()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None, gamesListWidget=QWidget()))
|
||||
manager.axis_moving = False
|
||||
manager._gamepad_handling_enabled = True
|
||||
navigation = MagicMock()
|
||||
monkeypatch.setattr(manager, "_start_dpad_repeat", MagicMock())
|
||||
monkeypatch.setattr(manager, "_route_dpad_navigation", navigation)
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: dialog)
|
||||
manager._handle_default_dpad(PAD_DPAD_Y, 1, 0.0)
|
||||
down.assert_called_once()
|
||||
navigation.assert_not_called()
|
||||
assert QApplication.focusWidget() is key
|
||||
dialog.close()
|
||||
|
||||
|
||||
@mark.parametrize("mode", ["import", "exe", "directory"])
|
||||
def test_file_picker_x_does_not_put_folder_in_executable_field(
|
||||
tmp_path: Any, monkeypatch: MonkeyPatch, mode: str,
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QListWidget
|
||||
from portprotonqt.dialogs.base import FileSelectedSignal
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
folder = tmp_path / "Games"
|
||||
folder.mkdir()
|
||||
picker = cast(Any, QDialog())
|
||||
picker.current_path = str(tmp_path)
|
||||
picker.directory_only = mode == "directory"
|
||||
picker.file_list = QListWidget(picker)
|
||||
picker.file_list.addItem("Games/")
|
||||
picker.file_list.setCurrentRow(0)
|
||||
picker.file_signal = FileSelectedSignal()
|
||||
selected = []
|
||||
picker.file_signal.file_selected.connect(selected.append)
|
||||
action = QPushButton(picker)
|
||||
if mode == "import":
|
||||
action.setObjectName("gamepadAddButton")
|
||||
clicked = MagicMock()
|
||||
action.clicked.connect(clicked)
|
||||
picker.show()
|
||||
app.processEvents()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None))
|
||||
manager.file_explorer = picker
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: picker)
|
||||
monkeypatch.setattr(QApplication, "activePopupWidget", lambda: None)
|
||||
manager.handle_file_explorer_button(next(iter(input_manager.BUTTONS["add_game"])), 1)
|
||||
assert clicked.call_count == int(mode == "import")
|
||||
assert selected == ([str(folder)] if mode == "directory" else [])
|
||||
assert picker.result() == (QDialog.DialogCode.Accepted if mode == "directory" else QDialog.DialogCode.Rejected)
|
||||
picker.close()
|
||||
|
||||
|
||||
def test_x_edits_keyboard_without_triggering_dialog_action(monkeypatch: MonkeyPatch) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
keyboard = QFrame(dialog)
|
||||
dialog.keyboard = keyboard
|
||||
action = QPushButton(dialog)
|
||||
action.setObjectName("gamepadAddButton")
|
||||
clicked = MagicMock()
|
||||
action.clicked.connect(clicked)
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(keyboard=None))
|
||||
handled = MagicMock()
|
||||
monkeypatch.setattr(manager, "_handle_common_ui_elements", lambda _button: False)
|
||||
monkeypatch.setattr(manager, "handle_virtual_keyboard", handled)
|
||||
monkeypatch.setattr(input_buttons, "VirtualKeyboard", QFrame)
|
||||
monkeypatch.setattr(QApplication, "activeWindow", lambda: dialog)
|
||||
button = next(iter(input_manager.BUTTONS["add_game"]))
|
||||
manager._handle_default_button(button, 1)
|
||||
handled.assert_called_once_with(button, 1)
|
||||
clicked.assert_not_called()
|
||||
dialog.close()
|
||||
+15
-109
@@ -1,6 +1,8 @@
|
||||
"""Progressive library loading, drawing and input regressions."""
|
||||
from collections.abc import Callable, Generator
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
@@ -15,8 +17,6 @@ from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
|
||||
LOAD_TIMEOUT_MS = 30000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_scene(
|
||||
@@ -399,7 +399,7 @@ def real_cover_scene(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("animation_type", ["gradient", "glow"])
|
||||
def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
real_cover_scene: tuple[GameLibraryManager, Path],
|
||||
monkeypatch: pytest.MonkeyPatch, animation_type: str,
|
||||
) -> None:
|
||||
@@ -407,14 +407,14 @@ def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
monkeypatch.setattr(manager.theme, "GAME_CARD_ANIMATION",
|
||||
{**manager.theme.GAME_CARD_ANIMATION, "card_animation_type": animation_type})
|
||||
threads: list[bool] = []
|
||||
paints: list[bool] = []
|
||||
ticks: list[tuple[int, int]] = []
|
||||
paints: list[tuple[float, bool]] = []
|
||||
ticks: list[float] = []
|
||||
original_paint = GameCard.paintEvent
|
||||
original_cover = GameCard.on_cover_loaded
|
||||
|
||||
def record_paint(card: GameCard, event: Any) -> None:
|
||||
if card.name == "Game 0":
|
||||
paints.append(card._focused or card._hovered)
|
||||
paints.append((perf_counter(), card._focused or card._hovered))
|
||||
original_paint(card, event)
|
||||
|
||||
def record_cover(card: GameCard, pixmap: QPixmap) -> None:
|
||||
@@ -422,7 +422,7 @@ def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
original_cover(card, pixmap)
|
||||
|
||||
def sample() -> None:
|
||||
ticks.append((len(manager.game_card_cache), len(threads)))
|
||||
ticks.append(perf_counter())
|
||||
if len(threads) == len(games) and not manager._incremental_add_queue:
|
||||
loop.quit()
|
||||
|
||||
@@ -434,14 +434,18 @@ def test_real_cover_loading_yields_and_focuses_first_paint(
|
||||
timer.setInterval(5)
|
||||
timer.timeout.connect(sample)
|
||||
timer.start()
|
||||
QTimer.singleShot(LOAD_TIMEOUT_MS, loop.quit)
|
||||
QTimer.singleShot(2000, loop.quit)
|
||||
manager.set_games(games)
|
||||
loop.exec()
|
||||
timer.stop()
|
||||
assert len(threads) == len(games) and all(threads), threads
|
||||
assert paints and paints[0], paints
|
||||
assert any(0 < cards < len(games) for cards, _ in ticks), ticks
|
||||
assert any(0 < covers < len(games) for _, covers in ticks), ticks
|
||||
highlighted = next((paint for paint in paints if paint[1]), None)
|
||||
assert highlighted is not None
|
||||
focus_delay = highlighted[0] - paints[0][0]
|
||||
max_gap = max(after - before for before, after in zip(ticks, ticks[1:], strict=False))
|
||||
logging.getLogger(__name__).info("%s: highlight %.1f ms, UI pause %.1f ms", animation_type, focus_delay * 1000, max_gap * 1000)
|
||||
assert focus_delay < 0.05, paints
|
||||
assert max_gap < 0.1, ticks
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", [("grid", 0), ("grid", 1), ("vertical", 0), ("vertical", 1)])
|
||||
@@ -541,101 +545,3 @@ def test_vertical_library_headers_align_with_values(
|
||||
assert heading.alignment() == value.alignment() == Qt.AlignmentFlag.AlignCenter
|
||||
assert abs(heading.mapToGlobal(heading.rect().center()).x()
|
||||
- value.mapToGlobal(value.rect().center()).x()) <= 1
|
||||
|
||||
|
||||
def test_control_click_toggles_card_without_opening_details(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
game = ("Game", "", "", "", "", "game.exe", "", "", "", "", 0, 0, "portproton")
|
||||
card = manager._create_game_card(game)
|
||||
position = card.rect().center().toPointF()
|
||||
for selected in (True, False):
|
||||
event = QMouseEvent(QEvent.Type.MouseButtonPress, position, position,
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||
Qt.KeyboardModifier.ControlModifier)
|
||||
card.mousePressEvent(event)
|
||||
assert card.batch_selected is selected
|
||||
cast(Any, manager.main_window).openGameDetailPage.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("activation", ["mouse", "gamepad"])
|
||||
@pytest.mark.parametrize("game_source", ["portproton", "gog", "egs"])
|
||||
def test_selection_mode_toggles_without_opening_game(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, activation: str, game_source: str,
|
||||
) -> None:
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
library, _ = library_scene
|
||||
menu = object.__new__(ContextMenuManager)
|
||||
menu.game_library_manager = library
|
||||
game = ("Game", "", "", "", "", "missing.exe", "", "", "", "", 0, 0, game_source)
|
||||
card = library._create_game_card(game)
|
||||
card.context_menu_manager = menu
|
||||
library.game_card_cache[(card.name, card.exec_line)] = card
|
||||
monkeypatch.setattr(QApplication, "focusWidget", lambda: card)
|
||||
menu.start_card_selection(card)
|
||||
assert menu.card_selection_mode and card.batch_selected
|
||||
for selected in (False, True):
|
||||
if activation == "mouse":
|
||||
position = card.rect().center().toPointF()
|
||||
card.mousePressEvent(QMouseEvent(
|
||||
QEvent.Type.MouseButtonPress, position, position,
|
||||
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||
))
|
||||
else:
|
||||
MainWindow.activateFocusedWidget(cast(Any, library.main_window))
|
||||
assert card.batch_selected is selected
|
||||
cast(Any, library.main_window).openGameDetailPage.assert_not_called()
|
||||
menu.clear_card_selection()
|
||||
assert not menu.card_selection_mode and not card.batch_selected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["grid", "horizontal", "horizontal_top", "list", "vertical"])
|
||||
def test_search_preserves_selection_across_different_queries(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, layout_mode: str,
|
||||
) -> None:
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
|
||||
library, _ = library_scene
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_library_layout_mode", lambda _: layout_mode)
|
||||
monkeypatch.setattr(library, "_incremental_batch_size", 1)
|
||||
library.rebuild_library_layout(layout_mode)
|
||||
games = [(name, "", "", "", "", name, "", "", "", "", 0, 0, "portproton")
|
||||
for name in ("DOOM64", "Akalabeth of Doom", "Biomance")]
|
||||
cast(Any, library.main_window)._loaded_library_cache = {"portproton": games}
|
||||
library.set_games(games, focus_first_card=False)
|
||||
assert library._update_timer is not None and library._incremental_add_timer is not None
|
||||
library._update_timer.stop()
|
||||
library._update_game_grid_immediate()
|
||||
while library._incremental_add_queue:
|
||||
library._process_incremental_add_batch()
|
||||
library._incremental_add_timer.stop()
|
||||
menu = object.__new__(ContextMenuManager)
|
||||
menu.game_library_manager = library
|
||||
cards = {card.name: card for card in library.game_card_cache.values()}
|
||||
for card in cards.values():
|
||||
card.context_menu_manager = menu
|
||||
for query in ("doom", "bio", ""):
|
||||
library.main_window.searchEdit.setText(query)
|
||||
library.filter_games_delayed()
|
||||
library._update_timer.stop()
|
||||
library._update_game_grid_immediate()
|
||||
while library._incremental_add_queue:
|
||||
library._process_incremental_add_batch()
|
||||
library._incremental_add_timer.stop()
|
||||
if query == "doom":
|
||||
menu.start_card_selection(cards["DOOM64"])
|
||||
cards["Akalabeth of Doom"].click()
|
||||
elif query == "bio":
|
||||
cards["Biomance"].click()
|
||||
assert not cards["DOOM64"].isVisible()
|
||||
if query != "doom":
|
||||
assert all(card.batch_selected for card in cards.values())
|
||||
assert menu.card_selection_mode
|
||||
menu.clear_card_selection()
|
||||
assert not any(card.batch_selected for card in cards.values())
|
||||
cast(Any, library.main_window).openGameDetailPage.assert_not_called()
|
||||
+8
-149
@@ -11,7 +11,7 @@ from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import psutil
|
||||
from pytest import MonkeyPatch, fixture, mark, raises
|
||||
from pytest import MonkeyPatch, fixture, mark
|
||||
from PySide6.QtCore import QEvent, QEventLoop, QObject, QPoint, QPointF, Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QMouseEvent, QPixmap, QWheelEvent
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -22,7 +22,6 @@ from PySide6.QtWidgets import (
|
||||
QLabel,
|
||||
QMenu,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
@@ -70,125 +69,6 @@ from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin
|
||||
import portprotonqt.localization as localization
|
||||
|
||||
|
||||
@mark.parametrize("game_path,script_path,expected", [
|
||||
("/games/Game.exe", "/opt/PortProton/start.sh", False),
|
||||
("/games/Игра/Game.exe", "/opt/PortProton/start.sh", True),
|
||||
("/games/Ґра.exe", "/opt/PortProton/start.sh", True),
|
||||
("/games/Game.exe", "/home/Игрок/PortProton/start.sh", True),
|
||||
("/games/日本語/Game.exe", "/opt/PortProton/start.sh", False),
|
||||
])
|
||||
def test_cyrillic_launch_warning(
|
||||
monkeypatch: MonkeyPatch, game_path: str, script_path: str, expected: bool,
|
||||
) -> None:
|
||||
message_box = MagicMock()
|
||||
message_box.addButton.return_value = object()
|
||||
message_box_type = MagicMock(return_value=message_box)
|
||||
message_box_type.Icon = QMessageBox.Icon
|
||||
message_box_type.StandardButton = QMessageBox.StandardButton
|
||||
message_box_type.ButtonRole = QMessageBox.ButtonRole
|
||||
window = SimpleNamespace(start_sh=[script_path])
|
||||
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
|
||||
|
||||
assert MainWindow._warn_cyrillic_launch_paths(cast(Any, window), game_path) is expected
|
||||
|
||||
assert message_box.exec.call_count == int(expected)
|
||||
if expected:
|
||||
assert (game_path if "games/" in game_path and game_path != "/games/Game.exe"
|
||||
else script_path) in message_box.setText.call_args.args[0]
|
||||
|
||||
|
||||
def test_cyrillic_launch_warning_resolves_symlinks(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "Игры"
|
||||
target.mkdir()
|
||||
link = tmp_path / "games"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
message_box = MagicMock()
|
||||
message_box.addButton.return_value = object()
|
||||
message_box_type = MagicMock(return_value=message_box)
|
||||
message_box_type.Icon = QMessageBox.Icon
|
||||
message_box_type.StandardButton = QMessageBox.StandardButton
|
||||
message_box_type.ButtonRole = QMessageBox.ButtonRole
|
||||
window = SimpleNamespace(start_sh=[])
|
||||
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
|
||||
|
||||
MainWindow._warn_cyrillic_launch_paths(cast(Any, window), str(link / "game.exe"))
|
||||
|
||||
message_box.exec.assert_called_once()
|
||||
assert str(target / "game.exe") in message_box.setText.call_args.args[0]
|
||||
|
||||
|
||||
def test_cyrillic_launch_warning_can_be_disabled(monkeypatch: MonkeyPatch) -> None:
|
||||
set_disabled = MagicMock()
|
||||
disable_button = object()
|
||||
message_box = MagicMock()
|
||||
message_box.addButton.return_value = disable_button
|
||||
message_box.clickedButton.return_value = disable_button
|
||||
message_box_type = MagicMock(return_value=message_box)
|
||||
message_box_type.Icon = QMessageBox.Icon
|
||||
message_box_type.StandardButton = QMessageBox.StandardButton
|
||||
message_box_type.ButtonRole = QMessageBox.ButtonRole
|
||||
window = SimpleNamespace(start_sh=[])
|
||||
monkeypatch.setattr(main_window_module.ui_config, "get_disable_cyrillic_path_check", lambda: False)
|
||||
monkeypatch.setattr(main_window_module.ui_config, "set_disable_cyrillic_path_check", set_disabled)
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box_type)
|
||||
|
||||
MainWindow._warn_cyrillic_launch_paths(cast(Any, window), "/games/Игра.exe")
|
||||
|
||||
set_disabled.assert_called_once_with(True)
|
||||
|
||||
|
||||
@mark.parametrize("source", ["local", "gog", "egs"])
|
||||
def test_cyrillic_paths_continue_launch(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path, source: str,
|
||||
) -> None:
|
||||
target = tmp_path / "Игры" / "Breach.exe"
|
||||
target.parent.mkdir()
|
||||
target.touch()
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
window.start_sh = ["/opt/PortProton/start.sh"]
|
||||
window.game_processes = []
|
||||
window.target_exe = None
|
||||
window.current_play_button = None
|
||||
window._check_alt_i586_dependencies_before_launch = lambda: True
|
||||
window._check_missing_prefix_before_launch = MagicMock()
|
||||
window._finish_silent_launch = MagicMock()
|
||||
window._start_gog_support_setup = MagicMock()
|
||||
api = SimpleNamespace(
|
||||
ensure_launch_parameters=lambda _app_id: None,
|
||||
get_installed_path=lambda _app_id: target.parent,
|
||||
get_launch_target=lambda _app_id: str(target),
|
||||
needs_support_setup=lambda _app_id: True,
|
||||
build_command=lambda arguments: arguments,
|
||||
get_environment=lambda: {},
|
||||
)
|
||||
window.gog_api = api
|
||||
window.egs_api = api
|
||||
warning = MagicMock(return_value=True)
|
||||
popen = MagicMock(side_effect=RuntimeError("launch reached"))
|
||||
window._warn_cyrillic_launch_paths = warning
|
||||
monkeypatch.setattr(main_window_module.subprocess, "Popen", popen)
|
||||
monkeypatch.setattr(main_window_module.SoundManager, "play", MagicMock())
|
||||
monkeypatch.setattr(main_window_module.QMessageBox, "warning", MagicMock())
|
||||
|
||||
if source == "local":
|
||||
window.toggleGame(shlex.quote(str(target)), game_name="Breach")
|
||||
elif source == "egs":
|
||||
with raises(RuntimeError, match="launch reached"):
|
||||
window._launch_egs_game("123")
|
||||
else:
|
||||
window._launch_gog_game("123")
|
||||
|
||||
warning.assert_called_once()
|
||||
if source == "gog":
|
||||
window._start_gog_support_setup.assert_called_once_with("123", None)
|
||||
else:
|
||||
popen.assert_called_once()
|
||||
|
||||
|
||||
@mark.parametrize("fullscreen, enabled", [(False, True), (True, True), (True, False)])
|
||||
def test_fullscreen_cursor_hides_after_inactivity(monkeypatch: MonkeyPatch, fullscreen: bool, enabled: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
@@ -312,7 +192,7 @@ def _tile_theme() -> Any:
|
||||
|
||||
@mark.parametrize(
|
||||
"worker_name",
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
|
||||
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "store_dlc_worker"),
|
||||
)
|
||||
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
|
||||
worker = MagicMock()
|
||||
@@ -1278,7 +1158,6 @@ def test_game_card_click_uses_select_callback() -> None:
|
||||
autoinstall_exe_name="",
|
||||
select_callback=select_callback,
|
||||
_get_missing_executable_path=lambda: "",
|
||||
context_menu_manager=None,
|
||||
)
|
||||
|
||||
GameCard.click(cast(Any, card))
|
||||
@@ -1881,7 +1760,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
|
||||
reports = []
|
||||
launch = SimpleNamespace(executable="/games/game.exe", duration=60.0)
|
||||
window.portproton_location = "/portproton"
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=reports.append)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.main_window.has_dxvk_vulkan_incompatibility",
|
||||
lambda _path: True,
|
||||
@@ -1893,7 +1772,7 @@ def test_dxvk_incompatibility_reports_after_manual_stop(
|
||||
|
||||
window._build_compatibility_report(launch, stopped_by_user=True)
|
||||
|
||||
assert reports == [("forced report", launch.executable)]
|
||||
assert reports == ["forced report"]
|
||||
|
||||
def test_disabled_crash_reports_skip_launch_analysis(
|
||||
monkeypatch: MonkeyPatch,
|
||||
@@ -1962,7 +1841,7 @@ def test_nonzero_wine_exit_reports_after_long_game(monkeypatch: MonkeyPatch) ->
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
window.portproton_location = "/portproton"
|
||||
reports = []
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=lambda *args: reports.append(args))
|
||||
window.compatibility_report_ready = SimpleNamespace(emit=reports.append)
|
||||
launch = main_window_module.CompatibilityLaunch("/games/Game.exe", 5, 120.0)
|
||||
monkeypatch.setattr(main_window_module, "has_dxvk_vulkan_incompatibility", lambda _path: False)
|
||||
monkeypatch.setattr(main_window_module.os.path, "isfile", lambda _path: True)
|
||||
@@ -1972,7 +1851,7 @@ def test_nonzero_wine_exit_reports_after_long_game(monkeypatch: MonkeyPatch) ->
|
||||
window._build_compatibility_report(launch, stopped_by_user=False)
|
||||
window._build_compatibility_report(launch, stopped_by_user=True)
|
||||
|
||||
assert reports == [("crash report", launch.executable)]
|
||||
assert reports == ["crash report"]
|
||||
|
||||
def test_update_prefix_log_does_not_mark_wine_launch_start() -> None:
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -1990,17 +1869,14 @@ def test_show_compatibility_report_uses_report_dialog(monkeypatch: MonkeyPatch)
|
||||
def __init__(self, parent: object, theme: object, report: str) -> None:
|
||||
calls.append((parent, theme, report))
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
calls.append(executable)
|
||||
|
||||
def exec(self) -> None:
|
||||
calls.append("exec")
|
||||
|
||||
monkeypatch.setattr("portprotonqt.main_window.CompatibilityReportDialog", ReportDialog)
|
||||
|
||||
window._show_compatibility_report("report text", "/games/Game.exe")
|
||||
window._show_compatibility_report("report text")
|
||||
|
||||
assert calls == [(window, window.theme, "report text"), "/games/Game.exe", "exec"]
|
||||
assert calls == [(window, window.theme, "report text"), "exec"]
|
||||
|
||||
def test_refresh_theme_store_visibility_adds_store(monkeypatch: Any) -> None:
|
||||
window: Any = MainWindow.__new__(MainWindow)
|
||||
@@ -3111,20 +2987,3 @@ def test_autoinstall_table_columns_match_header(
|
||||
- value.mapTo(window, value.rect().center()).x()) <= 1
|
||||
window.stackedWidget.deleteLater()
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_shutdown_stops_batch_before_store_processes() -> None:
|
||||
calls: list[str] = []
|
||||
process = MagicMock()
|
||||
process.terminate.side_effect = lambda: calls.append("terminate")
|
||||
process.waitForFinished.side_effect = [False, True]
|
||||
menu = SimpleNamespace(stop_card_actions=lambda: calls.append("stop_batch"))
|
||||
window = SimpleNamespace(
|
||||
context_menu_manager=menu, egs_process=process, store_download_queue=[("egs", {})],
|
||||
_stopWorkerThread=MagicMock(), _stopWorkerThreads=MagicMock(),
|
||||
)
|
||||
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
|
||||
assert calls == ["stop_batch", "terminate"]
|
||||
process.kill.assert_called_once_with()
|
||||
assert process.waitForFinished.call_count == 2
|
||||
assert window.store_download_queue == []
|
||||
@@ -94,6 +94,33 @@ class TestGetCurrentLauncherCommand:
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── Launcher tail extraction ─────────────────────────────────────────────────
|
||||
|
||||
class TestExtractLauncherTail:
|
||||
def test_appimage_with_silent(self):
|
||||
parts = ["/path/PortProtonQt.AppImage", "--silent", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_appimage_without_silent(self):
|
||||
parts = ["/path/PortProtonQt.AppImage", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_portprotonqt_with_silent(self):
|
||||
parts = ["portprotonqt", "--silent", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_start_sh_with_silent(self):
|
||||
parts = ["start.sh", "--silent", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) == ["/game.exe"]
|
||||
|
||||
def test_unknown_command(self):
|
||||
parts = ["/usr/bin/wine64", "/game.exe"]
|
||||
assert _extract_launcher_tail(parts) is None
|
||||
|
||||
def test_empty(self):
|
||||
assert _extract_launcher_tail([]) is None
|
||||
|
||||
|
||||
# ── Launcher line migration ──────────────────────────────────────────────────
|
||||
|
||||
class TestMigrateLauncherLine:
|
||||
|
||||
@@ -29,6 +29,11 @@ def test_scripts_path_uses_xdg_data_dirs(
|
||||
monkeypatch.setenv("XDG_DATA_DIRS", str(data_dir))
|
||||
monkeypatch.delenv("APPDIR", raising=False)
|
||||
monkeypatch.delenv("SHARUN_DIR", raising=False)
|
||||
monkeypatch.delenv("APPIMAGE", raising=False)
|
||||
is_file = Path.is_file
|
||||
monkeypatch.setattr(
|
||||
Path, "is_file", lambda path: path.is_relative_to(tmp_path) and is_file(path)
|
||||
)
|
||||
|
||||
assert get_portproton_scripts_path() == str(scripts_dir)
|
||||
|
||||
@@ -210,7 +215,7 @@ def test_game_launch_marker_is_emitted_for_wine_and_proton() -> None:
|
||||
)
|
||||
|
||||
marker = "printf '%s\\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'"
|
||||
assert helper.count(marker) == 1
|
||||
assert helper.count(marker) == 2
|
||||
|
||||
|
||||
def test_user_conf_get_without_name_lists_active_values(tmp_path: Path) -> None:
|
||||
@@ -274,52 +279,94 @@ def test_vk_gpu_info_uses_build_aux_binary() -> None:
|
||||
assert "dev-scripts/vk_gpu_info" not in helper
|
||||
|
||||
|
||||
def test_steam_uses_common_portwine_launch() -> None:
|
||||
def test_reshade_prefers_directx_for_unity() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
start = Path("build-aux/share/portproton/scripts/start.sh").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "steamplay_launch ()" not in helper
|
||||
assert 'export PW_STEAMPLAY="1"' in start
|
||||
assert '"${PROTON_RUNNER}" waitforexitandrun "${PW_EXE_FILE}" "$@"' in helper
|
||||
assert "source=$(echo \"$detect\" | grep '^SOURCE=' | cut -d= -f2-)" in helper
|
||||
assert (
|
||||
'[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] '
|
||||
"&& vulkan=false"
|
||||
) in helper
|
||||
|
||||
|
||||
def test_steam_skips_database_initialization() -> None:
|
||||
def test_reshade_and_optiscaler_are_mutually_exclusive() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
start = Path("build-aux/share/portproton/scripts/start.sh").read_text(
|
||||
|
||||
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"' in helper
|
||||
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"' in helper
|
||||
|
||||
|
||||
def test_reshade_repeated_link_does_not_create_nested_shaders(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
db_init = helper.index("pw_init_db ()")
|
||||
steam_db_return = helper.index("return 0", db_init)
|
||||
normal_db = helper.index('[[ -n "${PW_DEFAULT_WINE_USE:-}" ]]', steam_db_return)
|
||||
portproton_start = helper.index("start_portproton ()")
|
||||
steam_db = helper.index(
|
||||
'export PORTWINE_DB_FILE="${PW_EXE_FILE}.ppdb"',
|
||||
portproton_start,
|
||||
)
|
||||
steam_block_end = helper.index("\n fi\n\n pw_check_and_download_plugins", steam_db)
|
||||
steam_block = helper[steam_db:steam_block_end]
|
||||
prefix_guard = helper.index('if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then')
|
||||
normal_prefix = helper.index(
|
||||
'data/prefixes/${PW_PREFIX_NAME}"/drive_c/windows',
|
||||
prefix_guard,
|
||||
)
|
||||
api_guard = helper.index('if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then', normal_prefix)
|
||||
api_symlink = helper.index('print_info "Try create symlink DXVK files..."', api_guard)
|
||||
winetricks_guard = helper.index('if [[ "${PW_STEAMPLAY:-}" != "1" ]] ; then', api_symlink)
|
||||
winetricks_update = helper.index("update_winetricks", winetricks_guard)
|
||||
link_function = helper.split("try_force_link_dir () {", 1)[1].split("\n}", 1)[0]
|
||||
shaders = tmp_path / "shaders"
|
||||
shaders.mkdir()
|
||||
game = tmp_path / "game"
|
||||
game.mkdir()
|
||||
script = f'try_force_link_dir () {{{link_function}\n}}\n'
|
||||
link_call = helper.split(' [[ "$PW_USE_RESHADE" != 1 ]] && return 0', 1)[1]
|
||||
link_call = link_call.split(' pw_specialk_set_ini_value', 1)[0]
|
||||
script += f'root="$1"\nPATH_TO_GAME="$2"\n{link_call * 2}'
|
||||
|
||||
assert steam_db_return < normal_db
|
||||
assert portproton_start < steam_db
|
||||
assert "unset PW_PREFIX_NAME" in steam_block
|
||||
assert 'export WINEPREFIX="${STEAM_COMPAT_DATA_PATH}/pfx"' in steam_block
|
||||
assert "PW_PREFIX_NAME=" not in steam_block
|
||||
assert prefix_guard < normal_prefix
|
||||
assert api_guard < api_symlink
|
||||
assert winetricks_guard < winetricks_update
|
||||
assert "pw_init_db" in start
|
||||
subprocess.run(
|
||||
["bash", "-c", script, "bash", str(tmp_path), str(game)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert (game / "ReShade_shaders").resolve() == shaders
|
||||
assert not (shaders / "shaders").exists()
|
||||
|
||||
|
||||
def test_reshade_disable_cleans_unreal_shipping_directory(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
sync_function = helper.split("pw_reshade_sync_files () {", 1)[1].split("\n}", 1)[0]
|
||||
game_root = tmp_path / "game"
|
||||
game_root.mkdir()
|
||||
game_exe = game_root / "Praest.exe"
|
||||
game_exe.touch()
|
||||
game = game_root / "Praest" / "Binaries" / "Win64"
|
||||
game.mkdir(parents=True)
|
||||
runtime = tmp_path / "plugins" / "reshade_windows" / "reshade" / "6.8.0_Addon"
|
||||
runtime.mkdir(parents=True)
|
||||
reshade_dll = runtime / "ReShade32.dll"
|
||||
reshade_dll.touch()
|
||||
(game / "opengl32.dll").symlink_to(reshade_dll)
|
||||
(game / "opengl32.dll.b").symlink_to(reshade_dll)
|
||||
(game / "ReShadePreset.ini").write_text("[GENERAL]\n")
|
||||
(game / "reshade_version").write_text("6.8.0_Addon\nopengl32.dll\n")
|
||||
script = f'pw_reshade_sync_files () {{{sync_function}\n}}\n'
|
||||
script += 'try_remove_file () { rm -f "$1"; }\npw_reshade_sync_files\n'
|
||||
|
||||
subprocess.run(
|
||||
["bash", "-c", script],
|
||||
check=True,
|
||||
env={
|
||||
**os.environ,
|
||||
"PW_EXE_FILE": str(game_exe),
|
||||
"PW_PLUGINS_PATH": str(tmp_path / "plugins"),
|
||||
"PW_USE_RESHADE": "0",
|
||||
"PW_RESHADE_API": "",
|
||||
"PW_RESHADE_EXE": "",
|
||||
"PATH_TO_GAME": str(game),
|
||||
},
|
||||
)
|
||||
|
||||
assert not (game / "opengl32.dll").exists()
|
||||
assert not (game / "opengl32.dll.b").exists()
|
||||
assert not (game / "ReShadePreset.ini").exists()
|
||||
|
||||
|
||||
def test_ini_writer_matches_spaces_around_equals() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert '^[[:space:]]*$key[[:space:]]*=' in helper
|
||||
@@ -192,43 +192,3 @@ def test_vk_gpu_info_uses_build_aux_binary() -> None:
|
||||
|
||||
assert '/../../../bin/vk_gpu_info"' in helper
|
||||
assert "dev-scripts/vk_gpu_info" not in helper
|
||||
|
||||
|
||||
@mark.parametrize("filename,pe_status,expected_calls", [
|
||||
("Game [one].EXE", "0", ["pe"]),
|
||||
("Game [one].exe", "1", ["pe", "exiftool"]),
|
||||
("Game [one].lnk", "0", ["exiftool"]),
|
||||
])
|
||||
def test_executable_metadata_uses_pefile_and_preserves_lnk_fallback(
|
||||
tmp_path: Path, filename: str, pe_status: str, expected_calls: list[str],
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("use_exiftool () {", 1)[1].split("\npw_create_gui_png () {", 1)[0]
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
exiftool = bin_dir / "exiftool"
|
||||
exiftool.write_text('#!/bin/sh\nprintf "exiftool\\n" >> "$CALLS"\nprintf "Product Name: Fallback\\n"\n')
|
||||
exiftool.chmod(0o755)
|
||||
calls = tmp_path / "calls"
|
||||
script = '''python_module () {
|
||||
printf 'pe\\n' >> "$CALLS"
|
||||
if [[ "$PE_STATUS" == 0 ]]; then
|
||||
printf 'Product Name: Game\\nFile Description: Description\\nCompany Name: Sony\\n'
|
||||
fi
|
||||
return "$PE_STATUS"
|
||||
}
|
||||
'''
|
||||
script += 'use_exiftool () {' + definition + '\nuse_exiftool "$TARGET"\nuse_exiftool "$TARGET"\n'
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True, check=False,
|
||||
env={**os.environ, "PATH": f"{bin_dir}:{os.defpath}", "PW_TMPFS_PATH": str(tmp_path),
|
||||
"CALLS": str(calls), "PE_STATUS": pe_status, "TARGET": str(tmp_path / filename)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert calls.read_text().splitlines() == expected_calls
|
||||
metadata = (tmp_path / "exiftool.tmp").read_text()
|
||||
assert metadata.splitlines()[0] == str(tmp_path / filename)
|
||||
assert "Product Name:" in metadata
|
||||
if expected_calls == ["pe"]:
|
||||
assert "File Description: Description" in metadata
|
||||
assert "Company Name: Sony" in metadata
|
||||
@@ -2,52 +2,17 @@
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QMimeData, QUrl
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from portprotonqt.dialogs.proton_manager import ProtonManager
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.tabs import library_tab
|
||||
|
||||
|
||||
def test_nested_manager_registers_input_before_connecting_hints(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
from PySide6.QtWidgets import QDialog, QPushButton
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
main = cast(Any, QWidget())
|
||||
main.get_button_icon = MagicMock(return_value="placeholder")
|
||||
main.get_nav_icon = MagicMock(return_value="placeholder")
|
||||
settings = cast(Any, QDialog(main))
|
||||
events = []
|
||||
inputs = SimpleNamespace(
|
||||
gamepad=object(), gamepad_type="xbox",
|
||||
enable_proton_manager_mode=lambda dialog: events.append(("enable", dialog)),
|
||||
disable_proton_manager_mode=lambda: None,
|
||||
connect_surface_updates=lambda name, callback: events.append((name, callback)),
|
||||
)
|
||||
settings.input_manager = inputs
|
||||
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
|
||||
manager = module.ProtonManager(settings, theme=ThemeWrapper(load_theme("standart")), input_manager=inputs)
|
||||
|
||||
assert manager.main_window is main
|
||||
assert events[0] == ("enable", manager)
|
||||
assert events[1][0] == "proton_manager_dialog"
|
||||
assert main.get_button_icon.called and main.get_nav_icon.called
|
||||
manager.close()
|
||||
settings.deleteLater()
|
||||
main.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_cachyos_v3_builds_require_supported_cpu_level(monkeypatch) -> None:
|
||||
manager = ProtonManager.__new__(ProtonManager)
|
||||
manager.cpu_level = 2
|
||||
@@ -146,7 +111,6 @@ def test_extraction_does_not_change_process_directory(tmp_path, monkeypatch) ->
|
||||
|
||||
|
||||
def test_library_drop_opens_manager_for_wine_archive(tmp_path, monkeypatch) -> None:
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
archive_path = tmp_path / "PROTON_LG_10-30.tar.xz"
|
||||
archive_path.touch()
|
||||
mime_data = QMimeData()
|
||||
@@ -162,18 +126,9 @@ def test_library_drop_opens_manager_for_wine_archive(tmp_path, monkeypatch) -> N
|
||||
def acceptProposedAction(self):
|
||||
self.accepted = True
|
||||
|
||||
class Window(QWidget):
|
||||
class Window:
|
||||
portproton_location = "/tmp/PortProtonQt"
|
||||
input_manager = None
|
||||
_on_dropped_theme_installed = library_tab.MainWindowLibraryTabMixin._on_dropped_theme_installed
|
||||
_on_dropped_theme_failed = library_tab.MainWindowLibraryTabMixin._on_dropped_theme_failed
|
||||
_on_dropped_wine_archives = library_tab.MainWindowLibraryTabMixin._on_dropped_wine_archives
|
||||
_install_dropped_theme_archives = library_tab.MainWindowLibraryTabMixin._install_dropped_theme_archives
|
||||
theme = SimpleNamespace(MESSAGE_BOX_STYLE="", PROGRESS_BAR_STYLE="")
|
||||
|
||||
from portprotonqt.tabs.theme_store_workers import ThemeArchiveInstallWorker
|
||||
|
||||
monkeypatch.setattr(ThemeArchiveInstallWorker, "start", lambda self: self.wine_archives.emit(self.paths))
|
||||
|
||||
monkeypatch.setattr(
|
||||
library_tab, "show_proton_manager", lambda *args, **kwargs: calls.append(kwargs)
|
||||
|
||||
+46
-300
@@ -26,6 +26,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from portprotonqt.custom_widgets import CustomComboBox
|
||||
from portprotonqt.tabs.settings_tab import SettingsButtonsLayout
|
||||
|
||||
from portprotonqt.dialogs.settings_gamescope import (
|
||||
GAMESCOPE_TOGGLE_CATEGORIES,
|
||||
GAMESCOPE_TOGGLE_DESCRIPTIONS,
|
||||
@@ -36,10 +37,10 @@ from portprotonqt.dialogs.settings_mangohud import (
|
||||
MANGOHUD_TOGGLE_DESCRIPTIONS,
|
||||
MangoHudSettingsMixin,
|
||||
)
|
||||
from portprotonqt.dialogs.settings_reshade import ReShadeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin
|
||||
|
||||
|
||||
PAINT_SETTLE_MS = 250
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@@ -90,6 +91,45 @@ def test_settings_dialog_search_accepts_typing_without_search_focus() -> None:
|
||||
QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def test_reshade_collects_enable_exe_api_and_font() -> None:
|
||||
settings = ReShadeSettingsMixin()
|
||||
settings.current_settings = {
|
||||
"PW_USE_RESHADE": "1",
|
||||
"PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0",
|
||||
"PW_RESHADE_OVERLAY_KEY": "36,0,0,0",
|
||||
}
|
||||
settings.original_values = {"PW_USE_RESHADE": "0"}
|
||||
settings.reshade_original_values = {
|
||||
"PW_RESHADE_EXE": "",
|
||||
"PW_RESHADE_API": "auto",
|
||||
"PW_RESHADE_FONT": "",
|
||||
"PW_RESHADE_SCREENSHOT_PATH": "",
|
||||
"PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0",
|
||||
"PW_RESHADE_OVERLAY_KEY": "36,0,0,0",
|
||||
}
|
||||
settings.reshade_exe_edit = QLineEdit("/games/game.exe")
|
||||
settings.reshade_api_combo = CustomComboBox()
|
||||
settings.reshade_api_combo.addItems(["Auto", "Vulkan"])
|
||||
settings.reshade_api_combo.setCurrentText("Vulkan")
|
||||
settings.reshade_font_combo = CustomComboBox()
|
||||
settings.reshade_font_combo.addItem("DejaVu Sans", "DejaVu Sans")
|
||||
settings.reshade_screenshot_edit = QLineEdit("/screenshots")
|
||||
|
||||
assert settings._collect_reshade_changes() == [
|
||||
"PW_USE_RESHADE=1",
|
||||
"PW_RESHADE_EXE=/games/game.exe",
|
||||
"PW_RESHADE_API=vulkan",
|
||||
"PW_RESHADE_FONT=DejaVu Sans",
|
||||
"PW_RESHADE_SCREENSHOT_PATH=/screenshots",
|
||||
]
|
||||
assert settings._reshade_virtual_key(
|
||||
Qt.Key.Key_Semicolon.value, Qt.KeyboardModifier.NoModifier
|
||||
) == 186
|
||||
assert settings._reshade_virtual_key(
|
||||
Qt.Key.Key_1.value, Qt.KeyboardModifier.KeypadModifier
|
||||
) == 97
|
||||
|
||||
|
||||
def test_mangohud_search_ignores_unavailable_categories() -> None:
|
||||
settings = MangoHudSettingsMixin()
|
||||
settings.mangohud_category_groups = {}
|
||||
@@ -464,7 +504,7 @@ def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> N
|
||||
@pytest.mark.parametrize("close_dialog", [False, True])
|
||||
@pytest.mark.parametrize("themed", [False, True])
|
||||
def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed: bool) -> None:
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QComboBox, QDialog
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
@@ -472,7 +512,7 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.gamepad_tooltip = QLabel(dialog)
|
||||
dialog.gamepad_tooltip = QLabel(dialog, Qt.WindowType.ToolTip)
|
||||
dialog.gamepad_tooltip_timer = QTimer(dialog)
|
||||
dialog.gamepad_tooltip_timer.setSingleShot(True)
|
||||
combo = QComboBox(dialog)
|
||||
@@ -481,16 +521,9 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
from portprotonqt.themes.standart import styles
|
||||
|
||||
combo.setStyleSheet(ThemeWrapper(styles).COMBOBOX_STYLE)
|
||||
dialog.gamepad_tooltip.setStyleSheet(ThemeWrapper(styles).TOOLTIP_STYLE)
|
||||
combo.addItems(["Proton", "WINE-11.18-AMD64-WOW64" if themed else "Proton " * 40])
|
||||
dialog.resize(600, 400)
|
||||
dialog.move(20, 20)
|
||||
combo.setGeometry(450, 20, 140, 40)
|
||||
combo.view().setFixedWidth(180)
|
||||
combo.view().installEventFilter(dialog)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous: dialog._on_combo_highlighted(index.row(), combo),
|
||||
)
|
||||
combo.highlighted.connect(lambda row: dialog._on_combo_highlighted(row, combo))
|
||||
combo.setCurrentIndex(1)
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
@@ -498,11 +531,10 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
|
||||
assert dialog.gamepad_tooltip.isVisible()
|
||||
assert dialog.gamepad_tooltip.text() == combo.itemText(1)
|
||||
assert dialog.rect().contains(dialog.gamepad_tooltip.geometry())
|
||||
combo.view().setCurrentIndex(combo.model().index(0, 0))
|
||||
dialog._on_combo_highlighted(0, combo)
|
||||
assert not dialog.gamepad_tooltip.isVisible()
|
||||
assert not dialog.gamepad_tooltip_timer.isActive()
|
||||
combo.view().setCurrentIndex(combo.model().index(1, 0))
|
||||
dialog._on_combo_highlighted(1, combo)
|
||||
assert dialog.gamepad_tooltip.isVisible()
|
||||
|
||||
if close_dialog:
|
||||
@@ -517,112 +549,6 @@ def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_favorite_combo_preserves_separator_actions_and_source_updates() -> None:
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
table = QTableWidget(1, 2)
|
||||
source = QComboBox(table)
|
||||
source.addItems(["Wine", "Proton"])
|
||||
source.insertSeparator(source.count())
|
||||
source.addItem("Download", "download_wine")
|
||||
activated = []
|
||||
source.activated.connect(activated.append)
|
||||
source.activated.connect(lambda _index: source.setCurrentIndex(0))
|
||||
highlighted = []
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.theme = ThemeWrapper(load_theme("standart"))
|
||||
dialog.favorites_table = table
|
||||
dialog._on_combo_highlighted = lambda row, combo: highlighted.append((row, combo))
|
||||
dialog._add_favorite_combo(0, source)
|
||||
combo = cast(QComboBox, table.cellWidget(0, 1))
|
||||
|
||||
assert combo.model() is source.model()
|
||||
assert not combo.model().flags(combo.model().index(2, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
assert combo.itemData(3) == "download_wine"
|
||||
combo.setCurrentIndex(3)
|
||||
combo.activated.emit(3)
|
||||
assert activated == [3]
|
||||
assert combo.currentText() == source.currentText() == "Wine"
|
||||
source.addItem("New Wine")
|
||||
assert combo.itemText(combo.count() - 1) == "New Wine"
|
||||
highlighted.clear()
|
||||
combo.view().setCurrentIndex(combo.model().index(1, 0))
|
||||
assert highlighted == [(1, combo)]
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def linked_settings_combos() -> tuple:
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.theme = ThemeWrapper(load_theme("standart"))
|
||||
dialog.gamepad_tooltip = QLabel(dialog)
|
||||
dialog.gamepad_tooltip.setStyleSheet(dialog.theme.TOOLTIP_STYLE)
|
||||
dialog.gamepad_tooltip_timer = QTimer(dialog)
|
||||
stack = QStackedWidget(dialog)
|
||||
stack.setGeometry(0, 0, 600, 400)
|
||||
dialog.favorites_table = QTableWidget(1, 2)
|
||||
advanced = QTableWidget(1, 2)
|
||||
stack.addWidget(dialog.favorites_table)
|
||||
stack.addWidget(advanced)
|
||||
source = QComboBox()
|
||||
source.addItems(["Wine", "WINE-11.18-AMD64-WOW64 " * 3])
|
||||
advanced.setCellWidget(0, 1, source)
|
||||
source.view().installEventFilter(dialog)
|
||||
source.view().setMouseTracking(True)
|
||||
source.view().entered.connect(lambda index: dialog._on_combo_highlighted(index.row(), source))
|
||||
source.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _old: dialog._on_combo_highlighted(index.row(), source),
|
||||
)
|
||||
dialog._add_favorite_combo(0, source)
|
||||
favorite = cast(QComboBox, dialog.favorites_table.cellWidget(0, 1))
|
||||
dialog.resize(600, 400)
|
||||
dialog.show()
|
||||
return dialog, stack, favorite, source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("navigation", ["gamepad", "mouse"])
|
||||
def test_linked_settings_combos_keep_visible_tooltip_during_navigation(
|
||||
linked_settings_combos: tuple, navigation: str,
|
||||
) -> None:
|
||||
from PySide6.QtGui import QMouseEvent
|
||||
|
||||
dialog, stack, favorite, source = linked_settings_combos
|
||||
for page, combo in enumerate((favorite, source)):
|
||||
stack.setCurrentIndex(page)
|
||||
combo.view().setFixedWidth(180)
|
||||
combo.showPopup()
|
||||
QApplication.processEvents()
|
||||
for row in (1, 0):
|
||||
if navigation == "mouse":
|
||||
rect = combo.view().visualRect(combo.model().index(row, 0))
|
||||
point = rect.intersected(combo.view().viewport().rect()).center().toPointF()
|
||||
QApplication.sendEvent(combo.view().viewport(), QMouseEvent(
|
||||
QEvent.Type.MouseMove, point, point, Qt.MouseButton.NoButton,
|
||||
Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
|
||||
))
|
||||
else:
|
||||
combo.setCurrentIndex(row)
|
||||
assert dialog.gamepad_tooltip.isVisible() == bool(row), page
|
||||
if row:
|
||||
assert dialog.gamepad_tooltip.text() == combo.itemText(row)
|
||||
assert dialog.rect().contains(dialog.gamepad_tooltip.geometry())
|
||||
assert not dialog.gamepad_tooltip.isWindow()
|
||||
combo.hidePopup()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
|
||||
def test_settings_selected_row_keeps_text_readable(theme_name: str) -> None:
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
@@ -745,183 +671,3 @@ def test_winetricks_centered_checkboxes_and_install_selection(
|
||||
dialog.install_selected(force=True)
|
||||
dialog._start_install_process.assert_called_once_with(["art2kmin"], True)
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
|
||||
def test_settings_table_stops_repainting_when_idle(theme_name: str) -> None:
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
theme = ThemeWrapper(load_theme(theme_name))
|
||||
dialog = cast(ExeSettingsDialog, SimpleNamespace(theme=theme))
|
||||
|
||||
class PaintFilter(QObject):
|
||||
paints = 0
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if event.type() == QEvent.Type.Paint:
|
||||
self.paints += 1
|
||||
return ExeSettingsDialog.eventFilter(dialog, obj, event)
|
||||
return False
|
||||
|
||||
table = QTableWidget(3, 3)
|
||||
table.resize(800, 400)
|
||||
table.setStyleSheet(theme.WINETRICKS_TABBLE_STYLE)
|
||||
table.setColumnWidth(0, 500)
|
||||
table.setColumnHidden(2, True)
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
paint_filter = PaintFilter(table)
|
||||
table.viewport().installEventFilter(paint_filter)
|
||||
for row in range(3):
|
||||
table.setItem(row, 0, QTableWidgetItem("Setting\nDescription " * 5))
|
||||
combo = CustomComboBox(theme=theme)
|
||||
combo.addItem("Value")
|
||||
table.setCellWidget(row, 1, combo)
|
||||
table.show()
|
||||
for selected_row, width in ((0, 500), (1, 300)):
|
||||
table.selectRow(selected_row)
|
||||
table.setColumnWidth(0, width)
|
||||
for _ in range(10):
|
||||
QApplication.processEvents()
|
||||
QTest.qWait(PAINT_SETTLE_MS)
|
||||
paints = paint_filter.paints
|
||||
QTest.qWait(PAINT_SETTLE_MS)
|
||||
assert paint_filter.paints == paints
|
||||
table.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def autosave_dialog(request, monkeypatch, tmp_path) -> tuple[QDialog, list[list[str]], bool]:
|
||||
from PySide6.QtCore import QProcess
|
||||
from portprotonqt.dialogs import settings_dialog as module
|
||||
|
||||
dialog = module.ExeSettingsDialog.__new__(module.ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.saving = False
|
||||
dialog.settings_loading = False
|
||||
dialog.close_after_save = False
|
||||
user_conf = bool(request.param)
|
||||
dialog.user_conf = user_conf
|
||||
dialog.exe_path = str(tmp_path / 'game.exe')
|
||||
dialog.save_timer = QTimer(dialog)
|
||||
dialog.clear_ppdb_button = cast(module.AutoSizeButton, QPushButton(dialog))
|
||||
dialog.original_values = {'TEST': '0'}
|
||||
dialog.original_display_values = {}
|
||||
dialog.current_settings = {}
|
||||
dialog.blocked_keys = set()
|
||||
prefix = CustomComboBox(dialog)
|
||||
prefix.setEditable(True)
|
||||
prefix.setCurrentText('lower prefix')
|
||||
dialog.advanced_widgets = {} if user_conf else {'PW_PREFIX_NAME': prefix}
|
||||
dialog.value_mapping = {}
|
||||
dialog.game_source = ''
|
||||
dialog.gamescope_available = False
|
||||
dialog.settings_table = QTableWidget(1, 1, dialog)
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
item = QTableWidgetItem('TEST')
|
||||
item.setData(Qt.ItemDataRole.UserRole, 'TEST')
|
||||
dialog.settings_table.setItem(0, 0, item)
|
||||
checkbox = QCheckBox(dialog)
|
||||
dialog.value_widgets = {(0, 1): checkbox}
|
||||
dialog.mangohud_original_values = {}
|
||||
dialog.vkbasalt_original_values = {}
|
||||
dialog.gamescope_original_values = {}
|
||||
calls: list[list[str]] = []
|
||||
monkeypatch.setattr(dialog, '_collect_mangohud_changes', lambda: [])
|
||||
monkeypatch.setattr(dialog, '_collect_vkbasalt_changes', lambda: [])
|
||||
monkeypatch.setattr(dialog, '_get_process_args', lambda args: ['test-cli', *args])
|
||||
monkeypatch.setattr(QProcess, 'start', lambda _self, program, args: calls.append(args))
|
||||
|
||||
return dialog, calls, user_conf
|
||||
|
||||
|
||||
@pytest.mark.parametrize('autosave_dialog', [False, True], indirect=True)
|
||||
def test_settings_autosave_ignores_download_action(autosave_dialog: tuple) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
widget, calls, user_conf = autosave_dialog
|
||||
dialog = cast(ExeSettingsDialog, widget)
|
||||
combo = CustomComboBox(dialog)
|
||||
combo.addItem("Wine")
|
||||
combo.addItem("Download", "download_wine")
|
||||
combo.setCurrentIndex(1)
|
||||
key = 'PW_DEFAULT_WINE_USE' if user_conf else 'PW_WINE_USE'
|
||||
dialog.advanced_widgets = {key: combo}
|
||||
dialog.original_display_values = {key: "Wine"}
|
||||
dialog.apply_changes()
|
||||
assert calls == []
|
||||
assert not dialog.saving
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('autosave_dialog', [False, True], indirect=True)
|
||||
def test_settings_autosave_serializes_changes(autosave_dialog) -> None:
|
||||
from PySide6.QtCore import QProcess
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
widget, calls, user_conf = autosave_dialog
|
||||
dialog = cast(ExeSettingsDialog, widget)
|
||||
checkbox = dialog.value_widgets[(0, 1)]
|
||||
assert isinstance(checkbox, QCheckBox)
|
||||
checkbox.setChecked(True)
|
||||
dialog.apply_changes()
|
||||
assert dialog.saving
|
||||
assert len(calls) == 1
|
||||
checkbox.setChecked(False)
|
||||
dialog.apply_changes()
|
||||
assert len(calls) == 1
|
||||
process = dialog.user_conf_process if user_conf else dialog.save_process
|
||||
process.finished.emit(0, QProcess.ExitStatus.NormalExit)
|
||||
assert dialog.saving
|
||||
assert len(calls) == 2
|
||||
expected = ['TEST', '0'] if user_conf else ['TEST=0']
|
||||
assert calls[-1][-len(expected):] == expected
|
||||
process = dialog.user_conf_process if user_conf else dialog.save_process
|
||||
process.finished.emit(0, QProcess.ExitStatus.NormalExit)
|
||||
assert not dialog.saving
|
||||
assert dialog.original_values['TEST'] == '0'
|
||||
assert len(calls) == 2
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_settings_autosave_ignores_search_and_initial_loading() -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.settings_loading = True
|
||||
dialog.save_timer = QTimer(dialog)
|
||||
dialog.search_edit = QLineEdit(dialog)
|
||||
checkbox = QCheckBox(dialog)
|
||||
dialog._connect_autosave()
|
||||
checkbox.setChecked(True)
|
||||
assert not dialog.save_timer.isActive()
|
||||
dialog.settings_loading = False
|
||||
dialog.search_edit.setText('search')
|
||||
assert not dialog.save_timer.isActive()
|
||||
checkbox.setChecked(False)
|
||||
assert dialog.save_timer.isActive()
|
||||
dialog.save_timer.stop()
|
||||
dialog.deleteLater()
|
||||
|
||||
|
||||
def test_settings_close_waits_for_pending_save(monkeypatch) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.save_timer = QTimer(dialog)
|
||||
dialog.saving = True
|
||||
dialog.close_after_save = False
|
||||
monkeypatch.setattr(dialog, 'apply_changes', lambda: None)
|
||||
event = QCloseEvent()
|
||||
dialog.closeEvent(event)
|
||||
assert not event.isAccepted()
|
||||
assert dialog.close_after_save
|
||||
dialog.close_after_save = False
|
||||
dialog.reject()
|
||||
assert dialog.close_after_save
|
||||
dialog.deleteLater()
|
||||
+35
-961
File diff suppressed because it is too large.
Load diff
@@ -116,25 +116,3 @@ class TestDeleteCachedAppFiles:
|
||||
(tmp_path / "other.txt").write_text("hello")
|
||||
delete_cached_app_files(str(tmp_path), "steam_app_*.json")
|
||||
assert (tmp_path / "other.txt").exists()
|
||||
|
||||
|
||||
def test_matched_steam_game_keeps_cover_when_app_details_fail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import portprotonqt.steam_api.api as api
|
||||
|
||||
executable = tmp_path / "Praest.exe"
|
||||
executable.touch()
|
||||
app = {"appid": 1371690, "normalized_name": "graven"}
|
||||
monkeypatch.setattr(api.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr("portprotonqt.steam_api.cache.get_exiftool_data", lambda _exe: {})
|
||||
monkeypatch.setattr(api, "_has_custom_data_for_exe", lambda _name: False)
|
||||
monkeypatch.setattr(api, "get_steam_apps_and_index_async", lambda cb: cb(([app], {"graven": app})))
|
||||
monkeypatch.setattr(api, "fetch_app_info_async", lambda _appid, cb: cb(None))
|
||||
monkeypatch.setattr(api, "get_local_steam_cover", lambda *_args, **_kwargs: "")
|
||||
monkeypatch.setattr(api, "get_weanticheatyet_info_async", lambda _name, cb: cb({}))
|
||||
monkeypatch.setattr(api, "_add_ppdb_info", lambda result, _name, cb: cb(result))
|
||||
results = []
|
||||
api.get_steam_game_info_async("GRAVEN", str(executable), results.append)
|
||||
assert len(results) == 1
|
||||
assert results[0]["appid"] == 1371690
|
||||
assert results[0]["steam_game"] == "true"
|
||||
assert results[0]["cover"] == "https://steamcdn-a.akamaihd.net/steam/apps/1371690/library_600x900_2x.jpg"
|
||||
@@ -1350,78 +1350,3 @@ def test_store_storage_migrates_existing_data(
|
||||
assert not (api.data_dir / "bin").exists()
|
||||
assert not legacy.exists()
|
||||
assert api_class().library_path.read_text() == "[]"
|
||||
|
||||
|
||||
def test_batch_egs_deletion_skips_confirmation(monkeypatch: MonkeyPatch) -> None:
|
||||
message_box = MagicMock()
|
||||
monkeypatch.setattr(main_window_module, "QMessageBox", message_box)
|
||||
window = SimpleNamespace(
|
||||
context_menu_manager=SimpleNamespace(_batch_action=True),
|
||||
egs_api=SimpleNamespace(load_installed=lambda: {}), _start_egs_operation=MagicMock(),
|
||||
)
|
||||
MainWindow._delete_egs_game(cast(Any, window), "Game")
|
||||
message_box.assert_not_called()
|
||||
assert window._start_egs_operation.call_args.args[:2] == ("Game", ["uninstall", "Game", "-y"])
|
||||
|
||||
|
||||
def test_batch_gog_deletion_retains_worker_without_confirmation(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
|
||||
worker = MagicMock()
|
||||
monkeypatch.setattr(download_tab_module, "GOGDeleteWorker", MagicMock(return_value=worker))
|
||||
message_box = MagicMock()
|
||||
monkeypatch.setattr(download_tab_module, "QMessageBox", message_box)
|
||||
window = SimpleNamespace(
|
||||
context_menu_manager=SimpleNamespace(_batch_action=True),
|
||||
gog_api=SimpleNamespace(get_installed_path=lambda app_id: tmp_path),
|
||||
_on_gog_delete_finished=MagicMock(), gogAccountStatus=MagicMock(),
|
||||
)
|
||||
game = {"app_id": "1", "title": "Game"}
|
||||
GOGMixin._delete_gog_game(cast(Any, window), game)
|
||||
message_box.assert_not_called()
|
||||
assert window.gog_delete_worker is worker
|
||||
worker.finished.connect.assert_called_once_with(window._on_gog_delete_finished)
|
||||
worker.start.assert_called_once_with()
|
||||
|
||||
|
||||
@mark.parametrize("failure", [False, True])
|
||||
def test_gog_delete_worker_preserves_records_on_failure(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch, failure: bool,
|
||||
) -> None:
|
||||
from threading import get_ident
|
||||
|
||||
folder = tmp_path / "game"
|
||||
folder.mkdir()
|
||||
(folder / "game.exe").write_text("game")
|
||||
manifest = tmp_path / "heroic_gogdl" / "manifests" / "1"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text("manifest")
|
||||
api = SimpleNamespace(get_installed_path=lambda app_id: folder, config_dir=tmp_path,
|
||||
remove_installed_game=MagicMock())
|
||||
menu = SimpleNamespace(remove_gog_shortcuts=MagicMock())
|
||||
thread_ids: list[int] = []
|
||||
original_rmtree = download_tab_module.shutil.rmtree
|
||||
|
||||
def delete_installation(path: Path) -> None:
|
||||
thread_ids.append(get_ident())
|
||||
if failure:
|
||||
raise PermissionError("denied")
|
||||
original_rmtree(path)
|
||||
|
||||
monkeypatch.setattr(download_tab_module.shutil, "rmtree", delete_installation)
|
||||
worker = download_tab_module.GOGDeleteWorker(cast(Any, api), {"app_id": "1", "title": "Game"}, cast(Any, menu))
|
||||
worker.start()
|
||||
assert worker.wait(3000)
|
||||
assert thread_ids and thread_ids[0] != get_ident()
|
||||
assert bool(worker.error) is failure
|
||||
assert folder.exists() is failure
|
||||
assert manifest.exists() is failure
|
||||
assert api.remove_installed_game.call_count == int(not failure)
|
||||
assert menu.remove_gog_shortcuts.call_count == int(not failure)
|
||||
|
||||
|
||||
def test_failed_store_process_completes_callback_path() -> None:
|
||||
process = MagicMock()
|
||||
GOGMixin._handle_store_process_error(cast(Any, SimpleNamespace()), process,
|
||||
download_tab_module.QProcess.ProcessError.FailedToStart)
|
||||
process.finished.emit.assert_called_once_with(
|
||||
download_tab_module.os.EX_UNAVAILABLE, download_tab_module.QProcess.ExitStatus.CrashExit,
|
||||
)
|
||||
@@ -773,6 +773,62 @@ class TestThemeStylesIntegrity:
|
||||
assert '"glow_base_alpha"' in content
|
||||
assert '"glow_pulse_alpha"' in content
|
||||
|
||||
def test_classic_styles_use_fstrings_not_hardcoded(self):
|
||||
"""Classic QSS style constants should be f-strings, not plain strings
|
||||
(except HINT_BAR_STYLE and %-formatted styles for icon paths)."""
|
||||
content = self._read_theme("classic")
|
||||
tree = ast.parse(content)
|
||||
|
||||
plain_style_count = 0
|
||||
fstring_style_count = 0
|
||||
percent_style_count = 0
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if not target.id.endswith("_STYLE"):
|
||||
continue
|
||||
if isinstance(node.value, ast.JoinedStr):
|
||||
fstring_style_count += 1
|
||||
elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Mod):
|
||||
percent_style_count += 1
|
||||
elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
||||
plain_style_count += 1
|
||||
|
||||
assert fstring_style_count > 10, "Classic should use f-strings for most styles"
|
||||
assert plain_style_count <= 1, f"Classic has {plain_style_count} plain string styles"
|
||||
assert percent_style_count <= 3, f"Classic has {percent_style_count} %-formatted styles"
|
||||
|
||||
def test_classic_light_styles_use_fstrings_not_hardcoded(self):
|
||||
"""Classic-light QSS style constants should be f-strings, not plain strings
|
||||
(except %-formatted styles for icon paths)."""
|
||||
content = self._read_theme("classic-light")
|
||||
tree = ast.parse(content)
|
||||
|
||||
plain_style_count = 0
|
||||
fstring_style_count = 0
|
||||
percent_style_count = 0
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
for target in node.targets:
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if not target.id.endswith("_STYLE"):
|
||||
continue
|
||||
if isinstance(node.value, ast.JoinedStr):
|
||||
fstring_style_count += 1
|
||||
elif isinstance(node.value, ast.BinOp) and isinstance(node.value.op, ast.Mod):
|
||||
percent_style_count += 1
|
||||
elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
||||
plain_style_count += 1
|
||||
|
||||
assert fstring_style_count > 10, "Classic-light should use f-strings for most styles"
|
||||
assert plain_style_count == 0, f"Classic-light has {plain_style_count} plain string styles"
|
||||
assert percent_style_count <= 3, f"Classic-light has {percent_style_count} %-formatted styles"
|
||||
|
||||
def test_classic_theme_inherits_standart(self):
|
||||
content = self._read_theme("classic")
|
||||
assert 'THEME_INHERITS = "standart"' in content
|
||||
|
||||
+2
-128
@@ -2,139 +2,13 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
import io
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from PySide6.QtCore import QEvent, QMimeData, QPointF, Qt, QUrl
|
||||
from PySide6.QtCore import QEvent, QPointF, Qt
|
||||
from PySide6.QtGui import QEnterEvent, QMouseEvent
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QProgressDialog, QWidget
|
||||
from types import MethodType
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
from portprotonqt.tabs import theme_store
|
||||
from portprotonqt.tabs.theme_store import ThemeStoreCard, ThemeStoreMixin
|
||||
from portprotonqt.tabs import theme_store_workers
|
||||
from portprotonqt.tabs.library_tab import MainWindowLibraryTabMixin
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", ["installed", "failed", "wine_archives"])
|
||||
def test_theme_zip_drop_starts_retained_worker(tmp_path, monkeypatch, outcome) -> None:
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
archive_path = tmp_path / "theme.zip"
|
||||
archive_path.touch()
|
||||
mime = QMimeData()
|
||||
mime.setUrls([QUrl.fromLocalFile(str(archive_path))])
|
||||
accepted = []
|
||||
event = SimpleNamespace(
|
||||
mimeData=lambda: mime,
|
||||
acceptProposedAction=lambda: accepted.append(True),
|
||||
ignore=lambda: accepted.append(False),
|
||||
)
|
||||
started = []
|
||||
monkeypatch.setattr(theme_store_workers.ThemeArchiveInstallWorker, "start", lambda self: started.append(self))
|
||||
window = cast(Any, QWidget())
|
||||
window.theme = SimpleNamespace(MESSAGE_BOX_STYLE="", PROGRESS_BAR_STYLE="")
|
||||
window._on_dropped_theme_installed = started.append
|
||||
window._on_dropped_theme_failed = started.append
|
||||
window._on_dropped_wine_archives = started.append
|
||||
window._install_dropped_theme_archives = MethodType(
|
||||
MainWindowLibraryTabMixin._install_dropped_theme_archives, window
|
||||
)
|
||||
MainWindowLibraryTabMixin.dragEnterEvent(cast(Any, window), event)
|
||||
MainWindowLibraryTabMixin.dropEvent(cast(Any, window), event)
|
||||
|
||||
assert accepted == [True, True]
|
||||
assert started == window._themeArchiveWorkers
|
||||
assert started[0].paths == [str(archive_path)]
|
||||
progress = window.findChild(QProgressDialog)
|
||||
assert progress is not None
|
||||
assert progress.isVisible()
|
||||
assert progress.isModal()
|
||||
assert progress.minimum() == progress.maximum() == 0
|
||||
worker = started[0]
|
||||
getattr(worker, outcome).emit("error" if outcome == "failed" else ["custom"])
|
||||
assert progress.isVisible() == (outcome == "installed")
|
||||
worker.finished.emit()
|
||||
assert not progress.isVisible()
|
||||
assert window._themeArchiveWorkers == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("archive_format", ["zip", "tar.gz"])
|
||||
def test_dropped_theme_archive_installs_and_applies(tmp_path, monkeypatch, archive_format) -> None:
|
||||
archive_path = tmp_path / f"theme.{archive_format}"
|
||||
if archive_format == "zip":
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("custom/styles.py", "color = 'blue'\n")
|
||||
else:
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
content = b"color = 'blue'\n"
|
||||
entry = tarfile.TarInfo("custom/styles.py")
|
||||
entry.size = len(content)
|
||||
archive.addfile(entry, io.BytesIO(content))
|
||||
themes_dir = tmp_path / "themes"
|
||||
monkeypatch.setattr(theme_store_workers, "THEMES_DIRS", [themes_dir])
|
||||
applied = []
|
||||
invalidated = []
|
||||
refreshed = []
|
||||
window = SimpleNamespace(
|
||||
theme_manager=SimpleNamespace(invalidate_theme=invalidated.append),
|
||||
_refresh_theme_combo=refreshed.append,
|
||||
_apply_theme_and_restart=lambda name, variant: applied.append((name, variant)),
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.tabs.library_tab.ui_config.get_theme_variant", lambda: "dark")
|
||||
worker = theme_store_workers.ThemeArchiveInstallWorker([str(archive_path)])
|
||||
worker.installed.connect(
|
||||
lambda names: MainWindowLibraryTabMixin._on_dropped_theme_installed(cast(Any, window), names)
|
||||
)
|
||||
errors = []
|
||||
worker.failed.connect(errors.append)
|
||||
worker.run()
|
||||
|
||||
assert not errors
|
||||
assert (themes_dir / "custom/styles.py").read_text() == "color = 'blue'\n"
|
||||
assert invalidated == refreshed == ["custom"]
|
||||
assert applied == [("custom", "dark")]
|
||||
|
||||
|
||||
def test_dropped_archives_keep_wine_and_reject_unsafe_theme(tmp_path, monkeypatch) -> None:
|
||||
wine = tmp_path / "wine.tar.gz"
|
||||
with tarfile.open(wine, "w:gz") as archive:
|
||||
archive.addfile(tarfile.TarInfo("wine/bin/wine"))
|
||||
unsafe = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(unsafe, "w") as archive:
|
||||
archive.writestr("../styles.py", "")
|
||||
monkeypatch.setattr(theme_store_workers, "THEMES_DIRS", [tmp_path / "themes"])
|
||||
worker = theme_store_workers.ThemeArchiveInstallWorker([str(wine), str(unsafe)])
|
||||
wines = []
|
||||
errors = []
|
||||
installed = []
|
||||
worker.wine_archives.connect(wines.append)
|
||||
worker.failed.connect(errors.append)
|
||||
worker.installed.connect(installed.append)
|
||||
worker.run()
|
||||
|
||||
assert wines == [[str(wine)]]
|
||||
assert len(errors) == 1
|
||||
assert installed == []
|
||||
assert not (tmp_path / "styles.py").exists()
|
||||
|
||||
|
||||
def test_shutdown_waits_for_dropped_theme_workers() -> None:
|
||||
worker = SimpleNamespace()
|
||||
stopped = []
|
||||
window = SimpleNamespace(
|
||||
_themeArchiveWorkers=[worker],
|
||||
_stopWorkerThread=lambda *args: None,
|
||||
_stopWorkerThreads=lambda name: MainWindowWorkersMixin._stopWorkerThreads(cast(Any, window), name),
|
||||
_stopWorker=lambda *args: stopped.append(args),
|
||||
)
|
||||
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
|
||||
|
||||
assert stopped == [(worker, "_themeArchiveWorkers", None)]
|
||||
assert window._themeArchiveWorkers == []
|
||||
|
||||
|
||||
class FakeSignal:
|
||||
|
||||
@@ -6,9 +6,7 @@ Key regression areas (from git history):
|
||||
- Malformed playtime data robustness (dd65021)
|
||||
- #@_@# space encoding in stat paths
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from portprotonqt.time_utils import (
|
||||
_parse_last_launch_line,
|
||||
@@ -20,22 +18,9 @@ from portprotonqt.time_utils import (
|
||||
parse_playtime_file,
|
||||
get_playtime_for_exe,
|
||||
format_playtime,
|
||||
format_last_launch,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("detail_level", ["detailed", "brief", "steam"])
|
||||
def test_time_formatting_with_c_locale(
|
||||
monkeypatch: pytest.MonkeyPatch, detail_level: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.localization.locale.getlocale", lambda: ("c", None))
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: False)
|
||||
monkeypatch.setattr("portprotonqt.time_utils.ui_config.get_time_detail_level", lambda: detail_level)
|
||||
|
||||
assert format_last_launch(datetime.now() - timedelta(hours=2))
|
||||
assert format_playtime(7200)
|
||||
|
||||
|
||||
# ── _parse_last_launch_line ──────────────────────────────────────────────────
|
||||
|
||||
class TestParseLastLaunchLine:
|
||||
|
||||
@@ -18,7 +18,6 @@ from portprotonqt.steam_api.utils import (
|
||||
convert_steam_id,
|
||||
decode_text,
|
||||
get_steam_launch_commands,
|
||||
get_steam_runtime_library_paths,
|
||||
get_steam_compatibilitytools_dir,
|
||||
get_steam_compat_tool,
|
||||
get_last_steam_user,
|
||||
@@ -581,19 +580,6 @@ class TestIterExistingSteamDataDirs:
|
||||
result = _iter_existing_steam_data_dirs()
|
||||
assert len(result) == 1
|
||||
|
||||
def test_runtime_paths_from_all_installations(self, tmp_path: Path):
|
||||
native_steam = tmp_path / "native"
|
||||
snap_steam = tmp_path / "snap"
|
||||
(native_steam / "ubuntu12_32").mkdir(parents=True)
|
||||
(snap_steam / "ubuntu12_64").mkdir(parents=True)
|
||||
steam_dirs = (str(native_steam), str(snap_steam))
|
||||
|
||||
with patch("portprotonqt.steam_api.utils.STEAM_DATA_DIRS", steam_dirs):
|
||||
assert get_steam_runtime_library_paths() == [
|
||||
str(native_steam / "ubuntu12_32"),
|
||||
str(snap_steam / "ubuntu12_64"),
|
||||
]
|
||||
|
||||
def test_skips_nonexistent(self):
|
||||
with patch("portprotonqt.steam_api.utils.STEAM_DATA_DIRS", ("/nonexistent/path",)):
|
||||
result = _iter_existing_steam_data_dirs()
|
||||
@@ -781,11 +767,3 @@ def test_disabled_steam_proton_scanning_skips_discovery(monkeypatch: pytest.Monk
|
||||
with patch("portprotonqt.steam_api.utils._iter_existing_steam_data_dirs") as discover:
|
||||
assert get_steam_proton_versions() == []
|
||||
discover.assert_not_called()
|
||||
|
||||
|
||||
def test_steam_index_preserves_original_game_when_name_is_reused() -> None:
|
||||
apps = [
|
||||
{"appid": 1371690, "normalized_name": "graven"},
|
||||
{"appid": 5171110, "normalized_name": "graven"},
|
||||
]
|
||||
assert build_index(apps)["graven"]["appid"] == 1371690
|
||||
Reference in new issue
Block a user