from abc import abstractmethod from dataclasses import dataclass from datetime import datetime from enum import IntEnum from logging import getLogger from typing import Optional, List, Tuple from PyQt5.QtCore import QObject, pyqtSignal, QRunnable, QThreadPool, QSettings from legendary.lfs import eos from legendary.models.game import SaveGameFile, SaveGameStatus, Game, InstalledGame from legendary.utils.selective_dl import get_sdl_appname from rare.models.options import options from rare.lgndr.core import LegendaryCore from rare.models.install import UninstallOptionsModel, InstallOptionsModel logger = getLogger("RareGameBase") @dataclass class RareSaveGame: file: SaveGameFile status: SaveGameStatus = SaveGameStatus.NO_SAVE dt_local: Optional[datetime] = None dt_remote: Optional[datetime] = None description: Optional[str] = "" class RareGameBase(QObject): class State(IntEnum): IDLE = 0 RUNNING = 1 DOWNLOADING = 2 VERIFYING = 3 MOVING = 4 UNINSTALLING = 5 SYNCING = 6 class Signals: class Progress(QObject): start = pyqtSignal() update = pyqtSignal(int) finish = pyqtSignal(bool) class Widget(QObject): update = pyqtSignal() class Download(QObject): enqueue = pyqtSignal(str) dequeue = pyqtSignal(str) class Game(QObject): install = pyqtSignal(InstallOptionsModel) installed = pyqtSignal(str) uninstall = pyqtSignal(UninstallOptionsModel) uninstalled = pyqtSignal(str) launched = pyqtSignal(str) finished = pyqtSignal(str) def __init__(self): super(RareGameBase.Signals, self).__init__() self.progress = RareGameBase.Signals.Progress() self.widget = RareGameBase.Signals.Widget() self.download = RareGameBase.Signals.Download() self.game = RareGameBase.Signals.Game() def __del__(self): self.progress.deleteLater() self.widget.deleteLater() self.download.deleteLater() self.game.deleteLater() __slots__ = "igame" def __init__(self, legendary_core: LegendaryCore, game: Game): super(RareGameBase, self).__init__() self.signals = RareGameBase.Signals() self.core = legendary_core self.game: Game = game self._state = RareGameBase.State.IDLE def __del__(self): del self.signals @property def state(self) -> 'RareGameBase.State': return self._state @state.setter def state(self, state: 'RareGameBase.State'): if state != self._state: self._state = state self.signals.widget.update.emit() @property def is_idle(self): return self.state == RareGameBase.State.IDLE @property def app_name(self) -> str: return self.game.app_name @property def app_title(self) -> str: return self.game.app_title @property @abstractmethod def is_installed(self) -> bool: pass @abstractmethod def set_installed(self, installed: bool) -> None: pass @property def platforms(self) -> Tuple: """! @brief Property that holds the platforms a game is available for @return Tuple """ return tuple(self.game.asset_infos.keys()) @property def default_platform(self) -> str: return self.core.default_platform if self.core.default_platform in self.platforms else "Windows" @property def is_mac(self) -> bool: """! @brief Property to report if Game has a mac version @return bool """ return "Mac" in self.game.asset_infos.keys() @property def is_win32(self) -> bool: """! @brief Property to report if Game is 32bit game @return bool """ return "Win32" in self.game.asset_infos.keys() @property def is_origin(self) -> bool: """! @brief Property to report if a Game is an Origin game Legendary and by extenstion Rare can't launch Origin games directly, it just launches the Origin client and thus requires a bit of a special handling to let the user know. @return bool If the game is an Origin game """ return self.game.third_party_store in {"Origin", "the EA app"} @property def is_overlay(self): return self.app_name == eos.EOSOverlayApp.app_name @property def is_dlc(self) -> bool: """! @brief Property to report if Game is a dlc @return bool """ return self.game.is_dlc @property def is_launchable_addon(self) -> bool: # lk: the attribute doesn't exist in the currently released version # FIXME: remove after legendary >= 0.20.35 try: return self.game.is_launchable_addon except AttributeError: return False @property def sdl_name(self) -> Optional[str]: return get_sdl_appname(self.app_name) @property def version(self) -> str: """! @brief Reports the currently installed version of the Game If InstalledGame reports the currently installed version, which might be different from the remote version available from EGS. For not installed Games it reports the already known version. @return str The current version of the game """ return self.igame.version if self.igame is not None else self.game.app_version(self.default_platform) @property def install_path(self) -> Optional[str]: if self.igame: return self.igame.install_path return None class RareGameSlim(RareGameBase): def __init__(self, legendary_core: LegendaryCore, game: Game): super(RareGameSlim, self).__init__(legendary_core, game) # None if origin or not installed self.igame: Optional[InstalledGame] = self.core.get_installed_game(game.app_name) self.saves: List[RareSaveGame] = [] @property def is_installed(self) -> bool: if self.is_origin: return True return self.igame is not None def set_installed(self, installed: bool) -> None: pass @property def auto_sync_saves(self): auto_sync_cloud = QSettings(self).value( f"{self.app_name}/{options.auto_sync_cloud.key}", options.auto_sync_cloud.default, options.auto_sync_cloud.dtype ) auto_sync_cloud = auto_sync_cloud or QSettings(self).value(*options.auto_sync_cloud) return self.supports_cloud_saves and auto_sync_cloud @property def save_path(self) -> Optional[str]: if self.igame is not None: return self.igame.save_path return None @property def latest_save(self) -> Optional[RareSaveGame]: if self.saves: saves = sorted(self.saves, key=lambda s: s.file.datetime, reverse=True) return saves[0] return None @property def save_game_state(self) -> Tuple[SaveGameStatus, Tuple[Optional[datetime], Optional[datetime]]]: if self.saves and self.save_path: latest = self.latest_save # lk: if the save path wasn't known at startup, dt_local will be None # In that case resolve the save again before returning latest.status, (latest.dt_local, latest.dt_remote) = self.core.check_savegame_state( self.save_path, latest.file ) return latest.status, (latest.dt_local, latest.dt_remote) return SaveGameStatus.NO_SAVE, (None, None) def upload_saves(self, thread=True): status, (dt_local, dt_remote) = self.save_game_state def _upload(): logger.info(f"Uploading save for {self.app_title}") self.state = RareGameSlim.State.SYNCING self.core.upload_save(self.app_name, self.igame.save_path, dt_local) self.state = RareGameSlim.State.IDLE self.update_saves() if not self.supports_cloud_saves: return if status == SaveGameStatus.NO_SAVE or not dt_local: logger.warning("Can't upload non existing save") return if self.state == RareGameSlim.State.SYNCING: logger.error(f"{self.app_title} is already syncing") return if thread: worker = QRunnable.create(_upload) QThreadPool.globalInstance().start(worker) else: _upload() def download_saves(self, thread=True): status, (dt_local, dt_remote) = self.save_game_state def _download(): logger.info(f"Downloading save for {self.app_title}") self.state = RareGameSlim.State.SYNCING self.core.download_saves(self.app_name, self.latest_save.file.manifest_name, self.save_path) self.state = RareGameSlim.State.IDLE self.update_saves() if not self.supports_cloud_saves: return if status == SaveGameStatus.NO_SAVE or not dt_remote: logger.error("Can't download non existing save") return if self.state == RareGameSlim.State.SYNCING: logger.error(f"{self.app_title} is already syncing") return if thread: worker = QRunnable.create(_download) QThreadPool.globalInstance().start(worker) else: _download() def load_saves(self, saves: List[SaveGameFile]): """ Use only in a thread """ self.saves.clear() for save in saves: if self.save_path: status, (dt_local, dt_remote) = self.core.check_savegame_state(self.save_path, save) rsave = RareSaveGame(save, status, dt_local, dt_remote) else: rsave = RareSaveGame(save, SaveGameStatus.SAME_AGE, dt_local=None, dt_remote=save.datetime) self.saves.append(rsave) self.signals.widget.update.emit() def update_saves(self): """ Use only in a thread """ saves = self.core.get_save_games(self.app_name) self.load_saves(saves) @property def is_save_up_to_date(self): status, (_, _) = self.save_game_state return status in {SaveGameStatus.SAME_AGE, SaveGameStatus.NO_SAVE} @property def raw_save_path(self) -> str: if self.game.supports_cloud_saves: return self.game.metadata.get("customAttributes", {}).get("CloudSaveFolder", {}).get("value") return "" @property def raw_save_path_mac(self) -> str: if self.game.supports_mac_cloud_saves: return self.game.metadata.get("customAttributes", {}).get('CloudSaveFolder_MAC', {}).get('value') return "" @property def supports_cloud_saves(self): return self.game.supports_cloud_saves or self.game.supports_mac_cloud_saves