Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca321ac0ae
|
||
|
|
5048244ed7
|
||
|
|
e96550d155
|
||
|
|
eef9824596
|
||
|
|
c7acdd2ef4
|
||
|
|
4fb74c1d15
|
||
|
|
78dab249c1
|
||
|
|
9e91eeb0dc
|
||
|
|
cb8d149b2a
|
||
|
|
98475c0e59
|
No files matched your search
@@ -31,9 +31,24 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -74,9 +89,24 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
|
||||
@@ -24,9 +24,24 @@ jobs:
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install CI dependencies
|
||||
|
||||
@@ -83,9 +83,24 @@ jobs:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
@@ -198,9 +213,24 @@ jobs:
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirror
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
@@ -35,9 +36,13 @@
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
@@ -54,12 +59,14 @@
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
- При выключенной опции «Fix video in game» больше не блокируется создание DXGI Device Manager в Wine/Proton
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @iodnoorov
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2999,7 +2999,7 @@ start_portproton () {
|
||||
|
||||
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"
|
||||
else unset WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER
|
||||
fi
|
||||
|
||||
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
|
||||
|
||||
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# All available options
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,6 +73,14 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
@@ -94,13 +102,21 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
|
||||
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
|
||||
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
|
||||
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
|
||||
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
|
||||
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
|
||||
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
|
||||
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
|
||||
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
|
||||
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
|
||||
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
|
||||
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
|
||||
complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
|
||||
@@ -140,6 +140,38 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
|
||||
import orjson
|
||||
|
||||
from portprotonqt.cli import edit_game, list_games
|
||||
|
||||
try:
|
||||
if parsed_args.edit_game:
|
||||
result = edit_game(parsed_args.edit_game, parsed_args.settings)
|
||||
elif parsed_args.game_info:
|
||||
games = list_games()
|
||||
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
|
||||
if result is None:
|
||||
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
|
||||
return 1
|
||||
else:
|
||||
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
|
||||
if parsed_args.fields:
|
||||
entries = result if isinstance(result, list) else [result]
|
||||
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
|
||||
result = selected if isinstance(result, list) else selected[0]
|
||||
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
|
||||
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
|
||||
except OSError as error:
|
||||
get_logger(__name__).error("Library command failed: %s", error)
|
||||
return 1
|
||||
except ValueError as error:
|
||||
get_logger(__name__).error("Invalid library command: %s", error)
|
||||
return 1
|
||||
except RuntimeError as error:
|
||||
get_logger(__name__).error("Failed to edit game settings: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
|
||||
@@ -2,6 +2,7 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,27 @@ def parse_args():
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
library = parser.add_mutually_exclusive_group()
|
||||
library.add_argument(
|
||||
"--list-games", action="store_true",
|
||||
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
|
||||
)
|
||||
library.add_argument(
|
||||
"--game-info", metavar="ID",
|
||||
help="Print one game as JSON using its source-qualified ID from --list-games"
|
||||
)
|
||||
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
|
||||
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
|
||||
help="Set a game setting; repeat to change multiple values with --edit-game")
|
||||
parser.add_argument("--all", dest="all_games", action="store_true",
|
||||
help="Include uninstalled games from cached libraries in --list-games")
|
||||
parser.add_argument("--fields", metavar="FIELD,...",
|
||||
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
|
||||
formatting = parser.add_mutually_exclusive_group()
|
||||
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
|
||||
help="Format JSON with indentation (default)")
|
||||
formatting.add_argument("--raw", dest="pretty", action="store_false",
|
||||
help="Print compact JSON without indentation")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
@@ -106,10 +128,276 @@ def parse_args():
|
||||
args = parser.parse_args()
|
||||
launch_args = []
|
||||
|
||||
if args.settings and not args.edit_game:
|
||||
parser.error("--set requires --edit-game")
|
||||
if args.edit_game and not args.settings:
|
||||
parser.error("--edit-game requires at least one --set KEY=VALUE")
|
||||
if args.all_games and not args.list_games:
|
||||
parser.error("--all requires --list-games")
|
||||
if args.fields is not None:
|
||||
if not (args.list_games or args.game_info):
|
||||
parser.error("--fields requires --list-games or --game-info")
|
||||
args.fields = [field.strip() for field in args.fields.split(",")]
|
||||
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
|
||||
parser.error("--fields requires comma-separated field names without empty entries")
|
||||
args.launch_args = launch_args
|
||||
return args
|
||||
|
||||
|
||||
def _read_game_settings(path: Path) -> dict[str, str]:
|
||||
"""Read literal shell assignments without executing configuration code."""
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
settings: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return settings
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
get_logger(__name__).warning("Failed to read %s: %s", path, error)
|
||||
return settings
|
||||
for line in content.splitlines():
|
||||
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
value = match[2]
|
||||
if any(token in value for token in ("$", "`")):
|
||||
continue
|
||||
try:
|
||||
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) <= 1:
|
||||
settings[match[1]] = parts[0] if parts else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
|
||||
"""Read custom artwork and cached metadata without fetching remote resources."""
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.steam_api.api import get_cached_steam_game_info
|
||||
from portprotonqt.time_utils import (
|
||||
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
|
||||
)
|
||||
|
||||
metadata = get_cached_steam_game_info(name, exec_line)
|
||||
metadata.setdefault("name", name)
|
||||
metadata.setdefault("description", "")
|
||||
metadata.setdefault("cover", "")
|
||||
if not executable:
|
||||
return metadata
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
|
||||
metadata["custom_data_directory"] = str(folder)
|
||||
metadata_file = folder / "metadata.txt"
|
||||
if metadata_file.is_file():
|
||||
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
|
||||
metadata.update({key: value for key, value in translations.items() if value})
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover = folder / f"cover{extension}"
|
||||
if cover.is_file():
|
||||
metadata["cover"] = str(cover)
|
||||
break
|
||||
metadata["custom_icon"] = get_custom_icon_path(executable)
|
||||
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
|
||||
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
|
||||
return metadata
|
||||
|
||||
|
||||
def _get_game_runtime(
|
||||
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
|
||||
) -> dict:
|
||||
"""Export configured runtime values; never source PPDB files or launch Wine."""
|
||||
user_conf = location / "data/user.conf"
|
||||
ppdb = Path(f"{executable}.ppdb") if executable else None
|
||||
settings = _read_game_settings(ppdb) if ppdb else {}
|
||||
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
|
||||
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
|
||||
settings.update(overrides)
|
||||
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
|
||||
prefix = overrides.get("PW_PREFIX_NAME", prefix)
|
||||
resolved_runner = runner
|
||||
if runner == "PROTON_LG":
|
||||
resolved_runner = variables.get("PW_PROTON_LG_VER")
|
||||
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
|
||||
resolved_runner = variables.get("PW_WINE_LG_VER")
|
||||
runner_path = None
|
||||
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
|
||||
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
|
||||
location / "data/dist" / resolved_runner.upper()
|
||||
)
|
||||
elif resolved_runner == "USE_SYSTEM_WINE":
|
||||
wine = shutil.which("wine")
|
||||
runner_path = Path(wine).parent.parent if wine else None
|
||||
wine_directory = runner_path
|
||||
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
|
||||
wine_directory /= "files"
|
||||
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
|
||||
wine_directory / "bin-wow64"
|
||||
).is_dir() else "bin"
|
||||
prefix_path = location / "data/prefixes" / prefix if prefix else None
|
||||
return {
|
||||
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
|
||||
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
|
||||
"runner": resolved_runner,
|
||||
"runner_path": str(runner_path) if runner_path else None,
|
||||
"runner_installed": runner_path.is_dir() if runner_path else False,
|
||||
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
|
||||
"settings": settings, "user_config": str(user_conf),
|
||||
"ppdb_exists": ppdb.is_file() if ppdb else False,
|
||||
}
|
||||
|
||||
|
||||
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Export a cached GOG or Epic library using the existing store backends."""
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
api = GOGAPI() if source == "gog" else EGSAPI()
|
||||
installed = api.load_installed()
|
||||
games = []
|
||||
for record in api.load_library():
|
||||
app_id = str(record.get("app_id", ""))
|
||||
if not app_id:
|
||||
continue
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
|
||||
metadata = _get_game_metadata(name, target, target) if target else {}
|
||||
metadata.pop("name", None)
|
||||
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
|
||||
games.append({
|
||||
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
|
||||
**metadata,
|
||||
"id": f"{source}:{app_id}", "source": source, "name": name,
|
||||
"description": record.get("description") or metadata.get("description", ""),
|
||||
"cover": cover or record.get("cover") or metadata.get("cover", ""),
|
||||
"icon": icon, "installed": is_installed,
|
||||
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
|
||||
"executable": target, "directory": str(Path(target).parent) if target else None,
|
||||
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_steam_games() -> list[dict]:
|
||||
"""Export installed Steam games and their locally cached metadata."""
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import get_steam_installed_games
|
||||
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
|
||||
|
||||
games = []
|
||||
for name, appid, last_played, playtime in get_steam_installed_games():
|
||||
metadata = get_cached_full_steam_game_info(appid, name)
|
||||
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"steam:{appid}", "source": "steam", "name": name,
|
||||
"cover": cover or metadata.get("cover", ""),
|
||||
"installed": True, "exec": f"steam://rungameid/{appid}",
|
||||
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Return local PortProton shortcuts without launching games or downloading metadata."""
|
||||
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
|
||||
|
||||
if not location:
|
||||
return []
|
||||
games = []
|
||||
for desktop_path in sorted(Path(location).glob("*.desktop")):
|
||||
entry = parse_desktop_entry(str(desktop_path))
|
||||
if entry is None:
|
||||
continue
|
||||
exec_line = entry.get("Exec", "")
|
||||
executable = extract_exec_target_path(exec_line)
|
||||
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
|
||||
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"portproton:{desktop_path.name}", "source": "portproton",
|
||||
"installed": bool(executable and Path(executable).is_file()),
|
||||
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
|
||||
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
|
||||
"desktop_file": str(desktop_path),
|
||||
"exec": exec_line,
|
||||
"executable": executable,
|
||||
"directory": str(Path(executable).parent) if executable else None,
|
||||
"icon": icon,
|
||||
"ppdb": f"{executable}.ppdb" if executable else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def list_games() -> list[dict]:
|
||||
"""Return locally available library data from all supported game sources."""
|
||||
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
|
||||
|
||||
location = get_portproton_location()
|
||||
scripts = get_portproton_scripts_path()
|
||||
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
|
||||
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
|
||||
games = _list_portproton_games(location, variables, overrides)
|
||||
games.extend(_list_steam_games())
|
||||
for source in ("gog", "egs"):
|
||||
games.extend(_list_store_games(source, location, variables, overrides))
|
||||
return games
|
||||
|
||||
|
||||
def edit_game(game_id: str, changes: list[str]) -> dict:
|
||||
"""Update a game's PPDB through PortProton and verify the saved settings."""
|
||||
import subprocess
|
||||
|
||||
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
|
||||
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
|
||||
|
||||
game = next((game for game in list_games() if game["id"] == game_id), None)
|
||||
if game is None:
|
||||
raise ValueError(f"Game not found: {game_id}")
|
||||
executable = game.get("executable")
|
||||
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
|
||||
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
|
||||
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
|
||||
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
|
||||
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
|
||||
values: dict[str, str] = {}
|
||||
for change in changes:
|
||||
key, separator, value = change.partition("=")
|
||||
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
|
||||
raise ValueError(f"Unknown or invalid game setting: {key}")
|
||||
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
|
||||
raise ValueError(f"Unsupported shell syntax in setting: {key}")
|
||||
if key in toggles and value not in {"0", "1"}:
|
||||
raise ValueError(f"{key} must be 0 or 1")
|
||||
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
|
||||
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
|
||||
values[key] = value
|
||||
if not values:
|
||||
raise ValueError("At least one setting is required")
|
||||
command = get_portproton_start_command()
|
||||
if not command:
|
||||
raise ValueError("PortProton start.sh was not found")
|
||||
result = subprocess.run(
|
||||
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
|
||||
capture_output=True, text=True, errors="replace", check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
|
||||
saved = _read_game_settings(Path(f"{executable}.ppdb"))
|
||||
if any(saved.get(key) != value for key, value in values.items()):
|
||||
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
|
||||
return next(game for game in list_games() if game["id"] == game_id)
|
||||
|
||||
|
||||
def add_steam_compat_tool(force_install: bool = False) -> bool:
|
||||
"""
|
||||
Add PortProtonQt as a Steam compatibility tool.
|
||||
|
||||
@@ -352,14 +352,9 @@ class ProtonManager(DraggableDialog):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
|
||||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if hasattr(self, 'tab_widget'):
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -679,7 +674,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
|
||||
@@ -498,7 +498,7 @@ def load_pixmap_async(
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
|
||||
@@ -86,9 +86,8 @@ class MouseEmulationThread(QThread):
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
self.finished.emit(True)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
@@ -214,10 +213,6 @@ class InputManager(
|
||||
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
self.nav_timer = QTimer(self)
|
||||
@@ -265,6 +260,8 @@ class InputManager(
|
||||
|
||||
def _on_mouse_emulation_finished(self, success: bool):
|
||||
"""Handle mouse emulation thread completion."""
|
||||
if not self.mouse_emulation_enabled:
|
||||
return
|
||||
if success and self.mouse_emulation_thread:
|
||||
device = self.mouse_emulation_thread.get_device()
|
||||
if device:
|
||||
@@ -281,6 +278,22 @@ class InputManager(
|
||||
self.emulation_active = self.mouse_emulation_enabled
|
||||
if not self.emulation_active:
|
||||
self.emulation_triggered = False
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
return
|
||||
if self.emulation_triggered:
|
||||
if self.ui is None:
|
||||
self._async_enable_mouse_emulation()
|
||||
elif self.ui is not None:
|
||||
try:
|
||||
self.ui.close()
|
||||
except Exception as e:
|
||||
logger.error("EMUL: Error closing virtual mouse: %s", e)
|
||||
self.ui = None
|
||||
self.mouse_emulation_thread = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
|
||||
"""Common navigation logic for game cards in a container."""
|
||||
@@ -414,8 +427,10 @@ class InputManager(
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
if thread:
|
||||
thread.wait()
|
||||
if self.ui is None:
|
||||
self.ui = thread.get_device()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
|
||||
@@ -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-11-01 00:08+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -32,6 +32,8 @@ msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
|
||||
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
@@ -149,9 +151,8 @@ 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"
|
||||
msgstr "Selecionar vários"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
@@ -481,9 +482,13 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
|
||||
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
|
||||
"caracteres cirílicos:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
msgstr "Não lembrar novamente"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
@@ -964,19 +969,19 @@ msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
msgstr "Verificado: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
msgstr "Verificando os atalhos existentes: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
msgstr "Adicionando: {name}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar a pasta: {error}"
|
||||
msgstr "Falha ao criar atalho: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
@@ -993,9 +998,8 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Capa Personalizada:"
|
||||
msgstr "Ícone personalizado:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
@@ -1009,29 +1013,28 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Excluir Pasta"
|
||||
msgstr "Selecionar pasta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Jogos Recentes"
|
||||
msgstr "Processando jogos…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Processados: {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
msgstr "Nenhum jogo encontrado na pasta selecionada."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Jogos Recentes"
|
||||
msgstr "Selecionar jogos"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
@@ -2401,7 +2404,7 @@ msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
msgstr "Desativar aviso de caminho em cirílico"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"PO-Revision-Date: 2026-10-26 12:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -996,9 +996,8 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательская обложка:"
|
||||
msgstr "Пользовательский значок:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Chinese (Simplified Han script) <https://"
|
||||
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid ""
|
||||
@@ -29,7 +29,7 @@ msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
msgstr "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
@@ -143,9 +143,8 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "全选"
|
||||
msgstr "多选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
@@ -156,9 +155,8 @@ msgstr "停止游戏"
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "已配对"
|
||||
msgstr "修复"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "从桌面移除"
|
||||
@@ -176,7 +174,7 @@ msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "导入"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
@@ -260,9 +258,9 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
msgstr "未找到可执行文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
@@ -456,14 +454,14 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
msgstr "另一个 Epic 操作正在运行中"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
@@ -475,9 +473,12 @@ msgid ""
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
|
||||
"PortProton 移动到不包含西里尔字母字符的路径:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
msgstr "不再提示"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
@@ -930,19 +931,19 @@ msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
msgstr "正在扫描:{folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
msgstr "正在检查现有快捷方式:{path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
msgstr "正在添加:{name}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建文件夹失败:{error}"
|
||||
msgstr "创建快捷方式失败:{name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
@@ -979,21 +980,22 @@ msgstr "图像无效"
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "最近游戏"
|
||||
msgstr "正在处理游戏…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"已处理:{completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
msgstr "在所选文件夹中未找到任何游戏。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
@@ -1013,10 +1015,10 @@ msgid "No cover selected"
|
||||
msgstr "未选择封面"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
msgstr "兼容性报告"
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
msgstr "程序已停止。已自动收集兼容性详细信息。"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
@@ -1242,7 +1244,7 @@ msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "编辑 user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
@@ -1268,9 +1270,8 @@ msgstr "编辑 PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载主题失败"
|
||||
msgstr "加载全局游戏设置失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
@@ -1281,9 +1282,8 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
msgstr "打开文件失败:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
@@ -1303,9 +1303,8 @@ msgstr "默认"
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
msgstr "下载 Wine/Proton…"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
@@ -2104,13 +2103,11 @@ msgstr "全部清除"
|
||||
msgid "Now Downloading"
|
||||
msgstr "正在下载"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "警告"
|
||||
msgstr "请稍候…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "链接:"
|
||||
msgstr "磁盘: "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
@@ -2124,10 +2121,10 @@ msgid "Store"
|
||||
msgstr "存储"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
msgstr "将链接复制到剪贴板"
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
msgstr "退出 GOG 账号失败"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
@@ -2149,27 +2146,27 @@ msgid "Log out"
|
||||
msgstr "注销"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
msgstr "打开登录页面"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
msgstr "未连接 GOG 账号"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
msgstr "退出 Epic 账号失败"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
msgstr "将授权码复制到剪贴板"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
msgstr "Epic 授权中…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
@@ -2183,33 +2180,33 @@ msgstr "已连接"
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
msgstr "选择安装文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
msgstr "另一个 GOG 操作正在运行中"
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
msgstr "未找到 GOG 安装"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
msgstr "所选文件夹不是此 GOG 游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
@@ -2221,11 +2218,11 @@ msgstr "正在下载… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
msgstr "磁盘:{0} MiB/s"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ",预计剩余时间:{}秒"
|
||||
msgstr "ETA: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
@@ -2279,7 +2276,7 @@ msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "网格"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
@@ -2289,21 +2286,17 @@ msgstr "浅色"
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平块"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平卡片"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平无限块"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平无限卡片"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Layout"
|
||||
@@ -2331,7 +2324,7 @@ msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
msgstr "账号"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
@@ -2359,7 +2352,7 @@ msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "连接手柄时自动全屏"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
msgstr "禁用开机动画"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "关闭时最小化到系统托盘"
|
||||
@@ -2374,22 +2367,22 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
msgstr "隐藏操作提示"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
msgstr "强制使用英语"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
msgstr "游戏崩溃后显示兼容性报告"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
msgstr "禁用西里尔字母路径警告"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "禁用 ALT Linux i586 依赖项检查"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
@@ -2398,7 +2391,7 @@ msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
msgstr "禁用 Steam Proton 扫描"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
@@ -2487,7 +2480,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
msgstr "AppImage 已集成于:{path}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
@@ -2635,13 +2628,12 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "选择主题:"
|
||||
msgstr "删除主题“{0}”?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "加载主题失败"
|
||||
msgstr "删除主题失败。"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
@@ -1869,11 +1869,11 @@ def build_command(args: argparse.Namespace) -> list[str]:
|
||||
"""Build the command list to execute in the PTY."""
|
||||
if args.e:
|
||||
if len(args.e) == 1:
|
||||
return [
|
||||
default_shell(),
|
||||
"-lc",
|
||||
_with_portproton_functions(args.e[0]),
|
||||
]
|
||||
command = args.e[0]
|
||||
# functions_helper is bash-only, user shell may be zsh or fish
|
||||
if _needs_portproton_functions(command):
|
||||
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
|
||||
return [default_shell(), "-lc", command]
|
||||
return args.e
|
||||
return [default_shell()]
|
||||
|
||||
|
||||
@@ -177,6 +177,9 @@ def test_app_restarts_installed_appimage_after_integration(
|
||||
lambda: SimpleNamespace(
|
||||
debug_level="NOTSET",
|
||||
reinstall_steam_compat_tool=True,
|
||||
list_games=False,
|
||||
game_info=None,
|
||||
edit_game=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -408,3 +408,428 @@ class TestParseResolution:
|
||||
|
||||
def test_empty_string(self):
|
||||
assert parse_resolution("") is None
|
||||
|
||||
|
||||
def test_list_games_json_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
shortcut = tmp_path / "game.desktop"
|
||||
shortcut.write_text(
|
||||
'[Desktop Entry]\nName=Test Game\n'
|
||||
f'Exec=portprotonqt --silent "{executable}"\nIcon=test-icon\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(portproton, "get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {
|
||||
"name": "Test Game", "description": "Description", "cover": "cover.png",
|
||||
"appid": 123, "custom_icon": "",
|
||||
})
|
||||
games = _list_portproton_games(str(tmp_path), {}, {})
|
||||
assert len(games) == 1
|
||||
assert games[0]["description"] == "Description"
|
||||
assert games[0]["cover"] == "cover.png"
|
||||
assert games[0]["appid"] == 123
|
||||
assert not {"metadata", "desktop_entry", "custom_icon"} & games[0].keys()
|
||||
assert {key: games[0][key] for key in (
|
||||
"id", "name", "desktop_file", "exec", "executable", "directory", "icon", "ppdb"
|
||||
)} == {
|
||||
"id": "portproton:game.desktop", "name": "Test Game",
|
||||
"desktop_file": str(shortcut),
|
||||
"exec": f'portprotonqt --silent "{executable}"',
|
||||
"executable": str(executable), "directory": str(tmp_path),
|
||||
"icon": "test-icon", "ppdb": f"{executable}.ppdb",
|
||||
}
|
||||
|
||||
|
||||
def test_list_games_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_portproton_games
|
||||
from portprotonqt.config import portproton
|
||||
|
||||
monkeypatch.setattr(portproton, "get_portproton_location", lambda: None)
|
||||
assert _list_portproton_games(None, {}, {}) == []
|
||||
|
||||
|
||||
def test_parse_list_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games"])
|
||||
assert parse_args().list_games
|
||||
|
||||
|
||||
def test_export_custom_artwork(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _get_game_metadata
|
||||
from portprotonqt.config.portproton import resolve_custom_data_dir
|
||||
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_steam_game_info", lambda *args: {
|
||||
"name": "Cached", "description": "Cached description", "appid": "123",
|
||||
})
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_playtime_for_exe", lambda *args: 42)
|
||||
monkeypatch.setattr("portprotonqt.time_utils.get_last_launch_timestamp", lambda *args: 100)
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
folder = Path(resolve_custom_data_dir(str(tmp_path / "PortProtonQt/custom_data"), executable))
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "metadata.txt").write_text("name_en=Custom\ndescription_en=Description\n")
|
||||
(folder / "cover.png").touch()
|
||||
(folder / "icon.png").touch()
|
||||
metadata = _get_game_metadata("Original", "launch", executable)
|
||||
assert metadata["name"] == "Custom"
|
||||
assert metadata["description"] == "Description"
|
||||
assert metadata["cover"] == str(folder / "cover.png")
|
||||
assert metadata["custom_icon"] == str(folder / "icon.png")
|
||||
assert metadata["appid"] == "123"
|
||||
assert metadata["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_export_runtime(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
executable = str(tmp_path / "Game.exe")
|
||||
Path(f"{executable}.ppdb").write_text(
|
||||
'export PW_WINE_USE="PROTON_LG"\nexport PW_PREFIX_NAME="GAME"\n'
|
||||
)
|
||||
runner = tmp_path / "data/dist/PROTON_TEST"
|
||||
runner.mkdir(parents=True)
|
||||
runtime = _get_game_runtime(tmp_path, executable, {"PW_PROTON_LG_VER": "PROTON_TEST"}, {})
|
||||
assert runtime["runner"] == "PROTON_TEST"
|
||||
assert "runner_version" not in runtime
|
||||
assert runtime["runner_path"] == str(runner)
|
||||
assert runtime["runner_installed"]
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/GAME")
|
||||
|
||||
|
||||
def test_settings_do_not_execute_shell(tmp_path: Path) -> None:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
marker = tmp_path / "should-not-exist"
|
||||
settings = tmp_path / "settings"
|
||||
settings.write_text(
|
||||
f'export PW_WINE_USE="$(touch {marker})"\n'
|
||||
'export PW_PREFIX_NAME="Game prefix"\n'
|
||||
'check_variables PW_VULKAN_USE "1"\n'
|
||||
)
|
||||
assert _read_game_settings(settings) == {"PW_PREFIX_NAME": "Game prefix", "PW_VULKAN_USE": "1"}
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_list_games_exits_before_gui(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(list_games=True, game_info=None, edit_game=None, pretty=True, fields=None, all_games=True))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{"name": "Игра"}])
|
||||
gui = MagicMock(side_effect=AssertionError("GUI must not start"))
|
||||
monkeypatch.setattr(app, "QApplication", gui)
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
assert orjson.loads(output) == [{"name": "Игра"}]
|
||||
assert output == '[\n {\n "name": "Игра"\n }\n]\n'
|
||||
gui.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("external", [False, True])
|
||||
def test_runtime_wine_layout(tmp_path: Path, external: bool) -> None:
|
||||
from portprotonqt.cli import _get_game_runtime
|
||||
|
||||
runner = tmp_path / "External Wine" if external else tmp_path / "data/dist/PROTON_TEST"
|
||||
(runner / "files/bin-wow64").mkdir(parents=True)
|
||||
selected = str(runner) if external else "PROTON_TEST"
|
||||
runtime = _get_game_runtime(tmp_path, None, {}, {
|
||||
"PW_WINE_USE": selected, "PW_USE_WOW64": "1", "PW_PREFIX_NAME": "CUSTOM",
|
||||
})
|
||||
assert runtime["wine_path"] == str(runner / "files/bin-wow64/wine")
|
||||
assert runtime["wineserver_path"] == str(runner / "files/bin-wow64/wineserver")
|
||||
assert runtime["prefix_path"] == str(tmp_path / "data/prefixes/CUSTOM")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,backend", [("gog", "gog_api.GOGAPI"), ("egs", "egs_api.EGSAPI")])
|
||||
def test_list_store_games(source: str, backend: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_store_games
|
||||
|
||||
api = MagicMock()
|
||||
api.load_library.return_value = [
|
||||
{"app_id": "123", "title": "Installed", "description": "Details", "cover": "store.jpg"},
|
||||
{"app_id": "456", "title": "Owned"},
|
||||
]
|
||||
api.load_installed.return_value = {"123": {}}
|
||||
api.is_game_installed.side_effect = lambda app_id, installed: app_id in installed
|
||||
api.get_launch_target.return_value = "/games/game.exe"
|
||||
monkeypatch.setattr(f"portprotonqt.{backend}", lambda: api)
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda source, app_id, name: (name, "custom.jpg"))
|
||||
monkeypatch.setattr("portprotonqt.cli._get_game_metadata", lambda *args: {"custom_icon": "icon.png"})
|
||||
games = _list_store_games(source, None, {}, {})
|
||||
assert [game["id"] for game in games] == [f"{source}:123", f"{source}:456"]
|
||||
assert all(game["source"] == source for game in games)
|
||||
assert games[0]["description"] == "Details"
|
||||
assert games[0]["cover"] == "custom.jpg"
|
||||
assert games[0]["icon"] == "icon.png"
|
||||
assert games[0]["exec"] == f"{source}://launch/123"
|
||||
assert games[1]["exec"] == f"{source}://install/456"
|
||||
assert games[1]["executable"] is None
|
||||
api.get_launch_target.assert_called_once_with("123")
|
||||
|
||||
|
||||
def test_list_steam_games(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import _list_steam_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.steam_api.get_steam_installed_games", lambda: [("Game", 123, 100, 42)])
|
||||
monkeypatch.setattr("portprotonqt.steam_api.api.get_cached_full_steam_game_info",
|
||||
lambda *args: {"description": "Details", "cover": "cached.jpg"})
|
||||
monkeypatch.setattr("portprotonqt.main_window.MainWindow._get_custom_store_data",
|
||||
lambda *args: ("Custom", "custom.jpg"))
|
||||
game = _list_steam_games()[0]
|
||||
assert game["id"] == "steam:123"
|
||||
assert game["source"] == "steam"
|
||||
assert game["name"] == "Custom"
|
||||
assert game["cover"] == "custom.jpg"
|
||||
assert game["exec"] == "steam://rungameid/123"
|
||||
assert game["playtime_seconds"] == 42
|
||||
|
||||
|
||||
def test_list_all_sources_without_portproton(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import list_games
|
||||
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_scripts_path", lambda: None)
|
||||
monkeypatch.setattr("portprotonqt.cli._list_steam_games", lambda: [{"source": "steam"}])
|
||||
monkeypatch.setattr("portprotonqt.cli._list_store_games", lambda source, *args: [{"source": source}])
|
||||
assert [game["source"] for game in list_games()] == ["steam", "gog", "egs"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_flag,pretty", [("--pretty", True), ("--raw", False)])
|
||||
def test_json_format_args(format_flag: str, pretty: bool, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--game-info", "steam:123", format_flag])
|
||||
args = parse_args()
|
||||
assert args.game_info == "steam:123"
|
||||
assert args.pretty is pretty
|
||||
|
||||
|
||||
@pytest.mark.parametrize("game_id", ["steam:123", "gog:123", "missing"])
|
||||
def test_game_info_output(game_id: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(app, "parse_args", lambda: SimpleNamespace(
|
||||
list_games=False, game_info=game_id, edit_game=None, pretty=False, fields=None,
|
||||
))
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "steam:123", "source": "steam"}, {"id": "gog:123", "source": "gog"},
|
||||
])
|
||||
code = app.main()
|
||||
output = capsys.readouterr().out
|
||||
if game_id == "missing":
|
||||
assert code == 1
|
||||
assert output == ""
|
||||
else:
|
||||
assert code == 0
|
||||
assert orjson.loads(output)["id"] == game_id
|
||||
assert len(output.splitlines()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--pretty", "--raw"], ["--list-games", "--game-info", "steam:123"], ["--game-info"],
|
||||
])
|
||||
def test_library_conflicting_args(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editable_game(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict:
|
||||
from portprotonqt.cli import _read_game_settings
|
||||
|
||||
executable = tmp_path / "Game with spaces.exe"
|
||||
executable.touch()
|
||||
ppdb = Path(f"{executable}.ppdb")
|
||||
ppdb.write_text('# Keep this comment\nexport PW_MANGOHUD="0"\nexport PW_WINDOWS_VER="10"\n')
|
||||
game = {"id": "portproton:game.desktop", "source": "portproton", "executable": str(executable)}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{**game, "runtime": {"settings": _read_game_settings(ppdb)}},
|
||||
])
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
functions = "\n".join(
|
||||
name + helper.split(name, 1)[1].split("\n}\n", 1)[0] + "\n}"
|
||||
for name in ("set_several_variables () {", "edit_db_from_gui () {")
|
||||
)
|
||||
script = tmp_path / "start.sh"
|
||||
script.write_text(
|
||||
'#!/usr/bin/env bash\nset -e\n' + functions + '\n'
|
||||
'print_info () { printf "%s\\n" "$*"; }\n'
|
||||
'print_error () { printf "%s\\n" "$*" >&2; }\n'
|
||||
'PORTWINE_DB_FILE="${3}.ppdb"\n'
|
||||
'set_several_variables "${@:4}"\nedit_db_from_gui $keys_all\n'
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command",
|
||||
lambda: ["bash", str(script)])
|
||||
return {**game, "ppdb": ppdb, "script": script}
|
||||
|
||||
|
||||
def test_edit_game_roundtrip(editable_game: dict) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
result = edit_game(editable_game["id"], [
|
||||
"PW_MANGOHUD=1", 'LAUNCH_PARAMETERS=--option=value "quoted"',
|
||||
"PW_PREFIX_NAME=GAME", "WINEDLLOVERRIDES=d3d11=n,b;dxgi=n",
|
||||
])
|
||||
settings = result["runtime"]["settings"]
|
||||
assert settings["PW_MANGOHUD"] == "1"
|
||||
assert settings["LAUNCH_PARAMETERS"] == '--option=value "quoted"'
|
||||
assert settings["WINEDLLOVERRIDES"] == "d3d11=n,b;dxgi=n"
|
||||
assert settings["PW_PREFIX_NAME"] == "GAME"
|
||||
assert settings["PW_WINDOWS_VER"] == "10"
|
||||
assert editable_game["ppdb"].read_text().startswith("# Keep this comment\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [
|
||||
"PW_MANGOHUD=2", "NOT_A_SETTING=1", "PW_MANGOHUD", "PW_PREFIX_NAME=../other",
|
||||
"PW_PREFIX_NAME=", "PW_MANGOHUD=$(touch /tmp/forbidden)", "PW_MANGOHUD=1\ntrue",
|
||||
"LAUNCH_PARAMETERS=a|b", "LAUNCH_PARAMETERS=`id`",
|
||||
])
|
||||
def test_edit_game_rejects_invalid_values(editable_game: dict, change: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
before = editable_game["ppdb"].read_bytes()
|
||||
with pytest.raises(ValueError):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1", change])
|
||||
run.assert_not_called()
|
||||
assert editable_game["ppdb"].read_bytes() == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["portproton", "gog", "egs"])
|
||||
def test_edit_game_sources(editable_game: dict, source: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [{**editable_game, "source": source}])
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
assert 'export PW_MANGOHUD="1"' in editable_game["ppdb"].read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["missing", "steam", "uninstalled", "no-script", "failed", "not-saved"])
|
||||
def test_edit_game_errors(editable_game: dict, mode: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.cli import edit_game
|
||||
|
||||
if mode == "missing":
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
elif mode in {"steam", "uninstalled"}:
|
||||
game = {**editable_game, "source": "steam"} if mode == "steam" else {**editable_game, "executable": None}
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [game])
|
||||
elif mode == "no-script":
|
||||
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_start_command", lambda: None)
|
||||
else:
|
||||
editable_game["script"].write_text("exit 4\n" if mode == "failed" else "exit 0\n")
|
||||
expected = RuntimeError if mode in {"failed", "not-saved"} else ValueError
|
||||
with pytest.raises(expected):
|
||||
edit_game(editable_game["id"], ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--set", "PW_MANGOHUD=1"], ["--edit-game", "gog:123"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--list-games"],
|
||||
])
|
||||
def test_edit_game_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [False, True])
|
||||
def test_edit_game_cli_output(failure: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", "--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--raw",
|
||||
])
|
||||
edit = MagicMock(return_value={"id": "gog:123"})
|
||||
if failure:
|
||||
edit.side_effect = RuntimeError("write failed")
|
||||
monkeypatch.setattr("portprotonqt.cli.edit_game", edit)
|
||||
assert app.main() == (1 if failure else 0)
|
||||
output = capsys.readouterr().out
|
||||
assert output == "" if failure else orjson.loads(output) == {"id": "gog:123"}
|
||||
edit.assert_called_once_with("gog:123", ["PW_MANGOHUD=1"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("single", [False, True])
|
||||
@pytest.mark.parametrize("pretty", [False, True])
|
||||
def test_library_selected_fields(single: bool, pretty: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
command = ["--game-info", "gog:123"] if single else ["--list-games"]
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"portprotonqt", *command, "--fields", "executable, icon,name,source",
|
||||
"--pretty" if pretty else "--raw",
|
||||
])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [
|
||||
{"id": "gog:123", "name": "Game", "icon": "icon.png", "source": "gog",
|
||||
"executable": "/games/game.exe", "description": "Not selected", "installed": True},
|
||||
{"id": "steam:456", "name": "Steam Game", "source": "steam", "installed": True},
|
||||
])
|
||||
assert app.main() == 0
|
||||
output = capsys.readouterr().out
|
||||
expected = {"executable": "/games/game.exe", "icon": "icon.png", "name": "Game", "source": "gog"}
|
||||
assert orjson.loads(output) == (expected if single else [
|
||||
expected, {"executable": None, "icon": None, "name": "Steam Game", "source": "steam"},
|
||||
])
|
||||
assert (len(output.splitlines()) > 1) is pretty
|
||||
|
||||
|
||||
def test_library_selected_fields_empty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
from portprotonqt import app
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--list-games", "--fields", "name"])
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: [])
|
||||
assert app.main() == 0
|
||||
assert capsys.readouterr().out == "[]\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [
|
||||
["--fields", "name"], ["--list-games", "--fields", ""],
|
||||
["--list-games", "--fields", "name,,icon"], ["--list-games", "--fields", "name,"],
|
||||
["--list-games", "--fields", "name;icon"],
|
||||
["--edit-game", "gog:123", "--set", "PW_MANGOHUD=1", "--fields", "name"],
|
||||
])
|
||||
def test_library_selected_fields_argument_errors(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("include_all", [False, True])
|
||||
def test_list_games_installed_filter(include_all: bool, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
||||
import orjson
|
||||
from portprotonqt import app
|
||||
|
||||
arguments = ["portprotonqt", "--list-games", "--fields", "id", "--raw"]
|
||||
if include_all:
|
||||
arguments.append("--all")
|
||||
monkeypatch.setattr(sys, "argv", arguments)
|
||||
games = [
|
||||
{"id": "portproton:missing.desktop", "installed": False},
|
||||
{"id": "steam:123", "installed": True},
|
||||
{"id": "gog:456", "installed": False},
|
||||
{"id": "egs:789", "installed": True},
|
||||
]
|
||||
monkeypatch.setattr("portprotonqt.cli.list_games", lambda: games)
|
||||
assert app.main() == 0
|
||||
expected = [game for game in games if include_all or game["installed"]]
|
||||
assert orjson.loads(capsys.readouterr().out) == [{"id": game["id"]} for game in expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags", [["--all"], ["--game-info", "gog:123", "--all"]])
|
||||
def test_all_requires_list_games(flags: list[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags])
|
||||
with pytest.raises(SystemExit) as error:
|
||||
parse_args()
|
||||
assert error.value.code == 2
|
||||
+46
-1
@@ -1,3 +1,4 @@
|
||||
import argparse
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -6,9 +7,53 @@ from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.scripts_utils.easyterm import TerminalWidget
|
||||
from portprotonqt.scripts_utils.easyterm import (
|
||||
CONF_FALLBACK_SHELL,
|
||||
TerminalWidget,
|
||||
build_command,
|
||||
)
|
||||
|
||||
PROCESS_TIMEOUT_SECONDS = 30
|
||||
HELPER_PATH = "/usr/share/portproton/scripts/functions_helper"
|
||||
USER_SHELL = "/usr/bin/zsh"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_shell(monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm.default_shell", lambda: USER_SHELL
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.scripts_utils.easyterm._portproton_functions_helper_path",
|
||||
lambda: HELPER_PATH,
|
||||
)
|
||||
return USER_SHELL
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_portproton_functions(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["pw_run Game"]))
|
||||
|
||||
assert command == [
|
||||
CONF_FALLBACK_SHELL,
|
||||
"-lc",
|
||||
f"source {HELPER_PATH}; pw_run Game",
|
||||
]
|
||||
|
||||
|
||||
def test_build_command_uses_bash_for_exported_functions(
|
||||
user_shell: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("BASH_FUNC_launch_game%%", "() { :; }")
|
||||
|
||||
command = build_command(argparse.Namespace(e=["launch_game"]))
|
||||
|
||||
assert command[0] == CONF_FALLBACK_SHELL
|
||||
|
||||
|
||||
def test_build_command_keeps_user_shell_for_plain_commands(user_shell: str) -> None:
|
||||
command = build_command(argparse.Namespace(e=["ls -l"]))
|
||||
|
||||
assert command == [user_shell, "-lc", "ls -l"]
|
||||
|
||||
|
||||
def test_terminal_keeps_failed_command_output(
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from pytest import MonkeyPatch, raises
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
|
||||
import portprotonqt.game_card as game_card_module
|
||||
import portprotonqt.image_utils as image_utils
|
||||
@@ -608,9 +608,16 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
|
||||
]
|
||||
|
||||
|
||||
@mark.parametrize("url, filename", [
|
||||
("https://example.org/game.jpg", "game.jpg"),
|
||||
("https://cdn2.steamgriddb.com/grid/unique-cover.png", "unique-cover.png"),
|
||||
("https://deckyloader.ru/sgdb-cdn/grid/unique-cover.png", "unique-cover.png"),
|
||||
])
|
||||
def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
url: str,
|
||||
filename: str,
|
||||
) -> None:
|
||||
exe_path = tmp_path / "game.exe"
|
||||
exe_path.write_bytes(b"MZ")
|
||||
@@ -626,6 +633,8 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
timeout: int,
|
||||
callback: Callable[[str | None], None],
|
||||
) -> None:
|
||||
assert _url == url
|
||||
assert Path(_path).name == filename
|
||||
callbacks.append(callback)
|
||||
|
||||
monkeypatch.setattr("portprotonqt.icon_extractor.CACHE_DIR", tmp_path / "cache" / "PortProtonQt")
|
||||
@@ -642,13 +651,13 @@ def test_remote_cover_replaces_immediate_exe_fallback_only_on_success(
|
||||
)
|
||||
|
||||
image_utils.load_pixmap_async(
|
||||
"https://example.org/game.jpg", 100, 150,
|
||||
url, 100, 150,
|
||||
lambda pixmap: loaded_paths.append(cast(FakePixmap, pixmap).path),
|
||||
app_name="game", fallback_exe=str(exe_path),
|
||||
fallback_icon_path=str(fallback_path),
|
||||
)
|
||||
callbacks[0](None)
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / "game.jpg"
|
||||
downloaded_path = tmp_path / "cache" / "PortProtonQt" / "images" / filename
|
||||
downloaded_path.write_bytes(b"cover")
|
||||
callbacks[0](str(downloaded_path))
|
||||
|
||||
|
||||
@@ -1657,3 +1657,50 @@ def test_run_after_dpad_reaches_delete_from_entry(direction: int, column: int) -
|
||||
assert QApplication.focusWidget() == entry
|
||||
table.close()
|
||||
table.deleteLater()
|
||||
|
||||
|
||||
def test_mouse_device_follows_emulation_toggle(monkeypatch: MonkeyPatch) -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.emulation_triggered = False
|
||||
manager.mouse_emulation_thread = None
|
||||
manager.ui = None
|
||||
create = MagicMock()
|
||||
monkeypatch.setattr(manager, "_async_enable_mouse_emulation", create)
|
||||
|
||||
manager._update_emulation_flag()
|
||||
create.assert_not_called()
|
||||
manager.emulation_triggered = True
|
||||
manager._update_emulation_flag()
|
||||
create.assert_called_once_with()
|
||||
|
||||
device = MagicMock()
|
||||
manager.ui = device
|
||||
manager.emulation_triggered = False
|
||||
manager._update_emulation_flag()
|
||||
device.close.assert_called_once_with()
|
||||
assert manager.ui is None
|
||||
assert manager.mouse_emulation_enabled
|
||||
manager.emulation_triggered = True
|
||||
manager._update_emulation_flag()
|
||||
assert create.call_count == 2
|
||||
|
||||
|
||||
def test_mouse_shutdown_collects_pending_device() -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
device = MagicMock()
|
||||
thread = MagicMock()
|
||||
thread.get_device.return_value = device
|
||||
manager.mouse_emulation_thread = thread
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.ui = None
|
||||
|
||||
manager.disable_mouse_emulation()
|
||||
manager._on_mouse_emulation_finished(True)
|
||||
|
||||
thread.wait.assert_called_once_with()
|
||||
device.close.assert_called_once_with()
|
||||
assert manager.ui is None
|
||||
assert manager.mouse_emulation_thread is None
|
||||
@@ -42,6 +42,29 @@ gui_config () {
|
||||
assert ppdb.read_text() == expected
|
||||
|
||||
|
||||
@mark.parametrize("enabled", ["0", "1", ""])
|
||||
@mark.parametrize("inherited", [None, "0", "1"])
|
||||
def test_video_fix_controls_dxgi_device_manager(
|
||||
tmp_path: Path, enabled: str, inherited: str | None,
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
block = ' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]' + helper.split(
|
||||
' if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]', 1,
|
||||
)[1].split(" fi", 1)[0] + " fi\n"
|
||||
env = {"PATH": os.defpath, "PW_FIX_VIDEO_IN_GAME": enabled}
|
||||
if inherited is not None:
|
||||
env["WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER"] = inherited
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", block
|
||||
+ 'printf "%s" "${WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER-unset}"'],
|
||||
cwd=tmp_path, env=env, capture_output=True, text=True, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == ("1" if enabled == "1" else "unset")
|
||||
|
||||
|
||||
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
|
||||
@mark.parametrize("copies", [1, 3])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str, copies: int) -> None:
|
||||
|
||||
@@ -219,3 +219,74 @@ def test_disabled_steam_proton_scanning_keeps_local_versions(
|
||||
assert manager.get_installed_versions() == [("LocalWine", str(local_wine))]
|
||||
compat.assert_not_called()
|
||||
home.assert_not_called()
|
||||
|
||||
|
||||
def test_metadata_error_preserves_local_versions(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtWidgets import QLabel, QStackedWidget, QTabWidget
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
local_version = tmp_path / "GE-Proton"
|
||||
local_version.mkdir()
|
||||
manager = SimpleNamespace(
|
||||
content_stack=QStackedWidget(),
|
||||
tab_widget=QTabWidget(),
|
||||
theme=SimpleNamespace(GETWINE_WINDOW_STYLE=""),
|
||||
)
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.content_stack.addWidget(QWidget())
|
||||
manager.tab_widget.addTab(QWidget(), "Loading")
|
||||
|
||||
def create_installed_tab() -> None:
|
||||
assert local_version.is_dir()
|
||||
manager.tab_widget.addTab(QLabel(local_version.name), "Installed")
|
||||
|
||||
manager.create_installed_tab = create_installed_tab
|
||||
monkeypatch.setattr(module, "_", lambda text: text)
|
||||
module.ProtonManager.on_wine_data_load_error(cast(Any, manager), "ConnectionError: " + "x" * 2000)
|
||||
|
||||
assert manager.content_stack.currentIndex() == 1
|
||||
assert manager.tab_widget.count() == 1
|
||||
assert manager.tab_widget.currentIndex() == 0
|
||||
assert manager.tab_widget.widget(0).text() == local_version.name
|
||||
manager.tab_widget.deleteLater()
|
||||
manager.content_stack.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_installed_sizes_update_after_metadata_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
from PySide6.QtCore import QEventLoop, QTimer
|
||||
from PySide6.QtWidgets import QPushButton, QTableWidget
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
import portprotonqt.dialogs.proton_manager as module
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
version = tmp_path / "GE-Proton"
|
||||
version.mkdir()
|
||||
(version / "wine").write_bytes(b"x" * 2048)
|
||||
monkeypatch.setattr(module.ProtonManager, "start_loading_wine_data", lambda self: None)
|
||||
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **kwargs: QPushButton(text))
|
||||
monkeypatch.setattr(module.ProtonManager, "get_installed_versions", lambda self: [("GE-Proton", str(version))])
|
||||
manager = module.ProtonManager(portproton_location=str(tmp_path), theme=ThemeWrapper(load_theme("standart")))
|
||||
manager.on_wine_data_load_error("Offline")
|
||||
workers = list(manager._installed_size_tables)
|
||||
loop = QEventLoop()
|
||||
workers[0].finished.connect(loop.quit)
|
||||
QTimer.singleShot(3000, loop.quit)
|
||||
loop.exec()
|
||||
app.processEvents()
|
||||
try:
|
||||
installed_tab = manager.tab_widget.widget(0)
|
||||
assert installed_tab is not None
|
||||
table = installed_tab.findChild(QTableWidget)
|
||||
assert table is not None
|
||||
size_item = table.item(0, 2)
|
||||
assert size_item is not None
|
||||
assert size_item.text() == "2.0 KiB"
|
||||
finally:
|
||||
from shiboken6 import isValid
|
||||
for worker in workers:
|
||||
if isValid(worker):
|
||||
worker.wait()
|
||||
manager.close()
|
||||
app.processEvents()
|
||||
Reference in new issue
Block a user