diff --git a/portprotonqt/context_menu_manager.py b/portprotonqt/context_menu_manager.py index a9f8c05..fc80f14 100644 --- a/portprotonqt/context_menu_manager.py +++ b/portprotonqt/context_menu_manager.py @@ -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)) + )