feat: update context menu for egs games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
		| @@ -3,13 +3,26 @@ import shlex | ||||
| import glob | ||||
| import shutil | ||||
| import subprocess | ||||
| import threading | ||||
| import logging | ||||
| import re | ||||
| import json | ||||
| from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog | ||||
| from PySide6.QtCore import QUrl, QPoint | ||||
| from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt | ||||
| from PySide6.QtGui import QDesktopServices | ||||
| from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites | ||||
| from portprotonqt.localization import _ | ||||
| from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam | ||||
| from portprotonqt.dialogs import AddGameDialog | ||||
| from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable | ||||
| import vdf | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
| class ContextMenuSignals(QObject): | ||||
|     """Signals for thread-safe UI updates from worker threads.""" | ||||
|     show_status_message = Signal(str, int) | ||||
|     show_warning_dialog = Signal(str, str) | ||||
|  | ||||
| class ContextMenuManager: | ||||
|     """Manages context menu actions for game management in PortProtonQt.""" | ||||
| @@ -30,6 +43,56 @@ class ContextMenuManager: | ||||
|         self.theme = theme | ||||
|         self.load_games = load_games_callback | ||||
|         self.update_game_grid = update_game_grid_callback | ||||
|         self.legendary_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache", "legendary" | ||||
|         ) | ||||
|         self.legendary_config_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache" | ||||
|         ) | ||||
|         # Initialize signals for thread-safe UI updates | ||||
|         self.signals = ContextMenuSignals() | ||||
|         if self.parent.statusBar() is None: | ||||
|             logger.warning("Status bar is not initialized in MainWindow") | ||||
|         else: | ||||
|             self.signals.show_status_message.connect( | ||||
|                 self.parent.statusBar().showMessage, | ||||
|                 Qt.ConnectionType.QueuedConnection | ||||
|             ) | ||||
|             logger.debug("Connected show_status_message signal to statusBar") | ||||
|         self.signals.show_warning_dialog.connect( | ||||
|             self._show_warning_dialog, | ||||
|             Qt.ConnectionType.QueuedConnection | ||||
|         ) | ||||
|  | ||||
|     def _show_warning_dialog(self, title: str, message: str): | ||||
|         """Show a warning dialog in the main thread.""" | ||||
|         logger.debug("Showing warning dialog: %s - %s", title, message) | ||||
|         QMessageBox.warning(self.parent, title, message) | ||||
|  | ||||
|     def _is_egs_game_installed(self, app_name: str) -> bool: | ||||
|         """ | ||||
|         Check if an EGS game is installed by reading installed.json. | ||||
|  | ||||
|         Args: | ||||
|             app_name: The Legendary app_name (unique identifier for the game). | ||||
|  | ||||
|         Returns: | ||||
|             bool: True if the game is installed, False otherwise. | ||||
|         """ | ||||
|         installed_json_path = os.path.join(self.legendary_config_path, "installed.json") | ||||
|         if not os.path.exists(installed_json_path): | ||||
|             logger.debug("installed.json not found at %s", installed_json_path) | ||||
|             return False | ||||
|  | ||||
|         try: | ||||
|             with open(installed_json_path, encoding="utf-8") as f: | ||||
|                 installed_games = json.load(f) | ||||
|             return app_name in installed_games | ||||
|         except (OSError, json.JSONDecodeError) as e: | ||||
|             logger.error("Failed to read installed.json: %s", e) | ||||
|             return False | ||||
|  | ||||
|     def show_context_menu(self, game_card, pos: QPoint): | ||||
|         """ | ||||
| @@ -39,7 +102,6 @@ class ContextMenuManager: | ||||
|             game_card: The GameCard instance requesting the context menu. | ||||
|             pos: The position (in widget coordinates) where the menu should appear. | ||||
|         """ | ||||
|  | ||||
|         menu = QMenu(self.parent) | ||||
|         menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) | ||||
|  | ||||
| @@ -54,9 +116,31 @@ class ContextMenuManager: | ||||
|             favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True)) | ||||
|  | ||||
|         if game_card.game_source == "epic": | ||||
|                 import_action = menu.addAction(_("Import to Legendary")) | ||||
|                 import_action.triggered.connect( | ||||
|                     lambda: self.import_to_legendary(game_card.name, game_card.appid) | ||||
|             # Always show Import to Legendary | ||||
|             import_action = menu.addAction(_("Import to Legendary")) | ||||
|             import_action.triggered.connect( | ||||
|                 lambda: self.import_to_legendary(game_card.name, game_card.appid) | ||||
|             ) | ||||
|             # Show other actions only if the game is installed | ||||
|             if self._is_egs_game_installed(game_card.appid): | ||||
|                 uninstall_action = menu.addAction(_("Uninstall Game")) | ||||
|                 uninstall_action.triggered.connect( | ||||
|                     lambda: self.uninstall_egs_game(game_card.name, game_card.appid) | ||||
|                 ) | ||||
|                 is_in_steam = is_game_in_steam(game_card.name) | ||||
|                 if is_in_steam: | ||||
|                     remove_steam_action = menu.addAction(_("Remove from Steam")) | ||||
|                     remove_steam_action.triggered.connect( | ||||
|                         lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) | ||||
|                     ) | ||||
|                 else: | ||||
|                     add_steam_action = menu.addAction(_("Add to Steam")) | ||||
|                     add_steam_action.triggered.connect( | ||||
|                         lambda: self.add_egs_to_steam(game_card.name, game_card.appid) | ||||
|                     ) | ||||
|                 open_folder_action = menu.addAction(_("Open Game Folder")) | ||||
|                 open_folder_action.triggered.connect( | ||||
|                     lambda: self.open_egs_game_folder(game_card.appid) | ||||
|                 ) | ||||
|  | ||||
|         if game_card.game_source not in ("steam", "epic"): | ||||
| @@ -70,13 +154,17 @@ class ContextMenuManager: | ||||
|                 add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line)) | ||||
|  | ||||
|             edit_action = menu.addAction(_("Edit Shortcut")) | ||||
|             edit_action.triggered.connect(lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)) | ||||
|             edit_action.triggered.connect( | ||||
|                 lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path) | ||||
|             ) | ||||
|  | ||||
|             delete_action = menu.addAction(_("Delete from PortProton")) | ||||
|             delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) | ||||
|  | ||||
|             open_folder_action = menu.addAction(_("Open Game Folder")) | ||||
|             open_folder_action.triggered.connect(lambda: self.open_game_folder(game_card.name, game_card.exec_line)) | ||||
|             open_folder_action.triggered.connect( | ||||
|                 lambda: self.open_game_folder(game_card.name, game_card.exec_line) | ||||
|             ) | ||||
|  | ||||
|             applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") | ||||
|             desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop") | ||||
| @@ -87,20 +175,88 @@ class ContextMenuManager: | ||||
|                 add_action = menu.addAction(_("Add to Menu")) | ||||
|                 add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line)) | ||||
|  | ||||
|             # Add Steam-related actions | ||||
|             is_in_steam = is_game_in_steam(game_card.name) | ||||
|             if is_in_steam: | ||||
|                 remove_steam_action = menu.addAction(_("Remove from Steam")) | ||||
|                 remove_steam_action.triggered.connect(lambda: self.remove_from_steam(game_card.name, game_card.exec_line)) | ||||
|                 remove_steam_action.triggered.connect( | ||||
|                     lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) | ||||
|                 ) | ||||
|             else: | ||||
|                 add_steam_action = menu.addAction(_("Add to Steam")) | ||||
|                 add_steam_action.triggered.connect(lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)) | ||||
|                 add_steam_action.triggered.connect( | ||||
|                     lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path) | ||||
|                 ) | ||||
|  | ||||
|         menu.exec(game_card.mapToGlobal(pos)) | ||||
|  | ||||
|     def import_to_legendary(self, game_name, app_name): | ||||
|     def add_egs_to_steam(self, game_name: str, app_name: str): | ||||
|         """ | ||||
|         Imports an installed Epic Games Store game to Legendary using the provided app_name. | ||||
|         Adds an EGS game to Steam using the egs_api. | ||||
|  | ||||
|         Args: | ||||
|             game_name: The display name of the game. | ||||
|             app_name: The Legendary app_name (unique identifier for the game). | ||||
|         """ | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
|         if not os.path.exists(self.legendary_path): | ||||
|             self._show_warning_dialog(_("Error"), _("Legendary executable not found at {0}").format(self.legendary_path)) | ||||
|             return | ||||
|  | ||||
|         def on_add_to_steam_result(result: tuple[bool, str]): | ||||
|             success, message = result | ||||
|             if success: | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("The game was added successfully. Please restart Steam for changes to take effect."), 5000 | ||||
|                 ) | ||||
|             else: | ||||
|                 self.signals.show_warning_dialog.emit(_("Error"), message) | ||||
|  | ||||
|         if self.parent.statusBar(): | ||||
|             self.parent.statusBar().showMessage(_("Adding '{0}' to Steam...").format(game_name), 0) | ||||
|             logger.debug("Direct status message: Adding '%s' to Steam", game_name) | ||||
|         else: | ||||
|             logger.warning("Status bar not available when adding '%s' to Steam", game_name) | ||||
|         add_egs_to_steam(app_name, game_name, self.legendary_path, on_add_to_steam_result) | ||||
|  | ||||
|     def open_egs_game_folder(self, app_name: str): | ||||
|         """ | ||||
|         Opens the folder containing the EGS game's executable. | ||||
|  | ||||
|         Args: | ||||
|             app_name: The Legendary app_name (unique identifier for the game). | ||||
|         """ | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
|         exe_path = get_egs_executable(app_name, self.legendary_config_path) | ||||
|         if not exe_path or not os.path.exists(exe_path): | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Executable file not found for game: {0}").format(app_name) | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             folder_path = os.path.dirname(os.path.abspath(exe_path)) | ||||
|             QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Opened folder for EGS game '{0}'").format(app_name), 3000 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Opened folder for '%s'", app_name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when opening folder for '%s'", app_name) | ||||
|         except Exception as e: | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Failed to open game folder: {0}").format(str(e)) | ||||
|             ) | ||||
|  | ||||
|     def import_to_legendary(self, game_name, app_name): | ||||
|         """ | ||||
|         Imports an installed Epic Games Store game to Legendary asynchronously. | ||||
|  | ||||
|         Args: | ||||
|             game_name: The display name of the game. | ||||
| @@ -109,63 +265,118 @@ class ContextMenuManager: | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
|         # Открываем диалог для выбора папки с установленной игрой | ||||
|         folder_path = QFileDialog.getExistingDirectory( | ||||
|             self.parent, | ||||
|             _("Select Game Installation Folder"), | ||||
|             os.path.expanduser("~") | ||||
|         ) | ||||
|         if not folder_path: | ||||
|             self.parent.statusBar().showMessage(_("No folder selected"), 3000) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage(_("No folder selected"), 3000) | ||||
|                 logger.debug("Direct status message: No folder selected for '%s'", game_name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when no folder selected for '%s'", game_name) | ||||
|             return | ||||
|  | ||||
|         # Путь к legendary | ||||
|         legendary_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache", "legendary" | ||||
|         ) | ||||
|         if not os.path.exists(legendary_path): | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, | ||||
|         if not os.path.exists(self.legendary_path): | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Legendary executable not found at {0}").format(legendary_path) | ||||
|                 _("Legendary executable not found at {0}").format(self.legendary_path) | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         # Формируем команду для импорта | ||||
|         cmd = [legendary_path, "import", app_name, folder_path] | ||||
|  | ||||
|         try: | ||||
|             # Выполняем команду legendary import | ||||
|         def run_import(): | ||||
|             cmd = [self.legendary_path, "import", app_name, folder_path] | ||||
|             subprocess.run( | ||||
|                 cmd, | ||||
|                 capture_output=True, | ||||
|                 text=True, | ||||
|                 check=True | ||||
|             ) | ||||
|  | ||||
|         if self.parent.statusBar(): | ||||
|             self.parent.statusBar().showMessage( | ||||
|                 _("Successfully imported '{0}' to Legendary").format(game_name), 3000 | ||||
|                 _("Importing '{0}' to Legendary...").format(game_name), 0 | ||||
|             ) | ||||
|             logger.debug("Direct status message: Importing '%s' to Legendary", game_name) | ||||
|         else: | ||||
|             logger.warning("Status bar not available when importing '%s'", game_name) | ||||
|         threading.Thread(target=run_import, daemon=True).start() | ||||
|  | ||||
|     def uninstall_egs_game(self, game_name: str, app_name: str): | ||||
|         """ | ||||
|         Uninstalls an Epic Games Store game using Legendary asynchronously. | ||||
|  | ||||
|         except subprocess.CalledProcessError as e: | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, | ||||
|         Args: | ||||
|             game_name: The display name of the game. | ||||
|             app_name: The Legendary app_name (unique identifier for the game). | ||||
|         """ | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
|         if not os.path.exists(self.legendary_path): | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Failed to import '{0}' to Legendary: {1}").format(game_name, e.stderr) | ||||
|                 _("Legendary executable not found at {0}").format(self.legendary_path) | ||||
|             ) | ||||
|         except FileNotFoundError: | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, | ||||
|                 _("Error"), | ||||
|                 _("Legendary executable not found") | ||||
|             ) | ||||
|         except Exception as e: | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, | ||||
|                 _("Error"), | ||||
|                 _("Unexpected error during import: {0}").format(str(e)) | ||||
|             return | ||||
|  | ||||
|         reply = QMessageBox.question( | ||||
|             self.parent, | ||||
|             _("Confirm Uninstallation"), | ||||
|             _("Are you sure you want to uninstall '{0}'? This will remove the game files.").format(game_name), | ||||
|             QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||||
|             QMessageBox.StandardButton.No | ||||
|         ) | ||||
|         if reply != QMessageBox.StandardButton.Yes: | ||||
|             return | ||||
|  | ||||
|         def run_uninstall(): | ||||
|             cmd = [self.legendary_path, "uninstall", app_name, "--skip-uninstaller"] | ||||
|             try: | ||||
|                 subprocess.run( | ||||
|                     cmd, | ||||
|                     capture_output=True, | ||||
|                     text=True, | ||||
|                     check=True, | ||||
|                     env={"LEGENDARY_CONFIG_PATH": self.legendary_config_path} | ||||
|                 ) | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("Successfully uninstalled '{0}'").format(game_name), 3000 | ||||
|                 ) | ||||
|             except subprocess.CalledProcessError as e: | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("Failed to uninstall '{0}'").format(game_name), 3000 | ||||
|                 ) | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Failed to uninstall '{0}': {1}").format(game_name, e.stderr) | ||||
|                 ) | ||||
|             except FileNotFoundError: | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("Legendary executable not found"), 3000 | ||||
|                 ) | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Legendary executable not found") | ||||
|                 ) | ||||
|             except Exception as e: | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("Unexpected error during uninstall"), 3000 | ||||
|                 ) | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Unexpected error during uninstall: {0}").format(str(e)) | ||||
|                 ) | ||||
|  | ||||
|         if self.parent.statusBar(): | ||||
|             self.parent.statusBar().showMessage( | ||||
|                 _("Uninstalling '{0}'...").format(game_name), 0 | ||||
|             ) | ||||
|             logger.debug("Direct status message: Uninstalling '%s'", game_name) | ||||
|         else: | ||||
|             logger.warning("Status bar not available when uninstalling '%s'", game_name) | ||||
|         threading.Thread(target=run_uninstall, daemon=True).start() | ||||
|  | ||||
|     def toggle_favorite(self, game_card, add: bool): | ||||
|         """ | ||||
| @@ -179,18 +390,33 @@ class ContextMenuManager: | ||||
|         if add and game_card.name not in favorites: | ||||
|             favorites.append(game_card.name) | ||||
|             game_card.is_favorite = True | ||||
|             self.parent.statusBar().showMessage(_("Added '{0}' to favorites").format(game_card.name), 3000) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Added '{0}' to favorites").format(game_card.name), 3000 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Added '%s' to favorites", game_card.name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when adding '%s' to favorites", game_card.name) | ||||
|         elif not add and game_card.name in favorites: | ||||
|             favorites.remove(game_card.name) | ||||
|             game_card.is_favorite = False | ||||
|             self.parent.statusBar().showMessage(_("Removed '{0}' from favorites").format(game_card.name), 3000) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Removed '{0}' from favorites").format(game_card.name), 3000 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Removed '%s' from favorites", game_card.name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when removing '%s' from favorites", game_card.name) | ||||
|         save_favorites(favorites) | ||||
|         game_card.update_favorite_icon() | ||||
|  | ||||
|     def _check_portproton(self): | ||||
|         """Check if PortProton is available.""" | ||||
|         if self.portproton_location is None: | ||||
|             QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found.")) | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("PortProton is not found.") | ||||
|             ) | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
| @@ -214,33 +440,32 @@ class ContextMenuManager: | ||||
|                 if entry: | ||||
|                     exec_line = entry.get("Exec", entry.get("exec", "")).strip() | ||||
|                     if not exec_line: | ||||
|                         QMessageBox.warning( | ||||
|                             self.parent, _("Error"), | ||||
|                         self._show_warning_dialog( | ||||
|                             _("Error"), | ||||
|                             _("No executable command found in .desktop for game: {0}").format(game_name) | ||||
|                         ) | ||||
|                         return None | ||||
|                 else: | ||||
|                     QMessageBox.warning( | ||||
|                         self.parent, _("Error"), | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Failed to parse .desktop file for game: {0}").format(game_name) | ||||
|                     ) | ||||
|                     return None | ||||
|             except Exception as e: | ||||
|                 QMessageBox.warning( | ||||
|                     self.parent, _("Error"), | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Error reading .desktop file: {0}").format(e) | ||||
|                 ) | ||||
|                 return None | ||||
|         else: | ||||
|             # Fallback: Search all .desktop files | ||||
|             for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")): | ||||
|                 entry = parse_desktop_entry(file) | ||||
|                 if entry: | ||||
|                     exec_line = entry.get("Exec", entry.get("exec", "")).strip() | ||||
|                     if exec_line: | ||||
|                         return exec_line | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _(".desktop file not found for game: {0}").format(game_name) | ||||
|             ) | ||||
|             return None | ||||
| @@ -251,8 +476,8 @@ class ContextMenuManager: | ||||
|         try: | ||||
|             entry_exec_split = shlex.split(exec_line) | ||||
|             if not entry_exec_split: | ||||
|                 QMessageBox.warning( | ||||
|                     self.parent, _("Error"), | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Invalid executable command: {0}").format(exec_line) | ||||
|                 ) | ||||
|                 return None | ||||
| @@ -263,15 +488,15 @@ class ContextMenuManager: | ||||
|             else: | ||||
|                 exe_path = entry_exec_split[-1] | ||||
|             if not exe_path or not os.path.exists(exe_path): | ||||
|                 QMessageBox.warning( | ||||
|                     self.parent, _("Error"), | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Executable file not found: {0}").format(exe_path or "None") | ||||
|                 ) | ||||
|                 return None | ||||
|             return exe_path | ||||
|         except Exception as e: | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Failed to parse executable command: {0}").format(e) | ||||
|             ) | ||||
|             return None | ||||
| @@ -280,10 +505,17 @@ class ContextMenuManager: | ||||
|         """Remove a file and handle errors.""" | ||||
|         try: | ||||
|             os.remove(file_path) | ||||
|             self.parent.statusBar().showMessage(success_message.format(game_name), 3000) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage(success_message.format(game_name), 3000) | ||||
|                 logger.debug("Direct status message: %s", success_message.format(game_name)) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when removing file for '%s'", game_name) | ||||
|             return True | ||||
|         except OSError as e: | ||||
|             QMessageBox.warning(self.parent, _("Error"), error_message.format(e)) | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 error_message.format(e) | ||||
|             ) | ||||
|             return False | ||||
|  | ||||
|     def delete_game(self, game_name, exec_line): | ||||
| @@ -304,13 +536,12 @@ class ContextMenuManager: | ||||
|  | ||||
|         desktop_path = self._get_desktop_path(game_name) | ||||
|         if not os.path.exists(desktop_path): | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Could not locate .desktop file for '{0}'").format(game_name) | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         # Get exec_line and parse exe_path | ||||
|         exec_line = self._get_exec_line(game_name, exec_line) | ||||
|         if not exec_line: | ||||
|             return | ||||
| @@ -318,7 +549,6 @@ class ContextMenuManager: | ||||
|         exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|         exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None | ||||
|  | ||||
|         # Remove .desktop file | ||||
|         if not self._remove_file( | ||||
|             desktop_path, | ||||
|             _("Failed to delete .desktop file: {0}"), | ||||
| @@ -327,7 +557,6 @@ class ContextMenuManager: | ||||
|         ): | ||||
|             return | ||||
|  | ||||
|         # Remove custom data if we got an exe_name | ||||
|         if exe_name: | ||||
|             xdg_data_home = os.getenv( | ||||
|                 "XDG_DATA_HOME", | ||||
| @@ -338,15 +567,11 @@ class ContextMenuManager: | ||||
|                 try: | ||||
|                     shutil.rmtree(custom_folder) | ||||
|                 except OSError as e: | ||||
|                     QMessageBox.warning( | ||||
|                         self.parent, _("Error"), | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Failed to delete custom data: {0}").format(e) | ||||
|                     ) | ||||
|  | ||||
|         # Refresh UI | ||||
|         self.parent.games = self.load_games() | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def add_to_menu(self, game_name, exec_line): | ||||
|         """Copy the .desktop file to ~/.local/share/applications.""" | ||||
|         if not self._check_portproton(): | ||||
| @@ -354,25 +579,29 @@ class ContextMenuManager: | ||||
|  | ||||
|         desktop_path = self._get_desktop_path(game_name) | ||||
|         if not os.path.exists(desktop_path): | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Could not locate .desktop file for '{0}'").format(game_name) | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         # Destination path | ||||
|         applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") | ||||
|         os.makedirs(applications_dir, exist_ok=True) | ||||
|         dest_path = os.path.join(applications_dir, f"{game_name}.desktop") | ||||
|  | ||||
|         # Copy .desktop file | ||||
|         try: | ||||
|             shutil.copyfile(desktop_path, dest_path) | ||||
|             os.chmod(dest_path, 0o755)  # Ensure executable permissions | ||||
|             self.parent.statusBar().showMessage(_("Game '{0}' added to menu").format(game_name), 3000) | ||||
|             os.chmod(dest_path, 0o755) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Game '{0}' added to menu").format(game_name), 3000 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Game '%s' added to menu", game_name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when adding '%s' to menu", game_name) | ||||
|         except OSError as e: | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Failed to add game to menu: {0}").format(str(e)) | ||||
|             ) | ||||
|  | ||||
| @@ -394,25 +623,29 @@ class ContextMenuManager: | ||||
|  | ||||
|         desktop_path = self._get_desktop_path(game_name) | ||||
|         if not os.path.exists(desktop_path): | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Could not locate .desktop file for '{0}'").format(game_name) | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         # Destination path | ||||
|         desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip() | ||||
|         os.makedirs(desktop_dir, exist_ok=True) | ||||
|         dest_path = os.path.join(desktop_dir, f"{game_name}.desktop") | ||||
|  | ||||
|         # Copy .desktop file | ||||
|         try: | ||||
|             shutil.copyfile(desktop_path, dest_path) | ||||
|             os.chmod(dest_path, 0o755)  # Ensure executable permissions | ||||
|             self.parent.statusBar().showMessage(_("Game '{0}' added to desktop").format(game_name), 3000) | ||||
|             os.chmod(dest_path, 0o755) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Game '{0}' added to desktop").format(game_name), 3000 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Game '%s' added to desktop", game_name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when adding '%s' to desktop", game_name) | ||||
|         except OSError as e: | ||||
|             QMessageBox.warning( | ||||
|                 self.parent, _("Error"), | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Failed to add game to desktop: {0}").format(str(e)) | ||||
|             ) | ||||
|  | ||||
| @@ -429,7 +662,6 @@ class ContextMenuManager: | ||||
|  | ||||
|     def edit_game_shortcut(self, game_name, exec_line, cover_path): | ||||
|         """Opens the AddGameDialog in edit mode to modify an existing .desktop file.""" | ||||
|  | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
| @@ -441,7 +673,6 @@ class ContextMenuManager: | ||||
|         if not exe_path: | ||||
|             return | ||||
|  | ||||
|         # Open dialog in edit mode | ||||
|         dialog = AddGameDialog( | ||||
|             parent=self.parent, | ||||
|             theme=self.theme, | ||||
| @@ -457,16 +688,20 @@ class ContextMenuManager: | ||||
|             new_cover_path = dialog.coverEdit.text().strip() | ||||
|  | ||||
|             if not new_name or not new_exe_path: | ||||
|                 QMessageBox.warning(self.parent, _("Error"), _("Game name and executable path are required.")) | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Game name and executable path are required.") | ||||
|                 ) | ||||
|                 return | ||||
|  | ||||
|             # Generate new .desktop file content | ||||
|             desktop_entry, new_desktop_path = dialog.getDesktopEntryData() | ||||
|             if not desktop_entry or not new_desktop_path: | ||||
|                 QMessageBox.warning(self.parent, _("Error"), _("Failed to generate .desktop file data.")) | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Failed to generate .desktop file data.") | ||||
|                 ) | ||||
|                 return | ||||
|  | ||||
|             # If the name has changed, remove the old .desktop file | ||||
|             old_desktop_path = self._get_desktop_path(game_name) | ||||
|             if game_name != new_name and os.path.exists(old_desktop_path): | ||||
|                 self._remove_file( | ||||
| @@ -476,16 +711,17 @@ class ContextMenuManager: | ||||
|                     game_name | ||||
|                 ) | ||||
|  | ||||
|             # Save the updated .desktop file | ||||
|             try: | ||||
|                 with open(new_desktop_path, "w", encoding="utf-8") as f: | ||||
|                     f.write(desktop_entry) | ||||
|                     os.chmod(new_desktop_path, 0o755) | ||||
|             except OSError as e: | ||||
|                 QMessageBox.warning(self.parent, _("Error"), _("Failed to save .desktop file: {0}").format(e)) | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Failed to save .desktop file: {0}").format(e) | ||||
|                 ) | ||||
|                 return | ||||
|  | ||||
|             # Update custom cover if provided | ||||
|             if os.path.isfile(new_cover_path): | ||||
|                 exe_name = os.path.splitext(os.path.basename(new_exe_path))[0] | ||||
|                 xdg_data_home = os.getenv( | ||||
| @@ -500,16 +736,14 @@ class ContextMenuManager: | ||||
|                     try: | ||||
|                         shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}")) | ||||
|                     except OSError as e: | ||||
|                         QMessageBox.warning(self.parent, _("Error"), _("Failed to copy cover image: {0}").format(e)) | ||||
|                         self._show_warning_dialog( | ||||
|                             _("Error"), | ||||
|                             _("Failed to copy cover image: {0}").format(e) | ||||
|                         ) | ||||
|                         return | ||||
|  | ||||
|             # Refresh the game list | ||||
|             self.parent.games = self.load_games() | ||||
|             self.update_game_grid() | ||||
|  | ||||
|     def add_to_steam(self, game_name, exec_line, cover_path): | ||||
|         """Handle adding a non-Steam game to Steam via steam_api.""" | ||||
|  | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
| @@ -521,37 +755,174 @@ class ContextMenuManager: | ||||
|         if not exe_path: | ||||
|             return | ||||
|  | ||||
|         success, message = add_to_steam(game_name, exec_line, cover_path) | ||||
|         if success: | ||||
|             QMessageBox.information( | ||||
|                 self.parent, _("Restart Steam"), | ||||
|                 _("The game was added successfully.\nPlease restart Steam for changes to take effect.") | ||||
|         def on_add_to_steam_result(result: tuple[bool, str]): | ||||
|             success, message = result | ||||
|             if success: | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("The game was added successfully. Please restart Steam for changes to take effect."), 5000 | ||||
|                 ) | ||||
|             else: | ||||
|                 self.signals.show_warning_dialog.emit(_("Error"), message) | ||||
|  | ||||
|         if self.parent.statusBar(): | ||||
|             self.parent.statusBar().showMessage( | ||||
|                 _("Adding '{0}' to Steam...").format(game_name), 0 | ||||
|             ) | ||||
|             logger.debug("Direct status message: Adding '%s' to Steam", game_name) | ||||
|         else: | ||||
|             QMessageBox.warning(self.parent, _("Error"), message) | ||||
|  | ||||
|     def remove_from_steam(self, game_name, exec_line): | ||||
|         """Handle removing a non-Steam game from Steam via steam_api.""" | ||||
|             logger.warning("Status bar not available when adding '%s' to Steam", game_name) | ||||
|         add_to_steam(game_name, exec_line, cover_path) | ||||
|  | ||||
|     def remove_from_steam(self, game_name, exec_line, game_source): | ||||
|         """Handle removing a game from Steam via steam_api, supporting both EGS and non-EGS games.""" | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|  | ||||
|         exec_line = self._get_exec_line(game_name, exec_line) | ||||
|         if not exec_line: | ||||
|             return | ||||
|         def on_remove_from_steam_result(result: tuple[bool, str]): | ||||
|             success, message = result | ||||
|             if success: | ||||
|                 self.signals.show_status_message.emit( | ||||
|                     _("The game was removed successfully. Please restart Steam for changes to take effect."), 5000 | ||||
|                 ) | ||||
|             else: | ||||
|                 self.signals.show_warning_dialog.emit(_("Error"), message) | ||||
|  | ||||
|         exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|         if not exe_path: | ||||
|             return | ||||
|         if game_source == "epic": | ||||
|             # For EGS games, construct the script path used in Steam shortcuts.vdf | ||||
|             if not self.portproton_location: | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("PortProton directory not found") | ||||
|                 ) | ||||
|                 return | ||||
|             steam_scripts_dir = os.path.join(self.portproton_location, "steam_scripts") | ||||
|             safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip()) | ||||
|             script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh") | ||||
|             quoted_script_path = f'"{script_path}"' | ||||
|  | ||||
|             # Directly remove the shortcut by matching AppName and Exe | ||||
|             try: | ||||
|                 from portprotonqt.steam_api import get_steam_home, get_last_steam_user, convert_steam_id | ||||
|                 steam_home = get_steam_home() | ||||
|                 if not steam_home: | ||||
|                     self._show_warning_dialog(_("Error"), _("Steam directory not found")) | ||||
|                     return | ||||
|  | ||||
|                 last_user = get_last_steam_user(steam_home) | ||||
|                 if not last_user or 'SteamID' not in last_user: | ||||
|                     self._show_warning_dialog(_("Error"), _("Failed to get Steam user ID")) | ||||
|                     return | ||||
|  | ||||
|                 userdata_dir = os.path.join(steam_home, "userdata") | ||||
|                 user_id = last_user['SteamID'] | ||||
|                 unsigned_id = convert_steam_id(user_id) | ||||
|                 user_dir = os.path.join(userdata_dir, str(unsigned_id)) | ||||
|                 steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf") | ||||
|                 backup_path = f"{steam_shortcuts_path}.backup" | ||||
|  | ||||
|                 if not os.path.exists(steam_shortcuts_path): | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Steam shortcuts file not found") | ||||
|                     ) | ||||
|                     return | ||||
|  | ||||
|                 # Backup shortcuts.vdf | ||||
|                 try: | ||||
|                     shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|                     logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|                 except Exception as e: | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Failed to create backup of shortcuts.vdf: {0}").format(e) | ||||
|                     ) | ||||
|                     return | ||||
|  | ||||
|                 # Load shortcuts.vdf | ||||
|                 try: | ||||
|                     with open(steam_shortcuts_path, 'rb') as f: | ||||
|                         shortcuts_data = vdf.binary_load(f) | ||||
|                 except Exception as e: | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Failed to load shortcuts.vdf: {0}").format(e) | ||||
|                     ) | ||||
|                     return | ||||
|  | ||||
|                 shortcuts = shortcuts_data.get("shortcuts", {}) | ||||
|                 modified = False | ||||
|                 new_shortcuts = {} | ||||
|                 index = 0 | ||||
|  | ||||
|                 for _key, entry in shortcuts.items(): | ||||
|                     if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path: | ||||
|                         modified = True | ||||
|                         logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts") | ||||
|                         continue | ||||
|                     new_shortcuts[str(index)] = entry | ||||
|                     index += 1 | ||||
|  | ||||
|                 if not modified: | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Game '{0}' not found in Steam shortcuts").format(game_name) | ||||
|                     ) | ||||
|                     return | ||||
|  | ||||
|                 # Save updated shortcuts.vdf | ||||
|                 try: | ||||
|                     with open(steam_shortcuts_path, 'wb') as f: | ||||
|                         vdf.binary_dump({"shortcuts": new_shortcuts}, f) | ||||
|                     logger.info(f"Updated shortcuts.vdf, removed '{game_name}'") | ||||
|                     on_remove_from_steam_result((True, f"Game '{game_name}' removed from Steam")) | ||||
|                 except Exception as e: | ||||
|                     self._show_warning_dialog( | ||||
|                         _("Error"), | ||||
|                         _("Failed to update shortcuts.vdf: {0}").format(e) | ||||
|                     ) | ||||
|                     if os.path.exists(backup_path): | ||||
|                         try: | ||||
|                             shutil.copy2(backup_path, steam_shortcuts_path) | ||||
|                             logger.info("Restored shortcuts.vdf from backup") | ||||
|                         except Exception as restore_err: | ||||
|                             logger.error(f"Failed to restore shortcuts.vdf: {restore_err}") | ||||
|                     on_remove_from_steam_result((False, f"Failed to update shortcuts.vdf: {e}")) | ||||
|                     return | ||||
|  | ||||
|                 # Optionally, remove the script file | ||||
|                 if os.path.exists(script_path): | ||||
|                     try: | ||||
|                         os.remove(script_path) | ||||
|                         logger.info(f"Removed EGS script file: {script_path}") | ||||
|                     except Exception as e: | ||||
|                         logger.warning(f"Failed to remove EGS script file {script_path}: {e}") | ||||
|  | ||||
|             except Exception as e: | ||||
|                 self._show_warning_dialog( | ||||
|                     _("Error"), | ||||
|                     _("Failed to remove EGS game from Steam: {0}").format(e) | ||||
|                 ) | ||||
|                 on_remove_from_steam_result((False, f"Failed to remove EGS game from Steam: {e}")) | ||||
|                 return | ||||
|  | ||||
|         success, message = remove_from_steam(game_name, exec_line) | ||||
|         if success: | ||||
|             QMessageBox.information( | ||||
|                 self.parent, _("Restart Steam"), | ||||
|                 _("The game was removed successfully.\nPlease restart Steam for changes to take effect.") | ||||
|             ) | ||||
|         else: | ||||
|             QMessageBox.warning(self.parent, _("Error"), message) | ||||
|             # For non-EGS games, use the existing logic | ||||
|             exec_line = self._get_exec_line(game_name, exec_line) | ||||
|             if not exec_line: | ||||
|                 return | ||||
|  | ||||
|             exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|             if not exe_path: | ||||
|                 return | ||||
|  | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Removing '{0}' from Steam...").format(game_name), 0 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Removing '%s' from Steam", game_name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when removing '%s' from Steam", game_name) | ||||
|             remove_from_steam(game_name, exec_line) | ||||
|  | ||||
|     def open_game_folder(self, game_name, exec_line): | ||||
|         """Open the folder containing the game's executable.""" | ||||
| @@ -569,6 +940,15 @@ class ContextMenuManager: | ||||
|         try: | ||||
|             folder_path = os.path.dirname(os.path.abspath(exe_path)) | ||||
|             QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) | ||||
|             self.parent.statusBar().showMessage(_("Opened folder for '{0}'").format(game_name), 3000) | ||||
|             if self.parent.statusBar(): | ||||
|                 self.parent.statusBar().showMessage( | ||||
|                     _("Opened folder for '{0}'").format(game_name), 3000 | ||||
|                 ) | ||||
|                 logger.debug("Direct status message: Opened folder for '%s'", game_name) | ||||
|             else: | ||||
|                 logger.warning("Status bar not available when opening folder for '%s'", game_name) | ||||
|         except Exception as e: | ||||
|             QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e))) | ||||
|             self._show_warning_dialog( | ||||
|                 _("Error"), | ||||
|                 _("Failed to open game folder: {0}").format(str(e)) | ||||
|             ) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user