575 lines
23 KiB
Python
575 lines
23 KiB
Python
import os
|
||
import shlex
|
||
import glob
|
||
import shutil
|
||
import subprocess
|
||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog
|
||
from PySide6.QtCore import QUrl, QPoint
|
||
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
|
||
|
||
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)
|
||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||
|
||
favorites = read_favorites()
|
||
is_favorite = game_card.name in favorites
|
||
|
||
if is_favorite:
|
||
favorite_action = menu.addAction(_("Remove from Favorites"))
|
||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, False))
|
||
else:
|
||
favorite_action = menu.addAction(_("Add to Favorites"))
|
||
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)
|
||
)
|
||
|
||
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 import_to_legendary(self, game_name, app_name):
|
||
"""
|
||
Imports an installed Epic Games Store game to Legendary using the provided app_name.
|
||
|
||
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
|
||
|
||
# Открываем диалог для выбора папки с установленной игрой
|
||
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)
|
||
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,
|
||
_("Error"),
|
||
_("Legendary executable not found at {0}").format(legendary_path)
|
||
)
|
||
return
|
||
|
||
# Формируем команду для импорта
|
||
cmd = [legendary_path, "import", app_name, folder_path]
|
||
|
||
try:
|
||
# Выполняем команду legendary import
|
||
subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
check=True
|
||
)
|
||
self.parent.statusBar().showMessage(
|
||
_("Successfully imported '{0}' to Legendary").format(game_name), 3000
|
||
)
|
||
|
||
|
||
except subprocess.CalledProcessError as e:
|
||
QMessageBox.warning(
|
||
self.parent,
|
||
_("Error"),
|
||
_("Failed to import '{0}' to Legendary: {1}").format(game_name, e.stderr)
|
||
)
|
||
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))
|
||
)
|
||
|
||
def toggle_favorite(self, game_card, add: bool):
|
||
"""
|
||
Toggle the favorite status of a game and update its icon.
|
||
|
||
Args:
|
||
game_card: The GameCard instance to toggle.
|
||
add: True to add to favorites, False to remove.
|
||
"""
|
||
favorites = read_favorites()
|
||
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)
|
||
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)
|
||
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."))
|
||
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."""
|
||
|
||
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)))
|