PortProtonQt/portprotonqt/context_menu_manager.py
Boris Yumankulov 5d84dbad8e
refactor: rename steam_game to game_source for better clarity
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 20:11:05 +05:00

468 lines
19 KiB
Python

import os
import shlex
import glob
import shutil
import subprocess
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
from PySide6.QtCore import QUrl, QPoint
from PySide6.QtGui import QDesktopServices
from portprotonqt.config_utils import parse_desktop_entry
from portprotonqt.localization import _
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQT."""
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
"""
Initialize the ContextMenuManager.
Args:
parent: The parent widget (MainWindow instance).
portproton_location: Path to the PortProton directory.
theme: The current theme object.
load_games_callback: Callback to reload games list.
update_game_grid_callback: Callback to update the game grid UI.
"""
self.parent = parent
self.portproton_location = portproton_location
self.theme = theme
self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback
def show_context_menu(self, game_card, pos: QPoint):
"""
Show the context menu for a game card at the specified position.
Args:
game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear.
"""
menu = QMenu(self.parent)
if game_card.game_source not in ("steam", "epic"):
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
if os.path.exists(desktop_path):
remove_action = menu.addAction(_("Remove from Desktop"))
remove_action.triggered.connect(lambda: self.remove_from_desktop(game_card.name))
else:
add_action = menu.addAction(_("Add to Desktop"))
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))
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))
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
if os.path.exists(desktop_path):
remove_action = menu.addAction(_("Remove from Menu"))
remove_action.triggered.connect(lambda: self.remove_from_menu(game_card.name))
else:
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))
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))
menu.exec(game_card.mapToGlobal(pos))
def _check_portproton(self):
"""Check if PortProton is available."""
if self.portproton_location is None:
QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found."))
return False
return True
def _get_desktop_path(self, game_name):
"""Construct the .desktop file path, trying both original and sanitized game names."""
desktop_path = os.path.join(self.portproton_location, f"{game_name}.desktop")
if not os.path.exists(desktop_path):
sanitized_name = game_name.replace("/", "_").replace(":", "_").replace(" ", "_")
desktop_path = os.path.join(self.portproton_location, f"{sanitized_name}.desktop")
return desktop_path
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.strip() != "full":
return exec_line
desktop_path = self._get_desktop_path(game_name)
if os.path.exists(desktop_path):
try:
entry = parse_desktop_entry(desktop_path)
if entry:
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
if not exec_line:
QMessageBox.warning(
self.parent, _("Error"),
_("No executable command found in .desktop for game: {0}").format(game_name)
)
return None
else:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to parse .desktop file for game: {0}").format(game_name)
)
return None
except Exception as e:
QMessageBox.warning(
self.parent, _("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"),
_(".desktop file not found for game: {0}").format(game_name)
)
return None
return exec_line
def _parse_exe_path(self, exec_line, game_name):
"""Parse the executable path from exec_line."""
try:
entry_exec_split = shlex.split(exec_line)
if not entry_exec_split:
QMessageBox.warning(
self.parent, _("Error"),
_("Invalid executable command: {0}").format(exec_line)
)
return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2]
elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) >= 4:
exe_path = entry_exec_split[3]
else:
exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path):
QMessageBox.warning(
self.parent, _("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"),
_("Failed to parse executable command: {0}").format(e)
)
return None
def _remove_file(self, file_path, error_message, success_message, game_name):
"""Remove a file and handle errors."""
try:
os.remove(file_path)
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
return True
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), error_message.format(e))
return False
def delete_game(self, game_name, exec_line):
"""Delete the .desktop file and associated custom data for the game."""
reply = QMessageBox.question(
self.parent,
_("Confirm Deletion"),
_("Are you sure you want to delete '{0}'? This will remove the .desktop file and custom data.")
.format(game_name),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("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
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}"),
_("Game '{0}' deleted successfully"),
game_name
):
return
# Remove custom data if we got an exe_name
if exe_name:
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
if os.path.exists(custom_folder):
try:
shutil.rmtree(custom_folder)
except OSError as e:
QMessageBox.warning(
self.parent, _("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():
return
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("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)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to add game to menu: {0}").format(str(e))
)
def remove_from_menu(self, game_name):
"""Remove the .desktop file from ~/.local/share/applications."""
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
desktop_path = os.path.join(applications_dir, f"{game_name}.desktop")
self._remove_file(
desktop_path,
_("Failed to remove game from menu: {0}"),
_("Game '{0}' removed from menu"),
game_name
)
def add_to_desktop(self, game_name, exec_line):
"""Copy the .desktop file to Desktop folder."""
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("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)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to add game to desktop: {0}").format(str(e))
)
def remove_from_desktop(self, game_name):
"""Remove the .desktop file from Desktop folder."""
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
self._remove_file(
desktop_path,
_("Failed to remove game from Desktop: {0}"),
_("Game '{0}' removed from Desktop"),
game_name
)
def edit_game_shortcut(self, game_name, exec_line, cover_path):
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
from portprotonqt.dialogs import AddGameDialog # Local import to avoid circular dependency
if not self._check_portproton():
return
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
# Open dialog in edit mode
dialog = AddGameDialog(
parent=self.parent,
theme=self.theme,
edit_mode=True,
game_name=game_name,
exe_path=exe_path,
cover_path=cover_path
)
if dialog.exec() == QDialog.DialogCode.Accepted:
new_name = dialog.nameEdit.text().strip()
new_exe_path = dialog.exeEdit.text().strip()
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."))
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."))
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(
old_desktop_path,
_("Failed to remove old .desktop file: {0}"),
_("Old .desktop file removed for '{0}'"),
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))
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(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
os.makedirs(custom_folder, exist_ok=True)
ext = os.path.splitext(new_cover_path)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
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))
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
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
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.")
)
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."""
if not self._check_portproton():
return
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
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)
def open_game_folder(self, game_name, exec_line):
"""Open the folder containing the game's executable."""
if not self._check_portproton():
return
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
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)
except Exception as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e)))