"""Handle game specific actions""" # Standard Library # pylint: disable=too-many-public-methods import os from gettext import gettext as _ from typing import List from gi.repository import Gio, Gtk from lutris.config import duplicate_game_config from lutris.database.games import add_game, get_game_by_field from lutris.game import Game from lutris.gui import dialogs from lutris.gui.config.add_game_dialog import AddGameDialog from lutris.gui.config.edit_game import EditGameConfigDialog from lutris.gui.config.edit_game_categories import EditGameCategoriesDialog from lutris.gui.dialogs import InputDialog from lutris.gui.dialogs.log import LogWindow from lutris.gui.dialogs.uninstall_dialog import UninstallDialog from lutris.gui.widgets.utils import open_uri from lutris.monitored_command import MonitoredCommand from lutris.services.lutris import download_lutris_media from lutris.util import xdgshortcuts from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.steam import shortcut as steam_shortcut from lutris.util.strings import gtk_safe, slugify from lutris.util.system import path_exists class GameActions: """These classes provide a set of action to apply to a game or list of games, and can be used to populate menus. The base class handles the no-games case, for which there are no actions. But it also includes the code for actions that are shared between the subclasses. It also has methods for actions that are invokes externally by the GameBar.""" def __init__(self, window: Gtk.Window, application=None): self.application = application or Gio.Application.get_default() self.window = window # also used as a LaunchUIDelegate and InstallUIDelegate def get_games(self): """Return the list of games that the actions apply to.""" return [] def get_game_actions(self): """Return a list of game actions and their callbacks, Each item is a tuple of two strs and a callable, the action ID, it's human-readable name, and a callback to invoke to perform it. Menu separators are represented hre as (None, "-", None). """ return [] def get_displayed_entries(self): """Return a dictionary of flags indicating which actions are visible; the keys are the action ids from get_game_actions(), and the values are booleans indicating the action's visibility.""" return {} @property def is_game_launchable(self): for game in self.get_games(): if game.is_installed and not self.is_game_running: return True return False def on_game_launch(self, *_args): """Launch a game""" @property def is_game_running(self): for game in self.get_games(): if game.is_db_stored and self.application.is_game_running_by_id(game.id): return True return False def on_game_stop(self, *_args): """Stops the game""" games = self.get_running_games() for game in games: game.force_stop() def get_running_games(self): running_games = [] for game in self.get_games(): if game and game.is_db_stored: ids = self.application.get_running_game_ids() for game_id in ids: if str(game_id) == game.id: running_games.append(game) return running_games @property def is_installable(self): for game in self.get_games(): if not game.is_installed: return True return False def on_install_clicked(self, *_args): """Install a game""" # Install the currently selected game in the UI for game in self.get_games(): if not game.slug: game_id = game.id if game.is_db_stored else game.name raise RuntimeError("No game to install: %s" % game_id) game.install(launch_ui_delegate=self.window) def on_add_favorite_game(self, _widget): """Add to favorite Games list""" for game in self.get_games(): game.mark_as_favorite(True) def on_delete_favorite_game(self, _widget): """delete from favorites""" for game in self.get_games(): game.mark_as_favorite(False) def on_hide_game(self, _widget): """Add a game to the list of hidden games""" for game in self.get_games(): game.mark_as_hidden(True) def on_unhide_game(self, _widget): """Removes a game from the list of hidden games""" for game in self.get_games(): game.mark_as_hidden(False) def on_locate_installed_game(self, *_args): """Show the user a dialog to import an existing install to a DRM free service Params: games ([Game]): List of Game instances without a database ID, populated with fields the service can provides """ for game in self.get_games(): AddGameDialog(self.window, game=game, runner=game.runner_name) def on_view_game(self, _widget): """Callback to open a game on lutris.net""" for game in self.get_games(): open_uri("https://lutris.net/games/%s" % game.slug.replace("_", "-")) @property def is_game_removable(self): for game in self.get_games(): if game.is_installed or game.is_db_stored: return True return False def on_remove_game(self, *_args): """Callback that present the uninstall dialog to the user""" game_ids = [g.id for g in self.get_games() if g.is_installed or g.is_db_stored] application = Gio.Application.get_default() dlg = application.show_window(UninstallDialog, parent=self.window) dlg.add_games(game_ids) def on_edit_game_categories(self, _widget): """Edit game categories""" games = self.get_games() if len(games) == 1: # Individual games get individual separate windows self.application.show_window(EditGameCategoriesDialog, game=games[0], parent=self.window) else: def add_games(window): window.add_games(self.get_games()) # Multi-select means a common categories window for all of them; we can wind # up adding games to it if it's already open self.application.show_window(EditGameCategoriesDialog, update_function=add_games, parent=self.window) class MultiGameActions(GameActions): """This actions class handles actions on multiple games together, and only iof they are 'db stored' games, not service games. This supports a subset of the actions of SingleGameActions.""" def __init__(self, games: List[Game], window: Gtk.Window, application=None): super().__init__(window, application) self.games = games def get_games(self): return self.games def get_game_actions(self): return [ ("stop", _("Stop"), self.on_game_stop), (None, "-", None), ("category", _("Categories"), self.on_edit_game_categories), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), (None, "-", None), ("remove", _("Remove"), self.on_remove_game), ] def get_displayed_entries(self): return { "stop": self.is_game_running, "category": True, "favorite": any(g for g in self.games if not g.is_favorite), "deletefavorite": any(g for g in self.games if g.is_favorite), "hide": any(g for g in self.games if g.is_installed and not g.is_hidden), "unhide": any(g for g in self.games if g.is_hidden), "remove": self.is_game_removable, } class SingleGameActions(GameActions): """This actions class handles actions on a single game, which is a 'db stored' game, not a service game. This provides the largest selection of actions, including many that are unique to it.""" def __init__(self, game: Game, window: Gtk.Window, application=None): super().__init__(window, application) self.game = game def get_games(self): return [self.game] def get_game_actions(self): return [ ("play", _("Play"), self.on_game_launch), ("stop", _("Stop"), self.on_game_stop), ("execute-script", _("Execute script"), self.on_execute_script_clicked), ("show_logs", _("Show logs"), self.on_show_logs), (None, "-", None), ("configure", _("Configure"), self.on_edit_game_configuration), ("category", _("Categories"), self.on_edit_game_categories), ("browse", _("Browse files"), self.on_browse_files), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), (None, "-", None), ("install", _("Install"), self.on_install_clicked), ("install_more", _("Install another version"), self.on_install_clicked), ("install_dlcs", _("Install DLCs"), self.on_install_dlc_clicked), ("update", _("Install updates"), self.on_update_clicked), ("add", _("Locate installed game"), self.on_locate_installed_game), ("desktop-shortcut", _("Create desktop shortcut"), self.on_create_desktop_shortcut), ("rm-desktop-shortcut", _("Delete desktop shortcut"), self.on_remove_desktop_shortcut), ("menu-shortcut", _("Create application menu shortcut"), self.on_create_menu_shortcut), ("rm-menu-shortcut", _("Delete application menu shortcut"), self.on_remove_menu_shortcut), ("steam-shortcut", _("Create Steam shortcut"), self.on_create_steam_shortcut), ("rm-steam-shortcut", _("Delete Steam shortcut"), self.on_remove_steam_shortcut), ("view", _("View on Lutris.net"), self.on_view_game), ("duplicate", _("Duplicate"), self.on_game_duplicate), (None, "-", None), ("remove", _("Remove"), self.on_remove_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" game = self.game has_steam = steam_shortcut.vdf_file_exists() if has_steam: has_steam_shortcut = steam_shortcut.shortcut_exists(game) is_steam_game = steam_shortcut.is_steam_game(game) else: has_steam_shortcut = False is_steam_game = False return { "duplicate": game.is_installed, "install": self.is_installable, "add": not game.is_installed, "play": self.is_game_launchable, "update": game.is_updatable, "install_dlcs": game.is_updatable, "stop": self.is_game_running, "configure": bool(game.is_installed), "browse": game.is_installed and game.runner_name != "browser", "show_logs": game.is_installed, "category": True, "favorite": not game.is_favorite, "deletefavorite": game.is_favorite, "install_more": not game.service and game.is_installed, "execute-script": bool( game.is_installed and game.has_runner and game.runner.system_config.get("manual_command") ), "desktop-shortcut": bool( game.is_installed and not xdgshortcuts.desktop_launcher_exists(game.slug, game.id) ), "menu-shortcut": bool(game.is_installed and not xdgshortcuts.menu_launcher_exists(game.slug, game.id)), "steam-shortcut": bool(has_steam and game.is_installed and not has_steam_shortcut and not is_steam_game), "rm-desktop-shortcut": bool(game.is_installed and xdgshortcuts.desktop_launcher_exists(game.slug, game.id)), "rm-menu-shortcut": bool(game.is_installed and xdgshortcuts.menu_launcher_exists(game.slug, game.id)), "rm-steam-shortcut": bool(game.is_installed and has_steam_shortcut and not is_steam_game), "remove": self.is_game_removable, "view": True, "hide": game.is_installed and not game.is_hidden, "unhide": game.is_hidden, } def on_game_launch(self, *_args): """Launch a game""" game = self.game if game.is_installed and game.is_db_stored: if not self.application.is_game_running_by_id(game.id): game.launch(launch_ui_delegate=self.window) def on_execute_script_clicked(self, _widget): """Execute the game's associated script""" game = self.game manual_command = game.runner.system_config.get("manual_command") if path_exists(manual_command): MonitoredCommand( [manual_command], include_processes=[os.path.basename(manual_command)], cwd=game.directory, ).start() logger.info("Running %s in the background", manual_command) def on_show_logs(self, _widget): """Display game log""" game = self.game _buffer = game.log_buffer if not _buffer: logger.info("No log for game %s", game) return LogWindow(game=game, buffer=_buffer, application=self.application) def on_edit_game_configuration(self, _widget): """Edit game preferences""" self.application.show_window(EditGameConfigDialog, game=self.game, parent=self.window) def on_browse_files(self, _widget): """Callback to open a game folder in the file browser""" path = self.game.get_browse_dir() if not path: dialogs.NoticeDialog(_("This game has no installation directory")) elif path_exists(path): open_uri("file://%s" % path) else: dialogs.NoticeDialog(_("Can't open %s \nThe folder doesn't exist.") % path) def on_install_dlc_clicked(self, _widget): self.game.install_dlc(install_ui_delegate=self.window) def on_update_clicked(self, _widget): self.game.install_updates(install_ui_delegate=self.window) def on_create_menu_shortcut(self, *_args): """Add the selected game to the system's Games menu.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: xdgshortcuts.create_launcher(game.slug, game.id, game.name, launch_config_name, menu=True) def on_create_steam_shortcut(self, *_args): """Add the selected game to steam as a nonsteam-game.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: steam_shortcut.create_shortcut(game, launch_config_name) def on_create_desktop_shortcut(self, *_args): """Create a desktop launcher for the selected game.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: xdgshortcuts.create_launcher(game.slug, game.id, game.name, launch_config_name, desktop=True) def on_remove_menu_shortcut(self, *_args): """Remove an XDG menu shortcut""" game = self.game xdgshortcuts.remove_launcher(game.slug, game.id, menu=True) def on_remove_steam_shortcut(self, *_args): """Remove the selected game from list of non-steam apps.""" steam_shortcut.remove_shortcut(self.game) def on_remove_desktop_shortcut(self, *_args): """Remove a .desktop shortcut""" game = self.game xdgshortcuts.remove_launcher(game.slug, game.id, desktop=True) def on_game_duplicate(self, _widget): game = self.game duplicate_game_dialog = InputDialog( { "parent": self.window, "question": _( "Do you wish to duplicate %s?\nThe configuration will be duplicated, " "but the games files will not be duplicated.\n" "Please enter the new name for the copy:" ) % gtk_safe(game.name), "title": _("Duplicate game?"), "initial_value": game.name, } ) result = duplicate_game_dialog.run() if result != Gtk.ResponseType.OK: duplicate_game_dialog.destroy() return new_name = duplicate_game_dialog.user_value old_config_id = game.game_config_id if old_config_id: new_config_id = duplicate_game_config(game.slug, old_config_id) else: new_config_id = None categories = game.get_categories() duplicate_game_dialog.destroy() db_game = get_game_by_field(game.id, "id") db_game["name"] = new_name db_game["slug"] = slugify(new_name) if new_name != game.name else game.slug db_game["lastplayed"] = None db_game["playtime"] = 0.0 db_game["configpath"] = new_config_id db_game.pop("id") # Disconnect duplicate from service- there should be at most 1 database game for a service game. db_game.pop("service", None) db_game.pop("service_id", None) game_id = add_game(**db_game) new_game = Game(game_id) # add categories before the save, so it can emit the signal. add_game() # means the game is already on the database, so this is legit. for cat in categories: new_game.add_category(cat, no_signal=True) new_game.save() # Download in the background; we'll update the LutrisWindow when this # completes, no need to wait for it. AsyncCall(download_lutris_media, None, db_game["slug"]) def _select_game_launch_config_name(self, game): game_config = game.config.game_level.get("game", {}) configs = game_config.get("launch_configs") if not configs: return "" # use primary configuration dlg = dialogs.LaunchConfigSelectDialog(game, configs, title=_("Select shortcut target"), parent=self.window) if not dlg.confirmed: return None # no error here- the user cancelled out config_index = dlg.config_index return configs[config_index - 1]["name"] if config_index > 0 else "" class ServiceGameActions(GameActions): """This actions class supports a single service game, which has an idiosyncratic set of actions.""" def __init__(self, game: Game, window: Gtk.Window, application=None): super().__init__(window, application) self.game = game def get_games(self): return [self.game] def get_game_actions(self): return [ ("install", _("Install"), self.on_install_clicked), ("add", _("Locate installed game"), self.on_locate_installed_game), ("view", _("View on Lutris.net"), self.on_view_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" return {"install": self.is_installable, "add": self.is_installable, "view": True} def get_game_actions(games: List[Game], window: Gtk.Window, application=None) -> GameActions: """Creates a GameActions instance (which may be a subclass) for the list of games given. If it can't figure out a suitable class, it falls back to the base GameActions class, which provides no actions.""" if games: if len(games) == 1: game = games[0] if game.is_db_stored: return SingleGameActions(game, window, application) if game.service: return ServiceGameActions(game, window, application) elif all(g.is_db_stored for g in games): return MultiGameActions(games, window) # If given no games, or the games are not of a kind we can handle, # the base class acts as an empty set of actions. return GameActions(window, application)