diff --git a/dev-scripts/generate-completions.sh b/dev-scripts/generate-completions.sh index 93e125f7..eb110f29 100755 --- a/dev-scripts/generate-completions.sh +++ b/dev-scripts/generate-completions.sh @@ -84,8 +84,8 @@ _portprotonqt() { '--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]' @@ -120,8 +120,8 @@ 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" diff --git a/portprotonqt/app.py b/portprotonqt/app.py index 0d50f759..d2c4c395 100644 --- a/portprotonqt/app.py +++ b/portprotonqt/app.py @@ -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, @@ -271,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(): @@ -302,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)}" @@ -399,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) @@ -484,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 @@ -494,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) @@ -510,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") @@ -538,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: @@ -552,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) @@ -633,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:"): diff --git a/portprotonqt/cli.py b/portprotonqt/cli.py index 2ffbd44f..84fc7816 100644 --- a/portprotonqt/cli.py +++ b/portprotonqt/cli.py @@ -89,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", @@ -111,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("--") diff --git a/portprotonqt/context_menu_manager.py b/portprotonqt/context_menu_manager.py index ca835daf..37250f17 100644 --- a/portprotonqt/context_menu_manager.py +++ b/portprotonqt/context_menu_manager.py @@ -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: diff --git a/portprotonqt/debug_utils/debug_log_manager.py b/portprotonqt/debug_utils/debug_log_manager.py index e26f788b..dc23b4a0 100644 --- a/portprotonqt/debug_utils/debug_log_manager.py +++ b/portprotonqt/debug_utils/debug_log_manager.py @@ -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: diff --git a/portprotonqt/debug_utils/system_info.py b/portprotonqt/debug_utils/system_info.py index ae46a351..c8e9b2de 100644 --- a/portprotonqt/debug_utils/system_info.py +++ b/portprotonqt/debug_utils/system_info.py @@ -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) diff --git a/portprotonqt/detail_pages/__init__.py b/portprotonqt/detail_pages/__init__.py index dd889451..a4ed4260 100644 --- a/portprotonqt/detail_pages/__init__.py +++ b/portprotonqt/detail_pages/__init__.py @@ -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): diff --git a/portprotonqt/dialogs/base.py b/portprotonqt/dialogs/base.py index 9a06823c..505fa702 100644 --- a/portprotonqt/dialogs/base.py +++ b/portprotonqt/dialogs/base.py @@ -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"): diff --git a/portprotonqt/dialogs/file_explorer.py b/portprotonqt/dialogs/file_explorer.py index b7822abc..842c0b60 100644 --- a/portprotonqt/dialogs/file_explorer.py +++ b/portprotonqt/dialogs/file_explorer.py @@ -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) diff --git a/portprotonqt/dialogs/settings_dialog.py b/portprotonqt/dialogs/settings_dialog.py index 51da28c4..35064a89 100644 --- a/portprotonqt/dialogs/settings_dialog.py +++ b/portprotonqt/dialogs/settings_dialog.py @@ -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() diff --git a/portprotonqt/game_card.py b/portprotonqt/game_card.py index 81d0bf96..7ccf740f 100644 --- a/portprotonqt/game_card.py +++ b/portprotonqt/game_card.py @@ -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) diff --git a/portprotonqt/icon_extractor.py b/portprotonqt/icon_extractor.py index cf2afff4..48e31adb 100644 --- a/portprotonqt/icon_extractor.py +++ b/portprotonqt/icon_extractor.py @@ -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 diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 5ed1275e..e1a0ff46 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -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) diff --git a/portprotonqt/meson.build b/portprotonqt/meson.build index ee5e8f0d..deaf308c 100644 --- a/portprotonqt/meson.build +++ b/portprotonqt/meson.build @@ -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', diff --git a/portprotonqt/native_games.py b/portprotonqt/native_games.py new file mode 100644 index 00000000..928765c3 --- /dev/null +++ b/portprotonqt/native_games.py @@ -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) diff --git a/portprotonqt/steam_api/api.py b/portprotonqt/steam_api/api.py index 1fcca814..b1c3a58b 100644 --- a/portprotonqt/steam_api/api.py +++ b/portprotonqt/steam_api/api.py @@ -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: diff --git a/portprotonqt/steam_api/shortcuts.py b/portprotonqt/steam_api/shortcuts.py index 3bda3a79..75567d7f 100644 --- a/portprotonqt/steam_api/shortcuts.py +++ b/portprotonqt/steam_api/shortcuts.py @@ -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() diff --git a/portprotonqt/tabs/library_tab.py b/portprotonqt/tabs/library_tab.py index ef2559b4..7e5e0c7e 100644 --- a/portprotonqt/tabs/library_tab.py +++ b/portprotonqt/tabs/library_tab.py @@ -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(): diff --git a/portprotonqt/tabs/workers.py b/portprotonqt/tabs/workers.py index f6794e62..dc97de1c 100644 --- a/portprotonqt/tabs/workers.py +++ b/portprotonqt/tabs/workers.py @@ -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, diff --git a/portprotonqt/themes/standart/images/icons/platforms/badge_linux.svg b/portprotonqt/themes/standart/images/icons/platforms/badge_linux.svg new file mode 100644 index 00000000..5ce65734 --- /dev/null +++ b/portprotonqt/themes/standart/images/icons/platforms/badge_linux.svg @@ -0,0 +1 @@ + diff --git a/tests/test_cli.py b/tests/test_cli.py index 53e37482..f1f1ca5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" diff --git a/tests/test_detail_pages.py b/tests/test_detail_pages.py index 3865f9dc..39023aff 100644 --- a/tests/test_detail_pages.py +++ b/tests/test_detail_pages.py @@ -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 diff --git a/tests/test_file_explorer.py b/tests/test_file_explorer.py index de0181c8..0a0cb062 100644 --- a/tests/test_file_explorer.py +++ b/tests/test_file_explorer.py @@ -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() diff --git a/tests/test_icon_extractor.py b/tests/test_icon_extractor.py index b8f6be8c..271c100c 100644 --- a/tests/test_icon_extractor.py +++ b/tests/test_icon_extractor.py @@ -678,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'' + b'') 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(" None: + import tarfile + + header = bytearray(64) + header[:6] = b"\x7fELF\x02\x01" + header[8:11] = b"AI\x02" + struct.pack_into(" 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( diff --git a/tests/test_main_window.py b/tests/test_main_window.py index ff3c78ae..cbd4c8c5 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -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() diff --git a/tests/test_native_games.py b/tests/test_native_games.py new file mode 100644 index 00000000..204290d1 --- /dev/null +++ b/tests/test_native_games.py @@ -0,0 +1,1020 @@ +"""Native desktop discovery, library completion and launch regressions.""" + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock +import shutil +import sys +import os +import signal +import subprocess +from datetime import datetime, timedelta + +import pytest +from PySide6.QtCore import QEventLoop, QStandardPaths, QThread, QTimer, Qt +from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QPushButton +from PySide6.QtGui import QIcon + +from portprotonqt.config import favorites_config, game_config +from portprotonqt.context_menu_manager import ContextMenuManager +from portprotonqt.main_window import MainWindow +from portprotonqt.localization import _ +from portprotonqt.native_games import ( + NativeLibraryWorker, read_native_game, create_native_desktop_file, get_native_launch, get_native_ppdb_path, + save_native_settings, load_native_settings, build_native_launch_command, +) + + +@pytest.fixture(autouse=True) +def isolated_native_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.delenv("APPIMAGE", raising=False) + monkeypatch.delenv("FLATPAK_ID", raising=False) + monkeypatch.setattr("portprotonqt.native_games.get_portproton_scripts_path", lambda: None) + monkeypatch.setattr("portprotonqt.context_menu_manager.is_game_in_steam", lambda _name: False) + monkeypatch.setattr("portprotonqt.tabs.library_tab.is_game_in_steam", lambda _name: False) + monkeypatch.setattr("portprotonqt.debug_utils.debug_log_manager.generate_system_info", lambda *_args: "Operating system: test\nCPU: test") + monkeypatch.setattr("portprotonqt.time_utils._stats_migrated", True) + for module in ("native_games", "context_menu_manager"): + monkeypatch.setattr(f"portprotonqt.{module}.get_portproton_location", lambda: str(tmp_path / "portdata")) + monkeypatch.setattr(QStandardPaths, "writableLocation", lambda _location: str(tmp_path / "Desktop")) + + +@pytest.mark.parametrize("extra", [ + "Hidden=true", "NoDisplay=true", "Type=Link", "Categories=ArcadeGame;", + "Exec=steam steam://rungameid/42", 'Exec=wine "game.exe"', + "TryExec=missing-native-game-test", + 'Exec="unterminated', "Exec=", "Name=", + "MimeType=x-scheme-handler/unknown-launcher;", +]) +def test_discovery_rejects_unavailable_or_non_native_entries(tmp_path: Path, extra: str) -> None: + path = tmp_path / "game.desktop" + path.write_text("[Desktop Entry]\nType=Application\nName=Game\nExec=true\nCategories=Game;\n" + extra + "\n") + assert read_native_game(path) is None + + +@pytest.mark.parametrize("command", ["custom-wrapper --game", "flatpak run org.example.Game"]) +def test_discovery_does_not_depend_on_launcher_names(tmp_path: Path, command: str) -> None: + path = tmp_path / "game.desktop" + path.write_text("[Desktop Entry]\nType=Application\nName=Game\nCategories=Game;\nExec=" + command + "\n") + game = read_native_game(path) + assert game is not None + assert game[5] == "native://" + str(path) + + +def test_scan_respects_xdg_overrides_and_flatpak_exports( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + home, system = tmp_path / "user", tmp_path / "system" + monkeypatch.setenv("XDG_DATA_HOME", str(home)) + monkeypatch.setenv("XDG_DATA_DIRS", str(system)) + desktop = tmp_path / "Desktop" + desktop.mkdir() + monkeypatch.setattr(QStandardPaths, "writableLocation", lambda _location: str(desktop)) + entry = "[Desktop Entry]\nType=Application\nName=Native Game\nExec=true %U\nCategories=Game;\n" + for root in (home, system, home / "flatpak/exports/share"): + (root / "applications").mkdir(parents=True) + (system / "applications/hidden.desktop").write_text(entry) + (home / "applications/hidden.desktop").write_text("[Desktop Entry]\nHidden=true\n") + path = home / "applications/game.desktop" + path.write_text(entry) + (system / "applications/game.desktop").write_text(entry) + (home / "flatpak/exports/share/applications/flatpak.desktop").write_text(entry.replace("Native Game", "Flatpak Game")) + (desktop / "desktop.desktop").write_text(entry.replace("Native Game", "Desktop Game")) + original_rglob = Path.rglob + monkeypatch.setattr(Path, "rglob", lambda path, pattern: + original_rglob(path, pattern) if path.is_relative_to(tmp_path) else iter(())) + worker = NativeLibraryWorker() + worker.run() + assert [game[0] for game in worker.games] == ["Native Game", "Flatpak Game"] + assert worker.games[0][5] == "native://" + str(path) + assert all(game[12] == "native" for game in worker.games) + assert path.read_text() == entry + + +@pytest.mark.parametrize("display_filter", ["all", "favorites", "native"]) +def test_native_source_completes_library_loading( + monkeypatch: pytest.MonkeyPatch, display_filter: str, +) -> None: + monkeypatch.setattr(game_config, "get_display_filter", lambda: display_filter) + monkeypatch.setattr(game_config, "get_only_installed", lambda: False) + monkeypatch.setattr(favorites_config, "get_games", lambda: ["Native"]) + game = ("Native", "", "", "", "", "native:///game.desktop") + window = cast(Any, SimpleNamespace( + _loading_games=False, launch_exe=None, games_loaded=MagicMock(), + _load_portproton_games_async=lambda callback: callback([]), + _load_steam_games_async=lambda callback: callback([]), + _load_gog_games_async=lambda callback: callback([]), + _load_egs_games_async=lambda callback: callback([]), + _load_native_games_async=lambda callback: callback([game]), + )) + MainWindow.loadGames(window) + window.games_loaded.emit.assert_called_once_with([game]) + + +def test_native_launch_preserves_desktop_file_and_bypasses_wine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "Game with spaces.desktop" + path.write_text(f"[Desktop Entry]\nExec=game %U\nPath={tmp_path}\n") + process = MagicMock(pid=1234) + popen = MagicMock(return_value=process) + monkeypatch.setattr("portprotonqt.main_window.subprocess.Popen", popen) + monkeypatch.setattr("portprotonqt.main_window.save_last_launch", MagicMock()) + monkeypatch.setattr("portprotonqt.main_window.QTimer", MagicMock()) + window = cast(Any, SimpleNamespace( + game_processes=[], input_manager=MagicMock(), _update_last_launch_after_start=MagicMock(), + _start_launch_output_reader=MagicMock(), _set_running_button_stop=MagicMock(), checkTargetExe=MagicMock(), + )) + button = MagicMock() + MainWindow._launch_native_game(window, "native://" + str(path), button) + assert popen.call_args.args[0] == ["game"] + assert popen.call_args.kwargs["cwd"] == str(tmp_path) + assert popen.call_args.kwargs["start_new_session"] + assert window.game_processes == [process] + assert window.current_running_button is button + assert window.native_game_running + window._set_running_button_stop.assert_called_once() + + +def test_native_context_launch_does_not_require_portproton() -> None: + parent = SimpleNamespace(toggleGame=MagicMock()) + manager = cast(Any, SimpleNamespace(parent=parent)) + card = SimpleNamespace(game_source="native", exec_line="native:///game.desktop", name="Game") + ContextMenuManager._launch_game(manager, card) + parent.toggleGame.assert_called_once_with(card.exec_line, game_name="Game") + + +def test_native_scan_completes_once_on_owner_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = QApplication.instance() or QApplication([]) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + monkeypatch.setattr(NativeLibraryWorker, "run", lambda self: None) + results: list[tuple[list, QThread]] = [] + loop = QEventLoop() + + def completed(games: list) -> None: + results.append((games, QThread.currentThread())) + loop.quit() + + window._load_native_games_async(completed) + worker = window.native_library_worker + assert worker is not None + QTimer.singleShot(5000, loop.quit) + loop.exec() + worker.wait() + app.processEvents() + assert results == [([], window.thread())] + assert window.native_library_worker is None + window.deleteLater() + + +@pytest.mark.parametrize("filename", ['start', 'game with spaces', 'game%name$"\\test']) +def test_manual_native_shortcut_round_trips_linux_paths( + tmp_path: Path, filename: str, +) -> None: + executable = tmp_path / filename + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + result = create_native_desktop_file(str(executable), "My Native Game") + assert result is not None + content, destination = result + assert Path(destination) == tmp_path / "portdata/My Native Game.desktop" + assert "X-PortProtonQt-Native" not in content + Path(destination).write_text(content) + assert get_native_launch(destination) == ([str(executable)], str(tmp_path)) + game = read_native_game(Path(destination)) + assert game is not None and game[12] == "native" + + +@pytest.mark.parametrize("custom_icon", [False, True]) +def test_native_shortcut_uses_saved_icon_or_theme_fallback( + tmp_path: Path, custom_icon: bool, +) -> None: + from portprotonqt.config import get_custom_data_dir_name + + executable = tmp_path / "start" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + icon = Path(os.environ["XDG_DATA_HOME"]) / "PortProtonQt/custom_data" / get_custom_data_dir_name(str(executable)) / "icon.png" + if custom_icon: + icon.parent.mkdir(parents=True) + icon.write_bytes(b"saved icon") + result = create_native_desktop_file(str(executable), "Native") + assert result is not None + content, _destination = result + assert f"Icon={icon if custom_icon else 'applications-games'}\n" in content + + +def test_saved_native_desktop_has_valid_keys_and_preserves_settings(tmp_path: Path) -> None: + executable = tmp_path / "start" + 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) + validator = shutil.which("desktop-file-validate") + if validator: + checked = subprocess.run([validator, destination], capture_output=True, text=True) + assert checked.returncode == 0, checked.stdout + checked.stderr + original = content + "\n[X-PortProtonQt]\nPW_USE_GAMEMODE=1\n" + Path(destination).write_text(original) + save_native_settings(destination, {}) + saved = Path(destination).read_text() + assert saved == original + assert f'Exec="portprotonqt" "--silent" "{executable}"\n' in saved + assert "gamemoderun" not in saved + assert 'export PW_USE_GAMEMODE="1"' in get_native_ppdb_path(destination, load_native_settings(destination)).read_text() + assert load_native_settings(destination)["PW_USE_GAMEMODE"] == "1" + assert "gamemoderun" in get_native_launch(destination)[0] + + +@pytest.mark.parametrize("package", ["native", "flatpak", "appimage"]) +def test_native_desktop_matches_standard_actions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, package: str, +) -> None: + import configparser + import shlex + + executable = tmp_path / "start" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + launcher = ["portprotonqt"] + if package == "flatpak": + monkeypatch.setenv("FLATPAK_ID", "test.PortProtonQt") + launcher = ["flatpak", "run", "test.PortProtonQt"] + elif package == "appimage": + appimage = tmp_path / "PortProtonQt portable" + appimage.touch() + monkeypatch.setenv("APPIMAGE", str(appimage)) + launcher = [str(appimage)] + content, destination = cast(tuple[str, str], create_native_desktop_file(str(executable), "Native")) + Path(destination).write_text(content) + save_native_settings(destination, {}) + parser = configparser.ConfigParser(interpolation=None) + parser.read(destination) + assert parser["Desktop Entry"]["Comment"] == _('Launch "{name}" with PortProton').format(name="Native") + assert parser["Desktop Entry"]["Actions"] == "RunSilent;RunLog;" + for section, mode in (("Desktop Entry", "--silent"), ("Desktop Action RunSilent", "--silent"), + ("Desktop Action RunLog", "--log")): + assert shlex.split(parser[section]["Exec"]) == [*launcher, mode, str(executable)] + assert parser[section]["Icon"] == parser["Desktop Entry"]["Icon"] + assert get_native_launch(destination) == ([str(executable)], str(tmp_path)) + + +def test_native_settings_use_shared_linux_tools_without_wine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + executable = tmp_path / "start" + 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) + changes = {"PW_MANGOHUD": "1", "MANGOHUD_CONFIG": "fps,cpu_temp", "PW_USE_GAMEMODE": "1", + "PW_GAMESCOPE": "1", "PW_GAMESCOPE_ARGS_NEW": "-f -w 1280", "LAUNCH_PARAMETERS": '--name "a b"', + "PW_VKBASALT": "1", "PW_VKBASALT_EFFECTS": "cas", "PW_VKBASALT_FFX_CAS": "0.75"} + monkeypatch.setattr("portprotonqt.native_games.get_native_tool_paths", lambda: {}) + save_native_settings(destination, changes) + command, directory = get_native_launch(destination) + assert "gamescope" in command and "gamemoderun" in command and "mangohud" in command + assert "ENABLE_VKBASALT=1" in command + assert "MANGOHUD_CONFIG=fps,cpu_temp" in command + assert command[-3:] == [str(executable), "--name", "a b"] + assert directory == str(tmp_path) + assert "casSharpness = 0.75" in get_native_ppdb_path(destination, load_native_settings(destination)).with_suffix(".vkbasalt.conf").read_text() + assert load_native_settings(destination)["PW_USE_GAMEMODE"] == "1" + save_native_settings(destination, {"PW_USE_GAMEMODE": "0"}) + assert "gamemoderun" not in get_native_launch(destination)[0] + assert get_native_ppdb_path(destination, load_native_settings(destination)).is_file() + + +def test_native_container_uses_existing_runtime_without_wine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime = tmp_path / "runtime" + wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime" + wrapper.parent.mkdir(parents=True) + wrapper.touch() + (runtime / "runtime/files").mkdir(parents=True) + monkeypatch.setattr("portprotonqt.native_games.get_native_tool_paths", lambda: {"runtime": str(runtime)}) + command = build_native_launch_command('"/game/start"', {"PW_USE_RUNTIME": "1"}) + assert "pressure-vessel-unruntime" in command + assert "PRESSURE_VESSEL_RUNTIME_BASE=" in command + assert "wine" not in command.lower() + + +def test_native_metadata_completes_once_and_preserves_custom_cover(monkeypatch: pytest.MonkeyPatch) -> None: + callbacks = [] + monkeypatch.setattr("portprotonqt.main_window.ui_config.get_economy_mode", lambda: False) + monkeypatch.setattr("portprotonqt.main_window.get_steam_game_info_async", lambda _name, _exec, callback: callbacks.append(callback)) + games = [("Game", "", "custom.png", "", "", "native:///a.desktop"), + ("Other", "", "", "", "", "native:///b.desktop")] + complete = MagicMock() + window = SimpleNamespace(native_library_worker=SimpleNamespace(games=games, deleteLater=MagicMock()), + _native_games_callback=complete) + MainWindow._on_native_games_loaded(cast(Any, window)) + callbacks[1]({"cover": "downloaded.jpg"}) + complete.assert_not_called() + callbacks[0]({"cover": "wrong.jpg"}) + complete.assert_called_once() + result = complete.call_args.args[0] + assert [game[2] for game in result] == ["downloaded.jpg", "custom.png"] + + +def test_native_stop_uses_own_process_group_without_portproton() -> None: + stop = MagicMock() + window = SimpleNamespace(native_game_running=True, _terminate_game_processes=stop) + assert MainWindow.stop_running_game(cast(Any, window)) + stop.assert_called_once() + + +def test_native_settings_show_linux_tabs_and_ppdb_without_wine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog + from portprotonqt.dialogs.settings_gamescope import GamescopeSettingsMixin + from portprotonqt.theme_manager import load_theme + _app = QApplication.instance() or QApplication([]) + path = tmp_path / "native.desktop" + path.write_text(f'[Desktop Entry]\nType=Application\nName=Native\nExec="/game/start"\nPath={tmp_path}\n') + monkeypatch.setattr("portprotonqt.dialogs.settings_dialog.get_portproton_location", lambda: None) + monkeypatch.setattr("portprotonqt.dialogs.settings_dialog.get_portproton_start_command", lambda: None) + monkeypatch.setattr("portprotonqt.native_games.get_native_tool_paths", lambda: {}) + monkeypatch.setattr(GamescopeSettingsMixin, "_detect_gamescope_supported_options", lambda _self: None) + monkeypatch.setattr(ExeSettingsDialog, "init_virtual_keyboard", lambda self: setattr(self, "keyboard", QWidget(self))) + original_which = shutil.which + monkeypatch.setattr(shutil, "which", lambda command: "/fake/gamescope" if command == "gamescope" else original_which(command)) + dialog = ExeSettingsDialog(theme=load_theme("standart"), exe_path=str(path), game_source="native") + keys = {item.data(Qt.ItemDataRole.UserRole) for row in range(dialog.settings_table.rowCount()) + if (item := dialog.settings_table.item(row, 0)) is not None} + assert {"PW_USE_GAMEMODE", "PW_USE_RUNTIME"} <= keys + assert not ({"PW_WINE_USE", "PW_PREFIX_NAME", "PW_USE_ESYNC"} & keys) + assert not ({"PW_WINE_USE", "WINEDLLOVERRIDES"} & dialog.advanced_settings_by_key.keys()) + assert not ({"Exec", "Path"} & dialog.advanced_settings_by_key.keys()) + tabs = {dialog.tab_widget.tabText(index) for index in range(dialog.tab_widget.count())} + assert {"MangoHud", "vkBasalt", "Gamescope"} <= tabs + assert not dialog.open_ppdb_button.isHidden() + assert not dialog.clear_ppdb_button.isHidden() + assert dialog.current_settings["Exec"] == '"/game/start"' + dialog.close() + dialog.deleteLater() + + +@pytest.mark.parametrize("action", ["confirm", "cancel", "missing"]) +def test_native_clear_ppdb_uses_user_data_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, action: str, +) -> None: + from PySide6.QtWidgets import QMessageBox + from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog + _app = QApplication.instance() or QApplication([]) + executable = tmp_path / "start" + executable.touch() + desktop = tmp_path / "native.desktop" + desktop.write_text(f'[Desktop Entry]\nName=Native\nExec="{executable}"\nPath={tmp_path}\n') + database = get_native_ppdb_path(str(desktop), load_native_settings(str(desktop))) + database.parent.mkdir(parents=True, exist_ok=True) + if action != "missing": + database.write_text('export PW_USE_RUNTIME="1"\n') + dialog = cast(Any, QWidget()) + dialog.exe_path = str(desktop) + dialog.game_source = "native" + dialog.load_current_settings = MagicMock() + monkeypatch.setattr(QMessageBox, "exec", lambda _self: + QMessageBox.StandardButton.Yes if action == "confirm" else QMessageBox.StandardButton.No) + monkeypatch.setattr(QMessageBox, "information", MagicMock()) + ExeSettingsDialog.clear_ppdb_file(dialog) + assert database.exists() is (action == "cancel") + assert desktop.is_file() + assert dialog.load_current_settings.call_count == (0 if action == "cancel" else 1) + if action != "cancel": + assert load_native_settings(str(desktop))["PW_USE_RUNTIME"] == "0" + dialog.close() + dialog.deleteLater() + + +@pytest.mark.parametrize("matched", [False, True]) +def test_native_metadata_skips_pe_protondb_and_uses_cover_search( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, matched: bool, +) -> None: + import portprotonqt.steam_api.api as api + monkeypatch.setattr(api.ui_config, "get_economy_mode", lambda: False) + monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace( + is_fresh=lambda *_: False, save_json=MagicMock(), cache_dir=tmp_path, + )) + monkeypatch.setattr(api, "get_steam_apps_and_index_async", lambda callback: callback(([{}], {"game": {}}))) + monkeypatch.setattr(api, "search_app", lambda *_: {"appid": 42} if matched else None) + monkeypatch.setattr(api, "fetch_app_info_async", lambda _appid, callback: callback({"name": "Game"})) + monkeypatch.setattr(api, "fetch_sgdb_cover_async", lambda _name, callback: callback("actual-cover.jpg")) + monkeypatch.setattr(api, "get_local_steam_cover", lambda *_args, **_kwargs: "") + pe = MagicMock(side_effect=AssertionError("Native games must not use PE metadata")) + protondb = MagicMock(side_effect=AssertionError("Native games must not query ProtonDB")) + monkeypatch.setattr("portprotonqt.steam_api.cache.get_exiftool_data", pe) + monkeypatch.setattr(api, "get_protondb_tier_async", protondb) + results = [] + api.get_steam_game_info_async("Game", "native:///Game With Spaces.desktop", results.append) + assert len(results) == 1 + assert results[0]["cover"] == "actual-cover.jpg" + pe.assert_not_called() + protondb.assert_not_called() + + +def test_native_process_group_remains_running_after_wrapper_exits(monkeypatch: pytest.MonkeyPatch) -> None: + process = SimpleNamespace(pid=42, poll=lambda: 0) + child = SimpleNamespace(pid=43, info={"status": "running"}) + window = SimpleNamespace(native_game_running=True, game_processes=[process]) + monkeypatch.setattr("portprotonqt.main_window.psutil.process_iter", lambda **_: [child]) + monkeypatch.setattr("portprotonqt.main_window.os.getpgid", lambda _pid: 42) + assert MainWindow._has_running_game_process(cast(Any, window)) + child.info["status"] = "zombie" + assert not MainWindow._has_running_game_process(cast(Any, window)) + + +def test_native_play_stop_cycle_controls_a_real_linux_process(tmp_path: Path) -> None: + _app = QApplication.instance() or QApplication([]) + result = create_native_desktop_file(sys.executable, "Native process test") + assert result is not None + content, destination = result + Path(destination).write_text(content) + save_native_settings(destination, {"LAUNCH_PARAMETERS": '-c "import time; time.sleep(30)"'}) + window = MainWindow.__new__(MainWindow) + QMainWindow.__init__(window) + test_window = cast(Any, window) + test_window.game_processes = [] + test_window.input_manager = MagicMock() + test_window.theme_manager = SimpleNamespace(get_icon=lambda *_args, **_kwargs: QIcon()) + test_window._update_last_launch_after_start = MagicMock() + test_window._update_playtime_after_exit = MagicMock() + test_window._refresh_portproton_shortcuts = MagicMock() + button = QPushButton() + uri = "native://" + destination + process = None + try: + window.toggleGame(uri, button) + process = window.game_processes[0] + assert button.text() == _("Stop") + assert process.poll() is None + window.game_start_time = datetime.now() - timedelta(seconds=3) + window.toggleGame(uri, button) + process.wait(timeout=5) + window.checkTargetExe() + assert button.text() == _("Start") + assert window.target_exe is None + assert window.game_processes == [] + assert not window.native_game_running + test_window._update_playtime_after_exit.assert_called_once() + finally: + if process is None and window.game_processes: + process = window.game_processes[0] + if process is not None and process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + timer = getattr(window, "checkProcessTimer", None) + if timer is not None: + timer.stop() + window.deleteLater() + + +@pytest.mark.parametrize("switch_source", [False, True]) +def test_manual_native_add_creates_shortcuts_and_respects_late_source_switch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, switch_source: bool, +) -> None: + import portprotonqt.tabs.library_tab as tab + from PySide6.QtWidgets import QDialog + callbacks, selected = [], ["native"] + executable = tmp_path / "start" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + dialog = SimpleNamespace( + setFocus=MagicMock(), finished=MagicMock(), exec=lambda: QDialog.DialogCode.Accepted, + nameEdit=SimpleNamespace(text=lambda: "Native"), exeEdit=SimpleNamespace(text=lambda: str(executable)), + coverEdit=SimpleNamespace(text=lambda: ""), last_cover_path="", + native_checkbox=SimpleNamespace(isChecked=lambda: True), + add_to_menu_checkbox=SimpleNamespace(isChecked=lambda: True), + add_to_desktop_checkbox=SimpleNamespace(isChecked=lambda: True), + add_to_steam_checkbox=SimpleNamespace(isChecked=lambda: False), + getDesktopEntryData=lambda: create_native_desktop_file(str(executable), "Native"), + ) + monkeypatch.setattr(tab, "AddGameDialog", lambda *_: dialog) + monkeypatch.setattr(tab.ui_config, "get_auto_download_ppdb", lambda: False) + monkeypatch.setattr(tab.ui_config, "get_economy_mode", lambda: False) + monkeypatch.setattr(game_config, "get_display_filter", lambda: selected[0]) + monkeypatch.setattr("portprotonqt.steam_api.get_steam_game_info_async", lambda _name, _exec, callback: callbacks.append(callback)) + window = MainWindow.__new__(MainWindow) + owner = cast(Any, window) + owner.current_add_game_dialog, owner.theme = None, None + owner._loaded_library_cache = {source: [] for source in ("all", "native", "steam", "favorites")} + owner.game_library_manager = SimpleNamespace(add_game_incremental=MagicMock(), load_visible_images=MagicMock()) + owner._write_desktop_file = lambda content, path: Path(path).write_text(content) + window.openAddGameDialog() + assert set(owner._loaded_library_cache) == {"steam"} + selected[0] = "steam" if switch_source else "native" + callbacks[0]({"cover": "cover.jpg"}) + assert owner.game_library_manager.add_game_incremental.call_count == int(not switch_source) + if not switch_source: + assert owner.game_library_manager.add_game_incremental.call_args.args[0][12] == "native" + data_home = Path(os.environ["XDG_DATA_HOME"]) + saved = list((tmp_path / "portdata").glob("*.desktop")) + assert len(saved) == 1 + assert get_native_ppdb_path(str(saved[0]), load_native_settings(str(saved[0]))).is_file() + for directory in (data_home / "applications", tmp_path / "Desktop"): + assert (directory / saved[0].name).read_text() == saved[0].read_text() + + +def test_native_ppdb_edits_apply_cpu_affinity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + executable = tmp_path / "game" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + content, destination = cast(tuple[str, str], create_native_desktop_file(str(executable), "Game")) + Path(destination).write_text(content) + monkeypatch.setattr(os, "sched_getaffinity", lambda _pid: {2, 4, 6}) + save_native_settings(destination, {"PW_WINE_CPU_TOPOLOGY": "2"}) + assert get_native_launch(destination)[0] == ["taskset", "-c", "2,4", str(executable)] + database = get_native_ppdb_path(destination, load_native_settings(destination)) + database.write_text(database.read_text().replace('PW_WINE_CPU_TOPOLOGY="2"', 'PW_WINE_CPU_TOPOLOGY="1"')) + assert get_native_launch(destination)[0] == ["taskset", "-c", "2", str(executable)] + with pytest.raises(ValueError, match="CPU cores"): + build_native_launch_command("game", {"PW_WINE_CPU_TOPOLOGY": "4"}) + + +def test_native_debug_log_captures_output_without_portproton(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from portprotonqt.debug_utils.debug_log_manager import DebugLogManager + + monkeypatch.setattr("portprotonqt.debug_utils.debug_log_manager.get_portproton_location", lambda: None) + entry = tmp_path / "game.desktop" + entry.write_text(f'[Desktop Entry]\nExec="{sys.executable}" -c "import sys; sys.stdout.write(\\\"native output\\\"); sys.stderr.write(\\\"native error\\\")"\nPath={tmp_path}\n') + manager = DebugLogManager() + assert manager.start("native://" + str(entry), []) + assert manager.process is not None + manager.process.wait(timeout=5) + assert not manager.check_running() + destination = manager.stop() + from portprotonqt.native_games import get_native_ppdb_path + assert destination == str(get_native_ppdb_path(str(entry), load_native_settings(str(entry))).with_suffix(".log")) + assert destination is not None + log = Path(destination).read_text() + assert "native output" in log and "native error" in log + assert manager.native_output is None + + +def test_native_folder_opens_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from portprotonqt.detail_pages import DetailPageManager + + entry = tmp_path / "game.desktop" + entry.write_text(f"[Desktop Entry]\nExec=true\nPath={tmp_path}\n") + opened = MagicMock(return_value=True) + monkeypatch.setattr("portprotonqt.detail_pages.QDesktopServices.openUrl", opened) + manager = DetailPageManager.__new__(DetailPageManager) + manager._open_executable_folder("native://" + str(entry)) + assert opened.call_args.args[0].toLocalFile() == str(tmp_path) + + +def test_native_debug_shutdown_saves_log(tmp_path: Path) -> None: + from portprotonqt.debug_utils.debug_log_manager import DebugLogManager + + entry = tmp_path / "game.desktop" + entry.write_text(f'[Desktop Entry]\nExec="{sys.executable}" -c "import time; time.sleep(30)"\nPath={tmp_path}\n') + manager = DebugLogManager() + assert manager.start("native://" + str(entry), []) + process = manager.process + assert process is not None + try: + manager.cleanup_on_exit() + process.wait(timeout=5) + from portprotonqt.native_games import get_native_ppdb_path + assert get_native_ppdb_path(str(entry), load_native_settings(str(entry))).with_suffix(".log").is_file() + assert manager.native_output is None + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + +def test_native_folder_without_path_uses_game_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from portprotonqt.detail_pages import DetailPageManager + + executable = tmp_path / "game" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + entry = tmp_path / "game.desktop" + entry.write_text(f'[Desktop Entry]\nExec=env TEST=1 "{executable}"\n') + assert get_native_launch(str(entry))[1] == str(tmp_path) + opened = MagicMock(return_value=True) + monkeypatch.setattr("portprotonqt.detail_pages.QDesktopServices.openUrl", opened) + manager = DetailPageManager.__new__(DetailPageManager) + manager._open_executable_folder("native://" + str(entry)) + assert opened.call_args.args[0].toLocalFile() == str(tmp_path) + + +@pytest.mark.parametrize("native", [True, False]) +def test_debug_report_keeps_system_sections_and_filters_wine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, native: bool, +) -> None: + from portprotonqt.debug_utils import system_info, env_utils + + executable = tmp_path / "game" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + entry = tmp_path / "game.desktop" + entry.write_text(f'[Desktop Entry]\nExec="{executable}"\nPath={tmp_path}\n') + database = get_native_ppdb_path(str(entry), {}) if native else Path(str(executable) + ".ppdb") + database.parent.mkdir(parents=True, exist_ok=True) + database.write_text('export PW_USE_RUNTIME="0"\n') + monkeypatch.setattr("portprotonqt.config.get_portproton_location", lambda: None if native else str(tmp_path)) + monkeypatch.setattr("portprotonqt.native_games.get_native_tool_paths", lambda: {}) + for name in ("get_libc_version", "get_os_info", "get_ram_info_detailed", "get_graphics_info_detailed", + "get_locale_info", "get_locale_available"): + monkeypatch.setattr(system_info, name, lambda: "system detail") + monkeypatch.setattr(system_info, "get_screen_info", lambda: ("screen detail", "primary screen")) + monkeypatch.setattr(system_info, "get_cpu_info", lambda: {"model": "CPU detail", "physical_cores": "2", "logical_cores": "4"}) + monkeypatch.setattr(system_info, "get_filesystem_info", lambda *_args: "filesystem detail") + for name in ("get_vulkan_use_info", "get_wine_version", "get_d3d_extras_status"): + def wine_only(*args: object) -> str: + assert not native + return "wine detail" + monkeypatch.setattr(env_utils, name, wine_only) + monkeypatch.setattr(system_info, "get_prefix_name", lambda _path: pytest.fail("native prefix lookup") if native else "DEFAULT") + monkeypatch.setattr(system_info, "get_winetricks_log", lambda *_args: "winetricks detail") + report = system_info.generate_system_info("native://" + str(entry) if native else str(executable)) + for heading in ("Operating system:", "CPU:", "RAM:", "Kernel:", "Graphic cards and drivers:", "locale:"): + assert heading in report + assert "screen detail" in report and "filesystem detail" in report + assert ("Version WINE in use:" in report) is not native + assert ("winetricks.log:" in report) is not native + assert ("Program bit depth:" in report) is not native + if native: + assert 'PW_USE_RUNTIME="0"' in report + assert str(executable) in report + + +def test_native_ppdb_migrates_settings_and_var_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from portprotonqt.native_games import read_native_variables, get_native_defaults + + executable = tmp_path / "start" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + entry = tmp_path / "game.desktop" + entry.write_text(f'[Desktop Entry]\nExec="{executable}"\nPath={tmp_path}\n[X-PortProtonQt]\nPW_MANGOHUD=1\n') + Path(str(entry) + ".ppdb").write_text('[X-PortProtonQt]\nPW_USE_GAMEMODE=1\n') + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "var").write_text('check_variables PW_VKBASALT_FFX_CAS "0.50"\ncheck_variables PW_USE_RUNTIME "1"\nexport DEFAULT_MANGOHUD_CONFIG="fps,cpu_temp,wine,winesync"\n') + monkeypatch.setattr("portprotonqt.native_games.get_portproton_scripts_path", lambda: str(scripts)) + assert get_native_defaults()["PW_USE_RUNTIME"] == "0" + save_native_settings(str(entry), {"LAUNCH_PARAMETERS": '--name "a b" --literal "$HOME`id`"'}) + database = get_native_ppdb_path(str(entry), load_native_settings(str(entry))) + content = database.read_text() + assert content.startswith("export ") and "[X-PortProtonQt]" not in content + values = read_native_variables(database) + assert values["PW_MANGOHUD"] == values["PW_USE_GAMEMODE"] == "1" + assert values["MANGOHUD_CONFIG"] == "fps,cpu_temp" + assert values["PW_VKBASALT_FFX_CAS"] == "0.50" + assert values["LAUNCH_PARAMETERS"] == '--name "a b" --literal "$HOME`id`"' + assert "Exec" not in values and "Path" not in values + assert load_native_settings(str(entry))["LAUNCH_PARAMETERS"] == values["LAUNCH_PARAMETERS"] + + +def test_native_runtime_uses_alt_driver_paths_and_portable_mangohud( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + from portprotonqt.native_games import build_native_runtime + + runtime, plugins = tmp_path / "runtime", tmp_path / "plugins" + wrapper = runtime / "pressure-vessel/bin/pressure-vessel-unruntime" + wrapper.parent.mkdir(parents=True) + wrapper.touch() + (runtime / "runtime/files").mkdir(parents=True) + shim = plugins / "portable/lib/lib64/libMangoHud_shim.so" + shim.parent.mkdir(parents=True) + shim.touch() + monkeypatch.setattr("portprotonqt.native_games.get_native_tool_paths", lambda: {"runtime": str(runtime), "plugins": str(plugins)}) + monkeypatch.setattr("portprotonqt.native_games.platform.freedesktop_os_release", lambda: {"ID": "altlinux"}) + original_is_dir, original_resolve = Path.is_dir, Path.resolve + driver_paths = {"/etc/libnvidiacurrent": "/usr/lib64/nvidia-test", "/etc/libnvidia32current": "/usr/lib/nvidia-test"} + monkeypatch.setattr(Path, "is_dir", lambda path: str(path) in driver_paths or original_is_dir(path)) + monkeypatch.setattr(Path, "resolve", lambda path: Path(driver_paths[str(path)]) if str(path) in driver_paths else original_resolve(path)) + monkeypatch.delenv("LD_LIBRARY_PATH", raising=False) + monkeypatch.delenv("LD_PRELOAD", raising=False) + command, environment = build_native_runtime({"PW_MANGOHUD": "1"}) + assert "PRESSURE_VESSEL_APP_LD_LIBRARY_PATH=/usr/lib64/nvidia-test:/usr/lib/nvidia-test" in environment + assert "PRESSURE_VESSEL_IMPORT_VULKAN_LAYERS=0" in environment + assert "MANGOHUD=1" in command and "LD_PRELOAD=libMangoHud_shim.so" in command + assert "mangohud" not in command + assert f"VK_ADD_IMPLICIT_LAYER_PATH={plugins / 'portable/share/vulkan/implicit_layer.d'}" in command + + +def test_native_context_shortcuts_and_delete_preserve_game(tmp_path: Path) -> None: + executable = tmp_path / "start" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + content, destination = cast(tuple[str, str], create_native_desktop_file(str(executable), "Game")) + source = Path(destination) + source.write_text(content) + save_native_settings(destination, {}) + manager = ContextMenuManager.__new__(ContextMenuManager) + owner = cast(Any, manager) + owner.parent = SimpleNamespace(refreshGames=MagicMock(), _loaded_library_cache={"native": [], "steam": []}) + owner.signals = SimpleNamespace(show_warning_dialog=MagicMock()) + owner._update_desktop_database = MagicMock() + owner._remove_statistics_entry = MagicMock() + owner.game_library_manager = SimpleNamespace(remove_game_incremental=MagicMock()) + directories = [Path(os.environ["XDG_DATA_HOME"]) / "applications", tmp_path / "Desktop"] + for directory in directories: + manager._toggle_native_shortcut("native://" + destination, str(directory)) + copied = (directory / source.name).read_text() + assert f'Exec="portprotonqt" "--silent" "native://{destination}"\n' in copied + assert f'Exec="portprotonqt" "--log" "native://{destination}"\n' in copied + assert source.read_text() == content + manager._toggle_native_shortcut("native://" + destination, str(directories[0])) + assert not (directories[0] / source.name).exists() + assert source.is_file() + manager._delete_game_without_confirm("Game", "native://" + destination) + assert not source.exists() and not (directories[1] / source.name).exists() + assert executable.is_file() + assert get_native_ppdb_path(destination, {"Name": "Game"}).is_file() + assert "native" not in owner.parent._loaded_library_cache + owner.game_library_manager.remove_game_incremental.assert_called_once_with("Game", "native://" + destination) + + +@pytest.mark.parametrize("in_steam", [False, True]) +@pytest.mark.parametrize("managed", [False, True]) +def test_native_context_menu_exposes_shortcut_actions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, in_steam: bool, managed: bool, +) -> None: + from PySide6.QtWidgets import QMenu + + _app = QApplication.instance() or QApplication([]) + manager = ContextMenuManager.__new__(ContextMenuManager) + owner = cast(Any, manager) + owner._get_safe_icon = lambda _name: QIcon() + owner._toggle_native_shortcut = MagicMock() + owner._edit_native_shortcut = MagicMock() + owner.add_to_steam, owner.remove_from_steam = MagicMock(), MagicMock() + monkeypatch.setattr("portprotonqt.context_menu_manager.get_steam_home", lambda: tmp_path) + monkeypatch.setattr("portprotonqt.context_menu_manager.is_game_in_steam", lambda _name: in_steam) + owner.delete_game = MagicMock() + owner.parent = SimpleNamespace(detail_page_manager=SimpleNamespace(_open_executable_folder=MagicMock())) + directory = tmp_path / "portdata" if managed else tmp_path / "system/applications" + card = SimpleNamespace(name="Game", exec_line="native://" + str(directory / "game.desktop"), cover_path="cover.png") + menu = QMenu() + manager._add_native_actions(menu, cast(Any, card)) + actions = {action.text(): action for action in menu.actions()} + shortcut_actions = {_('Add to Menu'), _('Delete'), _('Open Game Folder')} + assert _('Edit Shortcut') in actions + actions[_('Edit Shortcut')].trigger() + owner._edit_native_shortcut.assert_called_once_with(card) + assert _('Add to Desktop') in actions + actions[_('Add to Desktop')].trigger() + owner._toggle_native_shortcut.assert_called_once_with(card.exec_line, str(tmp_path / "Desktop")) + if managed: + assert shortcut_actions <= actions.keys() + else: + assert shortcut_actions.isdisjoint(actions) + actions[_("Remove from Steam") if in_steam else _("Add to Steam")].trigger() + if in_steam: + owner.remove_from_steam.assert_called_once_with("Game", card.exec_line, "native") + else: + owner.add_to_steam.assert_called_once_with("Game", card.exec_line, "cover.png") + if managed: + actions[_('Delete')].trigger() + owner.delete_game.assert_called_once_with("Game", card.exec_line) + + +def test_native_steam_add_uses_existing_shortcut_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + executable = tmp_path / "native game" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o755) + content, destination = cast(tuple[str, str], create_native_desktop_file(str(executable), "Native")) + Path(destination).write_text(content) + manager = ContextMenuManager.__new__(ContextMenuManager) + owner = cast(Any, manager) + owner._check_portproton = lambda: True + owner._batch_action = False + owner.signals = SimpleNamespace(show_warning_dialog=MagicMock(), show_info_dialog=MagicMock()) + add = MagicMock(return_value=(True, "added")) + remove = MagicMock(return_value=(True, "removed")) + monkeypatch.setattr("portprotonqt.context_menu_manager.add_to_steam", add) + monkeypatch.setattr("portprotonqt.context_menu_manager.remove_from_steam", remove) + manager.add_to_steam("Native", "native://" + destination, "cover.png") + add.assert_called_once_with("Native", "native://" + destination, "cover.png") + manager.remove_from_steam("Native", "native://" + destination, "native") + remove.assert_called_once_with("Native", "native://" + destination) + owner.signals.show_warning_dialog.emit.assert_not_called() + + +@pytest.mark.parametrize("system", [False, True]) +def test_native_shortcut_edit_only_updates_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, system: bool) -> None: + from PySide6.QtWidgets import QDialog + from portprotonqt.config.portproton import resolve_custom_data_dir + + source = tmp_path / ("system/game.desktop" if system else "portdata/game.desktop") + source.parent.mkdir(exist_ok=True) + content = "[Desktop Entry]\nType=Application\nName=Original\nExec=env FLAG=1 hytale-launcher\nCategories=Game;\n" + source.write_text(content) + cover = tmp_path / "selected.png" + cover.write_bytes(b"cover") + dialog = SimpleNamespace(exec=lambda: QDialog.DialogCode.Accepted, + nameEdit=SimpleNamespace(text=lambda: "Renamed"), coverEdit=SimpleNamespace(text=lambda: str(cover)), + last_cover_path=None, + add_to_steam_checkbox=SimpleNamespace(setChecked=MagicMock(), isChecked=lambda: False)) + monkeypatch.setattr("portprotonqt.context_menu_manager.AddGameDialog", lambda **_kwargs: dialog) + manager = ContextMenuManager.__new__(ContextMenuManager) + owner = cast(Any, manager) + owner.parent, owner.theme = SimpleNamespace(refreshGames=MagicMock()), None + manager._edit_native_shortcut(cast(Any, SimpleNamespace(name="Original", exec_line="native://" + str(source), cover_path=""))) + assert source.read_text() == content + assert list(source.parent.glob("*.desktop")) == [source] + custom = Path(resolve_custom_data_dir(str(Path(os.environ["XDG_DATA_HOME"]) / "PortProtonQt/custom_data"), str(source))) + assert (custom / "metadata.txt").read_text() == "name=Renamed\n" + assert (custom / "cover.png").read_bytes() == b"cover" + game = read_native_game(source) + assert game is not None + assert game[0] == "Renamed" + assert game[2] == str(custom / "cover.png") + owner.parent.refreshGames.assert_called_once() + + +@pytest.mark.parametrize("filename, executable, expected", [ + ("game", False, False), + ("game.sh", False, False), + ("game.bin", False, False), + ("game.bash", False, False), + ("game.AppImage", False, False), + ("game", True, True), + ("game.sh", True, True), + ("game.bin", True, True), + ("game.bash", True, True), + ("game.AppImage", True, True), + ("game.txt", True, True), + ("game.x86_64", True, True), + ("game.txt", False, False), + ("game.exe", True, False), + ("game.iso", True, False), +]) +def test_native_executable_detection( + tmp_path: Path, filename: str, executable: bool, expected: bool, +) -> None: + from portprotonqt.native_games import is_native_executable + + path = tmp_path / filename + path.write_bytes(b"test") + path.chmod(0o755 if executable else 0o644) + assert is_native_executable(str(path)) is expected + + +def test_native_appimage_uses_cached_icon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import portprotonqt.native_games as module + + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + executable = tmp_path / "Game.AppImage" + executable.touch() + executable.chmod(0o755) + icon = tmp_path / "cached.png" + icon.write_bytes(b"cached icon") + monkeypatch.setattr(module, "get_custom_icon_path", lambda _path: "") + monkeypatch.setattr(module, "get_exe_icon_cache_path", lambda _path: str(icon)) + result = create_native_desktop_file(str(executable), "Game") + assert result is not None + content, destination = result + assert f"Icon={icon}\n" in content + Path(destination).write_text(content) + game = read_native_game(Path(destination)) + assert game is not None + assert game[2] == str(icon) + + +def test_system_native_settings_never_modify_desktop_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + import portprotonqt.native_games as module + + system = tmp_path / "system" + system.mkdir() + executable = system / "hytale" + executable.touch() + executable.chmod(0o755) + entry = system / "hytale.desktop" + content = f'[Desktop Entry]\nType=Application\nName=Hytale\nExec="{executable}"\nCategories=Game;\n' + entry.write_text(content) + entry.chmod(0o444) + copies = [Path(os.environ["XDG_DATA_HOME"]) / "applications" / entry.name, + tmp_path / "Desktop" / entry.name] + for copy in copies: + copy.parent.mkdir(parents=True, exist_ok=True) + copy.write_text("user-owned shortcut\n") + original_access = os.access + monkeypatch.setattr(os, "access", lambda path, mode: + False if Path(path) == system and mode == os.W_OK else original_access(path, mode)) + monkeypatch.setattr(module, "get_native_tool_paths", lambda: {}) + settings = load_native_settings(str(entry)) + database = module.get_native_ppdb_path(str(entry), settings) + assert database == Path(os.environ["XDG_DATA_HOME"]) / "PortProtonQt/native_games/Hytale/Hytale.ppdb" + save_native_settings(str(entry), {"PW_USE_GAMEMODE": "1", "PW_VKBASALT": "1"}) + assert database.is_file() + assert database.with_suffix(".vkbasalt.conf").is_file() + assert load_native_settings(str(entry))["PW_USE_GAMEMODE"] == "1" + assert "gamemoderun" in get_native_launch(str(entry))[0] + assert entry.read_text() == content + assert entry.stat().st_mode & 0o777 == 0o444 + assert all(copy.read_text() == "user-owned shortcut\n" for copy in copies) + assert not Path(str(executable) + ".ppdb").exists() + + +def test_native_log_is_saved_beside_ppdb_not_in_working_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + import io + from portprotonqt.debug_utils.debug_log_manager import DebugLogManager + + entry = tmp_path / "hytale.desktop" + entry.write_text("[Desktop Entry]\nExec=true\n") + database = tmp_path / "user-data" / "hytale.ppdb" + monkeypatch.setattr("portprotonqt.native_games.get_native_ppdb_path", lambda *_args: database) + manager = DebugLogManager() + manager.exe_path = str(entry) + working_directory = tmp_path / "unwritable-installation" + manager.native_directory = str(working_directory) + manager.native_output = io.StringIO("Hytale output\n") + destination = manager._save_native_log() + assert destination == str(database.with_suffix(".log")) + assert "Hytale output" in database.with_suffix(".log").read_text() + assert not (working_directory / "PortProtonQt.log").exists() + assert manager.native_output is None + + +def test_system_desktop_copy_launches_through_ppqt_without_library_refresh(tmp_path: Path) -> None: + source = tmp_path / "system" / "com.hypixel.HytaleLauncher.desktop" + source.parent.mkdir() + content = ("[Desktop Entry]\nName=Hytale Launcher\nComment=Hytale Launcher\n" + "Exec=env __NV_DISABLE_EXPLICIT_SYNC=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 hytale-launcher\n" + "Icon=hytale-launcher\nType=Application\nCategories=Game;\n" + "StartupWMClass=com.hypixel.HytaleLauncher\n") + source.write_text(content) + directory = tmp_path / "Desktop" + manager = ContextMenuManager.__new__(ContextMenuManager) + owner = cast(Any, manager) + owner.parent = SimpleNamespace(refreshGames=MagicMock()) + owner.signals = SimpleNamespace(show_warning_dialog=MagicMock()) + owner._update_desktop_database = MagicMock() + uri = "native://" + str(source) + manager._toggle_native_shortcut(uri, str(directory)) + copy = directory / source.name + copied = copy.read_text() + assert f'Exec="portprotonqt" "--silent" "{uri}"\n' in copied + assert "Icon=hytale-launcher\n" in copied + assert "StartupWMClass=com.hypixel.HytaleLauncher\n" in copied + assert source.read_text() == content + owner.parent.refreshGames.assert_not_called() + owner._update_desktop_database.assert_not_called() + manager._toggle_native_shortcut(uri, str(directory)) + assert not copy.exists() + assert source.read_text() == content + owner.parent.refreshGames.assert_not_called() + owner.signals.show_warning_dialog.emit.assert_not_called() + + +@pytest.mark.parametrize("name", ["My Native Game", "Game/Part 2", ".."]) +def test_native_data_uses_named_user_directory(tmp_path: Path, name: str) -> None: + safe_name = name.replace("/", "_").strip(".") or "native" + database = get_native_ppdb_path(str(tmp_path / "game.desktop"), {"Name": name, "Exec": "/usr/bin/game"}) + assert database == tmp_path / "data/PortProtonQt/native_games" / safe_name / (safe_name + ".ppdb") + assert database.with_suffix(".log").parent == database.parent + + +def test_native_shortcut_requires_portdata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + executable = tmp_path / "start" + executable.touch() + executable.chmod(0o755) + monkeypatch.setattr("portprotonqt.native_games.get_portproton_location", lambda: None) + assert create_native_desktop_file(str(executable), "Game") is None + assert not (tmp_path / "data/PortProtonQt/native_games").exists() + + +def test_native_portdata_shortcut_is_only_loaded_as_native( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + original_rglob = Path.rglob + monkeypatch.setattr(Path, "rglob", lambda path, pattern: + original_rglob(path, pattern) if path.is_relative_to(tmp_path) else iter(())) + executable = tmp_path / "start" + executable.touch() + executable.chmod(0o755) + content, destination = cast(tuple[str, str], create_native_desktop_file(str(executable), "Game")) + Path(destination).write_text(content) + nested = Path(destination).parent / "data" + nested.mkdir() + (nested / "duplicate.desktop").write_text(content) + worker = NativeLibraryWorker() + worker.run() + assert len(worker.games) == 1 + assert worker.games[0][5] == "native://" + destination + callback = MagicMock() + MainWindow._process_desktop_file_async(cast(Any, SimpleNamespace()), destination, callback) + callback.assert_called_once_with(None) diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py index e6235061..c029c0d2 100644 --- a/tests/test_shortcuts.py +++ b/tests/test_shortcuts.py @@ -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('') + 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" diff --git a/tests/test_store_games.py b/tests/test_store_games.py index cda8dcc1..425c1ea6 100644 --- a/tests/test_store_games.py +++ b/tests/test_store_games.py @@ -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()