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'') 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()