Compare commits

..
1 Commits
Author SHA1 Message Date
Codeberg Translateandadem 56e103793e Translated using Weblate (Russian)
Currently translated at 100.0% (808 of 808 strings)

Co-authored-by: Andrei Stepanov <adem4ik@gmail.com>
Translate-URL: https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/
Translation: PortProtonQt/PortProtonQt
2026-10-30 19:43:10 +00:00
21 changed files with 131 additions and 1177 deletions

No files matched your search

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