Compare commits

..
6 Commits
Author SHA1 Message Date
Boria138 6e9f935222 feat: added Linux Native games support
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-13 17:41:53 +05:00
Boria138 4fb74c1d15 fix: create virtual mouse only while emulation is active
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-13 10:51:50 +05:00
Boria138 78dab249c1 fix: prevent SGDB mirror cover cache collisions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-12 18:35:11 +05:00
Boria138 9e91eeb0dc feat: add library CLI
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-12 15:10:05 +05:00
Boria138 cb8d149b2a fix: show installed Wine/Proton versions when catalog loading fails
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-09 00:14:55 +05:00
Boria138 98475c0e59 fix: use Bash for PortProton functions in EasyTerm
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-11-08 16:55:50 +05:00
39 changed files with 3641 additions and 155 deletions

No files matched your search

+4
View File
@@ -7,6 +7,7 @@
### Added
- Добавлен CLI для получения локальных данных библиотеки
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
@@ -38,6 +39,9 @@
### Fixed
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
- Исправлено зависание при смене сетки библиотеки
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
+23 -7
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 --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,11 +73,19 @@ _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]'
'--silent[Launch .exe file without GUI]'
'--log[Enable PortProton logging]'
'--silent[Launch Windows file or native game in tray]'
'--log[Enable game logging]'
'--restore-prefix[Restore prefix from .ppack backup]:PPACK file:_files -g "*.ppack"'
'--create-backup[Create prefix backup]:PREFIX: :BACKUP_DIR:_files -/'
'(-h --help)'{-h,--help}'[Show help message]'
@@ -94,18 +102,26 @@ 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"
complete -c portprotonqt -l silent -d "Launch .exe file without GUI"
complete -c portprotonqt -l log -d "Enable PortProton logging"
complete -c portprotonqt -l silent -d "Launch Windows file or native game in tray"
complete -c portprotonqt -l log -d "Enable game logging"
complete -c portprotonqt -l restore-prefix -d "Restore prefix from .ppack backup" -r -a "(__fish_complete_suffix .ppack)"
complete -c portprotonqt -l create-backup -d "Create prefix backup" -r
complete -c portprotonqt -s h -l help -d "Show help message"
+66 -8
View File
@@ -25,6 +25,7 @@ from PySide6.QtGui import QIcon
from portprotonqt.config import (
consume_download_counter_skip,
display_config,
find_game_by_exe,
get_portproton_start_command,
get_portproton_location,
save_portdata_path_to_config,
@@ -33,6 +34,7 @@ from portprotonqt.config import (
window_config,
)
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.native_games import NATIVE_URI_PREFIX, create_native_desktop_file
from portprotonqt.cli import (
parse_args,
is_portproton_url,
@@ -140,6 +142,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":
@@ -239,6 +273,17 @@ def main():
app.setApplicationVersion(__app_version__)
args = parsed_args
native_launch_uri = None
if args.file_or_url and args.file_or_url.startswith(NATIVE_URI_PREFIX):
native_launch_uri = args.file_or_url
elif args.file_or_url and not is_launch_file(args.file_or_url):
target = normalize_launch_path(args.file_or_url)
if os.path.isfile(target) and os.access(target, os.X_OK) and not is_autoinstall_file(target) and not is_prefix_backup_file(target):
entry = find_game_by_exe(target)
name = entry.get("Name", os.path.basename(target)) if entry is not None else os.path.basename(target)
result = create_native_desktop_file(target, name)
if result and os.path.isfile(result[1]):
native_launch_uri = NATIVE_URI_PREFIX + result[1]
setup_logger(args.debug_level)
logger = get_logger(__name__)
if consume_download_counter_skip():
@@ -270,6 +315,9 @@ def main():
elif is_restore_prefix_request(args):
restore_prefix_path = normalize_launch_path(args.file_or_url)
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
elif native_launch_uri:
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{quote(native_launch_uri, safe='')}"
elif args.file_or_url and is_launch_file(args.file_or_url):
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
@@ -367,7 +415,7 @@ def main():
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
# Check if we have a portproton:// URL or launch file to handle
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri and not native_launch_uri:
if is_portproton_url(args.file_or_url):
# Parse the portproton:// URL to get the full download URL
download_url = parse_portproton_url(args.file_or_url)
@@ -452,7 +500,7 @@ def main():
launch_path = exe_path or autoinstall_path
silent_game_request = is_silent_launch and not args.log and bool(
exe_path or gog_launch_uri or egs_launch_uri
exe_path or gog_launch_uri or egs_launch_uri or native_launch_uri
)
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
window.silent_launch_mode = silent_game_request
@@ -462,9 +510,9 @@ def main():
window.tray_manager.set_minimal_mode()
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
exec_line = f"{source}://launch/{app_id}"
exec_line = NATIVE_URI_PREFIX + app_id if source == "native" else f"{source}://launch/{app_id}"
if log_mode:
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
window._pending_log_exe = exec_line if source == "native" else getattr(window, f"{source}_api").get_launch_target(app_id)
def open_card(games: list[tuple]) -> None:
game = next((item for item in games if item[5] == exec_line), None)
@@ -478,7 +526,7 @@ def main():
"anticheat_status": game[9], "playtime_seconds": game[11],
"game_source": game[12], "anticheat_slug": game[13],
"ppdb_id": game[14], "ppdb_rating": game[15],
"protondb_appid": game[16],
"protondb_appid": game[16] if len(game) > 16 else "",
})
loader = getattr(window, f"_load_{source}_games_async")
@@ -506,6 +554,12 @@ def main():
restart_target = target
app.quit()
return
if target.startswith(NATIVE_URI_PREFIX):
if silent and not log_mode:
window.toggleGame(target)
else:
open_store_game_card("native", target.removeprefix(NATIVE_URI_PREFIX), log_mode)
return
if is_launch_file(target):
path = normalize_launch_path(target)
if silent:
@@ -520,7 +574,11 @@ def main():
open_store_game_card(source, app_id, log_mode)
# Handle launch file if provided
if exe_path:
if native_launch_uri:
def handle_native_launch() -> None:
handle_game_request(native_launch_uri, is_silent_launch and not args.log, args.log)
QTimer.singleShot(0, handle_native_launch)
elif exe_path:
# Defer the call until after the window is shown
def handle_launch_exe() -> None:
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
@@ -601,8 +659,8 @@ def main():
if launch_path and is_launch_file(launch_path):
logger.info("Opening launch file via IPC: %s", launch_path)
handle_game_request(launch_path, False, log_mode)
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
handle_game_request(unquote(launch_path), False, True)
elif launch_path.startswith(NATIVE_URI_PREFIX) or (log_mode and launch_path.startswith(("gog://launch/", "egs://launch/"))):
handle_game_request(unquote(launch_path), False, log_mode)
else:
logger.warning("Invalid launch file via IPC: %s", launch_path)
elif msg.startswith("restore:"):
+291 -3
View File
@@ -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",
@@ -67,12 +89,12 @@ def parse_args():
parser.add_argument(
"--silent",
action="store_true",
help="Launch supported Windows file in tray without showing the main window"
help="Launch supported Windows file or native game in tray without showing the main window"
)
parser.add_argument(
"--log",
action="store_true",
help="Enable PortProton logging for the launched Windows file"
help="Enable logging for the launched Windows file or native game"
)
parser.add_argument(
"--restore-prefix",
@@ -89,7 +111,7 @@ def parse_args():
parser.add_argument(
'file_or_url',
nargs='?',
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg) or portproton:// URL"
help="Launch file path (.exe/.bat/.cmd/.msi/.reg/.iso/.mdf/.nrg), native executable, or portproton:// URL"
)
if os.environ.get("STEAM_COMPAT") == "1" and "--" in sys.argv[1:]:
separator_index = sys.argv.index("--")
@@ -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.
+125 -5
View File
@@ -2,6 +2,8 @@ import os
import glob
import shutil
import tempfile
import configparser
from pathlib import Path
from typing import TYPE_CHECKING
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
@@ -9,6 +11,7 @@ from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
from portprotonqt.localization import _
from portprotonqt.config import (
create_desktop_file,
get_portproton_location,
extract_exec_target_path,
favorites_config,
favorites_folders_config,
@@ -29,6 +32,7 @@ from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.dialogs.base import DraggableDialog
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.native_games import NATIVE_URI_PREFIX, build_native_shortcut_command, quote_native_arguments
from portprotonqt.logger import get_logger
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
@@ -127,6 +131,8 @@ class ContextMenuManager:
Returns:
bool: True if the game is running, False otherwise.
"""
if game_card.game_source == "native":
return getattr(self.parent, "target_exe", None) == os.path.basename(game_card.exec_line.removeprefix("native://"))
if game_card.game_source == "steam":
return False
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
@@ -731,6 +737,9 @@ class ContextMenuManager:
)
)
if game_card.game_source == "native":
self._add_native_actions(menu, game_card)
if game_card.game_source == "steam":
desktop_dir = QStandardPaths.writableLocation(
QStandardPaths.StandardLocation.DesktopLocation
@@ -829,6 +838,96 @@ class ContextMenuManager:
menu.popup(game_card.mapToGlobal(pos))
def _add_native_actions(self, menu: QMenu, card: "GameCard") -> None:
source = Path(card.exec_line.removeprefix(NATIVE_URI_PREFIX))
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
managed = str(source.parent) == get_portproton_location() or source.parent == data / "PortProtonQt/native_games"
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
for directory, add_text, remove_text, icon in (
(desktop, _("Add to Desktop"), _("Remove from Desktop"), "desktop"),
(self._get_applications_dir() if managed else "", _("Add to Menu"), _("Remove from Menu"), "menu"),
):
if not directory:
continue
exists = (Path(directory) / source.name).is_file()
action = menu.addAction(self._get_safe_icon("delete" if exists else icon), remove_text if exists else add_text)
action.triggered.connect(lambda _checked=False, target=directory: self._toggle_native_shortcut(card.exec_line, target))
if get_steam_home() is not None:
is_in_steam = is_game_in_steam(card.name)
action = menu.addAction(self._get_safe_icon("delete" if is_in_steam else "menu_steam"),
_("Remove from Steam") if is_in_steam else _("Add to Steam"))
action.triggered.connect(lambda: self.remove_from_steam(card.name, card.exec_line, "native")
if is_in_steam else self.add_to_steam(card.name, card.exec_line, card.cover_path))
action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
action.triggered.connect(lambda: self._edit_native_shortcut(card))
if managed:
action = menu.addAction(self._get_safe_icon("delete"), _("Delete"))
action.triggered.connect(lambda: self.delete_game(card.name, card.exec_line))
action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
action.triggered.connect(lambda: self.parent.detail_page_manager._open_executable_folder(card.exec_line))
def _toggle_native_shortcut(self, exec_line: str, directory: str) -> None:
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
target = Path(directory) / source.name
try:
if target.is_file():
if target == source:
retained = Path(get_portproton_location() or source.parent) / source.name
retained.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, retained)
target.unlink()
else:
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = lambda optionstr: optionstr
parser.read_string(source.read_text(encoding="utf-8"))
command = quote_native_arguments([exec_line])
parser["Desktop Entry"]["Exec"] = build_native_shortcut_command(command)
for action, mode in (("RunSilent", "--silent"), ("RunLog", "--log")):
section = f"Desktop Action {action}"
if parser.has_section(section):
parser[section]["Exec"] = build_native_shortcut_command(command, mode)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8") as output:
parser.write(output, space_around_delimiters=False)
target.chmod(0o755)
if directory == self._get_applications_dir():
self._update_desktop_database()
self.parent.refreshGames()
except Exception as error:
logger.warning("Cannot update native shortcut: %s", error)
self.signals.show_warning_dialog.emit(_("Error"), str(error))
def _edit_native_shortcut(self, card: "GameCard") -> None:
source = card.exec_line.removeprefix(NATIVE_URI_PREFIX)
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
game_name=card.name, exe_path=source, cover_path=card.cover_path)
dialog.add_to_steam_checkbox.setChecked(is_game_in_steam(card.name))
if dialog.exec() != QDialog.DialogCode.Accepted:
return
name = dialog.nameEdit.text().strip()
if not name:
return
cover = dialog.coverEdit.text().strip()
if not os.path.isfile(cover) and dialog.last_cover_path:
cover = dialog.last_cover_path
try:
self._save_local_shortcut_name(source, name)
data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
directory = resolve_custom_data_dir(os.path.join(data, "PortProtonQt/custom_data"), source)
if os.path.isfile(cover) and Path(cover).suffix.lower() in COVER_IMAGE_EXTENSIONS:
target = Path(directory) / ("cover" + Path(cover).suffix.lower())
if os.path.abspath(cover) != str(target):
self._remove_old_cover_files(directory)
shutil.copyfile(cover, target)
if dialog.add_to_steam_checkbox.isChecked():
if not is_game_in_steam(card.name):
self.add_to_steam(name, card.exec_line, cover)
elif is_game_in_steam(card.name):
self.remove_from_steam(card.name, card.exec_line, "native")
self.parent.refreshGames()
except OSError as error:
logger.warning("Cannot edit native metadata: %s", error)
def _launch_game(self, game_card):
"""
Launch or stop a game based on its current state.
@@ -836,6 +935,9 @@ class ContextMenuManager:
Args:
game_card: The GameCard instance containing game data.
"""
if game_card.game_source == "native":
self.parent.toggleGame(game_card.exec_line, game_name=game_card.name)
return
if not self._check_portproton():
return
@@ -996,6 +1098,9 @@ class ContextMenuManager:
def _get_exec_line(self, game_name, exec_line):
"""Retrieve and validate exec_line from .desktop file if necessary."""
if exec_line and exec_line.startswith(NATIVE_URI_PREFIX):
entry = parse_desktop_entry(exec_line.removeprefix(NATIVE_URI_PREFIX))
return entry.get("Exec") if entry else None
if exec_line and exec_line.strip() != "full":
return exec_line
desktop_path = self._get_desktop_path(game_name)
@@ -1127,6 +1232,20 @@ class ContextMenuManager:
def _delete_game_without_confirm(self, game_name: str, exec_line: str) -> None:
card_exec_line = exec_line
if exec_line.startswith(NATIVE_URI_PREFIX):
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
data = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
desktop = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
paths = [str(source), str(data / "PortProtonQt/native_games" / source.name),
str(Path(self._get_applications_dir()) / source.name)]
if desktop:
paths.append(str(Path(desktop) / source.name))
self._remove_installed_shortcuts(game_name, paths)
self._remove_statistics_entry(str(source), game_name)
self.game_library_manager.remove_game_incremental(game_name, exec_line)
for key in ("all", "native", "favorites"):
getattr(self.parent, "_loaded_library_cache", {}).pop(key, None)
return
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
@@ -1754,10 +1873,11 @@ class ContextMenuManager:
"""
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
if not exec_line.startswith(("gog://launch/", "egs://launch/")):
if not exec_line.startswith((NATIVE_URI_PREFIX, "gog://launch/", "egs://launch/")):
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
@@ -1812,11 +1932,11 @@ class ContextMenuManager:
_(message).format(game_name=game_name)
)
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line or not exec_line.startswith(NATIVE_URI_PREFIX):
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = extract_exec_target_path(exec_line)
if not exe_path:
if not exec_line.startswith(NATIVE_URI_PREFIX) and not extract_exec_target_path(exec_line):
return
logger.debug("Removing game '%s' from Steam", game_name)
try:
+63 -8
View File
@@ -6,6 +6,9 @@ import signal
import subprocess
import threading
import time
import tempfile
import shutil
from typing import IO
from portprotonqt.logger import get_logger
from portprotonqt.config import get_portproton_location
@@ -18,7 +21,7 @@ logger = get_logger(__name__)
class DebugLogManager:
"""Manages debug log creation with game launch and Wine output capture."""
"""Manage debug logs for Wine and native game launches."""
def __init__(self):
self.process: subprocess.Popen | None = None
@@ -26,15 +29,19 @@ class DebugLogManager:
self.start_command: list[str] | None = None
self.wine_output: list[str] = []
self.runtime_env: dict[str, str] | None = None
self.native_output: IO[str] | None = None
self.native_directory: str | None = None
self.is_running = False
self.output_queue = queue.Queue()
self.output_thread = None
self._stop_event = threading.Event()
def start(self, exe_path: str, start_command: list[str]) -> bool:
"""Start game with PW_LOG=1 and capture output."""
"""Capture game output, enabling PW_LOG for PortProton launches."""
if self.is_running:
return False
if exe_path.startswith("native://"):
return self._start_native(exe_path)
self.exe_path = exe_path
self.start_command = start_command
@@ -86,6 +93,52 @@ class DebugLogManager:
logger.error("Failed to start debug session: %s", e)
return False
def _start_native(self, uri: str) -> bool:
from portprotonqt.native_games import get_native_launch
try:
command, directory = get_native_launch(uri.removeprefix("native://"))
self.native_output = tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace")
self.process = subprocess.Popen(
command, cwd=directory, stdout=self.native_output,
stderr=subprocess.STDOUT, start_new_session=True,
)
except Exception as error:
logger.error("Failed to start native debug session: %s", error)
if self.native_output is not None:
self.native_output.close()
self.native_output = None
return False
self.exe_path = uri.removeprefix("native://")
self.start_command = command
self.native_directory = directory
self.is_running = True
return True
def _save_native_log(self) -> str | None:
output = self.native_output
if output is None:
return None
from portprotonqt.native_games import load_native_settings, get_native_ppdb_path
try:
settings = load_native_settings(str(self.exe_path))
destination = get_native_ppdb_path(str(self.exe_path), settings).with_suffix(".log")
destination.parent.mkdir(parents=True, exist_ok=True)
output.seek(0)
with destination.open("w", encoding="utf-8") as log:
log.write(generate_system_info("native://" + str(self.exe_path), self.start_command))
log.write(f"\nCommand: {self.start_command!r}\nWorking directory: {self.native_directory}\n\n")
shutil.copyfileobj(output, log)
return str(destination)
except OSError as error:
logger.error("Failed to save native debug log: %s", error)
return None
finally:
output.close()
self.native_output = None
self.native_directory = None
def _read_output(self):
"""Read output from subprocess in a separate thread."""
if self.process and self.process.stdout:
@@ -98,19 +151,20 @@ class DebugLogManager:
logger.debug("Error reading output: %s", e)
def stop(self) -> str | None:
"""Stop game and save debug log with captured Wine output."""
self._capture_env_vars()
"""Stop the game and save its diagnostic log."""
if self.native_output is None:
self._capture_env_vars()
self._stop_event.set()
if self.is_running and self.process:
try:
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGTERM)
try:
time.sleep(0.1)
if self.process.poll() is None:
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
os.killpg(self.process.pid if self.native_output is not None else os.getpgid(self.process.pid), signal.SIGKILL)
except ProcessLookupError:
pass
except Exception as e:
@@ -123,7 +177,7 @@ class DebugLogManager:
except queue.Empty:
pass
log_file = self._save_log()
log_file = self._save_native_log() if self.native_output is not None else self._save_log()
if self.process and self.process.stdout:
try:
@@ -146,7 +200,8 @@ class DebugLogManager:
self.is_running = False
return False
self._capture_env_vars()
if self.native_output is None:
self._capture_env_vars()
return True
def _capture_env_vars(self) -> None:
+35 -22
View File
@@ -290,7 +290,8 @@ def get_filesystem_info(exe_path: str | None, portproton_path: str) -> str:
game_dir = os.path.dirname(exe_path)
lines.append(f"Filesystem {game_dir} - {get_fs_type(game_dir)}")
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
if portproton_path:
lines.append(f"Filesystem {portproton_path} - {get_fs_type(portproton_path)}")
tmp_dir = f"/tmp/PortProton_{os.environ.get('USER', 'user')}"
if os.path.exists(tmp_dir):
@@ -313,8 +314,17 @@ def generate_system_info(
get_d3d_extras_status,
)
portproton_path = get_portproton_location()
if not portproton_path:
from portprotonqt.native_games import load_native_settings, get_native_launch, get_native_ppdb_path
native = bool(exe_path and exe_path.startswith("native://"))
desktop_path = exe_path.removeprefix("native://") if native and exe_path else None
if desktop_path:
runtime_env = load_native_settings(desktop_path)
command, directory = get_native_launch(desktop_path)
exe_path = next((part for part in reversed(command)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), directory)
portproton_path = get_portproton_location() or ""
if not portproton_path and not native:
return "Error: PortProton location not found"
lines = []
@@ -344,16 +354,17 @@ def generate_system_info(
lines.append(get_libc_version())
lines.append("-" * 61)
lines.append(get_vulkan_use_info(portproton_path, exe_path))
lines.append("-" * 61)
if not native:
lines.append(get_vulkan_use_info(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Version WINE in use:")
lines.append(get_wine_version(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Version WINE in use:")
lines.append(get_wine_version(portproton_path, exe_path))
lines.append("-" * 61)
lines.append("Program bit depth:")
lines.append(get_program_bit_depth(exe_path))
lines.append("-" * 61)
lines.append("Program bit depth:")
lines.append(get_program_bit_depth(exe_path))
lines.append("-" * 61)
lines.append("Date and time of start debug for PortProton:")
lines.append(datetime.now().strftime("%c %z"))
@@ -419,26 +430,28 @@ def generate_system_info(
lines.append(locale_avail)
lines.append("-" * 61)
lines.append(get_d3d_extras_status(portproton_path, exe_path))
lines.append("-" * 61)
prefix_name = get_prefix_name(exe_path)
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
if winetricks_content:
lines.append("winetricks.log:")
lines.append(winetricks_content)
if not native:
lines.append(get_d3d_extras_status(portproton_path, exe_path))
lines.append("-" * 61)
prefix_name = get_prefix_name(exe_path)
winetricks_content = get_winetricks_log(portproton_path, prefix_name)
if winetricks_content:
lines.append("winetricks.log:")
lines.append(winetricks_content)
lines.append("-" * 61)
if exe_path:
ppdb_content = get_ppdb_content(exe_path, start_cmd)
ppdb_path = str(get_native_ppdb_path(desktop_path, runtime_env)).removesuffix(".ppdb") if desktop_path and runtime_env else exe_path
ppdb_content = get_file_content(ppdb_path + ".ppdb") if native else get_ppdb_content(exe_path, start_cmd)
if ppdb_content:
lines.append(f"Use {exe_path}.ppdb db file:")
lines.append(f"Use {ppdb_path}.ppdb db file:")
if ppdb_log_vars:
lines.append("\n".join(ppdb_log_vars))
lines.append(ppdb_content)
lines.append("-" * 61)
user_overrides = get_user_overrides(portproton_path)
user_overrides = "" if native else get_user_overrides(portproton_path)
if user_overrides:
lines.append(user_overrides)
lines.append("-" * 61)
+47 -8
View File
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Qt, QUrl, QTimer, QProcess, QProcessEnvironment
from PySide6.QtGui import QDesktopServices
from portprotonqt.native_games import get_native_launch, load_native_settings
from portprotonqt.detail_pages.widgets import (
create_scroll_area,
create_back_button,
@@ -655,7 +657,7 @@ class DetailPageManager:
)
buttons_layout.addWidget(open_folder_button)
elif (
str(game_source).lower() not in {"gog", "egs"}
str(game_source).lower() not in {"gog", "egs", "native"}
and not self._has_game_shortcut(game_name)
):
add_button = self._make_action_button(
@@ -670,7 +672,24 @@ class DetailPageManager:
buttons_layout.addWidget(add_button)
# Show settings button for PortProton games or Steam games using PortProtonQt
if str(game_source).lower() == "portproton":
if source == "native":
settings_button = self._make_action_button(
_("Settings"), self.main_window.theme_manager.get_icon("settings", as_path=True),
)
settings_button.clicked.connect(lambda: self.main_window.open_native_settings(exec_line))
buttons_layout.addWidget(settings_button)
log_button = self._make_action_button(_("Create Log"), self.main_window.theme_manager.get_icon("edit", as_path=True))
log_button.clicked.connect(lambda: self.toggleDebugLog(exec_line, log_button))
buttons_layout.addWidget(log_button)
self._debug_log_button = log_button
data_home = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
if os.path.dirname(exec_line.removeprefix("native://")) in (
getattr(self.main_window, "portproton_location", None), os.path.join(data_home, "PortProtonQt/native_games")
):
folder_button = self._make_action_button(_("Open Folder"), self.main_window.theme_manager.get_icon("search", as_path=True))
folder_button.clicked.connect(lambda: self._open_executable_folder(exec_line))
buttons_layout.addWidget(folder_button)
elif str(game_source).lower() == "portproton":
self._add_portproton_buttons(buttons_layout, exec_line)
elif (
source in {"gog", "egs"}
@@ -725,6 +744,8 @@ class DetailPageManager:
def _get_current_exe(self, exec_line: str) -> str | None:
"""Extract current executable from exec line."""
if exec_line.startswith("native://"):
return os.path.basename(exec_line.removeprefix("native://"))
file_to_check = extract_exec_target_path(exec_line)
return os.path.basename(file_to_check) if file_to_check else None
@@ -822,9 +843,26 @@ class DetailPageManager:
def _open_executable_folder(self, exe_path: str | None) -> None:
"""Open the folder containing an executable."""
if not exe_path or not os.path.exists(exe_path):
if not exe_path:
return
folder_path = os.path.dirname(os.path.abspath(exe_path))
if exe_path.startswith("native://"):
try:
desktop_path = exe_path.removeprefix("native://")
settings = load_native_settings(desktop_path)
command, directory = get_native_launch(desktop_path)
executable = next((part for part in reversed(command)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), None)
folder_path = os.path.dirname(executable) if executable else settings.get("Path", "")
if not folder_path or not os.path.isdir(folder_path):
raise ValueError("Game directory is not available")
except Exception as error:
logger.warning("Cannot resolve native game folder: %s", error)
QMessageBox.warning(self.main_window, _("Error"), str(error))
return
else:
if not os.path.exists(exe_path):
return
folder_path = os.path.dirname(os.path.abspath(exe_path))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
def _has_game_shortcut(self, game_name: str) -> bool:
@@ -1454,14 +1492,15 @@ class DetailPageManager:
"""Start debug logging session."""
if exe_path is None:
return
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
native = exe_path.startswith("native://")
resolved_exe_path = exe_path if native else self.main_window.resolve_launch_file_path(exe_path)
if resolved_exe_path is None:
return
if not self.main_window._check_alt_i586_dependencies_before_launch():
if not native and not self.main_window._check_alt_i586_dependencies_before_launch():
return
start_command = get_portproton_start_command()
if not start_command:
start_command = [] if native else get_portproton_start_command()
if start_command is None:
return
if self.debug_log_manager.start(resolved_exe_path, start_command):
+16 -2
View File
@@ -10,7 +10,7 @@ from typing import Any, cast, TYPE_CHECKING
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication, QMessageBox
)
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
import psutil
@@ -31,6 +31,7 @@ from portprotonqt.config import (
ui_config,
)
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
from portprotonqt.native_games import create_native_desktop_file, is_native_executable
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
@@ -483,6 +484,9 @@ class AddGameDialog(DraggableDialog):
self.add_to_steam_checkbox.setChecked(self._steam_shortcut_exists(current_name))
self.add_to_menu_checkbox.setChecked(self._menu_shortcut_exists(current_name))
self.add_to_desktop_checkbox.setChecked(self._desktop_shortcut_exists(current_name))
if not edit_mode and is_native_executable(self.exeEdit.text().strip()):
self.add_to_menu_checkbox.setChecked(True)
self.add_to_desktop_checkbox.setChecked(True)
self.coverPreview = QLabel(self)
self.coverPreview.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -620,7 +624,10 @@ class AddGameDialog(DraggableDialog):
try:
from portprotonqt.dialogs.file_explorer import FileExplorer
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
def file_filter(path: str) -> bool:
return path.lower().endswith(LAUNCH_FILE_EXTENSIONS) or is_native_executable(path)
file_explorer = FileExplorer(self, file_filter=file_filter, initial_path=initial_path)
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
if not self.edit_mode:
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
@@ -960,6 +967,13 @@ class AddGameDialog(DraggableDialog):
if not name:
return None, None
if is_native_executable(exe_path):
cover = self.last_cover_path or self.coverEdit.text().strip()
result = create_native_desktop_file(exe_path, name, cover)
if result is None:
QMessageBox.warning(self, _("Error"), _("Failed to create shortcut: {name}").format(name=name))
return result or (None, None)
if exe_path.lower().endswith(DISC_IMAGE_EXTENSIONS):
parent_widget = cast("MainWindow | None", self.parentWidget())
if not parent_widget or not hasattr(parent_widget, "resolve_launch_file_path"):
+15 -3
View File
@@ -25,7 +25,7 @@ from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.localization import _
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
from portprotonqt.icon_extractor import generate_thumbnail
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
from portprotonqt.dialogs.base import DraggableDialog, FileSelectedSignal
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.scripts_utils.mount_points import list_mounted_drive_paths
@@ -116,7 +116,7 @@ class FileExplorer(DraggableDialog):
self.signals.thumbnail_ready.emit(self.file_path, image)
else:
logger.warning("Failed to load image: %s", self.file_path)
elif self.file_path.lower().endswith((".exe",) + DISC_IMAGE_EXTENSIONS):
elif self.file_path.lower().endswith((".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
thumbnail_source = self.file_path
if self.file_path.lower().endswith(DISC_IMAGE_EXTENSIONS) and callable(self.resolve_launch_file_path):
resolved_path = self.resolve_launch_file_path(self.file_path)
@@ -125,6 +125,14 @@ class FileExplorer(DraggableDialog):
return
thumbnail_source = resolved_path
if thumbnail_source.lower().endswith(".appimage"):
icon_path = get_exe_icon_cache_path(thumbnail_source)
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
image = QImage()
if os.path.isfile(icon_path) or generate_thumbnail(thumbnail_source, icon_path):
image = QImage(icon_path)
self.signals.thumbnail_ready.emit(self.file_path, image)
return
if not thumbnail_source.lower().endswith(".exe"):
return
@@ -142,6 +150,8 @@ class FileExplorer(DraggableDialog):
os.unlink(tmp_name)
except Exception as e:
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
if self.file_path.lower().endswith(".appimage"):
self.signals.thumbnail_ready.emit(self.file_path, QImage())
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent)
@@ -214,7 +224,7 @@ class FileExplorer(DraggableDialog):
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
continue
mime_type = mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe",) + DISC_IMAGE_EXTENSIONS):
if mime_type.startswith("image/") or file_path.lower().endswith(COVER_IMAGE_EXTENSIONS + (".exe", ".appimage") + DISC_IMAGE_EXTENSIONS):
self.pending_thumbnails.add(file_path)
resolver = None
main_window = cast("MainWindow | None", self.main_window)
@@ -246,6 +256,8 @@ class FileExplorer(DraggableDialog):
def _set_thumbnail_icon(self, file_path: str, image: QImage) -> None:
icon = QIcon(QPixmap.fromImage(image))
self.thumbnail_cache[file_path] = icon
if image.isNull():
return
item = self.file_items.get(file_path)
if item:
item.setData(THEMED_ICON_ROLE, None)
+3 -8
View File
@@ -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()):
+58 -5
View File
@@ -60,6 +60,7 @@ from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, Gamescop
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
from portprotonqt.localization import _, format_setting_name_for_display
from portprotonqt.logger import get_logger
from portprotonqt.native_games import NATIVE_TOGGLE_KEYS, NATIVE_ADVANCED_KEYS, load_native_settings, save_native_settings, get_native_ppdb_path
from portprotonqt.preloader import Preloader
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
from portprotonqt.settings_manager import (
@@ -166,11 +167,11 @@ class ExeSettingsDialog(
if not self.user_conf and not self.exe_path and not self.appid:
return
self.portproton_path = get_portproton_location()
if self.portproton_path is None:
if self.portproton_path is None and self.game_source != "native":
logger.error("PortProton location not found")
return
self.start_sh = get_portproton_start_command()
if self.start_sh is None:
if self.start_sh is None and self.game_source != "native":
logger.error("PortProton start command not found")
return
@@ -178,7 +179,7 @@ class ExeSettingsDialog(
self.lg_dist_aliases = {}
self.plugins_ver = ""
self.prefix_options = []
if self.portproton_path:
if self.portproton_path and self.game_source != "native":
scripts_path = get_portproton_scripts_path()
if scripts_path:
var_path = os.path.join(scripts_path, "var")
@@ -229,6 +230,9 @@ class ExeSettingsDialog(
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.toggle_settings = get_toggle_settings()
if self.game_source == "native":
self.toggle_settings = {key: description for key, description in self.toggle_settings.items() if key in NATIVE_TOGGLE_KEYS}
self.setWindowTitle(_("Native") + " — " + _("Settings"))
self.setup_ui()
if self.user_conf:
@@ -581,6 +585,10 @@ class ExeSettingsDialog(
self.settings_container.setCurrentIndex(0)
self.advanced_container.setCurrentIndex(0)
if self.game_source == "native":
self.on_show_ppdb_finished(0, QProcess.ExitStatus.NormalExit)
return
process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished)
command = ["cli", "--get-user-conf"] if self.user_conf else [
@@ -590,7 +598,7 @@ class ExeSettingsDialog(
process.start(args[0], args[1:])
def on_show_ppdb_finished(self, exit_code, exit_status):
"""Handle --show-ppdb output."""
"""Populate shared settings controls from the selected game's data."""
process = cast(QProcess, self.sender())
self.current_settings = {}
self.blocked_keys = set()
@@ -605,7 +613,15 @@ class ExeSettingsDialog(
self.reject()
return
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
if self.game_source == "native":
try:
self.current_settings = load_native_settings(cast(str, self.exe_path))
except Exception as error:
logger.warning("Cannot load native settings: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
self.reject()
return
elif exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
for line in output.splitlines():
line_stripped = line.strip()
@@ -707,6 +723,16 @@ class ExeSettingsDialog(
return
db_path = self.exe_path + ".ppdb"
if self.game_source == "native":
try:
settings = load_native_settings(self.exe_path)
db_path = str(get_native_ppdb_path(self.exe_path, settings))
if not os.path.exists(db_path):
save_native_settings(self.exe_path, {})
except Exception as error:
logger.warning("Cannot create native PPDB: %s", error)
QMessageBox.critical(self, _("Error"), str(error))
return
if not os.path.exists(db_path):
QMessageBox.critical(self, _("Error"), _("PPDB file does not exist at: ") + db_path)
@@ -730,6 +756,14 @@ class ExeSettingsDialog(
return
db_path = self.exe_path + ".ppdb"
if self.game_source == "native":
try:
settings = load_native_settings(self.exe_path)
db_path = str(get_native_ppdb_path(self.exe_path, settings))
except Exception as error:
logger.warning("Cannot resolve native PPDB: %s", error)
QMessageBox.critical(self, _("Error"), str(error))
return
if not os.path.exists(db_path):
QMessageBox.information(self, _("Information"), _("PPDB file does not exist at: ") + db_path)
self.load_current_settings()
@@ -890,6 +924,14 @@ class ExeSettingsDialog(
dist_options=self.dist_options,
prefix_options=self.prefix_options
)
if self.game_source == "native":
advanced_settings = [setting for setting in advanced_settings if setting['key'] in NATIVE_ADVANCED_KEYS]
for setting in advanced_settings:
if setting["key"] == "PW_WINE_CPU_TOPOLOGY":
setting["options"] = [disabled_text] + [str(count) for count in range(1, len(os.sched_getaffinity(0)) + 1)]
for setting in advanced_settings:
if setting['key'] == 'LAUNCH_PARAMETERS':
setting['description'] = _("Launch Arguments")
if self.user_conf:
for setting in advanced_settings:
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
@@ -1672,6 +1714,17 @@ class ExeSettingsDialog(
if not changes:
return
if self.game_source == "native":
try:
values = dict(change.split('=', 1) for change in changes)
save_native_settings(cast(str, self.exe_path), values)
except Exception as error:
logger.warning("Cannot save native settings: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
return
self.load_current_settings()
return
self.saving = True
self.clear_ppdb_button.setEnabled(False)
self.saved_current_settings = self.current_settings.copy()
+13
View File
@@ -402,6 +402,7 @@ class GameCard(AnimatedCard):
self.steam_visible = str(game_source).lower() == "steam"
self.gog_visible = str(game_source).lower() == "gog"
self.egs_visible = str(game_source).lower() == "egs"
self.native_visible = str(game_source).lower() == "native"
self.portproton_visible = str(game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
@@ -515,6 +516,13 @@ class GameCard(AnimatedCard):
)
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
self.nativeLabel = SourceCorner(
icon=self.theme_manager.get_icon("badge_linux", as_path=True),
config=corner_config,
parent=self.coverWidget,
)
self.nativeLabel.setVisible(self.native_visible and not self.vertical_layout)
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
self.portprotonLabel = SourceCorner(
icon=portproton_icon,
@@ -661,6 +669,7 @@ class GameCard(AnimatedCard):
(self.steamLabel, "badge_steam"),
(self.gogLabel, "badge_gog"),
(self.egsLabel, "badge_egs"),
(self.nativeLabel, "badge_linux"),
(self.portprotonLabel, "badge_portproton"),
):
icon = self.theme_manager.get_icon(
@@ -882,6 +891,7 @@ class GameCard(AnimatedCard):
(self.steam_visible, self.steamLabel),
(self.gog_visible, self.gogLabel),
(self.egs_visible, self.egsLabel),
(self.native_visible, self.nativeLabel),
(self.portproton_visible, self.portprotonLabel),
]
@@ -905,6 +915,7 @@ class GameCard(AnimatedCard):
self.portprotonLabel.raise_()
self.gogLabel.raise_()
self.egsLabel.raise_()
self.nativeLabel.raise_()
self.steamLabel.raise_()
except RuntimeError:
pass
@@ -1046,6 +1057,7 @@ class GameCard(AnimatedCard):
self.steam_visible = str(self.game_source).lower() == "steam"
self.gog_visible = str(self.game_source).lower() == "gog"
self.egs_visible = str(self.game_source).lower() == "egs"
self.native_visible = str(self.game_source).lower() == "native"
self.portproton_visible = str(self.game_source).lower() == "portproton"
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
@@ -1056,6 +1068,7 @@ class GameCard(AnimatedCard):
self.steamLabel.setVisible(self.steam_visible)
self.gogLabel.setVisible(self.gog_visible)
self.egsLabel.setVisible(self.egs_visible)
self.nativeLabel.setVisible(self.native_visible)
self.portprotonLabel.setVisible(self.portproton_visible)
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
+89 -2
View File
@@ -1,11 +1,18 @@
"""Unified icon extractor for NE and PE files."""
"""Unified icon extractor for NE, PE and type-2 AppImages."""
import os
import hashlib
import re
import shlex
import struct
import io
import subprocess
import tarfile
import posixpath
import glob
import pefile
from PySide6.QtCore import QByteArray, QBuffer, QIODevice
from PySide6.QtGui import QImage, QImageWriter
from PIL import Image
from portprotonqt.config.base import CACHE_DIR
from portprotonqt.config.portproton import get_custom_icon_path
@@ -16,6 +23,82 @@ logger = get_logger(__name__)
RT_ICON = 3
RT_GROUP_ICON = 14
THUMBNAIL_SIZE = 128
APPIMAGE_EXTRACT_TIMEOUT = 10
ELF_SECTION_LAYOUTS = {1: (32, "I", 46), 2: (40, "Q", 58)}
def _read_dwarfs_icon(path: str, offset: int) -> bytes:
"""Read an icon and resolve archive-local links without extracting to disk."""
filename = ".DirIcon"
seen: set[str] = set()
while filename not in seen:
seen.add(filename)
result = subprocess.run(
["dwarfsextract", "-i", os.path.abspath(path), "-O", str(offset),
"-f", "pax", "-o", "-", "--pattern", ":" + glob.escape(filename),
"--skip-devices", "--skip-specials", "--log-level", "error"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
member = next((item for item in archive if item.name.removeprefix("./") == filename), None)
if member is None:
return b""
if member.issym() or member.islnk():
target = member.linkname
base = posixpath.dirname(filename) if member.issym() else ""
filename = posixpath.normpath(target.lstrip("/") if target.startswith("/")
else posixpath.join(base, target))
if filename == ".." or filename.startswith("../"):
return b""
continue
if not member.isfile():
return b""
stream = archive.extractfile(member)
return stream.read() if stream is not None else b""
return b""
def _get_appimage_icon(path: str) -> io.BytesIO | None:
"""Read .DirIcon from a type-2 AppImage without executing its runtime."""
with open(path, "rb") as source:
header = source.read(64)
if len(header) < 64 or header[:4] != b"\x7fELF" or header[8:11] != b"AI\x02":
return None
layout = ELF_SECTION_LAYOUTS.get(header[4])
if layout is None or header[5] not in (1, 2):
return None
endian = "<" if header[5] == 1 else ">"
section_offset, offset_format, size_offset = layout
offset = struct.unpack_from(endian + offset_format, header, section_offset)[0]
entry_size, entry_count = struct.unpack_from(endian + "HH", header, size_offset)
offset += entry_size * entry_count
source.seek(offset)
magic = source.read(6)
if not magic.startswith(b"hsqs") and magic != b"DWARFS":
return None
try:
if magic == b"DWARFS":
data = _read_dwarfs_icon(path, offset)
else:
result = subprocess.run(
["unsquashfs", "-cat", "-o", str(offset), os.path.abspath(path), ".DirIcon"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
data = result.stdout
except OSError as error:
logger.warning("Cannot read AppImage icon from %s: %s", path, error)
return None
except subprocess.SubprocessError as error:
logger.warning("Cannot extract AppImage icon from %s: %s", path, error)
return None
image = QImage.fromData(data)
if image.isNull():
return None
buffer = QBuffer()
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
if not QImageWriter(buffer, QByteArray(b"PNG")).write(image):
return None
return io.BytesIO(buffer.data().data())
def _resolve_batch_icon_target(inputfile: str) -> str | None:
@@ -54,6 +137,8 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
if custom_icon:
return custom_icon
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
if exe_path.lower().endswith(".appimage"):
icon_name += "-" + hashlib.sha256(os.fsencode(os.path.abspath(exe_path))).hexdigest()
return os.path.join(
CACHE_DIR,
"images",
@@ -64,7 +149,7 @@ def get_exe_icon_cache_path(exe_path: str) -> str:
def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resize: bool = True) -> bool:
"""
Generates a thumbnail for an .exe file.
Generates a thumbnail for a Windows executable or type-2 AppImage.
inputfile: the input file path
outfile: output filename
@@ -117,6 +202,8 @@ class IconExtractor:
"""
try:
with open(self.file_path, "rb") as f:
if f.read(4) == b"\x7fELF":
return _get_appimage_icon(self.file_path)
if not self._parse_file(f):
return None
+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"):
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"
+22 -7
View File
@@ -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: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
"PO-Revision-Date: 2026-10-26 12:27+0000\n"
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
"portprotonqt/ru/>\n"
@@ -996,8 +996,9 @@ msgstr "Пользовательская обложка:"
msgid "Enter local path or URL for cover image"
msgstr "Введите локальный путь или URL обложки"
#, fuzzy
msgid "Custom Icon:"
msgstr "Пользовательский значок:"
msgstr "Пользовательская обложка:"
msgid "Add shortcut to:"
msgstr "Добавить ярлык:"
+136 -11
View File
@@ -29,6 +29,7 @@ from portprotonqt.detail_pages import DetailPageManager
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
from portprotonqt.gog_api import GOGAPI
from portprotonqt.egs_api import EGSAPI
from portprotonqt.native_games import NativeLibraryWorker, NATIVE_URI_PREFIX, get_native_launch, read_native_game
from portprotonqt.debug_utils import get_prefix_name
from portprotonqt.input_manager import InputManager, MainWindowProtocol
from portprotonqt.context_menu_manager import ContextMenuManager
@@ -92,6 +93,7 @@ from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
STORE_LAUNCH_GRACE_SECONDS = 10
GAME_PROCESS_CHECK_INTERVAL_MS = 500
if TYPE_CHECKING:
from portprotonqt.appimage_updater import AppImageUpdateWorker
@@ -1036,7 +1038,7 @@ class MainWindow(
self.games_loaded.emit(games)
# Load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
completed = dict.fromkeys(results, False)
def check_completion():
@@ -1066,13 +1068,19 @@ class MainWindow(
completed['egs'] = True
check_completion()
def native_callback(games: list[tuple]) -> None:
results['native'] = games
completed['native'] = True
check_completion()
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
self._load_gog_games_async(gog_callback)
self._load_egs_games_async(egs_callback)
self._load_native_games_async(native_callback)
else:
# Load selected sources in parallel.
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': [], 'native': []}
selected_sources = set(display_filter.split(","))
completed = {
source: bool(selected_sources & results.keys()) and source not in selected_sources
@@ -1119,9 +1127,14 @@ class MainWindow(
completed['egs'] = True
check_completion()
def native_callback(games: list[tuple]) -> None:
results['native'] = games
completed['native'] = True
check_completion()
callbacks = {
"portproton": portproton_callback, "steam": steam_callback,
"gog": gog_callback, "egs": egs_callback,
"gog": gog_callback, "egs": egs_callback, "native": native_callback,
}
for source, callback in callbacks.items():
if not completed[source]:
@@ -1130,6 +1143,90 @@ class MainWindow(
# Run loading immediately to show status without delay
start_loading()
def _load_native_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
self._native_games_callback = callback
self.native_library_worker = NativeLibraryWorker()
self.native_library_worker.finished.connect(self._on_native_games_loaded)
self.native_library_worker.start()
def open_native_settings(self, exec_line: str) -> None:
desktop_path = exec_line.removeprefix(NATIVE_URI_PREFIX)
try:
dialog = ExeSettingsDialog(self, self.theme, desktop_path, game_source="native")
except OSError as error:
logger.warning("Cannot open native settings for %s: %s", desktop_path, error)
QMessageBox.warning(self, _("Error"), str(error))
return
dialog.exec()
dialog.deleteLater()
def _launch_native_game(self, exec_line: str, button: QPushButton | None = None) -> None:
desktop_path = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
if self.game_processes:
if getattr(self, "native_game_running", False) and self.game_start_exe == str(desktop_path):
self.stop_running_game(button)
else:
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
return
try:
if not desktop_path.is_absolute() or desktop_path.suffix != ".desktop":
raise ValueError("Invalid native desktop path")
command, directory = get_native_launch(str(desktop_path))
process = subprocess.Popen(command, cwd=directory, start_new_session=True, text=True)
except Exception as error:
logger.warning("Failed to launch native game %s: %s", desktop_path, error)
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(error))
return
self.native_game_running = True
self.game_stopped_by_user = False
self.game_processes = [process]
self.current_running_button = button
self.target_exe = desktop_path.name
self.game_start_exe = str(desktop_path)
self.game_start_exact_path = True
self.game_start_time = datetime.now()
self.game_launch_monotonic = time.monotonic()
self.game_launch_started = True
self.wine_download_seen = False
save_last_launch(desktop_path.stem, self.game_start_time)
self._update_last_launch_after_start(str(desktop_path), self.game_start_time)
self.launch_output_queue = Queue()
self.input_manager.suspend_gamepad_polling()
self._set_running_button_stop()
SoundManager().play("game_launch")
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(GAME_PROCESS_CHECK_INTERVAL_MS)
@Slot()
def _on_native_games_loaded(self) -> None:
worker = self.native_library_worker
if worker is None:
return
self.native_library_worker = None
games = worker.games
worker.deleteLater()
if not games:
self._native_games_callback([])
return
completed = []
def on_info(info: dict, game: tuple) -> None:
updated = list(game)
updated[1] = info.get("description", "") or game[1]
updated[2] = game[2] or info.get("cover", "")
updated[3] = info.get("appid", "")
updated[4] = info.get("controller_support", "")
completed.append(tuple(updated))
if len(completed) == len(games):
self._native_games_callback(completed)
for game in games:
if ui_config.get_economy_mode():
on_info(get_cached_steam_game_info(game[0], game[5]), game)
else:
get_steam_game_info_async(game[0], game[5], lambda info, game=game: on_info(info, game))
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
api = getattr(self, "gog_api", None) or GOGAPI()
installed = api.load_installed()
@@ -1463,7 +1560,7 @@ class MainWindow(
callback: Callable[[tuple | None], None],
):
entry = parse_desktop_entry(file_path)
if not entry:
if not entry or read_native_game(Path(file_path)) is not None:
callback(None)
return
desktop_name = entry.get("Name", _("Unknown Application"))
@@ -1805,11 +1902,13 @@ class MainWindow(
self.detail_page_manager.openGameDetailPage(game_data)
pending_log_exe = self._pending_log_exe
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
if exec_line.startswith(("gog://launch/", "egs://launch/")):
if exec_line.startswith(NATIVE_URI_PREFIX):
detail_exe = exec_line
elif exec_line.startswith(("gog://launch/", "egs://launch/")):
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
if pending_log_exe and detail_exe:
if os.path.abspath(detail_exe) == pending_log_exe:
if detail_exe == pending_log_exe or os.path.abspath(detail_exe) == pending_log_exe:
self._pending_log_exe = None
log_button = self.detail_page_manager._debug_log_button
if log_button:
@@ -2126,7 +2225,7 @@ class MainWindow(
def checkTargetExe(self):
"""Update launch state from the marker and PortProton process."""
"""Update launch state and reset controls after the game exits."""
dependency_active = self._drain_launch_output_progress()
child_running = self._has_running_game_process()
@@ -2251,6 +2350,7 @@ class MainWindow(
self.game_processes = [] if launcher_only else [
proc for proc in self.game_processes if proc.poll() is None
]
self.native_game_running = False
if not getattr(self, "_animated_covers_suspended", False):
self.input_manager.resume_gamepad_polling()
self._refresh_portproton_shortcuts()
@@ -2345,6 +2445,8 @@ class MainWindow(
def _get_game_executable(self, exec_line: str) -> str:
"""Resolve a library launch entry to its tracked executable."""
if exec_line.startswith(NATIVE_URI_PREFIX):
return exec_line.removeprefix(NATIVE_URI_PREFIX)
if exec_line.startswith("gog://launch/"):
app_id = exec_line.rsplit("/", 1)[-1]
return str(self.gog_api.get_launch_target(app_id) or "")
@@ -2354,6 +2456,19 @@ class MainWindow(
return extract_exec_target_path(exec_line) or ""
def _has_running_game_process(self) -> bool:
if getattr(self, "native_game_running", False):
if any(process.poll() is None for process in self.game_processes):
return True
groups = {process.pid for process in self.game_processes}
for process in psutil.process_iter(attrs=["pid", "status"]):
try:
if process.info["status"] != psutil.STATUS_ZOMBIE and os.getpgid(process.pid) in groups:
return True
except ProcessLookupError:
continue
except PermissionError:
continue
return False
if self._observe_game_processes():
return True
process_alive = any(proc.poll() is None for proc in self.game_processes)
@@ -2475,6 +2590,8 @@ class MainWindow(
self._start_launch_output_reader(game_process)
def _analyze_short_launch(self) -> None:
if getattr(self, "native_game_running", False):
return
if not ui_config.get_crash_reports_enabled():
logger.info("Compatibility report skipped: crash reports disabled")
return
@@ -2546,11 +2663,12 @@ class MainWindow(
def _terminate_game_processes(self) -> None:
for proc in self.game_processes:
if proc.poll() is not None:
if proc.poll() is not None and not getattr(self, "native_game_running", False):
continue
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.kill()
os.killpg(proc.pid if getattr(self, "native_game_running", False) else os.getpgid(proc.pid), signal.SIGTERM)
if not getattr(self, "native_game_running", False):
proc.kill()
except ProcessLookupError:
continue
except OSError as e:
@@ -2569,11 +2687,14 @@ class MainWindow(
return True
def stop_running_game(self, button=None) -> bool:
"""Stop current game via PortProton CLI stop command."""
"""Stop the current game's owned processes and launcher."""
if button is not None:
self.current_running_button = button
self.game_stopped_by_user = True
self.egs_launch_cancelled = True
if getattr(self, "native_game_running", False):
self._terminate_game_processes()
return True
if getattr(self, "launcher_process_only", False):
if self._run_portproton_stop_command():
return True
@@ -3081,6 +3202,10 @@ class MainWindow(
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
def toggleGame(self, exec_line, button=None, game_name=None):
if exec_line.startswith(NATIVE_URI_PREFIX):
self._launch_native_game(exec_line, button)
return
# Handle Steam games
if exec_line.startswith("steam://"):
self._launch_steam_game(exec_line)
+1
View File
@@ -22,6 +22,7 @@ install_data(
'localization.py',
'logger.py',
'main_window.py',
'native_games.py',
'port_data_path_selector.py',
'portproton_api.py',
'preloader.py',
+511
View File
@@ -0,0 +1,511 @@
"""Discover installed native games from desktop application entries."""
import configparser
import platform
import os
import re
import shlex
import shutil
import tempfile
from pathlib import Path
from datetime import datetime
from PySide6.QtCore import QThread
from portprotonqt.config import LAUNCH_FILE_EXTENSIONS, WINDOWS_LAUNCH_EXTENSIONS, get_portproton_location, get_portproton_scripts_path, get_custom_data_dir_name
from portprotonqt.config.portproton import get_custom_icon_path, extract_exec_target_path, resolve_custom_data_dir, parse_desktop_entry
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.icon_extractor import get_exe_icon_cache_path
from portprotonqt.time_utils import (
get_statistics_path, parse_playtime_file, get_last_launch_path, _parse_last_launch_line,
format_last_launch, format_playtime,
)
logger = get_logger(__name__)
NATIVE_URI_PREFIX = "native://"
NATIVE_SETTINGS_SECTION = "X-PortProtonQt"
NATIVE_TOGGLE_KEYS = frozenset({"PW_USE_GAMEMODE", "PW_USE_RUNTIME", "PW_REDUCE_PULSE_LATENCY",
"PW_USE_GALLIUM_ZINK", "PW_USE_OBS_VKCAPTURE", "PW_USE_INHIBIT_SLEEP"})
NATIVE_ADVANCED_KEYS = frozenset({"LAUNCH_PARAMETERS", "PW_MESA_GL_VERSION_OVERRIDE", "PW_LOCALE_SELECT",
"PW_MESA_VK_WSI_PRESENT_MODE", "PW_WINE_CPU_TOPOLOGY"})
def is_native_executable(path: str) -> bool:
"""Recognize native launch files while preserving Windows and disc formats."""
return (
Path(path).suffix.lower() not in LAUNCH_FILE_EXTENSIONS
and os.path.isfile(path)
and os.access(path, os.X_OK)
)
def quote_native_arguments(arguments: list[str]) -> str:
"""Quote arguments using desktop entry escaping, without a shell."""
escaped = []
for argument in arguments:
value = argument.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$")
escaped.append('"' + value.replace("\\", "\\\\").replace("%", "%%") + '"')
return " ".join(escaped)
def build_native_shortcut_command(command: str, mode: str = "--silent") -> str:
"""Use the installed application launcher for native desktop shortcuts."""
flatpak_id = os.getenv("FLATPAK_ID")
appimage = os.getenv("APPIMAGE", "").strip()
launcher = ["portprotonqt"]
if flatpak_id:
launcher = ["flatpak", "run", flatpak_id]
elif appimage and os.path.isfile(appimage):
launcher = [appimage]
if mode:
launcher.append(mode)
return quote_native_arguments(launcher) + " " + command
def get_native_source_command(command: str) -> str:
"""Unwrap a PortProtonQt desktop command before applying native settings."""
parts = shlex.split(command.replace("\\\\", "\\"))
if "--silent" in parts and (parts[0] in {"portprotonqt", "flatpak", os.getenv("APPIMAGE", "").strip()} or parts[0].lower().endswith(".appimage")):
target = extract_exec_target_path(parts)
if target:
target = target.replace("\\$", "$").replace("\\`", "`").replace("%%", "%")
return quote_native_arguments([target])
return command
def get_native_tool_paths() -> dict[str, str]:
"""Locate the existing PortProton runtime and shader bundle without loading Wine."""
scripts = get_portproton_scripts_path()
location = get_portproton_location()
if not scripts or not location:
return {}
values = {}
try:
for line in (Path(scripts) / "var").read_text(encoding="utf-8").splitlines():
key, separator, value = line.removeprefix("export ").partition("=")
if separator and key in {"PW_LIBS_VER", "PW_PLUGINS_VER"}:
values[key] = value.strip().strip('"\'')
except OSError as error:
logger.warning("Cannot read native runtime versions: %s", error)
return {}
root = Path(location) / "data/tmp"
return {"runtime": str(root / ("libs" + values.get("PW_LIBS_VER", ""))),
"plugins": str(root / ("plugins" + values.get("PW_PLUGINS_VER", ""))),
"PW_PLUGINS_VER": values.get("PW_PLUGINS_VER", "")}
def read_native_variables(path: Path) -> dict[str, str]:
"""Read literal var/PPDB assignments without executing shell commands."""
values = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip().startswith(("export ", "check_variables ")):
continue
parts = shlex.split(line, comments=True)
if not parts:
continue
if parts[0] == "check_variables" and len(parts) == 3:
values[parts[1]] = parts[2]
elif parts[0] == "export" and len(parts) == 2 and "=" in parts[1]:
key, value = parts[1].split("=", 1)
values[key] = value.replace("\\$", "$").replace("\\`", "`")
return values
def get_native_ppdb_path(desktop_path: str, settings: dict[str, str]) -> Path:
"""Keep native settings and logs in a named per-game user data directory."""
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
name = settings.get("Name", Path(desktop_path).stem).replace("/", "_").strip(".") or "native"
return data_home / "PortProtonQt/native_games" / name / (name + ".ppdb")
def get_native_defaults() -> dict[str, str]:
"""Read supported Linux defaults from the existing PortProton var file."""
values = dict.fromkeys(NATIVE_TOGGLE_KEYS | NATIVE_ADVANCED_KEYS, "0")
values.update(dict.fromkeys(NATIVE_ADVANCED_KEYS, "disabled"))
values.update({"LAUNCH_PARAMETERS": "", "PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "0",
"PW_VKBASALT": "0", "PW_VKBASALT_USER_CONF": "0", "PW_GAMESCOPE": "0",
"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""})
scripts = get_portproton_scripts_path()
if scripts and (Path(scripts) / "var").is_file():
defaults = read_native_variables(Path(scripts) / "var")
values.update({key: value for key, value in defaults.items()
if (key in values and key != "PW_USE_RUNTIME") or key.startswith(("PW_VKBASALT_", "PW_GAMESCOPE_"))})
values["MANGOHUD_CONFIG"] = ",".join(item for item in defaults.get("DEFAULT_MANGOHUD_CONFIG", "").split(",")
if item not in {"wine", "winesync"})
return values
def load_native_settings(desktop_path: str) -> dict[str, str]:
"""Load var defaults, existing desktop settings, and the game PPDB."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = lambda optionstr: optionstr
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
settings = get_native_defaults()
if parser.has_section(NATIVE_SETTINGS_SECTION):
settings.update(parser[NATIVE_SETTINGS_SECTION])
settings["Path"] = parser["Desktop Entry"].get("Path", "").replace("\\\\", "\\")
database = Path(desktop_path + ".ppdb")
if database.is_file():
content = database.read_text(encoding="utf-8")
if content.lstrip().startswith("["):
parser.read_string(content)
settings.update(parser[NATIVE_SETTINGS_SECTION])
else:
settings.update(read_native_variables(database))
settings.setdefault("Name", parser["Desktop Entry"].get("Name", Path(desktop_path).stem))
settings.setdefault("Exec", parser["Desktop Entry"]["Exec"])
settings["Exec"] = get_native_source_command(settings["Exec"])
parts = shlex.split(settings["Exec"].replace("\\\\", "\\"))
executable = next((part.replace("%%", "%") for part in reversed(parts)
if os.path.isabs(part.replace("%%", "%")) and Path(part.replace("%%", "%")).is_file()), None)
if executable is None and parts:
executable = shutil.which(parts[0])
legacy_paths = [Path((executable or desktop_path) + ".ppdb")]
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
legacy_paths.append(data_home / "PortProtonQt/native_games" / (get_custom_data_dir_name(desktop_path) + ".ppdb"))
for legacy in legacy_paths:
if legacy.is_file():
settings.update(read_native_variables(legacy))
database = get_native_ppdb_path(desktop_path, settings)
if database.is_file():
settings.update(read_native_variables(database))
settings.setdefault("PW_PLUGINS_VER", get_native_tool_paths().get("PW_PLUGINS_VER", ""))
return settings
def build_native_runtime(settings: dict[str, str]) -> tuple[list[str], list[str]]:
"""Use PortProton's runtime and portable Linux tools without loading Wine."""
paths = get_native_tool_paths()
runtime = Path(paths.get("runtime", ""))
wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime"
if not wrapper.is_file() or not (runtime / "runtime/files").is_dir():
raise ValueError("PortProton container runtime is not installed")
environment = [f"PRESSURE_VESSEL_RUNTIME_BASE={runtime}", "PRESSURE_VESSEL_RUNTIME=runtime",
f"PRESSURE_VESSEL_VARIABLE_DIR={runtime / 'var'}", "PRESSURE_VESSEL_COPY_RUNTIME=1",
"PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1"]
if platform.freedesktop_os_release().get("ID") == "altlinux":
drivers = [str(path.resolve()) for path in (Path("/etc/libnvidiacurrent"), Path("/etc/libnvidia32current"))
if path.is_dir()]
if drivers:
environment.append("PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=" + ":".join(filter(None, [os.getenv("LD_LIBRARY_PATH", ""), *drivers])))
environment.append("LIBGL_DRIVERS_PATH=/usr/lib/X11/modules/dri:/usr/lib64/X11/modules/dri")
command = [str(wrapper), "--share-home", "--remove-game-overlay", "--share-pid", "--", "env"]
portable = Path(paths.get("plugins", "")) / "portable"
command += [f"PATH={portable / 'bin'}:{os.environ.get('PATH', '')}"]
if settings.get("PW_MANGOHUD") == "1" or settings.get("PW_VKBASALT") == "1":
layers = portable / "share/vulkan/implicit_layer.d"
libraries = ":".join(str(portable / "lib" / arch) for arch in ("lib64", "lib32"))
command += [f"LD_LIBRARY_PATH={libraries}:/overrides/lib/x86_64-linux-gnu:/overrides/lib/i386-linux-gnu",
f"VK_ADD_LAYER_PATH={layers}", f"VK_ADD_IMPLICIT_LAYER_PATH={layers}"]
environment.remove("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=1")
environment.append("PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0")
if settings.get("PW_MANGOHUD") == "1":
if not (portable / "lib/lib64/libMangoHud_shim.so").is_file():
raise ValueError("PortProton portable MangoHud is not installed")
preload = ":".join(filter(None, [os.getenv("LD_PRELOAD", ""), "libMangoHud_shim.so"]))
command += ["MANGOHUD=1", f"LD_PRELOAD={preload}"]
return command, environment
def build_native_launch_command(command: str, settings: dict[str, str]) -> str:
"""Compose Linux launch wrappers and environment for a desktop entry."""
prefix = []
environment = []
for key, variable in (("PW_REDUCE_PULSE_LATENCY", "PULSE_LATENCY_MSEC=60"),
("PW_USE_GALLIUM_ZINK", "MESA_LOADER_DRIVER_OVERRIDE=zink"),
("PW_USE_OBS_VKCAPTURE", "OBS_VKCAPTURE=1")):
if settings.get(key) == "1":
environment.append(variable)
for key, variable in (("PW_LOCALE_SELECT", "LC_ALL"), ("PW_MESA_GL_VERSION_OVERRIDE", "MESA_GL_VERSION_OVERRIDE"),
("PW_MESA_VK_WSI_PRESENT_MODE", "MESA_VK_WSI_PRESENT_MODE")):
if settings.get(key) not in (None, "", "disabled"):
environment.append(f"{variable}={settings[key]}")
if settings.get("PW_USE_INHIBIT_SLEEP") == "1":
prefix += ["systemd-inhibit", "--what=idle:sleep", "--why=Native game", "--"]
if settings.get("PW_GAMESCOPE") == "1":
prefix += ["gamescope", *shlex.split(settings.get("PW_GAMESCOPE_ARGS_NEW", "")), "--"]
if settings.get("PW_USE_RUNTIME") == "1":
runtime_command, runtime_env = build_native_runtime(settings)
prefix += runtime_command
environment += runtime_env
cores = settings.get("PW_WINE_CPU_TOPOLOGY", "disabled")
if cores not in ("", "disabled"):
available = sorted(os.sched_getaffinity(0))
count = int(cores)
if not 1 <= count <= len(available):
raise ValueError("Invalid CPU cores limit")
prefix += ["taskset", "-c", ",".join(str(cpu) for cpu in available[:count])]
if settings.get("PW_USE_GAMEMODE") == "1":
prefix.append("gamemoderun")
if settings.get("PW_MANGOHUD") == "1":
if settings.get("PW_USE_RUNTIME") != "1":
prefix += ["mangohud", "--dlsym"]
if settings.get("PW_MANGOHUD_USER_CONF") != "1":
config = settings.get("MANGOHUD_CONFIG", "")
if settings.get("FPS_LIMIT"):
config += f",fps_limit={settings['FPS_LIMIT']}"
environment.append(f"MANGOHUD_CONFIG={config.strip(',')}")
if settings.get("PW_VKBASALT") == "1":
environment.append("ENABLE_VKBASALT=1")
if settings.get("VKBASALT_CONFIG_FILE"):
environment.append(f"VKBASALT_CONFIG_FILE={settings['VKBASALT_CONFIG_FILE']}")
if environment:
prefix = ["env", *environment, *prefix]
suffix = quote_native_arguments(shlex.split(settings.get("LAUNCH_PARAMETERS", "")))
return " ".join(part for part in (quote_native_arguments(prefix), command, suffix) if part)
def write_native_vkbasalt_config(desktop_path: str, settings: dict[str, str]) -> None:
"""Keep native vkBasalt configuration isolated from global settings."""
if settings.get("PW_VKBASALT") != "1" or settings.get("PW_VKBASALT_USER_CONF") == "1":
settings.pop("VKBASALT_CONFIG_FILE", None)
return
config = get_native_ppdb_path(desktop_path, settings).with_suffix(".vkbasalt.conf")
plugins = Path(get_native_tool_paths().get("plugins", "")) / "reshade"
lines = [f"effects = {settings.get('PW_VKBASALT_EFFECTS', 'cas')}",
f"casSharpness = {settings.get('PW_VKBASALT_FFX_CAS', '0.50')}",
f"toggleKey = {settings.get('PW_VKBASALT_TOOGLE_KEY', 'Home')}",
f"reshadeTexturePath = {plugins / 'textures'}", f"reshadeIncludePath = {plugins}"]
for shader in settings.get("PW_VKBASALT_EFFECTS", "").split(":"):
if shader and shader != "cas":
if Path(shader).name != shader:
raise ValueError("Invalid vkBasalt shader name")
shader_path = plugins / "shaders" / (shader + ".fx")
if not shader_path.is_file():
raise ValueError(f"vkBasalt shader is not installed: {shader}")
lines.append(f"{shader} = {shader_path}")
config.write_text("\n".join(lines) + "\n", encoding="utf-8")
settings["VKBASALT_CONFIG_FILE"] = str(config)
def save_native_settings(desktop_path: str, changes: dict[str, str]) -> None:
"""Save native settings only to PPDB, leaving installed launchers unchanged."""
settings = load_native_settings(desktop_path)
settings.update(changes)
settings["Exec"] = get_native_source_command(settings["Exec"])
command, directory = settings["Exec"], settings.get("Path", "")
if not shlex.split(command.replace("\\\\", "\\")) or any(char in "".join(settings.values()) for char in "\n\r\x00"):
raise ValueError("Invalid native launch command or directory")
if directory and (not os.path.isabs(directory) or not os.path.isdir(directory)):
raise ValueError("Working directory must be an existing absolute path")
database = get_native_ppdb_path(desktop_path, settings)
database.parent.mkdir(parents=True, exist_ok=True)
write_native_vkbasalt_config(desktop_path, settings)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=database.parent, delete=False) as output:
temporary = Path(output.name)
for key, value in sorted(settings.items()):
if key in {"Name", "Icon", "Exec", "Path"}:
continue
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$").replace("`", "\\`")
output.write(f'export {key}="{escaped}"\n')
os.replace(temporary, database)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def create_native_desktop_file(executable: str, name: str, icon: str = "") -> tuple[str, str] | None:
"""Build a native launcher through PortProtonQt with the game's directory."""
path = Path(executable).expanduser().absolute()
if not path.is_file() or not os.access(path, os.X_OK) or path.suffix.lower() in WINDOWS_LAUNCH_EXTENSIONS:
logger.warning("Not an executable native game: %s", path)
return None
if not name.strip() or any(char in str(path) + icon for char in "\n\r\x00"):
logger.warning("Invalid native game name or path")
return None
name = " ".join(name.splitlines()).strip()
# Desktop Exec uses double quoting and doubles literal percent signs.
command = build_native_shortcut_command(quote_native_arguments([str(path)]))
directory = str(path.parent).replace("\\", "\\\\")
icon = get_custom_icon_path(str(path)) or icon
if not icon and path.suffix.lower() == ".appimage":
icon = get_exe_icon_cache_path(str(path))
icon = icon.replace("\\", "\\\\") if os.path.isfile(icon) else "applications-games"
comment = _('Launch "{name}" with PortProton').format(name=name)
content = (f"[Desktop Entry]\nType=Application\nName={name}\nComment={comment}\nExec={command}\n"
f"Path={directory}\nIcon={icon}\nTerminal=false\nCategories=Game;\nStartupNotify=true\nActions=RunSilent;RunLog;\n")
for action, label, mode in (("RunSilent", _("Run in silent mode"), "--silent"),
("RunLog", _("Run in logging mode"), "--log")):
action_command = build_native_shortcut_command(quote_native_arguments([str(path)]), mode)
content += f"\n[Desktop Action {action}]\nName={label}\nExec={action_command}\nIcon={icon}\n"
location = get_portproton_location()
if not location:
logger.warning("PortProton data directory is unavailable")
return None
filename = name.replace("/", "_") + ".desktop"
destination = Path(location) / filename
try:
destination.parent.mkdir(parents=True, exist_ok=True)
except OSError as error:
logger.warning("Cannot create native library directory: %s", error)
return None
return content, str(destination)
def read_native_game(path: Path) -> tuple | None:
"""Read a game entry without modifying the installed desktop file."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
parser.read_string(path.read_text(encoding="utf-8"))
if not parser.has_section("Desktop Entry"):
return None
entry = parser["Desktop Entry"]
if entry.get("Type") != "Application" or "Game" not in entry.get("Categories", "").split(";"):
return None
if any(entry.get(key, "false").lower() == "true" for key in ("Hidden", "NoDisplay")):
return None
if any(mime.startswith("x-scheme-handler/") for mime in entry.get("MimeType", "").split(";")):
return None
name, command = entry.get("Name", ""), entry.get("Exec", "")
parts = shlex.split(command.replace("\\\\", "\\"))
if not name or not parts:
return None
if any(part.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS)
or part.lower().startswith(("steam://", "gog://", "egs://")) for part in parts):
return None
if entry.get("TryExec") and not shutil.which(entry["TryExec"]):
return None
original = parser[NATIVE_SETTINGS_SECTION].get("Exec", command) if parser.has_section(NATIVE_SETTINGS_SECTION) else command
executable = extract_exec_target_path(shlex.split(original.replace("\\\\", "\\"))) or ""
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
root = str(data_home / "PortProtonQt/custom_data")
custom = Path(resolve_custom_data_dir(root, str(path)))
if not custom.is_dir() and executable:
custom = Path(resolve_custom_data_dir(root, executable))
description = entry.get("Comment", "")
metadata = custom / "metadata.txt"
if metadata.is_file():
translations = read_metadata_translations(str(metadata), get_metadata_language())
name = translations.get("name") or name
description = translations.get("description") or description
cover = next((str(path) for path in custom.glob("cover.*") if path.is_file()), "")
if not cover:
from portprotonqt.steam_api.api import get_cached_sgdb_cover
cover = get_cached_sgdb_cover(name)
if not cover and executable.lower().endswith(".appimage"):
cached_icon = get_exe_icon_cache_path(executable)
if os.path.isfile(cached_icon):
cover = cached_icon
return (name, description, cover,
"", "", NATIVE_URI_PREFIX + str(path), _("Never"), "", "", "", 0, 0,
"native", "", "", "")
except configparser.Error as error:
logger.warning("Invalid native desktop entry %s: %s", path, error)
except OSError as error:
logger.warning("Cannot read native desktop entry %s: %s", path, error)
except UnicodeError as error:
logger.warning("Invalid native desktop encoding %s: %s", path, error)
except ValueError as error:
logger.warning("Invalid native desktop command %s: %s", path, error)
return None
def get_native_launch(desktop_path: str) -> tuple[list[str], str]:
"""Resolve desktop field codes into a direct Linux process command."""
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.read_string(Path(desktop_path).read_text(encoding="utf-8"))
entry = parser["Desktop Entry"]
settings = load_native_settings(desktop_path)
entry["Exec"] = settings["Exec"]
if get_native_ppdb_path(desktop_path, settings).is_file():
entry["Exec"] = build_native_launch_command(settings["Exec"], settings)
entry["Path"] = settings.get("Path", "").replace("\\", "\\\\")
escapes = {"\\": "\\", "s": " ", "n": "\n", "t": "\t", "r": "\r"}
command = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Exec", ""))
arguments = []
for part in shlex.split(command):
part = part.replace("\\$", "$").replace("\\`", "`")
if part == "%i":
if entry.get("Icon"):
arguments.extend(["--icon", entry["Icon"]])
continue
value = re.sub(r"%%|%[fFuUdDnNvmick]", lambda match:
{"%%": "%", "%c": entry.get("Name", ""), "%k": desktop_path}.get(match[0], ""), part)
if value:
arguments.append(value)
if not arguments:
raise ValueError("Empty native launch command")
directory = re.sub(r"\\([\\sntr])", lambda match: escapes[match[1]], entry.get("Path", ""))
if directory and not os.path.isdir(directory):
raise ValueError(f"Working directory does not exist: {directory}")
executable = next((part for part in reversed(arguments)
if os.path.isabs(part) and os.path.isfile(part) and os.access(part, os.X_OK)), arguments[0])
fallback = Path(executable).parent if os.path.isabs(executable) else Path.home()
return arguments, directory or str(fallback)
def load_native_game_times(games: list[tuple]) -> list[tuple]:
"""Read shared statistics once for the native library batch."""
statistics, launches = {}, {}
try:
path = get_statistics_path()
if os.path.isfile(path):
statistics = parse_playtime_file(path)
path = get_last_launch_path()
if os.path.isfile(path):
launches = dict(parsed for line in Path(path).read_text(encoding="utf-8").splitlines()
if (parsed := _parse_last_launch_line(line)))
except OSError as error:
logger.warning("Cannot read native game statistics: %s", error)
updated_games = []
for game in games:
path = Path(game[5].removeprefix(NATIVE_URI_PREFIX))
updated = list(game)
updated[11] = statistics.get(str(path).replace(" ", "#@_@#"), 0)
updated[7] = format_playtime(updated[11])
if path.stem in launches:
try:
launched = datetime.fromisoformat(launches[path.stem])
updated[6], updated[10] = format_last_launch(launched), launched.timestamp()
except ValueError as error:
logger.warning("Invalid native last launch time for %s: %s", path, error)
updated_games.append(tuple(updated))
return updated_games
class NativeLibraryWorker(QThread):
"""Scan application directories outside the UI thread."""
def __init__(self) -> None:
super().__init__()
self.games: list[tuple] = []
def run(self) -> None:
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(os.pathsep)
roots = [data_home, *(Path(directory) for directory in data_dirs if directory),
data_home / "flatpak/exports/share", Path("/var/lib/flatpak/exports/share")]
directories = [root / "applications" for root in roots]
directories.insert(0, data_home / "PortProtonQt/native_games")
location = get_portproton_location()
if location:
directories.insert(0, Path(location))
seen: set[str] = set()
commands: set[tuple[str, str, str]] = set()
for applications in directories:
try:
for path in sorted(applications.glob("*.desktop") if location and applications == Path(location)
else applications.rglob("*.desktop")):
if self.isInterruptionRequested():
return
desktop_id = str(path.relative_to(applications)).replace(os.sep, "-")
if desktop_id in seen:
continue
seen.add(desktop_id)
game = read_native_game(path)
if not game:
continue
entry = parse_desktop_entry(str(path)) or {}
command = get_native_source_command(entry.get("Exec", ""))
identity = (command, entry.get("Path", ""), entry.get("Name", ""))
if identity in commands:
continue
commands.add(identity)
self.games.append(game)
except OSError as error:
logger.warning("Cannot scan native games in %s: %s", applications, error)
self.games = load_native_game_times(self.games)
+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:
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()]
+45 -4
View File
@@ -575,6 +575,23 @@ def get_cached_full_steam_game_info(appid: int, fallback_name: str = "") -> dict
)
def get_cached_sgdb_cover(game_name: str) -> str:
"""Read a cached SGDB grid URL without creating a download worker."""
cache = CacheManager()
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
key = f"sgdb_{safe_name}_grids"
if not cache.is_fresh(key, SGDB_CACHE_DURATION):
return ""
cover = cache.load_json(key)
if not isinstance(cover, str):
return ""
from portprotonqt.portproton_api import get_user_conf_setting
if get_user_conf_setting("MIRROR") == "CLOUD":
cover = cover.replace("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/", 1)
return cover
def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
"""Return Steam metadata from local cache only."""
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
@@ -582,7 +599,8 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
game_exe = extract_exec_target_path(exec_line) or exec_line
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
names = [desktop_name] if exec_line.startswith("native://") else [desktop_name, exe_name, folder_name]
candidates = remove_duplicates(filter_candidates(names))
apps_path = CacheManager().cache_dir / "steam_apps.json"
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
if not steam_apps:
@@ -660,6 +678,7 @@ def get_steam_game_info_async(
is_autoinstall = exec_line.startswith("autoinstall:")
is_store_uri = exec_line.startswith(("gog://", "egs://"))
is_native = exec_line.startswith("native://")
if game_exe.lower().endswith(('.bat', '.cmd')):
if os.path.exists(game_exe):
@@ -681,7 +700,7 @@ def get_steam_game_info_async(
else:
logger.error("Command file not found: %s", game_exe)
if cached_match is not None or is_store_uri:
if cached_match is not None or is_store_uri or is_native:
meta_data = {}
elif not game_exe.lower().endswith('.exe'):
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
@@ -705,9 +724,9 @@ def get_steam_game_info_async(
candidates.append(file_description)
if desktop_name:
candidates.append(desktop_name)
if exe_name and not is_store_uri:
if exe_name and not (is_store_uri or is_native):
candidates.append(exe_name)
if folder_name and not is_store_uri:
if folder_name and not (is_store_uri or is_native):
candidates.append(folder_name)
candidates = filter_candidates(candidates)
candidates = remove_duplicates(candidates)
@@ -724,6 +743,9 @@ def get_steam_game_info_async(
if matching_app is None and (not steam_apps or not steam_apps_index):
game_name = desktop_name or exe_name
if is_native:
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
return
def on_sgdb_cover_failure(cover: str) -> None:
result = _build_game_info_result(
@@ -762,6 +784,9 @@ def get_steam_game_info_async(
game_name = desktop_name or exe_name.capitalize()
if not matching_app:
if is_native:
fetch_sgdb_cover_async(game_name, lambda cover: callback({"name": game_name, "cover": cover}))
return
def on_sgdb_cover_non_steam(cover: str) -> None:
def on_anticheat_info_non_steam(anticheat_info: dict) -> None:
result = _build_game_info_result(
@@ -790,6 +815,22 @@ def get_steam_game_info_async(
cache_manager.save_json(match_key, matching_app)
def on_app_info(app_info: dict | None) -> None:
if is_native:
info = app_info or {}
result = {"appid": appid, "name": info.get("name", game_name),
"description": info.get("short_description", ""),
"controller_support": info.get("controller_support", "")}
def on_native_cover(cover: str) -> None:
result["cover"] = cover
callback(result)
local_cover = get_local_steam_cover(appid, prefer_exact=False)
if local_cover:
on_native_cover(local_cover)
else:
fetch_sgdb_cover_async(result["name"], on_native_cover)
return
if not app_info:
cover = get_local_steam_cover(appid, prefer_exact=False)
if not cover and not has_custom_data:
+44 -6
View File
@@ -1,6 +1,7 @@
"""Steam shortcuts management for PortProtonQt."""
import base64
import glob
import os
import random
import re
@@ -16,7 +17,10 @@ import requests
import vdf
import websocket
from portprotonqt.config.portproton import get_custom_icon_path
from PySide6.QtGui import QIcon, QImageReader
from PySide6.QtCore import QStandardPaths
from portprotonqt.config.portproton import get_custom_icon_path, parse_desktop_entry
from portprotonqt.logger import get_logger
from portprotonqt.downloader import Downloader
from portprotonqt.icon_extractor import generate_thumbnail
@@ -171,7 +175,7 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
def _parse_exec_line(exec_line: str) -> tuple[str | None, str]:
"""Parse exec_line to extract executable path."""
try:
exe_path = extract_exec_target_path(exec_line)
exe_path = exec_line if exec_line.startswith("native://") else extract_exec_target_path(exec_line)
if not exe_path:
logger.error("Failed to parse exec_line: %s", exec_line)
return None, "Failed to parse executable command: no target path"
@@ -224,6 +228,33 @@ export LD_PRELOAD=
return script_path, ""
def _resolve_desktop_icon(source: str) -> QIcon:
"""Resolve a desktop icon through Qt, then standard XDG icon directories."""
if not source:
return QIcon()
if os.path.isfile(source):
return QIcon(source)
icon = QIcon.fromTheme(source)
if not icon.isNull() or os.path.basename(source) != source:
return icon
formats = {bytes(value.data()).decode("ascii") for value in QImageReader.supportedImageFormats()}
filename = glob.escape(source)
if Path(source).suffix.lstrip(".").lower() not in formats:
filename += ".*"
roots = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.GenericDataLocation)
patterns = (f"icons/hicolor/*/*/{filename}", f"icons/*/*/*/{filename}",
f"icons/{filename}", f"pixmaps/{filename}")
for root in roots:
for pattern in patterns:
for path in sorted(Path(root).glob(pattern)):
if not path.is_file() or path.suffix.lstrip(".").lower() not in formats:
continue
icon = QIcon(str(path))
if not icon.isNull():
return icon
return QIcon()
def _generate_icon(exe_path: str, portproton_dir: str) -> str:
"""Generate thumbnail icon for the game."""
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', os.path.basename(exe_path))
@@ -236,7 +267,13 @@ def _generate_icon(exe_path: str, portproton_dir: str) -> str:
if os.path.exists(generated_icon_path):
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if exe_path.endswith(".desktop"):
entry = parse_desktop_entry(exe_path) or {}
source = entry.get("Icon", "")
icon = _resolve_desktop_icon(source)
success = icon.pixmap(128, 128).save(generated_icon_path)
else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path):
logger.warning(f"Failed to generate thumbnail for {exe_path}")
return ""
@@ -424,7 +461,8 @@ def add_to_steam(
return (False, error)
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
if not is_store_uri and not os.path.exists(exe_path):
local_path = exe_path.removeprefix("native://")
if not is_store_uri and not os.path.exists(local_path):
logger.error(f"Executable not found: {exe_path}")
return (False, f"Executable file not found: {exe_path}")
@@ -438,8 +476,8 @@ def add_to_steam(
if error:
return (False, error)
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(exe_path)
icon_source = custom_icon or icon_source or (None if is_store_uri else exe_path)
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(local_path)
icon_source = custom_icon or icon_source or (None if is_store_uri else local_path)
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
steam_home = get_steam_home()
+55 -9
View File
@@ -1,5 +1,7 @@
import os
import re
import shutil
from pathlib import Path
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
@@ -40,6 +42,7 @@ from portprotonqt.dialogs.proton_manager import (
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.native_games import NATIVE_URI_PREFIX, save_native_settings, is_native_executable, read_native_game
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
@@ -117,7 +120,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
))
game_config.set_display_filter(display_filter)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton")
display_filter not in ("steam", "portproton", "native")
)
self._position_library_controls_widget()
self._on_only_installed_changed(game_config.get_only_installed())
@@ -362,13 +365,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
display_filter = game_config.get_display_filter()
only_installed = game_config.get_only_installed()
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "native", "favorites"]
self.filter_labels = [
_("All"),
"Steam",
"GOG",
"Epic Games",
"PortProton",
_("Native"),
_("Favorites"),
]
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
@@ -406,7 +410,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
self.onlyInstalledCheckBox.setVisible(
display_filter not in ("steam", "portproton")
display_filter not in ("steam", "portproton", "native")
)
self.badge_view_keys = ["detailed", "compact", "hidden"]
@@ -652,7 +656,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
path = url.toLocalFile().lower()
if path.endswith(
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
):
) or (os.path.isfile(url.toLocalFile()) and is_native_executable(url.toLocalFile())):
event.acceptProposedAction()
return
event.ignore()
@@ -676,7 +680,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
self.open_local_autoinstall_card(path)
event.acceptProposedAction()
break
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS):
if path_lower.endswith(LAUNCH_FILE_EXTENSIONS) or (os.path.isfile(path) and is_native_executable(path)):
self.openAddGameDialog(path)
event.acceptProposedAction()
break
@@ -755,6 +759,9 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
name = dialog.nameEdit.text().strip()
exe_path = dialog.exeEdit.text().strip()
user_cover = dialog.coverEdit.text().strip()
native = is_native_executable(exe_path)
if native:
user_cover = dialog.last_cover_path or user_cover
if not name or not exe_path:
return
@@ -789,14 +796,30 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
entry = parse_desktop_entry(desktop_path)
if not entry:
return
self._known_portproton_desktops.add(desktop_path)
if not native:
self._known_portproton_desktops.add(desktop_path)
elif cover_path:
desktop_entry = re.sub(r"^Icon=.*$", lambda _match: f"Icon={cover_path}", desktop_entry, flags=re.MULTILINE)
self._write_desktop_file(desktop_entry, desktop_path)
if native:
try:
save_native_settings(desktop_path, {})
except Exception as error:
logger.warning("Cannot create native PPDB: %s", error)
QMessageBox.warning(self, _("Error"), str(error))
return
for source in ("all", "native", "favorites"):
getattr(self, "_loaded_library_cache", {}).pop(source, None)
description = entry.get("Comment", "")
exec_line = entry.get("Exec", exe_path)
exec_line = NATIVE_URI_PREFIX + desktop_path if native else entry.get("Exec", exe_path)
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
self._sync_game_shortcuts_from_dialog(dialog, name, exec_line, cover_for_shortcut)
# User cover fallback
user_cover_path = cover_path # Already set if user provided
native_game = read_native_game(Path(desktop_path)) if native else None
if native_game:
user_cover_path = native_game[2] or user_cover_path
# Statistics (playtime, last launch - defaults for new)
playtime_seconds = 0
@@ -845,13 +868,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"portproton",
"native" if native else "portproton",
steam_info.get("anticheat_slug", ""),
steam_info.get("ppdb_id", ""),
steam_info.get("ppdb_rating", ""),
)
# Incremental add
if native and game_config.get_display_filter() not in ("all", "native"):
return
self.game_library_manager.add_game_incremental(game_data)
# Trigger visible images load
@@ -873,7 +898,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
cached_steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"portproton",
"native" if native else "portproton",
cached_steam_info.get("anticheat_slug", ""),
cached_steam_info.get("ppdb_id", ""),
cached_steam_info.get("ppdb_rating", ""),
@@ -885,6 +910,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
def _sync_game_shortcuts_from_dialog(self, dialog, game_name, exec_line, cover_path):
"""Apply shortcut options selected in add/edit game dialog."""
if exec_line.startswith(NATIVE_URI_PREFIX):
source = Path(exec_line.removeprefix(NATIVE_URI_PREFIX))
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
for directory, checkbox in (
(os.path.join(data_home, "applications"), dialog.add_to_menu_checkbox),
(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation), dialog.add_to_desktop_checkbox),
):
if directory and checkbox.isChecked():
try:
os.makedirs(directory, exist_ok=True)
shutil.copyfile(source, Path(directory) / source.name)
os.chmod(Path(directory) / source.name, 0o755)
except OSError as error:
logger.warning("Failed to copy native shortcut to %s: %s", directory, error)
QMessageBox.warning(self, _("Error"), str(error))
if dialog.add_to_steam_checkbox.isChecked():
if not is_game_in_steam(game_name):
self.context_menu_manager.add_to_steam(game_name, exec_line, cover_path)
elif is_game_in_steam(game_name):
self.context_menu_manager.remove_from_steam(game_name, exec_line, "native")
return
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
menu_path = os.path.join(applications_dir, f"{game_name}.desktop")
if dialog.add_to_menu_checkbox.isChecked():
+1
View File
@@ -32,6 +32,7 @@ class MainWindowWorkersMixin:
"egs_auth_worker": None,
"gog_library_worker": None,
"gog_metadata_worker": None,
"native_library_worker": None,
"gog_repair_worker": None,
"gog_delete_worker": None,
"store_dlc_worker": None,
@@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0008 1a6.9996 6.9996 0 0 0-7.0008 6.9992 6.9996 6.9996 0 0 0 7.0008 7.0008 6.9996 6.9996 0 0 0 6.9992-7.0008 6.9996 6.9996 0 0 0-6.9992-6.9992zm0.03052 1.8345c0.49138 0 0.68069 0.026991 1.0522 0.151 0.77261 0.25794 1.4608 0.88663 1.857 1.698 0.36432 0.74592 0.47136 1.542 0.34538 2.5446-0.02796 0.2226-0.0498 0.49442-0.0498 0.60402v0.1992l0.24578 0.24739c0.8226 0.82687 1.5016 1.8186 1.8892 2.7614 0.0076 0.01852 0.01187 0.0377 0.01928 0.05622-0.49301 0.83904-1.1794 1.5485-2.0016 2.0691-0.04447-0.3775-0.13313-0.76334-0.26667-1.147-0.33295-0.95658-0.86947-1.775-1.5422-2.3534-0.22323-0.19199-0.53694-0.41582-0.75181-0.53655-0.0648-0.036441-0.11405-0.069295-0.11084-0.072289 0.0035-0.00285 0.10384-0.043441 0.22329-0.08996 0.47005-0.18305 0.84651-0.41233 1.1438-0.69558 0.4802-0.45754 0.7046-0.96565 0.70361-1.5984-0.0012-0.60346-0.212-1.0975-0.64418-1.5133-0.37522-0.36091-0.8574-0.54398-1.3639-0.53815-0.30388 0.003495-0.61702 0.07465-0.92048 0.21687-0.40675 0.1906-0.86555 0.57266-1.2353 1.0265-0.19966 0.24497-0.64341 0.92641-0.61687 1.0008 0.17687 0.49573 0.05014 0.89774-0.68755 1.1213-0.14863 0.00956-0.2752 0.022977-0.28112 0.028916-0.01608 0.016029 0.06854 0.1696 0.16707 0.30843 0.14523 0.20473 0.39623 0.4208 0.6763 0.57992 0.0689 0.039149 0.12571 0.076335 0.1253 0.081928 0 0.00577-0.07689 0.054243-0.16867 0.10763-0.66042 0.38426-1.248 1.1985-1.6 2.2201-0.14407 0.4181-0.23536 0.77816-0.28755 1.1454-0.55629-0.51797-1.0112-1.1354-1.3414-1.8201 0.18238-0.35341 0.3666-0.624 0.69237-1.0217 0.32937-0.40208 0.89278-0.92816 1.3494-1.2594l0.17671-0.12851v-0.12209c1e-6 -0.11617-0.0027-0.11963-0.04659-0.10602-0.13353 0.041394-0.41447 0.20677-0.53012 0.31165l-0.05783 0.053012 0.01125-0.24418c0.01809-0.40899 0.1444-0.77469 0.40482-1.1647l0.13815-0.20562-0.0498-0.1751c-0.1341-0.46267-0.11978-1.0338 0.04016-1.5133 0.15572-0.46694 0.38428-0.82639 0.76144-1.2032 0.5179-0.51738 1.0636-0.80828 1.8201-0.96867 0.24284-0.051483 0.30063-0.056286 0.71165-0.056225zm0.45783 3.1325c0.39072-0.00731 0.65853 0.36199 0.5494 0.75663-0.03707 0.13416-0.22104 0.31614-0.36466 0.35823-0.13428 0.039318-0.26887 0.039149-0.38072 0v-0.00161c-0.19556-0.069357-0.35076-0.27993-0.36787-0.5012-0.01367-0.17688 0.04474-0.32481 0.17831-0.4498 0.12845-0.12009 0.21938-0.15913 0.38554-0.16225zm-0.14297 0.33574c-0.06851 0.00128-0.10608 0.017958-0.15904 0.06747-0.05507 0.051532-0.07953 0.11181-0.0739 0.18474 0.0071 0.091227 0.07199 0.17863 0.15261 0.20723 0.04612 0.016141 0.10046 0.01621 0.15582 0 0.05921-0.017353 0.13572-0.090873 0.151-0.14618 0.045-0.1627-0.06542-0.31627-0.22651-0.31325zm-3.0185 0.55904c-0.01687-1.298e-4 -0.03412 0.00176-0.05301 0.00321-0.12511 0.00961-0.15006 0.020885-0.27149 0.12048-0.15317 0.12564-0.3168 0.33226-0.45301 0.57349-0.10345 0.18332-0.24198 0.53868-0.2008 0.51566 0.0143-0.00799 0.11568-0.087633 0.22329-0.17671 0.26056-0.21554 0.35801-0.28499 0.67148-0.47068 0.36751-0.21785 0.41804-0.28276 0.32932-0.42892-0.05724-0.094184-0.12772-0.13564-0.24578-0.13655z" fill="#fff" stroke-width="0"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+3
View File
@@ -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(
+480
View File
@@ -49,6 +49,8 @@ def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNam
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr("portprotonqt.config.portproton.get_portproton_location", lambda: str(tmp_path))
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
@@ -232,6 +234,59 @@ def test_other_launch_modes_keep_main_window(
startup.window.assert_called_once()
@pytest.mark.parametrize("flags", [[], ["--silent"], ["--log"], ["--silent", "--log"]])
@pytest.mark.parametrize("existing", [False, True])
def test_native_shortcut_launch_modes(
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, flags: list[str], existing: bool,
) -> None:
from urllib.parse import quote
from PySide6.QtCore import QThread
from portprotonqt.native_games import create_native_desktop_file
startup = silent_startup
executable = startup.exe.with_name("native game")
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o755)
result = create_native_desktop_file(str(executable), "Native")
assert result is not None
content, destination = result
Path(destination).write_text(content)
uri = "native://" + destination
startup.socket.waitForConnected.return_value = existing
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, str(executable)])
mode = "log" if "--log" in flags else "silent" if "--silent" in flags else "open"
if existing:
startup.app.main()
startup.socket.write.assert_called_once_with(f"{mode}:{quote(uri, safe='')}".encode())
startup.window.assert_not_called()
return
window, timers = MagicMock(), MagicMock()
window.game_processes = []
startup.window.side_effect, startup.window.return_value = None, window
monkeypatch.setattr(startup.app, "QTimer", timers)
monkeypatch.setattr(QThread, "start", MagicMock())
monkeypatch.setattr(startup.app.display_config, "get_auto_fullscreen_gamepad", lambda: False)
monkeypatch.setattr(startup.app.display_config, "get_start_minimized", lambda: False)
def event_loop() -> int:
callback = next(call.args[1] for call in timers.singleShot.call_args_list
if getattr(call.args[1], "__name__", "") == "handle_native_launch")
callback()
return 0
startup.qt_app.exec.side_effect = event_loop
with pytest.raises(SystemExit):
startup.app.main()
assert window.silent_launch_mode is (mode == "silent")
if mode == "silent":
window.toggleGame.assert_called_once_with(uri)
else:
window._load_native_games_async.assert_called_once()
window._load_native_games_async.call_args.args[0]([
("Native", "", "", "", "", uri, "", "", "", "", 0, 0, "native", "", "", "")])
assert window.openGameDetailPage.call_args.args[0]["exec_line"] == uri
if mode == "log":
assert window._pending_log_exe == uri
class TestNormalizeLaunchPath:
def test_plain_path(self):
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
@@ -408,3 +463,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
+21 -1
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
from pytest import MonkeyPatch
from pytest import MonkeyPatch, mark
from PySide6.QtWidgets import QWidget
import portprotonqt.detail_pages.utils as detail_utils
@@ -701,3 +701,23 @@ def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
add_count = sum(1 for t in button_texts if "Add" in t or "Добавить" in t)
assert edit_count == 1
assert add_count == 0
@mark.parametrize("managed", [False, True])
def test_native_detail_folder_button_only_for_managed_games(
tmp_path: Path, monkeypatch: MonkeyPatch, managed: bool,
) -> None:
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
data = tmp_path / "data"
monkeypatch.setenv("XDG_DATA_HOME", str(data))
directory = data / "PortProtonQt/native_games" if managed else tmp_path / "usr/share/applications"
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
manager._create_game_buttons_layout({
"game_source": "native",
"exec_line": "native://" + str(directory / "hytale.desktop"),
"name": "Hytale Launcher",
})
buttons = _get_added_buttons(manager)
assert "Settings" in buttons
assert "Create Log" in buttons
assert ("Open Folder" in buttons) is managed
+46 -1
View File
@@ -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(
+58
View File
@@ -147,3 +147,61 @@ def test_list_icon_uses_theme_state_colors(
QIcon.Mode.Selected: "folder-#222222.svg",
QIcon.Mode.Disabled: "folder-#333333.svg",
}
def test_appimage_thumbnail_worker_caches_icon_and_finishes(
tmp_path: Path, monkeypatch: MonkeyPatch,
) -> None:
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QImage
source = tmp_path / "game.AppImage"
source.touch()
cache = tmp_path / "cache" / "icon.png"
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
calls = []
def generate(path: str, output: str) -> bool:
calls.append(path)
image = QImage(16, 16, QImage.Format.Format_ARGB32)
image.fill(QColor("red"))
return image.save(output)
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", generate)
pool = QThreadPool()
for _attempt in range(2):
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
received = []
loader.signals.thumbnail_ready.connect(lambda path, image, received=received: received.append((path, image)))
pool.start(loader)
assert pool.waitForDone(5000)
_application.processEvents()
assert len(received) == 1
assert received[0][0] == str(source)
assert not received[0][1].isNull()
assert calls == [str(source)]
assert pool.activeThreadCount() == 0
def test_appimage_thumbnail_failure_completes_without_replacing_icon(
tmp_path: Path, monkeypatch: MonkeyPatch,
) -> None:
from PySide6.QtGui import QImage
source = tmp_path / "game.AppImage"
source.touch()
cache = tmp_path / "cache" / "icon.png"
monkeypatch.setattr(file_explorer_module, "get_exe_icon_cache_path", lambda _path: str(cache))
monkeypatch.setattr(file_explorer_module, "generate_thumbnail", lambda *_args: False)
loader = FileExplorer.ThumbnailLoader(str(source), "application/vnd.appimage")
received = []
loader.signals.thumbnail_ready.connect(lambda path, image: received.append((path, image)))
loader.run()
assert len(received) == 1
assert received[0][0] == str(source)
assert received[0][1].isNull()
item = MagicMock()
explorer = SimpleNamespace(thumbnail_cache={}, file_items={str(source): item})
FileExplorer._set_thumbnail_icon(cast(FileExplorer, explorer), str(source), QImage())
assert str(source) in explorer.thumbnail_cache
item.setIcon.assert_not_called()
+84 -3
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
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))
@@ -669,3 +678,75 @@ def test_custom_icon_overrides_extracted_icon(tmp_path: Path, monkeypatch: Monke
icon.unlink()
assert get_custom_icon_path(executable) == ""
assert get_exe_icon_cache_path(executable) != str(icon)
@mark.parametrize("elf_class,endian", [(1, "<"), (2, "<"), (2, ">")])
@mark.parametrize("svg", [False, True])
def test_appimage_icon_extraction(tmp_path: Path, elf_class: int, endian: str, svg: bool) -> None:
from portprotonqt.icon_extractor import APPIMAGE_EXTRACT_TIMEOUT, ELF_SECTION_LAYOUTS
header = bytearray(64)
header[:4] = b"\x7fELF"
header[4:6] = bytes([elf_class, 1 if endian == "<" else 2])
header[8:11] = b"AI\x02"
offset_position, offset_format, size_position = ELF_SECTION_LAYOUTS[elf_class]
struct.pack_into(endian + offset_format, header, offset_position, len(header))
struct.pack_into(endian + "HH", header, size_position, 0, 0)
source = tmp_path / "game.AppImage"
source.write_bytes(header + b"hsqs")
output = tmp_path / "icon.png"
icon = io.BytesIO()
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">'
b'<rect width="16" height="16" fill="red"/></svg>') if svg else icon.getvalue()
with patch("portprotonqt.icon_extractor.subprocess.run", return_value=SimpleNamespace(stdout=payload)) as run:
assert generate_thumbnail(str(source), str(output))
run.assert_called_once_with(
["unsquashfs", "-cat", "-o", "64", str(source), ".DirIcon"],
capture_output=True, check=True, timeout=APPIMAGE_EXTRACT_TIMEOUT,
)
with Image.open(output) as image:
assert image.size == (128, 128)
assert image.convert("RGBA").getpixel((64, 64)) == (255, 0, 0, 255)
def test_appimage_icon_missing_tool(tmp_path: Path) -> None:
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
header[8:11] = b"AI\x02"
struct.pack_into("<Q", header, 40, len(header))
source = tmp_path / "game.AppImage"
source.write_bytes(header + b"hsqs")
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=FileNotFoundError):
assert not generate_thumbnail(str(source), str(tmp_path / "icon.png"))
@mark.parametrize("target", ["icon.png", "../outside", ".DirIcon"])
def test_dwarfs_icon_symlink(tmp_path: Path, target: str) -> None:
import tarfile
header = bytearray(64)
header[:6] = b"\x7fELF\x02\x01"
header[8:11] = b"AI\x02"
struct.pack_into("<Q", header, 40, len(header))
source = tmp_path / "game.AppImage"
source.write_bytes(header + b"DWARFS")
link_archive = io.BytesIO()
with tarfile.open(fileobj=link_archive, mode="w") as archive:
member = tarfile.TarInfo(".DirIcon")
member.type = tarfile.SYMTYPE
member.linkname = target
archive.addfile(member)
icon = io.BytesIO()
Image.new("RGBA", (16, 16), "red").save(icon, "PNG")
icon_archive = io.BytesIO()
with tarfile.open(fileobj=icon_archive, mode="w") as archive:
member = tarfile.TarInfo("icon.png")
member.size = len(icon.getvalue())
archive.addfile(member, io.BytesIO(icon.getvalue()))
results = [SimpleNamespace(stdout=data.getvalue()) for data in (link_archive, icon_archive)]
with patch("portprotonqt.icon_extractor.subprocess.run", side_effect=results) as run:
assert generate_thumbnail(str(source), str(tmp_path / "icon.png")) is (target == "icon.png")
assert run.call_count == (2 if target == "icon.png" else 1)
assert run.call_args_list[0].args[0][0] == "dwarfsextract"
assert "--skip-specials" in run.call_args_list[0].args[0]
+47
View File
@@ -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
+18 -1
View File
@@ -13,7 +13,7 @@ from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
from portprotonqt import image_utils
from portprotonqt.game_card import GameCard
from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.theme_manager import load_theme
from portprotonqt.theme_manager import ThemeManager, load_theme
LOAD_TIMEOUT_MS = 30000
@@ -54,6 +54,23 @@ def library_scene(
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
def test_native_card_uses_linux_source_corner(
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager, _ = library_scene
monkeypatch.setattr(ThemeManager(), "current_theme_name", "standart")
card = manager._create_game_card((
"Native", "", "", "", "", "native:///game.desktop", "", "", "", "", 0, 0, "native",
))
assert card.native_visible
assert isinstance(card.nativeLabel._icon, str)
assert Path(card.nativeLabel._icon).name == "badge_linux.svg"
assert Path(card.nativeLabel._icon).is_file()
assert not card.portproton_visible
card.deleteLater()
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
def test_new_horizontal_cards_fit_before_layout_insertion(
+21 -13
View File
@@ -316,7 +316,7 @@ def _tile_theme() -> Any:
@mark.parametrize(
"worker_name",
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
("egs_library_worker", "gog_library_worker", "gog_metadata_worker", "native_library_worker", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker"),
)
def test_shutdown_waits_for_store_library_workers(worker_name: str) -> None:
worker = MagicMock()
@@ -435,14 +435,16 @@ def test_minimal_tray_exits_after_stopping_game(monkeypatch: MonkeyPatch) -> Non
quit_app.assert_called_once_with()
@mark.parametrize(("vertical_layout", "ribbon_visible"), [(False, True), (True, True)])
@mark.parametrize("source", ["portproton", "native"])
def test_source_ribbon_visibility(
monkeypatch: MonkeyPatch,
vertical_layout: bool,
ribbon_visible: bool,
source: str,
) -> None:
card = MagicMock()
card.coverLabel = MagicMock()
card.game_source = "portproton"
card.game_source = source
card.ppdb_id = "report-id"
card.protondb_tier = "Platinum"
card.anticheat_status = "Supported"
@@ -453,9 +455,9 @@ def test_source_ribbon_visibility(
card.parentWidget.return_value = None
monkeypatch.setattr("portprotonqt.game_card.ui_config.get_economy_mode", lambda: False)
GameCard.update_badge_visibility(card, "portproton")
GameCard.update_badge_visibility(card, source)
card.portprotonLabel.setVisible.assert_called_with(ribbon_visible)
getattr(card, f"{source}Label").setVisible.assert_called_with(ribbon_visible)
card.ppdbLabel.setVisible.assert_called_with(False)
card.protondbLabel.setVisible.assert_called_with(False)
card.anticheatLabel.setVisible.assert_called_with(False)
@@ -1707,7 +1709,8 @@ def test_open_local_autoinstall_card_uses_autoinstall_page(tmp_path: Path) -> No
assert game_data["exec_line"] == f"autoinstall:{shlex.quote(str(script_path))}"
assert return_tab_index == 0
def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
@mark.parametrize("native", [False, True])
def test_open_game_detail_starts_pending_log(tmp_path: Path, native: bool) -> None:
exe_path = tmp_path / "Game.exe"
exe_path.touch()
started = []
@@ -1732,11 +1735,12 @@ def test_open_game_detail_starts_pending_log(tmp_path: Path) -> None:
window.detail_page_manager = FakeDetailPageManager()
window.stackedWidget = FakeStackedWidget()
window.currentDetailPage = None
window._pending_log_exe = str(exe_path)
target = "native://" + str(tmp_path / "Native Game.desktop") if native else str(exe_path)
window._pending_log_exe = target
window.openGameDetailPage({"name": "Game", "exec_line": str(exe_path)})
window.openGameDetailPage({"name": "Game", "exec_line": target})
assert started == [(str(exe_path), window.detail_page_manager._debug_log_button)]
assert started == [(target, window.detail_page_manager._debug_log_button)]
assert window._pending_log_exe is None
def test_launch_exe_skips_library_load_for_ppai() -> None:
@@ -2734,6 +2738,7 @@ def test_add_game_dialog_does_not_reprocess_its_shortcut(
exeEdit=SimpleNamespace(text=lambda: "/games/praest.exe"),
coverEdit=SimpleNamespace(text=lambda: ""),
getDesktopEntryData=lambda: (entry, str(desktop_path)),
native_checkbox=SimpleNamespace(isChecked=lambda: False),
last_cover_path="",
)
window: Any = MainWindow.__new__(MainWindow)
@@ -3280,27 +3285,30 @@ def test_library_source_multiselection(
)
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch) -> None:
@mark.parametrize("source", ["portproton", "native"])
def test_library_loads_only_selected_sources(monkeypatch: MonkeyPatch, source: str) -> None:
_application = QApplication.instance() or QApplication([])
monkeypatch.setattr(game_config, "get_display_filter", lambda: "gog,portproton")
monkeypatch.setattr(game_config, "get_display_filter", lambda: f"gog,{source}")
monkeypatch.setattr(game_config, "get_only_installed", lambda: False)
callbacks = {}
loaded = MagicMock()
window = cast(Any, SimpleNamespace(
_loading_games=False, launch_exe=None, games_loaded=SimpleNamespace(emit=loaded),
_load_gog_games_async=lambda callback: callbacks.update(gog=callback),
_load_portproton_games_async=lambda callback: callbacks.update(portproton=callback),
_load_portproton_games_async=MagicMock(), _load_native_games_async=MagicMock(),
_load_steam_games_async=MagicMock(), _load_egs_games_async=MagicMock(),
))
setattr(window, f"_load_{source}_games_async", lambda callback: callbacks.update({source: callback}))
MainWindow.loadGames(window, force_load=True)
pp_game = ("PP", "", "", "", "", "pp.exe")
gog_game = ("GOG", "", "", "", "", "gog://launch/1")
callbacks["gog"]([gog_game])
loaded.assert_not_called()
callbacks["portproton"]([pp_game])
callbacks[source]([pp_game])
loaded.assert_called_once_with([pp_game, gog_game])
loaded.assert_called_once_with([pp_game, gog_game] if source == "portproton" else [gog_game, pp_game])
getattr(window, f"_load_{'native' if source == 'portproton' else 'portproton'}_games_async").assert_not_called()
window._load_steam_games_async.assert_not_called()
window._load_egs_games_async.assert_not_called()
File diff suppressed because it is too large. Load diff
+71
View File
@@ -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()
+86 -7
View File
@@ -69,7 +69,7 @@ def test_game_card_context_menu_does_not_block_gamepad_polling(monkeypatch) -> N
assert app is not None
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id"])
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id", "native"])
def test_add_to_steam_for_all_accounts(
tmp_path: Path,
monkeypatch: Any,
@@ -77,8 +77,10 @@ def test_add_to_steam_for_all_accounts(
) -> None:
steam_home = tmp_path / "steam"
user_ids = ("76561197960265729", "76561197960265730")
executable = tmp_path / "game.exe"
executable = tmp_path / ("Hytale Launcher.desktop" if target == "native" else "game.exe")
executable.touch()
if target == "native":
target = "native://" + str(executable)
monkeypatch.setattr(steam_shortcuts, "get_steam_home", lambda: steam_home)
monkeypatch.setattr(
steam_shortcuts,
@@ -112,6 +114,9 @@ def test_add_to_steam_for_all_accounts(
)
assert result is True
if target.startswith("native://"):
script = tmp_path / "steam_scripts" / "Test Game.sh"
assert "'" + target + "'" in script.read_text()
generate_icon.assert_not_called()
assert custom_icon_lookup.call_args_list[0].args == (
str(tmp_path / "steam_scripts" / "Test Game.sh"),
@@ -1568,7 +1573,8 @@ def test_local_shortcut_name_keeps_metadata_on_write_failure(
assert list(game_dir.iterdir()) == [metadata]
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id"])
@pytest.mark.parametrize("target", ["game.exe", "gog://launch/123", "egs://launch/game-id",
"native:///usr/share/applications/com.hypixel.HytaleLauncher.desktop"])
@pytest.mark.parametrize("batch,result,expected", [
(True, (True, "restart"), (0, 0)),
(False, (True, "Game added to Steam via CEF API"), (0, 0)),
@@ -1582,7 +1588,8 @@ def test_steam_addition_dialogs(monkeypatch: Any, batch: bool, result: tuple[boo
manager = object.__new__(ContextMenuManager)
manager._batch_action = batch
manager._check_portproton = lambda: True
monkeypatch.setattr(manager, "_get_exec_line", lambda game_name, exec_line: exec_line)
resolve_exec = MagicMock(side_effect=lambda game_name, exec_line: exec_line)
monkeypatch.setattr(manager, "_get_exec_line", resolve_exec)
if target == "game.exe":
monkeypatch.setattr(manager, "_parse_exe_path", lambda exec_line, game_name: exec_line)
manager.signals = MagicMock()
@@ -1591,7 +1598,11 @@ def test_steam_addition_dialogs(monkeypatch: Any, batch: bool, result: tuple[boo
add_shortcut = MagicMock(return_value=result)
monkeypatch.setattr("portprotonqt.context_menu_manager.add_to_steam", add_shortcut)
manager.add_to_steam("Game", target, "cover")
if target != "game.exe":
if target.startswith("native://"):
resolve_exec.assert_not_called()
api.get_launch_target.assert_not_called()
add_shortcut.assert_called_once_with("Game", target, "cover")
elif target != "game.exe":
api.get_launch_target.assert_called_once_with(target.rsplit("/", 1)[-1])
add_shortcut.assert_called_once_with("Game", target, "cover", "installed.exe")
assert manager.signals.show_info_dialog.emit.call_count == expected[0]
@@ -2015,7 +2026,7 @@ def test_game_folder_import_completion_refreshes_completed_games(
@pytest.mark.parametrize("edit_mode", [False, True])
def test_add_game_file_picker_offers_folder_selection_only_for_adding(
monkeypatch: pytest.MonkeyPatch, edit_mode: bool,
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, edit_mode: bool,
) -> None:
from PySide6.QtWidgets import QHBoxLayout
import portprotonqt.dialogs.base as module
@@ -2023,7 +2034,8 @@ def test_add_game_file_picker_offers_folder_selection_only_for_adding(
app = QApplication.instance() or QApplication([])
explorer = SimpleNamespace(file_signal=MagicMock(), button_layout=QHBoxLayout(), show=MagicMock())
monkeypatch.setattr(explorer_module, "FileExplorer", lambda *_args, **_kwargs: explorer)
create_explorer = MagicMock(return_value=explorer)
monkeypatch.setattr(explorer_module, "FileExplorer", create_explorer)
monkeypatch.setattr(module, "AutoSizeButton", lambda text, **_kwargs: QPushButton(text))
dialog = SimpleNamespace(
last_exe_path=None, edit_mode=edit_mode, onExeSelected=MagicMock(),
@@ -2033,6 +2045,17 @@ def test_add_game_file_picker_offers_folder_selection_only_for_adding(
module.AddGameDialog.browseExe(cast(Any, dialog))
file_filter = create_explorer.call_args.kwargs["file_filter"]
assert file_filter("/game.exe")
for filename in ("game", "game.AppImage", "game.sh", "game.txt"):
path = tmp_path / filename
path.touch()
path.chmod(0o644)
assert not file_filter(str(path))
path.chmod(0o755)
assert file_filter(str(path))
assert not file_filter(str(tmp_path))
assert not file_filter(str(tmp_path / "missing"))
explorer.file_signal.file_selected.connect.assert_called_once_with(dialog.onExeSelected)
assert explorer.button_layout.count() == int(not edit_mode)
if not edit_mode:
@@ -2683,3 +2706,59 @@ def test_sgdb_cloud_urls_use_proxy(
api.fetch_sgdb_cover_async("Portal 2", completed.append, image_type)
assert completed == [f"https://deckyloader.ru/sgdb-cdn/{image_type}/asset.png"]
@pytest.mark.parametrize("themed", [False, True])
def test_native_steam_icon_uses_desktop_icon(tmp_path: Path, monkeypatch: Any, themed: bool) -> None:
from PySide6.QtGui import QPixmap
app = QApplication.instance() or QApplication([])
image = tmp_path / "pixmaps/hytale-launcher.png"
image.parent.mkdir()
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.GlobalColor.red)
assert pixmap.save(str(image))
source = tmp_path / "hytale.desktop"
icon = "hytale-launcher" if themed else str(image)
source.write_text(f"[Desktop Entry]\nType=Application\nName=Hytale\nIcon={icon}\nExec=hytale-launcher\n")
monkeypatch.setattr(steam_shortcuts.QIcon, "fromTheme", lambda _name: QIcon())
monkeypatch.setattr(steam_shortcuts.QStandardPaths, "standardLocations", lambda _location: [str(tmp_path)])
generated = steam_shortcuts._generate_icon(str(source), str(tmp_path))
assert generated
assert not QPixmap(generated).isNull()
assert app is not None
@pytest.mark.parametrize("relative", [
"pixmaps/hytale.xpm", "icons/hytale.png", "icons/hicolor/128x128/apps/hytale.png",
"icons/hicolor/scalable/apps/hytale.svg", "icons/other/64x64/games/hytale.png",
])
def test_desktop_icon_searches_xdg_formats(tmp_path: Path, monkeypatch: Any, relative: str) -> None:
from PySide6.QtGui import QPixmap
app = QApplication.instance() or QApplication([])
image = tmp_path / relative
image.parent.mkdir(parents=True)
if image.suffix == ".svg":
image.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" fill="red"/></svg>')
else:
pixmap = QPixmap(32, 32)
pixmap.fill(Qt.GlobalColor.red)
assert pixmap.save(str(image))
monkeypatch.setattr(steam_shortcuts.QIcon, "fromTheme", lambda _name: QIcon())
monkeypatch.setattr(steam_shortcuts.QStandardPaths, "standardLocations", lambda _location: [str(tmp_path)])
assert not steam_shortcuts._resolve_desktop_icon("hytale").pixmap(32, 32).isNull()
assert not steam_shortcuts._resolve_desktop_icon(image.name).pixmap(32, 32).isNull()
assert steam_shortcuts._resolve_desktop_icon("missing").isNull()
assert app is not None
def test_native_cached_metadata_does_not_search_desktop_directory(monkeypatch: Any) -> None:
import portprotonqt.steam_api.api as api
monkeypatch.setattr(api, "_get_cached_data_and_index", lambda *_args: ([{"appid": 1}], {}))
search = MagicMock(return_value=None)
monkeypatch.setattr(api, "search_app", search)
assert api.get_cached_steam_game_info("Hytale Launcher", "native:///usr/share/applications/com.hypixel.HytaleLauncher.desktop") == {}
assert search.call_count == 1
assert search.call_args.args[0] == "Hytale Launcher"
+13 -3
View File
@@ -122,7 +122,6 @@ def test_dlc_worker_released_before_modal_and_retry(
dialog = download_tab_module.QDialog(self)
QTimer.singleShot(0, dialog.reject)
dialog.exec()
loop.quit()
window: Any = Window()
window.gog_api = cast(Any, object())
@@ -136,18 +135,29 @@ def test_dlc_worker_released_before_modal_and_retry(
def warning(*args: Any) -> None:
observations.append((window.store_dlc_worker, QThread.currentThread()))
loop.quit()
monkeypatch.setattr(download_tab_module.StoreDLCWorker, "run", run)
monkeypatch.setattr(download_tab_module.QMessageBox, "warning", warning)
timeout = QTimer()
timeout.setSingleShot(True)
timeout.timeout.connect(loop.quit)
for _attempt in range(2):
ready = QTimer()
ready.setInterval(10)
for attempt in range(2):
def finish_when_observed(expected: int = attempt + 1) -> None:
if len(observations) == expected and window.store_dlc_worker is None:
loop.quit()
ready.timeout.connect(finish_when_observed)
window._select_store_dlcs(source, {"app_id": "123", "title": "Game"})
ready.start()
timeout.start(3000)
loop.exec()
ready.stop()
ready.timeout.disconnect(finish_when_observed)
timeout.stop()
assert len(observations) == attempt + 1
assert window.store_dlc_worker is None
assert observations == [(None, application.thread())] * 2
window.deleteLater()