diff --git a/.gitmodules b/.gitmodules index a78c14ab..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +0,0 @@ -[submodule "legendary"] - path = rare/legendary - url = https://github.com/dummerle/legendary - branch = rare diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da4f8b49..7b8c56b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,3 +23,13 @@ To contribute fork the repository and clone **your** repo: `git clone https://gi and upload it to GitHub with `git commit -m "message"` and `git push`. Some IDEs like PyCharm can do this automatically. If you uploaded your changes, create a pull request + +# Code Style Guidelines + +## Signals and threads + +## Function naming + +## UI Classes + +### Widget and Layout naming \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4628bb83..1e6c7a5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,3 +34,13 @@ start = "rare.__main__:main" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[build-system] +requires = ["setuptools>=42", "wheel", "nuitka", "toml"] +build-backend = "nuitka.distutils.Build" + +[nuitka] +show-scons = true +enable-plugin = pyqt5,anti-bloat +show-anti-bloat-changes = true +nofollow-import-to = ["*.tests", "*.distutils"] diff --git a/rare/__main__.py b/rare/__main__.py index 79a1ef69..c510ed8d 100644 --- a/rare/__main__.py +++ b/rare/__main__.py @@ -13,8 +13,8 @@ def main(): multiprocessing.freeze_support() # insert legendary for installed via pip/setup.py submodule to path - if not __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "legendary")) + # if not __name__ == "__main__": + # sys.path.insert(0, os.path.join(os.path.dirname(__file__), "legendary")) # CLI Options parser = ArgumentParser() @@ -70,20 +70,18 @@ def main(): args = parser.parse_args() - if args.desktop_shortcut: - from rare.utils import utils + if args.desktop_shortcut or args.startmenu_shortcut: + from rare.utils.misc import create_desktop_link + + if args.desktop_shortcut: + create_desktop_link(type_of_link="desktop", for_rare=True) + + if args.startmenu_shortcut: + create_desktop_link(type_of_link="start_menu", for_rare=True) - utils.create_desktop_link(type_of_link="desktop", for_rare=True) print("Link created") return - if args.startmenu_shortcut: - from rare.utils import utils - - utils.create_desktop_link(type_of_link="start_menu", for_rare=True) - print("link created") - return - if args.version: from rare import __version__, code_name @@ -123,10 +121,10 @@ def main(): if __name__ == "__main__": # run from source # insert raw legendary submodule - sys.path.insert( - 0, os.path.join(pathlib.Path(__file__).parent.absolute(), "legendary") - ) + # sys.path.insert( + # 0, os.path.join(pathlib.Path(__file__).parent.absolute(), "legendary") + # ) # insert source directory - sys.path.insert(0, str(pathlib.Path(__file__).parents[1].absolute())) + #sys.path.insert(0, str(pathlib.Path(__file__).parents[1].absolute())) main() diff --git a/rare/app.py b/rare/app.py index 9ef19f97..46be17fd 100644 --- a/rare/app.py +++ b/rare/app.py @@ -149,17 +149,25 @@ class App(RareApp): def start_app(self): for igame in self.core.get_installed_list(): if not os.path.exists(igame.install_path): - legendary_utils.uninstall(igame.app_name, self.core) + # lk; since install_path is lost anyway, set keep_files to True + # lk: to avoid spamming the log with "file not found" errors + legendary_utils.uninstall_game(self.core, igame.app_name, keep_files=True) logger.info(f"Uninstalled {igame.title}, because no game files exist") continue - if not os.path.exists(os.path.join(igame.install_path, igame.executable.replace("\\", "/").lstrip("/"))): + # lk: games that don't have an override and can't find their executable due to case sensitivity + # lk: will still erroneously require verification. This might need to be removed completely + # lk: or be decoupled from the verification requirement + if override_exe := self.core.lgd.config.get(igame.app_name, "override_exe", fallback=""): + igame_executable = override_exe + else: + igame_executable = igame.executable + if not os.path.exists(os.path.join(igame.install_path, igame_executable.replace("\\", "/").lstrip("/"))): igame.needs_verification = True self.core.lgd.set_installed_game(igame.app_name, igame) logger.info(f"{igame.title} needs verification") self.mainwindow = MainWindow() - self.launch_dialog.close() - self.tray_icon = TrayIcon(self) + self.tray_icon: TrayIcon = TrayIcon(self) self.tray_icon.exit_action.triggered.connect(self.exit_app) self.tray_icon.start_rare.triggered.connect(self.show_mainwindow) self.tray_icon.activated.connect( @@ -226,6 +234,7 @@ class App(RareApp): self.mainwindow.hide() threadpool = QThreadPool.globalInstance() threadpool.waitForDone() + self.core.exit() if self.mainwindow is not None: self.mainwindow.close() if self.tray_icon is not None: diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 20b00e3a..65501956 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -1,28 +1,32 @@ import os -import platform +import platform as pf import sys -from multiprocessing import Queue as MPQueue -from typing import Tuple +from typing import Tuple, List, Union, Optional from PyQt5.QtCore import Qt, QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot from PyQt5.QtGui import QCloseEvent, QKeyEvent from PyQt5.QtWidgets import QDialog, QFileDialog, QCheckBox -from legendary.core import LegendaryCore from legendary.models.downloading import ConditionCheckResult from legendary.models.game import Game -from legendary.utils.selective_dl import games +from legendary.utils.selective_dl import get_sdl_appname +from rare.lgndr.cli import LegendaryCLI +from rare.lgndr.api_arguments import LgndrInstallGameArgs +from rare.lgndr.api_exception import LgndrException +from rare.lgndr.api_monkeys import LgndrIndirectStatus +from rare.lgndr.core import LegendaryCore from rare.shared import LegendaryCoreSingleton, ApiResultsSingleton, ArgumentsSingleton from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog from rare.utils.extra_widgets import PathEdit -from rare.utils.models import InstallDownloadModel, InstallQueueItemModel -from rare.utils.utils import get_size +from rare.models.install import InstallDownloadModel, InstallQueueItemModel +from rare.utils.misc import get_size +from rare.utils import config_helper class InstallDialog(QDialog, Ui_InstallDialog): result_ready = pyqtSignal(InstallQueueItemModel) - def __init__(self, dl_item: InstallQueueItemModel, update=False, silent=False, parent=None): + def __init__(self, dl_item: InstallQueueItemModel, update=False, repair=False, silent=False, parent=None): super(InstallDialog, self).__init__(parent) self.setupUi(self) self.setAttribute(Qt.WA_DeleteOnClose, True) @@ -31,7 +35,6 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.core = LegendaryCoreSingleton() self.api_results = ApiResultsSingleton() self.dl_item = dl_item - self.dl_item.status_q = MPQueue() self.app_name = self.dl_item.options.app_name self.game = ( self.core.get_game(self.app_name) @@ -42,6 +45,7 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.game_path = self.game.metadata.get("customAttributes", {}).get("FolderName", {}).get("value", "") self.update = update + self.repair = repair self.silent = silent self.options_changed = False @@ -84,18 +88,20 @@ class InstallDialog(QDialog, Ui_InstallDialog): platforms.append("Mac") self.platform_combo_box.addItems(platforms) self.platform_combo_box.currentIndexChanged.connect(lambda: self.option_changed(None)) + self.platform_combo_box.currentIndexChanged.connect(lambda: self.error_box()) self.platform_combo_box.currentIndexChanged.connect( lambda i: self.error_box( self.tr("Warning"), - self.tr("You will not be able to run the Game if you choose {}").format( + self.tr("You will not be able to run the game if you select {} as platform").format( self.platform_combo_box.itemText(i) ), ) - if (self.platform_combo_box.currentText() == "Mac" and platform.system() != "Darwin") + if (self.platform_combo_box.currentText() == "Mac" and pf.system() != "Darwin") else None ) + self.platform_combo_box.currentTextChanged.connect(self.setup_sdl_list) - if platform.system() == "Darwin" and "Mac" in platforms: + if pf.system() == "Darwin" and "Mac" in platforms: self.platform_combo_box.setCurrentIndex(platforms.index("Mac")) self.max_workers_spin.setValue(self.core.lgd.config.getint("Legendary", "max_workers", fallback=0)) @@ -109,22 +115,10 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.ignore_space_check.stateChanged.connect(self.option_changed) self.download_only_check.stateChanged.connect(lambda: self.non_reload_option_changed("download_only")) self.shortcut_cb.stateChanged.connect(lambda: self.non_reload_option_changed("shortcut")) - self.sdl_list_checks = list() - try: - for key, info in games[self.app_name].items(): - cb = QDataCheckBox(info["name"], info["tags"]) - if key == "__required": - self.dl_item.options.sdl_list.extend(info["tags"]) - cb.setChecked(True) - cb.setDisabled(True) - self.sdl_list_layout.addWidget(cb) - self.sdl_list_checks.append(cb) - self.sdl_list_frame.resize(self.sdl_list_frame.minimumSize()) - for cb in self.sdl_list_checks: - cb.stateChanged.connect(self.option_changed) - except (KeyError, AttributeError): - self.sdl_list_frame.setVisible(False) - self.sdl_list_label.setVisible(False) + + self.sdl_list_cbs: List[TagCheckBox] = [] + self.config_tags: Optional[List[str]] = None + self.setup_sdl_list("Mac" if pf.system() == "Darwin" and "Mac" in platforms else "Windows") self.install_button.setEnabled(False) @@ -138,7 +132,7 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.shortcut_cb.setVisible(False) self.shortcut_lbl.setVisible(False) - if platform.system() == "Darwin": + if pf.system() == "Darwin": self.shortcut_cb.setDisabled(True) self.shortcut_cb.setChecked(False) self.shortcut_cb.setToolTip(self.tr("Creating a shortcut is not supported on MacOS")) @@ -153,7 +147,7 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.verify_button.clicked.connect(self.verify_clicked) self.install_button.clicked.connect(self.install_clicked) - self.resize(self.minimumSize()) + self.install_dialog_layout.setSizeConstraint(self.install_dialog_layout.SetFixedSize) def execute(self): if self.silent: @@ -163,21 +157,54 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.verify_clicked() self.show() + @pyqtSlot(str) + def setup_sdl_list(self, platform="Windows"): + for cb in self.sdl_list_cbs: + cb.disconnect() + cb.deleteLater() + self.sdl_list_cbs.clear() + + if config_tags := self.core.lgd.config.get(self.game.app_name, 'install_tags', fallback=None): + self.config_tags = config_tags.split(",") + config_disable_sdl = self.core.lgd.config.getboolean(self.game.app_name, 'disable_sdl', fallback=False) + sdl_name = get_sdl_appname(self.game.app_name) + if not config_disable_sdl and sdl_name is not None: + # FIXME: this should be updated whenever platform changes + sdl_data = self.core.get_sdl_data(sdl_name, platform=platform) + if sdl_data: + for tag, info in sdl_data.items(): + cb = TagCheckBox(info["name"], info["tags"]) + if tag == "__required": + cb.setChecked(True) + cb.setDisabled(True) + if self.config_tags is not None: + if all(elem in self.config_tags for elem in info["tags"]): + cb.setChecked(True) + self.sdl_list_layout.addWidget(cb) + self.sdl_list_cbs.append(cb) + self.sdl_list_frame.resize(self.sdl_list_frame.minimumSize()) + for cb in self.sdl_list_cbs: + cb.stateChanged.connect(self.option_changed) + else: + self.sdl_list_frame.setVisible(False) + self.sdl_list_label.setVisible(False) + def get_options(self): self.dl_item.options.base_path = self.install_dir_edit.text() if not self.update else None self.dl_item.options.max_workers = self.max_workers_spin.value() - self.dl_item.options.max_shm = self.max_memory_spin.value() - self.dl_item.options.dl_optimizations = self.dl_optimizations_check.isChecked() + self.dl_item.options.shared_memory = self.max_memory_spin.value() + self.dl_item.options.order_opt = self.dl_optimizations_check.isChecked() self.dl_item.options.force = self.force_download_check.isChecked() - self.dl_item.options.ignore_space_req = self.ignore_space_check.isChecked() + self.dl_item.options.ignore_space = self.ignore_space_check.isChecked() self.dl_item.options.no_install = self.download_only_check.isChecked() self.dl_item.options.platform = self.platform_combo_box.currentText() - self.dl_item.options.sdl_list = [""] - for cb in self.sdl_list_checks: - if data := cb.isChecked(): - # noinspection PyTypeChecker - self.dl_item.options.sdl_list.extend(data) + if self.sdl_list_cbs: + self.dl_item.options.install_tag = [""] + for cb in self.sdl_list_cbs: + if data := cb.isChecked(): + # noinspection PyTypeChecker + self.dl_item.options.install_tag.extend(data) def get_download_info(self): self.dl_item.download = None @@ -218,6 +245,12 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.dl_item.options.install_preqs = self.install_preqs_check.isChecked() def cancel_clicked(self): + if self.config_tags: + config_helper.add_option(self.game.app_name, 'install_tags', ','.join(self.config_tags)) + else: + # lk: this is purely for cleaning any install tags we might have added erroneously to the config + config_helper.remove_option(self.game.app_name, 'install_tags') + self.dl_item.download = None self.reject_close = False self.close() @@ -243,7 +276,7 @@ class InstallDialog(QDialog, Ui_InstallDialog): self.cancel_button.setEnabled(True) if self.silent: self.close() - if platform.system() == "Windows" or ArgumentsSingleton().debug: + if pf.system() == "Windows" or ArgumentsSingleton().debug: if dl_item.igame.prereq_info and not dl_item.igame.prereq_info.get("installed", False): self.install_preqs_check.setVisible(True) self.install_preqs_lbl.setVisible(True) @@ -299,59 +332,34 @@ class InstallInfoWorker(QRunnable): self.signals = InstallInfoWorker.Signals() self.core = core self.dl_item = dl_item - self.is_overlay_install = self.dl_item.options.overlay self.game = game @pyqtSlot() def run(self): try: - if not self.is_overlay_install: - download = InstallDownloadModel( - *self.core.prepare_download( - app_name=self.dl_item.options.app_name, - base_path=self.dl_item.options.base_path, - force=self.dl_item.options.force, - no_install=self.dl_item.options.no_install, - status_q=self.dl_item.status_q, - max_shm=self.dl_item.options.max_shm, - max_workers=self.dl_item.options.max_workers, - # game_folder=, - # disable_patching=, - # override_manifest=, - # override_old_manifest=, - # override_base_url=, - platform=self.dl_item.options.platform, - # file_prefix_filter=, - # file_exclude_filter=, - # file_install_tag=, - dl_optimizations=self.dl_item.options.dl_optimizations, - # dl_timeout=, - repair=self.dl_item.options.repair, - # repair_use_latest=, - ignore_space_req=self.dl_item.options.ignore_space_req, - # disable_delta=, - # override_delta_manifest=, - # reset_sdl=, - sdl_prompt=lambda app_name, title: self.dl_item.options.sdl_list, - ) + if not self.dl_item.options.overlay: + cli = LegendaryCLI(self.core) + status = LgndrIndirectStatus() + result = cli.install_game( + LgndrInstallGameArgs(**self.dl_item.options.as_install_kwargs(), indirect_status=status) ) - + if result: + download = InstallDownloadModel(*result) + else: + raise LgndrException(status.message) else: if not os.path.exists(path := self.dl_item.options.base_path): os.makedirs(path) dlm, analysis, igame = self.core.prepare_overlay_install( - path=self.dl_item.options.base_path, - status_queue=self.dl_item.status_q, - max_workers=self.dl_item.options.max_workers, - force=self.dl_item.options.force, + path=self.dl_item.options.base_path ) download = InstallDownloadModel( - dlmanager=dlm, + dlm=dlm, analysis=analysis, - game=self.game, igame=igame, + game=self.game, repair=False, repair_file="", res=ConditionCheckResult(), # empty @@ -361,19 +369,21 @@ class InstallInfoWorker(QRunnable): self.signals.result.emit(download) else: self.signals.failed.emit("\n".join(str(i) for i in download.res.failures)) + except LgndrException as ret: + self.signals.failed.emit(ret.message) except Exception as e: self.signals.failed.emit(str(e)) self.signals.finished.emit() -class QDataCheckBox(QCheckBox): - def __init__(self, text, data=None, parent=None): - super(QDataCheckBox, self).__init__(parent) +class TagCheckBox(QCheckBox): + def __init__(self, text, tags: List[str], parent=None): + super(TagCheckBox, self).__init__(parent) self.setText(text) - self.data = data + self.tags = tags - def isChecked(self): - if super(QDataCheckBox, self).isChecked(): - return self.data + def isChecked(self) -> Union[bool, List[str]]: + if super(TagCheckBox, self).isChecked(): + return self.tags else: return False diff --git a/rare/components/dialogs/launch_dialog.py b/rare/components/dialogs/launch_dialog.py index 14696b8f..4a1de3e8 100644 --- a/rare/components/dialogs/launch_dialog.py +++ b/rare/components/dialogs/launch_dialog.py @@ -10,7 +10,7 @@ from rare.models.apiresults import ApiResults from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton, ApiResultsSingleton from rare.shared.image_manager import ImageManagerSingleton from rare.ui.components.dialogs.launch_dialog import Ui_LaunchDialog -from rare.utils.utils import CloudWorker +from rare.utils.misc import CloudWorker logger = getLogger("Login") diff --git a/rare/components/dialogs/login/__init__.py b/rare/components/dialogs/login/__init__.py index ab5fe050..2357adcf 100644 --- a/rare/components/dialogs/login/__init__.py +++ b/rare/components/dialogs/login/__init__.py @@ -117,8 +117,9 @@ class LoginDialog(QDialog): self.close() else: raise ValueError("Login failed.") - except ValueError as e: + except Exception as e: logger.error(str(e)) + self.core.lgd.invalidate_userdata() self.ui.next_button.setEnabled(False) self.logged_in = False - QMessageBox.warning(self, "Error", str(e)) + QMessageBox.warning(None, self.tr("Login error"), str(e)) diff --git a/rare/components/dialogs/login/browser_login.py b/rare/components/dialogs/login/browser_login.py index bb497aee..49c1eb66 100644 --- a/rare/components/dialogs/login/browser_login.py +++ b/rare/components/dialogs/login/browser_login.py @@ -10,7 +10,7 @@ from legendary.utils import webview_login from rare.ui.components.dialogs.login.browser_login import Ui_BrowserLogin from rare.utils.extra_widgets import IndicatorLineEdit -from rare.utils.utils import icon +from rare.utils.misc import icon logger = getLogger("BrowserLogin") diff --git a/rare/components/dialogs/uninstall_dialog.py b/rare/components/dialogs/uninstall_dialog.py index 13caab00..0d4fb88b 100644 --- a/rare/components/dialogs/uninstall_dialog.py +++ b/rare/components/dialogs/uninstall_dialog.py @@ -1,36 +1,38 @@ +from typing import Tuple + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, QLabel, QVBoxLayout, QCheckBox, - QFormLayout, QHBoxLayout, QPushButton, ) from legendary.models.game import Game -from rare.utils.utils import icon +from rare.utils.misc import icon class UninstallDialog(QDialog): def __init__(self, game: Game): super(UninstallDialog, self).__init__() - self.setWindowTitle("Uninstall Game") - self.info = 0 self.setAttribute(Qt.WA_DeleteOnClose, True) - self.layout = QVBoxLayout() + self.setWindowTitle("Uninstall Game") + layout = QVBoxLayout() self.info_text = QLabel( - self.tr("Do you really want to uninstall {}").format(game.app_title) + self.tr("Do you really want to uninstall {} ?").format(game.app_title) ) - self.layout.addWidget(self.info_text) - self.keep_files = QCheckBox(self.tr("Keep Files")) - self.form = QFormLayout() - self.form.setContentsMargins(0, 10, 0, 10) - self.form.addRow(QLabel(self.tr("Do you want to keep files?")), self.keep_files) - self.layout.addLayout(self.form) + layout.addWidget(self.info_text) + self.keep_files = QCheckBox(self.tr("Keep game files?")) + self.keep_config = QCheckBox(self.tr("Keep game configuation?")) + form_layout = QVBoxLayout() + form_layout.setContentsMargins(6, 6, 0, 6) + form_layout.addWidget(self.keep_files) + form_layout.addWidget(self.keep_config) + layout.addLayout(form_layout) - self.button_layout = QHBoxLayout() + button_layout = QHBoxLayout() self.ok_button = QPushButton( icon("ei.remove-circle", color="red"), self.tr("Uninstall") ) @@ -39,20 +41,22 @@ class UninstallDialog(QDialog): self.cancel_button = QPushButton(self.tr("Cancel")) self.cancel_button.clicked.connect(self.cancel) - self.button_layout.addStretch(1) - self.button_layout.addWidget(self.ok_button) - self.button_layout.addWidget(self.cancel_button) - self.layout.addLayout(self.button_layout) - self.setLayout(self.layout) + button_layout.addWidget(self.ok_button) + button_layout.addStretch(1) + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + self.setLayout(layout) - def get_information(self): + self.options: Tuple[bool, bool, bool] = (False, False, False) + + def get_options(self) -> Tuple[bool, bool, bool]: self.exec_() - return self.info + return self.options def ok(self): - self.info = {"keep_files": self.keep_files.isChecked()} + self.options = (True, self.keep_files.isChecked(), self.keep_config.isChecked()) self.close() def cancel(self): - self.info = 0 + self.options = (False, False, False) self.close() diff --git a/rare/components/tabs/__init__.py b/rare/components/tabs/__init__.py index bb213582..a8e62e82 100644 --- a/rare/components/tabs/__init__.py +++ b/rare/components/tabs/__init__.py @@ -2,14 +2,14 @@ from PyQt5.QtCore import QSize from PyQt5.QtWidgets import QMenu, QTabWidget, QWidget, QWidgetAction, QShortcut from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton -from rare.components.tabs.account import MiniWidget +from rare.components.tabs.account import AccountWidget from rare.components.tabs.downloads import DownloadsTab from rare.components.tabs.games import GamesTab from rare.components.tabs.settings import SettingsTab from rare.components.tabs.settings.debug import DebugSettings from rare.components.tabs.shop import Shop from rare.components.tabs.tab_utils import MainTabBar, TabButtonWidget -from rare.utils.utils import icon +from rare.utils.misc import icon class TabWidget(QTabWidget): @@ -54,9 +54,9 @@ class TabWidget(QTabWidget): self.addTab(self.account, "") self.setTabEnabled(disabled_tab + 1, False) - self.mini_widget = MiniWidget() + self.account_widget = AccountWidget() account_action = QWidgetAction(self) - account_action.setDefaultWidget(self.mini_widget) + account_action.setDefaultWidget(self.account_widget) account_button = TabButtonWidget("mdi.account-circle", "Account", fallback_icon="fa.user") account_button.setMenu(QMenu()) account_button.menu().addAction(account_action) diff --git a/rare/components/tabs/account/__init__.py b/rare/components/tabs/account/__init__.py index 8b327b73..2f522b67 100644 --- a/rare/components/tabs/account/__init__.py +++ b/rare/components/tabs/account/__init__.py @@ -3,34 +3,33 @@ import webbrowser from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMessageBox, QLabel, QPushButton from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton -from rare.utils.utils import icon +from rare.utils.misc import icon -class MiniWidget(QWidget): +class AccountWidget(QWidget): def __init__(self): - super(MiniWidget, self).__init__() - self.layout = QVBoxLayout() + super(AccountWidget, self).__init__() self.core = LegendaryCoreSingleton() self.signals = GlobalSignalsSingleton() - self.layout.addWidget(QLabel("Account")) + username = self.core.lgd.userdata.get("display_name") if not username: username = "Offline" - self.layout.addWidget(QLabel(self.tr("Logged in as {}").format(username))) - self.open_browser = QPushButton(icon("fa.external-link"), self.tr("Account settings")) self.open_browser.clicked.connect( lambda: webbrowser.open( "https://www.epicgames.com/account/personal?productName=epicgames" ) ) - self.layout.addWidget(self.open_browser) - self.logout_button = QPushButton(self.tr("Logout")) self.logout_button.clicked.connect(self.logout) - self.layout.addWidget(self.logout_button) - self.setLayout(self.layout) + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(self.tr("Account"))) + layout.addWidget(QLabel(self.tr("Logged in as {}").format(username))) + layout.addWidget(self.open_browser) + layout.addWidget(self.logout_button) def logout(self): reply = QMessageBox.question( diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 17225e39..26739a26 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -1,8 +1,8 @@ import datetime from logging import getLogger -from typing import List, Dict +from typing import List, Dict, Union -from PyQt5.QtCore import QThread, pyqtSignal, QSettings +from PyQt5.QtCore import QThread, pyqtSignal, QSettings, pyqtSlot from PyQt5.QtWidgets import ( QWidget, QMessageBox, @@ -11,17 +11,17 @@ from PyQt5.QtWidgets import ( QPushButton, QGroupBox, ) - from legendary.core import LegendaryCore -from legendary.models.downloading import UIUpdate from legendary.models.game import Game, InstalledGame -from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton + from rare.components.dialogs.install_dialog import InstallDialog from rare.components.tabs.downloads.dl_queue_widget import DlQueueWidget, DlWidget from rare.components.tabs.downloads.download_thread import DownloadThread +from rare.lgndr.downloading import UIUpdate +from rare.models.install import InstallOptionsModel, InstallQueueItemModel +from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton from rare.ui.components.tabs.downloads.downloads_tab import Ui_DownloadsTab -from rare.utils.models import InstallOptionsModel, InstallQueueItemModel -from rare.utils.utils import get_size +from rare.utils.misc import get_size, create_desktop_link logger = getLogger("Download") @@ -56,8 +56,8 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.update_layout.addWidget(self.update_text) self.update_text.setVisible(len(updates) == 0) - for name in updates: - self.add_update(self.core.get_installed_game(name)) + for app_name in updates: + self.add_update(app_name) self.queue_widget.item_removed.connect(self.queue_item_removed) @@ -66,7 +66,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.signals.game_uninstalled.connect(self.remove_update) self.signals.add_download.connect( - lambda app_name: self.add_update(self.core.get_installed_game(app_name)) + lambda app_name: self.add_update(app_name) ) self.signals.game_uninstalled.connect(self.game_uninstalled) @@ -77,14 +77,17 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): w.update_button.setDisabled(False) w.update_with_settings.setDisabled(False) - def add_update(self, igame: InstalledGame): - widget = UpdateWidget(self.core, igame, self) + def add_update(self, app_name: str): + if old_widget := self.update_widgets.get(app_name, False): + old_widget.deleteLater() + self.update_widgets.pop(app_name) + widget = UpdateWidget(self.core, app_name, self) self.update_layout.addWidget(widget) - self.update_widgets[igame.app_name] = widget + self.update_widgets[app_name] = widget widget.update_signal.connect(self.get_install_options) if QSettings().value("auto_update", False, bool): self.get_install_options( - InstallOptionsModel(app_name=igame.app_name, update=True, silent=True) + InstallOptionsModel(app_name=app_name, update=True, silent=True) ) widget.update_button.setDisabled(True) self.update_text.setVisible(False) @@ -97,14 +100,14 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.queue_widget.update_queue(self.dl_queue) break - # game has available update - if app_name in self.update_widgets.keys(): - self.remove_update(app_name) - # if game is updating if self.active_game and self.active_game.app_name == app_name: self.stop_download() + # game has available update + if app_name in self.update_widgets.keys(): + self.remove_update(app_name) + def remove_update(self, app_name): if w := self.update_widgets.get(app_name): w.deleteLater() @@ -120,6 +123,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): def stop_download(self): self.thread.kill() + self.kill_button.setEnabled(False) def install_game(self, queue_item: InstallQueueItemModel): if self.active_game is None: @@ -134,8 +138,8 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.queue_widget.update_queue(self.dl_queue) self.active_game = queue_item.download.game self.thread = DownloadThread(self.core, queue_item) - self.thread.status.connect(self.status) - self.thread.statistics.connect(self.statistics) + self.thread.ret_status.connect(self.status) + self.thread.ui_update.connect(self.progress_update) self.thread.start() self.kill_button.setDisabled(False) self.analysis = queue_item.download.analysis @@ -143,8 +147,16 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.signals.installation_started.emit(self.active_game.app_name) - def status(self, text): - if text == "finish": + @pyqtSlot(DownloadThread.ReturnStatus) + def status(self, result: DownloadThread.ReturnStatus): + if result.ret_code == result.ReturnCode.FINISHED: + if result.shortcuts: + if not create_desktop_link(result.app_name, self.core, "desktop"): + # maybe add it to download summary, to show in finished downloads + pass + else: + logger.info("Desktop shortcut written") + self.dl_name.setText(self.tr("Download finished. Reload library")) logger.info(f"Download finished: {self.active_game.app_title}") @@ -179,10 +191,10 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): else: self.queue_widget.update_queue(self.dl_queue) - elif text[:5] == "error": - QMessageBox.warning(self, "warn", f"Download error: {text[6:]}") + elif result.ret_code == result.ReturnCode.ERROR: + QMessageBox.warning(self, self.tr("Error"), f"Download error: {result.message}") - elif text == "stop": + elif result.ret_code == result.ReturnCode.STOPPED: self.reset_infos() if w := self.update_widgets.get(self.active_game.app_name): w.update_button.setDisabled(False) @@ -202,7 +214,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.downloaded.setText("n/a") self.analysis = None - def statistics(self, ui_update: UIUpdate): + def progress_update(self, ui_update: UIUpdate): self.progress_bar.setValue( 100 * ui_update.total_downloaded // self.analysis.dl_size ) @@ -218,12 +230,16 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): 100 * ui_update.total_downloaded // self.analysis.dl_size ) - def get_time(self, seconds: int) -> str: + def get_time(self, seconds: Union[int, float]) -> str: return str(datetime.timedelta(seconds=seconds)) def on_install_dialog_closed(self, download_item: InstallQueueItemModel): if download_item: self.install_game(download_item) + # lk: In case the download in comming from game verification/repair + if w := self.update_widgets.get(download_item.options.app_name): + w.update_button.setDisabled(True) + w.update_with_settings.setDisabled(True) self.signals.set_main_tab_index.emit(1) else: if w := self.update_widgets.get(download_item.options.app_name): @@ -241,19 +257,6 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): install_dialog.result_ready.connect(self.on_install_dialog_closed) install_dialog.execute() - def start_download(self, download_item: InstallQueueItemModel): - downloads = ( - len(self.downloadTab.dl_queue) - + len(self.downloadTab.update_widgets.keys()) - + 1 - ) - self.setTabText( - 1, "Downloads" + ((" (" + str(downloads) + ")") if downloads != 0 else "") - ) - self.setCurrentIndex(1) - self.downloadTab.install_game(download_item) - self.games_tab.start_download(download_item.options.app_name) - @property def is_download_active(self): return self.active_game is not None @@ -262,37 +265,37 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): class UpdateWidget(QWidget): update_signal = pyqtSignal(InstallOptionsModel) - def __init__(self, core: LegendaryCore, igame: InstalledGame, parent): + def __init__(self, core: LegendaryCore, app_name: str, parent): super(UpdateWidget, self).__init__(parent=parent) self.core = core - self.game = igame + self.game: Game = core.get_game(app_name) + self.igame: InstalledGame = self.core.get_installed_game(app_name) - self.layout = QVBoxLayout() - self.title = QLabel(self.game.title) - self.layout.addWidget(self.title) + layout = QVBoxLayout() + self.title = QLabel(self.igame.title) + layout.addWidget(self.title) self.update_button = QPushButton(self.tr("Update Game")) self.update_button.clicked.connect(lambda: self.update_game(True)) self.update_with_settings = QPushButton("Update with settings") self.update_with_settings.clicked.connect(lambda: self.update_game(False)) - self.layout.addWidget(self.update_button) - self.layout.addWidget(self.update_with_settings) - self.layout.addWidget( + layout.addWidget(self.update_button) + layout.addWidget(self.update_with_settings) + layout.addWidget( QLabel( - self.tr("Version: ") - + self.game.version - + " -> " - + self.core.get_asset( - self.game.app_name, self.game.platform, False - ).build_version + self.tr("Version: ") + + self.igame.version + + " >> " + + self.game.app_version(self.igame.platform) + + "" ) ) - self.setLayout(self.layout) + self.setLayout(layout) def update_game(self, auto: bool): self.update_button.setDisabled(True) self.update_with_settings.setDisabled(True) self.update_signal.emit( - InstallOptionsModel(app_name=self.game.app_name, silent=auto) + InstallOptionsModel(app_name=self.igame.app_name, silent=auto) ) # True if settings diff --git a/rare/components/tabs/downloads/dl_queue_widget.py b/rare/components/tabs/downloads/dl_queue_widget.py index 2b0455c1..18152273 100644 --- a/rare/components/tabs/downloads/dl_queue_widget.py +++ b/rare/components/tabs/downloads/dl_queue_widget.py @@ -10,8 +10,8 @@ from PyQt5.QtWidgets import ( QWidget, ) -from rare.utils.models import InstallQueueItemModel -from rare.utils.utils import icon +from rare.models.install import InstallQueueItemModel +from rare.utils.misc import icon logger = getLogger("QueueWidget") diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index 914e3f1a..5f672558 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -1,209 +1,150 @@ import os import platform import queue -import sys import time +from dataclasses import dataclass +from enum import IntEnum from logging import getLogger -from queue import Empty +from typing import List, Optional, Dict -import psutil from PyQt5.QtCore import QThread, pyqtSignal, QProcess from legendary.core import LegendaryCore -from legendary.models.downloading import UIUpdate, WriterTask -from rare.shared import GlobalSignalsSingleton -from rare.utils.models import InstallQueueItemModel -from rare.utils.utils import create_desktop_link +from rare.lgndr.api_monkeys import DLManagerSignals +from rare.lgndr.cli import LegendaryCLI +from rare.lgndr.downloading import UIUpdate +from rare.models.install import InstallQueueItemModel +from rare.shared import GlobalSignalsSingleton, ArgumentsSingleton logger = getLogger("DownloadThread") class DownloadThread(QThread): - status = pyqtSignal(str) - statistics = pyqtSignal(UIUpdate) + @dataclass + class ReturnStatus: + class ReturnCode(IntEnum): + ERROR = 1 + STOPPED = 2 + FINISHED = 3 - def __init__(self, core: LegendaryCore, queue_item: InstallQueueItemModel): + app_name: str + ret_code: ReturnCode = ReturnCode.ERROR + message: str = "" + dlcs: Optional[List[Dict]] = None + sync_saves: bool = False + tip_url: str = "" + shortcuts: bool = False + + ret_status = pyqtSignal(ReturnStatus) + ui_update = pyqtSignal(UIUpdate) + + def __init__(self, core: LegendaryCore, item: InstallQueueItemModel): super(DownloadThread, self).__init__() - self.core = core self.signals = GlobalSignalsSingleton() - self.dlm = queue_item.download.dlmanager - self.no_install = queue_item.options.no_install - self.status_q = queue_item.status_q - self.igame = queue_item.download.igame - self.repair = queue_item.download.repair - self.repair_file = queue_item.download.repair_file - self.queue_item = queue_item - - self._kill = False + self.core: LegendaryCore = core + self.item: InstallQueueItemModel = item + self.dlm_signals: DLManagerSignals = DLManagerSignals() def run(self): - start_time = time.time() - dl_stopped = False + cli = LegendaryCLI(self.core) + self.item.download.dlm.logging_queue = cli.logging_queue + self.item.download.dlm.proc_debug = ArgumentsSingleton().debug + ret = DownloadThread.ReturnStatus(self.item.download.game.app_name) + start_t = time.time() try: - - self.dlm.start() + self.item.download.dlm.start() time.sleep(1) - while self.dlm.is_alive(): - if self._kill: - self.status.emit("stop") - logger.info("Download stopping...") - - # The code below is a temporary solution. - # It should be removed once legendary supports stopping downloads more gracefully. - - self.dlm.running = False - - # send conditions to unlock threads if they aren't already - for cond in self.dlm.conditions: - with cond: - cond.notify() - - # make sure threads are dead. - for t in self.dlm.threads: - t.join(timeout=5.0) - if t.is_alive(): - logger.warning(f"Thread did not terminate! {repr(t)}") - - # clean up all the queues, otherwise this process won't terminate properly - for name, q in zip( - ( - "Download jobs", - "Writer jobs", - "Download results", - "Writer results", - ), - ( - self.dlm.dl_worker_queue, - self.dlm.writer_queue, - self.dlm.dl_result_q, - self.dlm.writer_result_q, - ), - ): - logger.debug(f'Cleaning up queue "{name}"') - try: - while True: - _ = q.get_nowait() - except Empty: - q.close() - q.join_thread() - except AttributeError: - logger.warning(f"Queue {name} did not close") - - if self.dlm.writer_queue: - # cancel installation - self.dlm.writer_queue.put_nowait(WriterTask("", kill=True)) - - # forcibly kill DL workers that are not actually dead yet - for child in self.dlm.children: - if child.exitcode is None: - child.terminate() - - if self.dlm.shared_memory: - # close up shared memory - self.dlm.shared_memory.close() - self.dlm.shared_memory.unlink() - self.dlm.shared_memory = None - - self.dlm.kill() - - # force kill any threads that are somehow still alive - for proc in psutil.process_iter(): - # check whether the process name matches - if ( - sys.platform in ["linux", "darwin"] - and proc.name() == "DownloadThread" - ): - proc.kill() - elif ( - sys.platform == "win32" - and proc.name() == "python.exe" - and proc.create_time() >= start_time - ): - proc.kill() - - logger.info("Download stopped. It can be continued later.") - dl_stopped = True + while self.item.download.dlm.is_alive(): try: - if not dl_stopped: - self.statistics.emit(self.status_q.get(timeout=1)) + self.ui_update.emit(self.item.download.dlm.status_queue.get(timeout=1.0)) except queue.Empty: pass - - self.dlm.join() - + if self.dlm_signals.update: + try: + self.item.download.dlm.signals_queue.put(self.dlm_signals, block=False, timeout=1.0) + except queue.Full: + pass + time.sleep(self.item.download.dlm.update_interval / 10) + self.item.download.dlm.join() except Exception as e: - logger.error( - f"Installation failed after {time.time() - start_time:.02f} seconds: {e}" - ) - self.status.emit(f"error {e}") - return - - else: - if dl_stopped: - return - self.status.emit("dl_finished") end_t = time.time() - logger.info(f"Download finished in {end_t - start_time}s") - game = self.core.get_game(self.igame.app_name) + logger.error(f"Installation failed after {end_t - start_t:.02f} seconds.") + logger.warning(f"The following exception occurred while waiting for the downloader to finish: {e!r}.") + ret.ret_code = ret.ReturnCode.ERROR + ret.message = f"{e!r}" + self.ret_status.emit(ret) + return + else: + end_t = time.time() + if self.dlm_signals.kill is True: + logger.info(f"Download stopped after {end_t - start_t:.02f} seconds.") + ret.ret_code = ret.ReturnCode.STOPPED + self.ret_status.emit(ret) + return + logger.info(f"Download finished in {end_t - start_t:.02f} seconds.") - if self.queue_item.options.overlay: + ret.ret_code = ret.ReturnCode.FINISHED + + if self.item.options.overlay: self.signals.overlay_installation_finished.emit() - self.core.finish_overlay_install(self.igame) - self.status.emit("finish") + self.core.finish_overlay_install(self.item.download.igame) + self.ret_status.emit(ret) return - if not self.no_install: - postinstall = self.core.install_game(self.igame) + if not self.item.options.no_install: + postinstall = self.core.install_game(self.item.download.igame) if postinstall: - self._handle_postinstall(postinstall, self.igame) + # LegendaryCLI(self.core)._handle_postinstall( + # postinstall, + # self.item.download.igame, + # False, + # self.item.options.install_preqs, + # ) + self._handle_postinstall(postinstall, self.item.download.igame) - dlcs = self.core.get_dlc_for_game(self.igame.app_name) - if dlcs: - print("The following DLCs are available for this game:") + dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name) + if dlcs and not self.item.options.skip_dlcs: + ret.dlcs = [] for dlc in dlcs: - print( - f" - {dlc.app_title} (App name: {dlc.app_name}, version: {dlc.app_version})" + ret.dlcs.append( + { + "app_name": dlc.app_name, + "app_title": dlc.app_title, + "app_version": dlc.app_version(self.item.options.platform), + } ) - print( - "Manually installing DLCs works the same; just use the DLC app name instead." - ) - # install_dlcs = QMessageBox.question(self, "", "Do you want to install the prequisites", QMessageBox.Yes|QMessageBox.No) == QMessageBox.Yes - # TODO - if game.supports_cloud_saves and not game.is_dlc: - logger.info( - 'This game supports cloud saves, syncing is handled by the "sync-saves" command.' - ) - logger.info( - f'To download saves for this game run "legendary sync-saves {game.app_name}"' - ) - old_igame = self.core.get_installed_game(game.app_name) - if old_igame and self.repair and os.path.exists(self.repair_file): - if old_igame.needs_verification: - old_igame.needs_verification = False - self.core.install_game(old_igame) + if ( + self.item.download.game.supports_cloud_saves + or self.item.download.game.supports_mac_cloud_saves + ) and not self.item.download.game.is_dlc: + ret.sync_saves = True - logger.debug("Removing repair file.") - os.remove(self.repair_file) - if old_igame and old_igame.install_tags != self.igame.install_tags: - old_igame.install_tags = self.igame.install_tags - self.logger.info("Deleting now untagged files.") - self.core.uninstall_tag(old_igame) - self.core.install_game(old_igame) + # show tip again after installation finishes so users hopefully actually see it + if tip_url := self.core.get_game_tip(self.item.download.igame.app_name): + ret.tip_url = tip_url - if not self.queue_item.options.update and self.queue_item.options.create_shortcut: - if not create_desktop_link(self.queue_item.options.app_name, self.core, "desktop"): - # maybe add it to download summary, to show in finished downloads - pass - else: - logger.info("Desktop shortcut written") + LegendaryCLI(self.core).install_game_cleanup( + self.item.download.game, + self.item.download.igame, + self.item.download.repair, + self.item.download.repair_file, + ) - self.status.emit("finish") + if not self.item.options.update and self.item.options.create_shortcut: + ret.shortcuts = True + + self.ret_status.emit(ret) def _handle_postinstall(self, postinstall, igame): - logger.info(f"Postinstall info: {postinstall}") + logger.info("This game lists the following prequisites to be installed:") + logger.info(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}') if platform.system() == "Windows": - if self.queue_item.options.install_preqs: + if not self.item.options.install_preqs: + logger.info("Marking prerequisites as installed...") + self.core.prereq_installed(self.item.download.igame.app_name) + else: + logger.info("Launching prerequisite executable..") self.core.prereq_installed(igame.app_name) req_path, req_exec = os.path.split(postinstall["path"]) work_dir = os.path.join(igame.install_path, req_path) @@ -211,15 +152,14 @@ class DownloadThread(QThread): proc = QProcess() proc.setProcessChannelMode(QProcess.MergedChannels) proc.readyReadStandardOutput.connect( - lambda: logger.debug( - str(proc.readAllStandardOutput().data(), "utf-8", "ignore") - )) - proc.start(fullpath, postinstall.get("args", [])) + lambda: logger.debug(str(proc.readAllStandardOutput().data(), "utf-8", "ignore")) + ) + proc.setNativeArguments(postinstall.get("args", [])) + proc.setWorkingDirectory(work_dir) + proc.start(fullpath) proc.waitForFinished() # wait, because it is inside the thread - else: - self.core.prereq_installed(self.igame.app_name) else: logger.info("Automatic installation not available on Linux.") def kill(self): - self._kill = True + self.dlm_signals.kill = True diff --git a/rare/components/tabs/games/__init__.py b/rare/components/tabs/games/__init__.py index 28b7f98e..e6db3967 100644 --- a/rare/components/tabs/games/__init__.py +++ b/rare/components/tabs/games/__init__.py @@ -139,6 +139,16 @@ class GamesTab(QStackedWidget): self.view_stack.setCurrentWidget(self.icon_view_scroll) self.head_bar.search_bar.textChanged.connect(lambda x: self.filter_games("", x)) + self.head_bar.search_bar.textChanged.connect( + lambda x: self.icon_view_scroll.verticalScrollBar().setSliderPosition( + self.icon_view_scroll.verticalScrollBar().minimum() + ) + ) + self.head_bar.search_bar.textChanged.connect( + lambda x: self.list_view_scroll.verticalScrollBar().setSliderPosition( + self.list_view_scroll.verticalScrollBar().minimum() + ) + ) self.head_bar.filterChanged.connect(self.filter_games) self.head_bar.refresh_list.clicked.connect(self.update_list) self.head_bar.view.toggled.connect(self.toggle_view) @@ -320,8 +330,8 @@ class GamesTab(QStackedWidget): visible = True if ( - search_text not in widget.game.app_name.lower() - and search_text not in widget.game.app_title.lower() + search_text.lower() not in widget.game.app_name.lower() + and search_text.lower() not in widget.game.app_title.lower() ): opacity = 0.25 else: @@ -345,7 +355,7 @@ class GamesTab(QStackedWidget): # lk: it sorts by installed then by title installing_widget = self.icon_view.layout().remove(type(self.installing_widget).__name__) if sort_by: - self.icon_view.layout().sort(lambda x: (sort_by not in x.widget().game.app_title.lower(),)) + self.icon_view.layout().sort(lambda x: (sort_by.lower() not in x.widget().game.app_title.lower(),)) else: self.icon_view.layout().sort( lambda x: ( diff --git a/rare/components/tabs/games/cloud_save_utils.py b/rare/components/tabs/games/cloud_save_utils.py index ff50b39b..53832633 100644 --- a/rare/components/tabs/games/cloud_save_utils.py +++ b/rare/components/tabs/games/cloud_save_utils.py @@ -11,7 +11,7 @@ from legendary.core import LegendaryCore from legendary.models.game import SaveGameStatus, InstalledGame, SaveGameFile from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton, ApiResultsSingleton from rare.ui.components.dialogs.sync_save_dialog import Ui_SyncSaveDialog -from rare.utils.utils import icon +from rare.utils.misc import icon logger = getLogger("Cloud Saves") diff --git a/rare/components/tabs/games/game_info/game_dlc.py b/rare/components/tabs/games/game_info/game_dlc.py index 7ed75528..192cd28c 100644 --- a/rare/components/tabs/games/game_info/game_dlc.py +++ b/rare/components/tabs/games/game_info/game_dlc.py @@ -7,7 +7,7 @@ from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton from rare.shared.image_manager import ImageManagerSingleton, ImageSize from rare.ui.components.tabs.games.game_info.game_dlc import Ui_GameDlc from rare.ui.components.tabs.games.game_info.game_dlc_widget import Ui_GameDlcWidget -from rare.utils.models import InstallOptionsModel +from rare.models.install import InstallOptionsModel from rare.widgets.image_widget import ImageWidget diff --git a/rare/components/tabs/games/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py index fa303b2e..9f8fa904 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -37,9 +37,9 @@ from rare.shared.image_manager import ImageManagerSingleton, ImageSize from rare.ui.components.tabs.games.game_info.game_info import Ui_GameInfo from rare.utils.extra_widgets import PathEdit from rare.utils.legendary_utils import VerifyWorker -from rare.utils.models import InstallOptionsModel +from rare.models.install import InstallOptionsModel from rare.utils.steam_grades import SteamWorker -from rare.utils.utils import get_size +from rare.utils.misc import get_size from rare.widgets.image_widget import ImageWidget logger = getLogger("GameInfo") @@ -117,77 +117,111 @@ class GameInfo(QWidget, Ui_GameInfo): self.game_utils.update_list.emit(self.game.app_name) self.uninstalled.emit(self.game.app_name) + @pyqtSlot() def repair(self): - repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.game.app_name}.repair") + """ This function is to be called from the button only """ + repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.igame.app_name}.repair") if not os.path.exists(repair_file): QMessageBox.warning( self, - "Warning", + self.tr("Error - {}").format(self.igame.title), self.tr( "Repair file does not exist or game does not need a repair. Please verify game first" ), ) return + self.repair_game(self.igame) + + def repair_game(self, igame: InstalledGame): + game = self.core.get_game(igame.app_name) + ans = False + if igame.version != game.app_version(igame.platform): + ans = QMessageBox.question( + self, + self.tr("Repair and update?"), + self.tr( + "There is an update for {} from {} to {}." + "Do you want to update the game while repairing it?" + ).format(igame.title, igame.version, game.app_version(igame.platform)), + ) == QMessageBox.Yes self.signals.install_game.emit( - InstallOptionsModel(app_name=self.game.app_name, repair=True, update=True) + InstallOptionsModel( + app_name=igame.app_name, repair_mode=True, repair_and_update=ans, update=True + ) ) + @pyqtSlot() def verify(self): + """ This function is to be called from the button only """ if not os.path.exists(self.igame.install_path): - logger.error("Path does not exist") + logger.error(f"Installation path {self.igame.install_path} for {self.igame.title} does not exist") QMessageBox.warning( self, - "Warning", - self.tr("Installation path of {} does not exist. Cannot verify").format(self.igame.title), + self.tr("Error - {}").format(self.igame.title), + self.tr("Installation path for {} does not exist. Cannot continue.").format(self.igame.title), ) return + self.verify_game(self.igame) + + def verify_game(self, igame: InstalledGame): self.verify_widget.setCurrentIndex(1) - verify_worker = VerifyWorker(self.game.app_name) - verify_worker.signals.status.connect(self.verify_statistics) - verify_worker.signals.summary.connect(self.finish_verify) + verify_worker = VerifyWorker(igame.app_name) + verify_worker.signals.status.connect(self.verify_status) + verify_worker.signals.result.connect(self.verify_result) + verify_worker.signals.error.connect(self.verify_error) self.verify_progress.setValue(0) - self.verify_threads[self.game.app_name] = verify_worker + self.verify_threads[igame.app_name] = verify_worker self.verify_pool.start(verify_worker) self.move_button.setEnabled(False) - def verify_statistics(self, num, total, app_name): + def verify_cleanup(self, app_name: str): + self.verify_widget.setCurrentIndex(0) + self.verify_threads.pop(app_name) + self.move_button.setEnabled(True) + self.verify_button.setEnabled(True) + + @pyqtSlot(str, str) + def verify_error(self, app_name, message): + self.verify_cleanup(app_name) + igame = self.core.get_installed_game(app_name) + QMessageBox.warning( + self, + self.tr("Error - {}").format(igame.title), + message + ) + + @pyqtSlot(str, int, int, float, float) + def verify_status(self, app_name, num, total, percentage, speed): # checked, max, app_name if app_name == self.game.app_name: self.verify_progress.setValue(num * 100 // total) - def finish_verify(self, failed, missing, app_name): - if failed == missing == 0: + @pyqtSlot(str, bool, int, int) + def verify_result(self, app_name, success, failed, missing): + self.verify_cleanup(app_name) + self.repair_button.setDisabled(success) + igame = self.core.get_installed_game(app_name) + if success: QMessageBox.information( self, - "Summary", - "Game was verified successfully. No missing or corrupt files found", + self.tr("Summary - {}").format(igame.title), + self.tr("{} has been verified successfully. " + "No missing or corrupt files found").format(igame.title), ) - igame = self.core.get_installed_game(app_name) - if igame.needs_verification: - igame.needs_verification = False - self.core.lgd.set_installed_game(igame.app_name, igame) - self.verification_finished.emit(igame) - elif failed == missing == -1: - QMessageBox.warning(self, "Warning", self.tr("Something went wrong")) - + self.verification_finished.emit(igame) else: ans = QMessageBox.question( self, - "Summary", + self.tr("Summary - {}").format(igame.title), self.tr( - "Verification failed, {} file(s) corrupted, {} file(s) are missing. Do you want to repair them?" + "Verification failed, {} file(s) corrupted, {} file(s) are missing. " + "Do you want to repair them?" ).format(failed, missing), QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) if ans == QMessageBox.Yes: - self.signals.install_game.emit( - InstallOptionsModel(app_name=app_name, repair=True, update=True) - ) - self.verify_widget.setCurrentIndex(0) - self.verify_threads.pop(app_name) - self.move_button.setEnabled(True) - self.verify_button.setEnabled(True) + self.repair_game(igame) @pyqtSlot(str) def move_game(self, dest_path): @@ -318,7 +352,9 @@ class GameInfo(QWidget, Ui_GameInfo): self.uninstall_button.setDisabled(False) self.verify_button.setDisabled(False) if not self.args.offline: - self.repair_button.setDisabled(False) + self.repair_button.setDisabled( + not os.path.exists(os.path.join(self.core.lgd.get_tmp_path(), f"{self.igame.app_name}.repair")) + ) self.game_actions_stack.setCurrentIndex(0) try: diff --git a/rare/components/tabs/games/game_info/game_settings.py b/rare/components/tabs/games/game_info/game_settings.py index d3b0be90..3808b9cc 100644 --- a/rare/components/tabs/games/game_info/game_settings.py +++ b/rare/components/tabs/games/game_info/game_settings.py @@ -10,7 +10,7 @@ from rare.components.tabs.settings import DefaultGameSettings from rare.components.tabs.settings.widgets.pre_launch import PreLaunchSettings from rare.utils import config_helper from rare.utils.extra_widgets import PathEdit -from rare.utils.utils import icon, WineResolver, get_raw_save_path +from rare.utils.misc import icon, WineResolver, get_raw_save_path logger = getLogger("GameSettings") diff --git a/rare/components/tabs/games/game_info/uninstalled_info.py b/rare/components/tabs/games/game_info/uninstalled_info.py index 79c582c9..5c99e648 100644 --- a/rare/components/tabs/games/game_info/uninstalled_info.py +++ b/rare/components/tabs/games/game_info/uninstalled_info.py @@ -15,7 +15,7 @@ from rare.shared.image_manager import ImageManagerSingleton, ImageSize from rare.ui.components.tabs.games.game_info.game_info import Ui_GameInfo from rare.utils.extra_widgets import SideTabWidget from rare.utils.json_formatter import QJsonModel -from rare.utils.models import InstallOptionsModel +from rare.models.install import InstallOptionsModel from rare.utils.steam_grades import SteamWorker from rare.widgets.image_widget import ImageWidget diff --git a/rare/components/tabs/games/game_utils.py b/rare/components/tabs/games/game_utils.py index 56b6bf1c..81394eb3 100644 --- a/rare/components/tabs/games/game_utils.py +++ b/rare/components/tabs/games/game_utils.py @@ -13,7 +13,7 @@ from rare.components.tabs.games import CloudSaveUtils from rare.game_launch_helper import message_models from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton from rare.utils import legendary_utils -from rare.utils import utils +from rare.utils import misc from rare.utils.meta import RareGameMeta logger = getLogger("GameUtils") @@ -24,7 +24,7 @@ class GameProcess(QObject): game_launched = pyqtSignal(str) tried_connections = 0 - def __init__(self, app_name: str, on_startup=False, always_ask_sync: bool= False): + def __init__(self, app_name: str, on_startup=False, always_ask_sync: bool = False): super(GameProcess, self).__init__() self.app_name = app_name self.on_startup = on_startup @@ -152,7 +152,7 @@ class GameUtils(QObject): if not os.path.exists(igame.install_path): if QMessageBox.Yes == QMessageBox.question( None, - "Uninstall", + self.tr("Uninstall - {}").format(igame.title), self.tr( "Game files of {} do not exist. Remove it from installed games?" ).format(igame.title), @@ -164,10 +164,12 @@ class GameUtils(QObject): else: return False - infos = UninstallDialog(game).get_information() - if infos == 0: + proceed, keep_files, keep_config = UninstallDialog(game).get_options() + if not proceed: return False - legendary_utils.uninstall(game.app_name, self.core, infos) + success, message = legendary_utils.uninstall_game(self.core, game.app_name, keep_files, keep_config) + if not success: + QMessageBox.warning(None, self.tr("Uninstall - {}").format(igame.title), message, QMessageBox.Close) self.signals.game_uninstalled.emit(app_name) return True @@ -206,7 +208,7 @@ class GameUtils(QObject): wine_pfx: str = None, ask_always_sync: bool = False, ): - executable = utils.get_rare_executable() + executable = misc.get_rare_executable() executable, args = executable[0], executable[1:] args.extend([ "start", app_name diff --git a/rare/components/tabs/games/game_widgets/base_installed_widget.py b/rare/components/tabs/games/game_widgets/base_installed_widget.py index e7c30b1d..7f1d9837 100644 --- a/rare/components/tabs/games/game_widgets/base_installed_widget.py +++ b/rare/components/tabs/games/game_widgets/base_installed_widget.py @@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QFrame, QMessageBox, QAction from rare.components.tabs.games.game_utils import GameUtils from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton from rare.shared.image_manager import ImageManagerSingleton, ImageSize -from rare.utils.utils import create_desktop_link +from rare.utils.misc import create_desktop_link from rare.widgets.image_widget import ImageWidget logger = getLogger("Game") diff --git a/rare/components/tabs/games/game_widgets/installed_icon_widget.py b/rare/components/tabs/games/game_widgets/installed_icon_widget.py index 7b995089..f6b84680 100644 --- a/rare/components/tabs/games/game_widgets/installed_icon_widget.py +++ b/rare/components/tabs/games/game_widgets/installed_icon_widget.py @@ -9,7 +9,7 @@ from rare.components.tabs.games.game_widgets.base_installed_widget import ( ) from rare.shared import LegendaryCoreSingleton from rare.shared.image_manager import ImageSize -from rare.utils.utils import icon +from rare.utils.misc import icon from rare.widgets.elide_label import ElideLabel logger = getLogger("GameWidgetInstalled") @@ -43,7 +43,7 @@ class InstalledIconWidget(BaseInstalledWidget): minilayout.setSpacing(0) miniwidget.setLayout(minilayout) - self.title_label = ElideLabel(f"

{self.game.app_title}

", parent=miniwidget) + self.title_label = ElideLabel(f"{self.game.app_title}", parent=miniwidget) self.title_label.setAlignment(Qt.AlignTop) self.title_label.setObjectName("game_widget") minilayout.addWidget(self.title_label, stretch=2) diff --git a/rare/components/tabs/games/game_widgets/installed_list_widget.py b/rare/components/tabs/games/game_widgets/installed_list_widget.py index 0950a61d..c4239f02 100644 --- a/rare/components/tabs/games/game_widgets/installed_list_widget.py +++ b/rare/components/tabs/games/game_widgets/installed_list_widget.py @@ -6,8 +6,7 @@ from PyQt5.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout from rare.components.tabs.games.game_widgets.base_installed_widget import ( BaseInstalledWidget, ) -from rare.utils.utils import get_size -from rare.utils.utils import icon +from rare.utils.misc import icon, get_size logger = getLogger("GameWidget") diff --git a/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py b/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py index f3daf240..c0c2e548 100644 --- a/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py +++ b/rare/components/tabs/games/game_widgets/uninstalled_icon_widget.py @@ -29,7 +29,7 @@ class UninstalledIconWidget(BaseUninstalledWidget): minilayout.setSpacing(0) miniwidget.setLayout(minilayout) - self.title_label = ElideLabel(f"

{game.app_title}

", parent=miniwidget) + self.title_label = ElideLabel(f"{game.app_title}", parent=miniwidget) self.title_label.setAlignment(Qt.AlignTop) self.title_label.setObjectName("game_widget") minilayout.addWidget(self.title_label, stretch=2) diff --git a/rare/components/tabs/games/head_bar.py b/rare/components/tabs/games/head_bar.py index aaa32134..e22ca957 100644 --- a/rare/components/tabs/games/head_bar.py +++ b/rare/components/tabs/games/head_bar.py @@ -10,7 +10,7 @@ from qtawesome import IconWidget from rare.shared import ApiResultsSingleton from rare.utils.extra_widgets import SelectViewWidget, ButtonLineEdit -from rare.utils.utils import icon +from rare.utils.misc import icon class GameListHeadBar(QWidget): diff --git a/rare/components/tabs/games/import_sync/egl_sync_group.py b/rare/components/tabs/games/import_sync/egl_sync_group.py index 56df606c..79d3ab4d 100644 --- a/rare/components/tabs/games/import_sync/egl_sync_group.py +++ b/rare/components/tabs/games/import_sync/egl_sync_group.py @@ -6,6 +6,7 @@ from typing import Tuple, Iterable, List from PyQt5.QtCore import Qt, QThreadPool, QRunnable, pyqtSlot, pyqtSignal from PyQt5.QtWidgets import QGroupBox, QListWidgetItem, QFileDialog, QMessageBox +from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton from rare.ui.components.tabs.games.import_sync.egl_sync_group import Ui_EGLSyncGroup from rare.ui.components.tabs.games.import_sync.egl_sync_list_group import ( @@ -13,7 +14,7 @@ from rare.ui.components.tabs.games.import_sync.egl_sync_list_group import ( ) from rare.utils.extra_widgets import PathEdit from rare.utils.models import PathSpec -from rare.utils.utils import WineResolver +from rare.utils.misc import WineResolver logger = getLogger("EGLSync") @@ -183,11 +184,18 @@ class EGLSyncListItem(QListWidgetItem): def is_checked(self) -> bool: return self.checkState() == Qt.Checked - def action(self) -> None: + def action(self) -> str: + error = "" if self.export: - error = self.core.egl_export(self.game.app_name) + try: + self.core.egl_export(self.game.app_name) + except LgndrException as ret: + error = ret.message else: - error = self.core.egl_import(self.game.app_name) + try: + self.core.egl_import(self.game.app_name) + except LgndrException as ret: + error = ret.message return error @property @@ -307,85 +315,3 @@ class EGLSyncWorker(QRunnable): def run(self): self.import_list.action() self.export_list.action() - - -""" -from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QCheckBox, QPushButton, QDialog - - -class DisableSyncDialog(QDialog): - info = 1, False - - def __init__(self, parent=None): - super(DisableSyncDialog, self).__init__(parent=parent) - self.layout = QVBoxLayout() - - self.question = QLabel(self.tr("Do you really want to disable sync with Epic Games Store")) - self.layout.addWidget(self.question) - - self.remove_metadata = QCheckBox(self.tr("Remove metadata from installed games")) - self.layout.addWidget(self.remove_metadata) - - self.button_layout = QHBoxLayout() - self.button_layout.addStretch(1) - - self.ok_button = QPushButton(self.tr("Ok")) - self.cancel_button = QPushButton(self.tr("Cancel")) - - self.ok_button.clicked.connect(self.ok) - self.cancel_button.clicked.connect(self.cancel) - - self.button_layout.addWidget(self.ok_button) - self.button_layout.addWidget(self.cancel_button) - - self.layout.addStretch(1) - self.layout.addLayout(self.button_layout) - - self.setLayout(self.layout) - - def ok(self): - self.info = 0, self.remove_metadata.isChecked() - self.close() - - def cancel(self): - self.close() - - def get_information(self): - self.exec_() - return self.info - - -class EGLSyncItemWidget(QGroupBox): - def __init__(self, game, export: bool, parent=None): - super(EGLSyncItemWidget, self).__init__(parent=parent) - self.layout = QHBoxLayout() - self.export = export - self.game = game - if export: - self.app_title_label = QLabel(game.title) - else: - title = self.core.get_game(game.app_name).app_title - self.app_title_label = QLabel(title) - self.layout.addWidget(self.app_title_label) - self.button = QPushButton(self.tr("Export") if export else self.tr("Import")) - - if export: - self.button.clicked.connect(self.export_game) - else: - self.button.clicked.connect(self.import_game) - - self.layout.addWidget(self.button) - self.setLayout(self.layout) - - def export_game(self): - self.core.egl_export(self.game.app_name) - # FIXME: on update_egl_widget this is going to crash because - # FIXME: the item is not removed from the list in the python's side - self.deleteLater() - - def import_game(self): - self.core.egl_import(self.game.app_name) - # FIXME: on update_egl_widget this is going to crash because - # FIXME: the item is not removed from the list in the python's side - self.deleteLater() -""" diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 3726aa62..c4315a6d 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -10,10 +10,13 @@ from PyQt5.QtCore import Qt, QModelIndex, pyqtSignal, QRunnable, QObject, QThrea from PyQt5.QtGui import QStandardItemModel from PyQt5.QtWidgets import QFileDialog, QGroupBox, QCompleter, QTreeView, QHeaderView, qApp, QMessageBox +from rare.lgndr.cli import LegendaryCLI +from rare.lgndr.api_arguments import LgndrImportGameArgs +from rare.lgndr.api_monkeys import LgndrIndirectStatus from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup -from rare.utils import legendary_utils from rare.utils.extra_widgets import IndicatorLineEdit, PathEdit +from rare.widgets.elide_label import ElideLabel logger = getLogger("Import") @@ -25,8 +28,7 @@ def find_app_name(path: str, core) -> Optional[str]: with open(os.path.join(path, ".egstore", i)) as file: app_name = json.load(file).get("AppName") return app_name - elif app_name := legendary_utils.resolve_aliases( - core, os.path.basename(os.path.normpath(path))): + elif app_name := LegendaryCLI(core).resolve_aliases(os.path.basename(os.path.normpath(path))): # return None if game does not exist (Workaround for overlay) if not core.get_game(app_name): return None @@ -45,7 +47,9 @@ class ImportResult(IntEnum): @dataclass class ImportedGame: result: ImportResult + path: Optional[str] = None app_name: Optional[str] = None + app_title: Optional[str] = None message: Optional[str] = None @@ -54,14 +58,15 @@ class ImportWorker(QRunnable): finished = pyqtSignal(list) progress = pyqtSignal(int) - def __init__(self, path: str, import_folder: bool = False, app_name: str = None): + def __init__(self, path: str, app_name: str = None, import_folder: bool = False, import_dlcs: bool = False): super(ImportWorker, self).__init__() self.signals = self.Signals() self.core = LegendaryCoreSingleton() + self.path = Path(path) - self.import_folder = import_folder self.app_name = app_name - self.tr = lambda message: qApp.translate("ImportThread", message) + self.import_folder = import_folder + self.import_dlcs = import_dlcs def run(self) -> None: result_list: List = [] @@ -80,26 +85,30 @@ class ImportWorker(QRunnable): self.signals.finished.emit(result_list) def __try_import(self, path: Path, app_name: str = None) -> ImportedGame: - result = ImportedGame(ImportResult.ERROR, None, None) + result = ImportedGame(ImportResult.ERROR) + result.path = str(path) if app_name or (app_name := find_app_name(str(path), self.core)): result.app_name = app_name - err = self.__import_game(app_name, path) - if err: + result.app_title = app_title = self.core.get_game(app_name).app_title + success, message = self.__import_game(path, app_name, app_title) + if not success: result.result = ImportResult.FAILED - result.message = err + result.message = message else: result.result = ImportResult.SUCCESS - else: - result.message = self.tr("Could not find AppName for {}").format(str(path)) return result - def __import_game(self, app_name: str, path: Path) -> str: - if not (err := legendary_utils.import_game(self.core, app_name=app_name, path=str(path))): - igame = self.core.get_installed_game(app_name) - logger.info(f"Successfully imported {igame.title}") - return "" - else: - return err + def __import_game(self, path: Path, app_name: str, app_title: str): + cli = LegendaryCLI(self.core) + status = LgndrIndirectStatus() + args = LgndrImportGameArgs( + app_path=str(path), + app_name=app_name, + indirect_status=status, + get_boolean_choice=lambda prompt, default=True: self.import_dlcs + ) + cli.import_game(args) + return status.success, status.message class AppNameCompleter(QCompleter): @@ -181,17 +190,17 @@ class ImportGroup(QGroupBox): self.app_name_edit.textChanged.connect(self.app_name_changed) self.ui.app_name_layout.addWidget(self.app_name_edit) + self.ui.import_folder_check.stateChanged.connect(self.import_folder_changed) + self.ui.import_dlcs_check.setEnabled(False) + self.ui.import_button.setEnabled(False) self.ui.import_button.clicked.connect( lambda: self.import_pressed(self.path_edit.text()) ) - self.ui.import_folder_check.stateChanged.connect( - lambda s: self.ui.import_button.setEnabled(s or (not s and self.app_name_edit.is_valid)) - ) - self.ui.import_folder_check.stateChanged.connect( - lambda s: self.app_name_edit.setEnabled(not s) - ) + self.info_label = ElideLabel(text="", parent=self) + self.ui.button_info_layout.addWidget(self.info_label) + self.threadpool = QThreadPool.globalInstance() def path_edit_cb(self, path) -> Tuple[bool, str, str]: @@ -205,8 +214,8 @@ class ImportGroup(QGroupBox): return False, path, "" def path_changed(self, path): - self.ui.info_label.setText("") - self.ui.import_folder_check.setChecked(False) + self.info_label.setText("") + self.ui.import_folder_check.setCheckState(Qt.Unchecked) if self.path_edit.is_valid: self.app_name_edit.setText(find_app_name(path, self.core)) else: @@ -220,17 +229,36 @@ class ImportGroup(QGroupBox): else: return False, text, IndicatorLineEdit.reasons.game_not_installed - def app_name_changed(self, text): - self.ui.info_label.setText("") + def app_name_changed(self, app_name: str): + self.info_label.setText("") + self.ui.import_dlcs_check.setCheckState(Qt.Unchecked) if self.app_name_edit.is_valid: + self.ui.import_dlcs_check.setEnabled( + bool(self.core.get_dlc_for_game(app_name)) + ) self.ui.import_button.setEnabled(True) else: + self.ui.import_dlcs_check.setEnabled(False) self.ui.import_button.setEnabled(False) + def import_folder_changed(self, state): + self.app_name_edit.setEnabled(not state) + self.ui.import_dlcs_check.setCheckState(Qt.Unchecked) + self.ui.import_dlcs_check.setEnabled( + state + or (self.app_name_edit.is_valid and bool(self.core.get_dlc_for_game(self.app_name_edit.text()))) + ) + self.ui.import_button.setEnabled(state or (not state and self.app_name_edit.is_valid)) + def import_pressed(self, path=None): if not path: path = self.path_edit.text() - worker = ImportWorker(path, self.ui.import_folder_check.isChecked(), self.app_name_edit.text()) + worker = ImportWorker( + path, + self.app_name_edit.text(), + self.ui.import_folder_check.isChecked(), + self.ui.import_dlcs_check.isChecked(), + ) worker.signals.finished.connect(self.import_finished) worker.signals.progress.connect(self.import_progress) self.threadpool.start(worker) @@ -251,16 +279,16 @@ class ImportGroup(QGroupBox): if len(result) == 1: res = result[0] if res.result == ImportResult.SUCCESS: - self.ui.info_label.setText( - self.tr("{} was imported successfully").format(self.core.get_game(res.app_name).app_title) + self.info_label.setText( + self.tr("Success: {} imported").format(res.app_title) ) elif res.result == ImportResult.FAILED: - self.ui.info_label.setText( - self.tr("Failed: {}").format(res.message) + self.info_label.setText( + self.tr("Failed: {} - {}").format(res.app_title, res.message) ) else: - self.ui.info_label.setText( - self.tr("Error: {}").format(res.message) + self.info_label.setText( + self.tr("Error: Could not find AppName for {}").format(res.path) ) else: success = [r for r in result if r.result == ImportResult.SUCCESS] @@ -280,15 +308,15 @@ class ImportGroup(QGroupBox): details: List = [] for res in success: details.append( - self.tr("{} was imported successfully").format(self.core.get_game(res.app_name).app_title) + self.tr("Success: {} imported").format(res.app_title) ) for res in failure: details.append( - self.tr("Failed: {}").format(res.message) + self.tr("Failed: {} - {}").format(res.app_title, res.message) ) for res in errored: details.append( - self.tr("Error: {}").format(res.message) + self.tr("Error: Could not find AppName for {}").format(res.path) ) messagebox.setDetailedText("\n".join(details)) messagebox.show() diff --git a/rare/components/tabs/settings/__init__.py b/rare/components/tabs/settings/__init__.py index a4596e12..436e9d9b 100644 --- a/rare/components/tabs/settings/__init__.py +++ b/rare/components/tabs/settings/__init__.py @@ -1,11 +1,9 @@ -import platform - +from rare.components.tabs.settings.widgets.linux import LinuxSettings from rare.utils.extra_widgets import SideTabWidget from .about import About -from .legendary import LegendarySettings -from rare.components.tabs.settings.widgets.linux import LinuxSettings -from .rare import RareSettings from .default_game_settings import DefaultGameSettings +from .legendary import LegendarySettings +from .rare import RareSettings class SettingsTab(SideTabWidget): diff --git a/rare/components/tabs/settings/legendary.py b/rare/components/tabs/settings/legendary.py index 1f7c3435..51f22e88 100644 --- a/rare/components/tabs/settings/legendary.py +++ b/rare/components/tabs/settings/legendary.py @@ -11,7 +11,7 @@ from rare.components.tabs.settings.widgets.ubisoft_activation import UbiActivati from rare.shared import LegendaryCoreSingleton from rare.ui.components.tabs.settings.legendary import Ui_LegendarySettings from rare.utils.extra_widgets import PathEdit, IndicatorLineEdit -from rare.utils.utils import get_size +from rare.utils.misc import get_size logger = getLogger("LegendarySettings") @@ -174,10 +174,10 @@ class LegendarySettings(QWidget, Ui_LegendarySettings): if not keep_manifests: logger.debug("Removing manifests...") installed = [ - (ig.app_name, ig.version) for ig in self.core.get_installed_list() + (ig.app_name, ig.version, ig.platform) for ig in self.core.get_installed_list() ] installed.extend( - (ig.app_name, ig.version) for ig in self.core.get_installed_dlc_list() + (ig.app_name, ig.version, ig.platform) for ig in self.core.get_installed_dlc_list() ) self.core.lgd.clean_manifests(installed) diff --git a/rare/components/tabs/settings/rare.py b/rare/components/tabs/settings/rare.py index 187fb94d..48bdc573 100644 --- a/rare/components/tabs/settings/rare.py +++ b/rare/components/tabs/settings/rare.py @@ -10,14 +10,15 @@ from PyQt5.QtWidgets import QWidget, QMessageBox from rare.shared import LegendaryCoreSingleton from rare.components.tabs.settings.widgets.rpc import RPCSettings from rare.ui.components.tabs.settings.rare import Ui_RareSettings -from rare.utils import utils from rare.utils.paths import cache_dir -from rare.utils.utils import ( +from rare.utils.misc import ( get_translations, get_color_schemes, set_color_pallete, get_style_sheets, set_style_sheet, + get_size, + create_desktop_link, ) logger = getLogger("RareSettings") @@ -148,7 +149,7 @@ class RareSettings(QWidget, Ui_RareSettings): for i in os.listdir(logdir): size += os.path.getsize(os.path.join(logdir, i)) - self.log_dir_size_label.setText(utils.get_size(size)) + self.log_dir_size_label.setText(get_size(size)) # self.log_dir_clean_button.setVisible(False) # self.log_dir_size_label.setVisible(False) @@ -160,7 +161,7 @@ class RareSettings(QWidget, Ui_RareSettings): def create_start_menu_link(self): try: if not os.path.exists(self.start_menu_link): - utils.create_desktop_link(type_of_link="start_menu", for_rare=True) + create_desktop_link(type_of_link="start_menu", for_rare=True) self.startmenu_link_btn.setText(self.tr("Remove start menu link")) else: os.remove(self.start_menu_link) @@ -169,23 +170,24 @@ class RareSettings(QWidget, Ui_RareSettings): logger.error(str(e)) QMessageBox.warning( self, - "Error", - f"Permission error, cannot remove {self.start_menu_link}", + self.tr("Error"), + self.tr("Permission error, cannot remove {}").format(self.start_menu_link), ) def create_desktop_link(self): try: if not os.path.exists(self.desktop_file): - utils.create_desktop_link(type_of_link="desktop", for_rare=True) + create_desktop_link(type_of_link="desktop", for_rare=True) self.desktop_link_btn.setText(self.tr("Remove Desktop link")) else: os.remove(self.desktop_file) self.desktop_link_btn.setText(self.tr("Create desktop link")) except PermissionError as e: + logger.error(str(e)) logger.warning( self, - "Error", - f"Permission error, cannot remove {self.desktop_file}", + self.tr("Error"), + self.tr("Permission error, cannot remove {}").format(self.start_menu_link), ) def on_color_select_changed(self, color): diff --git a/rare/components/tabs/settings/widgets/env_vars.py b/rare/components/tabs/settings/widgets/env_vars.py index f5d3128e..c5cb9cd5 100644 --- a/rare/components/tabs/settings/widgets/env_vars.py +++ b/rare/components/tabs/settings/widgets/env_vars.py @@ -6,7 +6,7 @@ from PyQt5.QtWidgets import QGroupBox, QTableWidgetItem, QMessageBox, QPushButto from rare.shared import LegendaryCoreSingleton from rare.ui.components.tabs.settings.widgets.env_vars import Ui_EnvVars from rare.utils import config_helper -from rare.utils.utils import icon +from rare.utils.misc import icon logger = getLogger("EnvVars") diff --git a/rare/components/tabs/settings/widgets/eos.py b/rare/components/tabs/settings/widgets/eos.py index bd937382..27a064cb 100644 --- a/rare/components/tabs/settings/widgets/eos.py +++ b/rare/components/tabs/settings/widgets/eos.py @@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QGroupBox, QMessageBox from legendary.utils import eos from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton from rare.ui.components.tabs.settings.widgets.eos_widget import Ui_EosWidget -from rare.utils.models import InstallOptionsModel +from rare.models.install import InstallOptionsModel logger = getLogger("EOS") diff --git a/rare/components/tabs/settings/widgets/ubisoft_activation.py b/rare/components/tabs/settings/widgets/ubisoft_activation.py index 08da43ad..028d3c5c 100644 --- a/rare/components/tabs/settings/widgets/ubisoft_activation.py +++ b/rare/components/tabs/settings/widgets/ubisoft_activation.py @@ -7,7 +7,7 @@ from PyQt5.QtWidgets import QWidget, QLabel, QHBoxLayout, QSizePolicy, QPushButt from legendary.models.game import Game from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton -from rare.utils.utils import icon +from rare.utils.misc import icon logger = getLogger("Ubisoft") diff --git a/rare/components/tabs/settings/widgets/wrapper.py b/rare/components/tabs/settings/widgets/wrapper.py index 819f6753..45d63f5d 100644 --- a/rare/components/tabs/settings/widgets/wrapper.py +++ b/rare/components/tabs/settings/widgets/wrapper.py @@ -11,7 +11,7 @@ from PyQt5.QtWidgets import QHBoxLayout, QLabel, QPushButton, QInputDialog, QFra from rare import shared from rare.ui.components.tabs.settings.widgets.wrapper import Ui_WrapperSettings from rare.utils import config_helper -from rare.utils.utils import icon +from rare.utils.misc import icon logger = getLogger("Wrapper Settings") diff --git a/rare/components/tabs/shop/game_info.py b/rare/components/tabs/shop/game_info.py index 8b08f9a6..67b69b65 100644 --- a/rare/components/tabs/shop/game_info.py +++ b/rare/components/tabs/shop/game_info.py @@ -17,7 +17,7 @@ from rare.shared import LegendaryCoreSingleton from rare.components.tabs.shop.shop_models import ShopGame from rare.ui.components.tabs.store.shop_game_info import Ui_shop_info from rare.utils.extra_widgets import WaitingSpinner, ImageLabel -from rare.utils.utils import icon +from rare.utils.misc import icon logger = logging.getLogger("ShopInfo") diff --git a/rare/components/tabs/shop/game_widgets.py b/rare/components/tabs/shop/game_widgets.py index 9e985a94..0da342d2 100644 --- a/rare/components/tabs/shop/game_widgets.py +++ b/rare/components/tabs/shop/game_widgets.py @@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout from rare.components.tabs.shop.shop_models import ImageUrlModel from rare.ui.components.tabs.store.wishlist_widget import Ui_WishlistWidget from rare.utils.extra_widgets import ImageLabel -from rare.utils.utils import icon +from rare.utils.misc import icon logger = logging.getLogger("GameWidgets") diff --git a/rare/components/tabs/shop/wishlist.py b/rare/components/tabs/shop/wishlist.py index d9b40c4a..df1cae19 100644 --- a/rare/components/tabs/shop/wishlist.py +++ b/rare/components/tabs/shop/wishlist.py @@ -5,7 +5,7 @@ from rare.components.tabs.shop import ShopApiCore from rare.components.tabs.shop.game_widgets import WishlistWidget from rare.ui.components.tabs.store.wishlist import Ui_Wishlist from rare.utils.extra_widgets import WaitingSpinner -from rare.utils.utils import icon +from rare.utils.misc import icon class Wishlist(QStackedWidget, Ui_Wishlist): diff --git a/rare/components/tabs/tab_utils.py b/rare/components/tabs/tab_utils.py index 8d545352..b419011a 100644 --- a/rare/components/tabs/tab_utils.py +++ b/rare/components/tabs/tab_utils.py @@ -1,7 +1,7 @@ from PyQt5.QtCore import QSize from PyQt5.QtWidgets import QTabBar, QToolButton -from rare.utils.utils import icon +from rare.utils.misc import icon class MainTabBar(QTabBar): diff --git a/rare/game_launch_helper/__init__.py b/rare/game_launch_helper/__init__.py index 12aa226c..6b26435d 100644 --- a/rare/game_launch_helper/__init__.py +++ b/rare/game_launch_helper/__init__.py @@ -69,7 +69,7 @@ class GameProcessApp(RareApp): self.game_process = QProcess() self.app_name = app_name self.logger = getLogger(self.app_name) - self.core = LegendaryCoreSingleton(True) + self.core = LegendaryCoreSingleton(init=True) lang = self.settings.value("language", self.core.language_code, type=str) self.load_translator(lang) diff --git a/rare/legendary b/rare/legendary deleted file mode 160000 index 50f71cbd..00000000 --- a/rare/legendary +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 50f71cbd9b2ae0b31615e8f7d8d8595922d3cdf3 diff --git a/rare/lgndr/__init__.py b/rare/lgndr/__init__.py new file mode 100644 index 00000000..18d65d6b --- /dev/null +++ b/rare/lgndr/__init__.py @@ -0,0 +1,5 @@ +""" +Module that overloads and monkeypatches legendary's classes/methods to work with Rare + +Files with the 'api_' prefix are not part of legendary's source, and contain facilities relating to Rare. +""" \ No newline at end of file diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py new file mode 100644 index 00000000..abbe9bba --- /dev/null +++ b/rare/lgndr/api_arguments.py @@ -0,0 +1,136 @@ +from dataclasses import dataclass +from enum import IntEnum +from typing import Callable, List, Optional, Dict + +from .api_monkeys import ( + LgndrIndirectStatus, + GetBooleanChoiceProtocol, + get_boolean_choice, + verify_stdout, + DLManagerSignals +) +from .downloading import UIUpdate + +""" +@dataclass(kw_only=True) +class LgndrCommonArgs: + # keep this here for future reference + # when we move to 3.10 we can use 'kw_only' to do dataclass inheritance + app_name: str + platform: str = "Windows" + yes: bool = False +""" + + +@dataclass +class LgndrImportGameArgs: + app_path: str + app_name: str + platform: str = "Windows" + disable_check: bool = False + skip_dlcs: bool = False + with_dlcs: bool = False + yes: bool = False + # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() + get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice + + +@dataclass +class LgndrUninstallGameArgs: + app_name: str + keep_files: bool = False + yes: bool = False + # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() + get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice + + +@dataclass +class LgndrVerifyGameArgs: + app_name: str + # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() + verify_stdout: Callable[[int, int, float, float], None] = verify_stdout + + +@dataclass +class LgndrInstallGameArgs: + app_name: str + base_path: str = "" + shared_memory: int = 0 + max_workers: int = 0 + force: bool = False + disable_patching: bool = False + game_folder: str = "" + override_manifest: str = "" + override_old_manifest: str = "" + override_base_url: str = "" + platform: str = "Windows" + file_prefix: List = None + file_exclude_prefix: List = None + install_tag: Optional[List[str]] = None + order_opt: bool = False + dl_timeout: int = 10 + repair_mode: bool = False + repair_and_update: bool = False + disable_delta: bool = False + override_delta_manifest: str = "" + egl_guid: str = "" + preferred_cdn: str = None + no_install: bool = False + ignore_space: bool = False + disable_sdl: bool = False + reset_sdl: bool = False + skip_sdl: bool = False + disable_https: bool = False + # FIXME: move to LgndrInstallGameRealArgs + skip_dlcs: bool = False + with_dlcs: bool = False + # end of FIXME + yes: bool = True + # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() + get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice + sdl_prompt: Callable[[str, str], List[str]] = lambda app_name, title: [""] + verify_stdout: Callable[[int, int, float, float], None] = verify_stdout + + # def __post_init__(self): + # if self.sdl_prompt is None: + # self.sdl_prompt: Callable[[str, str], list] = \ + # lambda app_name, title: self.install_tag if self.install_tag is not None else [""] + + +@dataclass +class LgndrInstallGameRealArgs: + app_name: str + platform: str = "Windows" + repair_mode: bool = False + repair_file: str = "" + no_install: bool = False + save_path: str = "" + skip_dlcs: bool = False + with_dlcs: bool = False + dlm_debug: bool = False + yes: bool = False + # Rare: Extra arguments + install_preqs: bool = False + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() + ui_update: Callable[[UIUpdate], None] = lambda ui: None + dlm_signals: DLManagerSignals = DLManagerSignals() + + +@dataclass +class LgndrInstallGameRealRet: + class ReturnCode(IntEnum): + ERROR = 1 + STOPPED = 2 + FINISHED = 3 + + app_name: str + ret_code: ReturnCode = ReturnCode.ERROR + message: str = "" + dlcs: Optional[List[Dict]] = None + sync_saves: bool = False + tip_url: str = "" + shortcuts: bool = False diff --git a/rare/lgndr/api_exception.py b/rare/lgndr/api_exception.py new file mode 100644 index 00000000..11779f79 --- /dev/null +++ b/rare/lgndr/api_exception.py @@ -0,0 +1,32 @@ +import logging +import warnings + + +class LgndrException(RuntimeError): + def __init__(self, message="Error in Legendary"): + self.message = message + super(LgndrException, self).__init__(self.message) + + +class LgndrWarning(RuntimeWarning): + def __init__(self, message="Warning in Legendary"): + self.message = message + super(LgndrWarning, self).__init__(self.message) + + +class LgndrCLILogHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + # lk: FATAL is the same as CRITICAL + if record.levelno == logging.ERROR or record.levelno == logging.CRITICAL: + raise LgndrException(record.getMessage()) + # if record.levelno < logging.ERROR or record.levelno == logging.WARNING: + # warnings.warn(record.getMessage()) + + +class LgndrCoreLogHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + # lk: FATAL is the same as CRITICAL + if record.levelno == logging.CRITICAL: + raise LgndrException(record.getMessage()) + # if record.levelno < logging.CRITICAL: + # warnings.warn(record.getMessage()) diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py new file mode 100644 index 00000000..b764b23d --- /dev/null +++ b/rare/lgndr/api_monkeys.py @@ -0,0 +1,106 @@ +import logging +from dataclasses import dataclass + +from typing_extensions import Protocol + + +class GetBooleanChoiceProtocol(Protocol): + def __call__(self, prompt: str, default: bool = ...) -> bool: + ... + + +def get_boolean_choice(prompt: str, default: bool = True) -> bool: + return default + + +def verify_stdout(a0: int, a1: int, a2: float, a3: float) -> None: + print(f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r") + + +class DLManagerSignals: + _kill = False + _update = False + + @property + def kill(self) -> bool: + self._update = False + return self._kill + + @kill.setter + def kill(self, value: bool) -> None: + self._update = True + self._kill = value + + @property + def update(self) -> bool: + _update = self._update + self._update = False + return _update + + +@dataclass +class LgndrIndirectStatus: + success: bool = False + message: str = "" + + def __len__(self): + if self.message: + return 2 + else: + return 0 + + def __bool__(self): + return self.success + + def __getitem__(self, item): + if item == 0: + return self.success + elif item == 1: + return self.message + else: + raise IndexError + + def __iter__(self): + return iter((self.success, self.message)) + + def __str__(self): + return self.message + + +class LgndrIndirectLogger: + def __init__( + self, status: LgndrIndirectStatus, logger: logging.Logger = None, level: int = logging.ERROR + ): + self.logger = logger + self.level = level + self.status = status + + def set_logger(self, logger: logging.Logger): + self.logger = logger + + def set_level(self, level: int): + self.level = level + + def _log(self, level: int, msg: str): + self.status.success = True if level < self.level else False + self.status.message = msg + if self.logger: + self.logger.log(level, msg) + + def debug(self, msg: str): + self._log(logging.DEBUG, msg) + + def info(self, msg: str): + self._log(logging.INFO, msg) + + def warning(self, msg: str): + self._log(logging.WARNING, msg) + + def error(self, msg: str): + self._log(logging.ERROR, msg) + + def critical(self, msg: str): + self._log(logging.CRITICAL, msg) + + def fatal(self, msg: str): + self.critical(msg) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py new file mode 100644 index 00000000..d0331e1e --- /dev/null +++ b/rare/lgndr/cli.py @@ -0,0 +1,623 @@ +import logging +import os +import queue +import subprocess +import time +from typing import Optional, Union, Tuple + +from legendary.cli import LegendaryCLI as LegendaryCLIReal +from legendary.models.downloading import AnalysisResult, ConditionCheckResult +from legendary.models.game import Game, InstalledGame, VerifyResult +from legendary.utils.lfs import validate_files +from legendary.utils.selective_dl import get_sdl_appname + +from .api_arguments import ( + LgndrInstallGameArgs, + LgndrImportGameArgs, + LgndrVerifyGameArgs, + LgndrUninstallGameArgs, + LgndrInstallGameRealArgs, + LgndrInstallGameRealRet, +) +from .api_monkeys import LgndrIndirectStatus, LgndrIndirectLogger +from .core import LegendaryCore +from .manager import DLManager + + +# fmt: off +class LegendaryCLI(LegendaryCLIReal): + + # noinspection PyMissingConstructor + def __init__(self, core: LegendaryCore): + self.core = core + self.logger = logging.getLogger('cli') + self.logging_queue = None + self.ql = self.setup_threaded_logging() + + def __del__(self): + self.ql.stop() + + def resolve_aliases(self, name): + return super(LegendaryCLI, self)._resolve_aliases(name) + + def install_game(self, args: LgndrInstallGameArgs) -> Optional[Tuple[DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult]]: + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(args.indirect_status, self.logger) + get_boolean_choice = args.get_boolean_choice + sdl_prompt = args.sdl_prompt + + args.app_name = self._resolve_aliases(args.app_name) + if self.core.is_installed(args.app_name): + igame = self.core.get_installed_game(args.app_name) + args.platform = igame.platform + if igame.needs_verification and not args.repair_mode: + logger.info('Game needs to be verified before updating, switching to repair mode...') + args.repair_mode = True + + repair_file = None + # Rare: The 'args.no_install' flags is set externally from the InstallDialog + if args.repair_mode: + args.repair_mode = True + args.no_install = args.repair_and_update is False + repair_file = os.path.join(self.core.lgd.get_tmp_path(), f'{args.app_name}.repair') + + # Rare: Rare is already logged in + + if args.file_prefix or args.file_exclude_prefix: + args.no_install = True + + # Rare: Rare runs updates on already installed games only + + game = self.core.get_game(args.app_name, update_meta=True, platform=args.platform) + + if not game: + logger.error(f'Could not find "{args.app_name}" in list of available games, ' + f'did you type the name correctly?') + return + + # Rare: Rare checks this before calling 'install_game' + + if args.platform not in game.asset_infos: + if not args.no_install: + if self.core.lgd.config.getboolean('Legendary', 'install_platform_fallback', fallback=True): + logger.warning(f'App has no asset for platform "{args.platform}", falling back to "Windows".') + args.platform = 'Windows' + else: + logger.error(f'No app asset found for platform "{args.platform}", run ' + f'"legendary info --platform {args.platform}" and make ' + f'sure the app is available for the specified platform.') + return + else: + logger.warning(f'No asset found for platform "{args.platform}", ' + f'trying anyway since --no-install is set.') + + if game.is_dlc: + logger.info('Install candidate is DLC') + app_name = game.metadata['mainGameItem']['releaseInfo'][0]['appId'] + base_game = self.core.get_game(app_name) + # check if base_game is actually installed + if not self.core.is_installed(app_name): + # download mode doesn't care about whether something's installed + if not args.no_install: + logger.fatal(f'Base game "{app_name}" is not installed!') + return + else: + base_game = None + + if args.repair_mode: + if not self.core.is_installed(game.app_name): + logger.error(f'Game "{game.app_title}" ({game.app_name}) is not installed!') + return + + if not os.path.exists(repair_file): + logger.info('Game has not been verified yet.') + # Rare: we do not want to verify while preparing the download in the InstallDialog + # Rare: we handle it differently through the GameInfo tab + logger.error('Game has not been verified yet.') + return + else: + logger.info(f'Using existing repair file: {repair_file}') + + # check if SDL should be disabled + sdl_enabled = not args.install_tag and not game.is_dlc + config_tags = self.core.lgd.config.get(game.app_name, 'install_tags', fallback=None) + config_disable_sdl = self.core.lgd.config.getboolean(game.app_name, 'disable_sdl', fallback=False) + # remove config flag if SDL is reset + if config_disable_sdl and args.reset_sdl and not args.disable_sdl: + self.core.lgd.config.remove_option(game.app_name, 'disable_sdl') + # if config flag is not yet set, set it and remove previous install tags + elif not config_disable_sdl and args.disable_sdl: + logger.info('Clearing install tags from config and disabling SDL for title.') + if config_tags: + self.core.lgd.config.remove_option(game.app_name, 'install_tags') + config_tags = None + self.core.lgd.config.set(game.app_name, 'disable_sdl', True) + sdl_enabled = False + # just disable SDL, but keep config tags that have been manually specified + elif config_disable_sdl or args.disable_sdl: + sdl_enabled = False + + if sdl_enabled and ((sdl_name := get_sdl_appname(game.app_name)) is not None): + if not self.core.is_installed(game.app_name) or config_tags is None or args.reset_sdl: + sdl_data = self.core.get_sdl_data(sdl_name, platform=args.platform) + if sdl_data: + if args.skip_sdl: + args.install_tag = [''] + if '__required' in sdl_data: + args.install_tag.extend(sdl_data['__required']['tags']) + else: + args.install_tag = sdl_prompt(sdl_data, game.app_title) + self.core.lgd.config.set(game.app_name, 'install_tags', ','.join(args.install_tag)) + else: + logger.error(f'Unable to get SDL data for {sdl_name}') + else: + args.install_tag = config_tags.split(',') + elif args.install_tag and not game.is_dlc and not args.no_install: + config_tags = ','.join(args.install_tag) + logger.info(f'Saving install tags for "{game.app_name}" to config: {config_tags}') + self.core.lgd.config.set(game.app_name, 'install_tags', config_tags) + elif not game.is_dlc: + if config_tags and args.reset_sdl: + logger.info('Clearing install tags from config.') + self.core.lgd.config.remove_option(game.app_name, 'install_tags') + elif config_tags: + logger.info(f'Using install tags from config: {config_tags}') + args.install_tag = config_tags.split(',') + + logger.info(f'Preparing download for "{game.app_title}" ({game.app_name})...') + # todo use status queue to print progress from CLI + # This has become a little ridiculous hasn't it? + dlm, analysis, igame = self.core.prepare_download(game=game, base_game=base_game, base_path=args.base_path, + force=args.force, max_shm=args.shared_memory, + max_workers=args.max_workers, game_folder=args.game_folder, + disable_patching=args.disable_patching, + override_manifest=args.override_manifest, + override_old_manifest=args.override_old_manifest, + override_base_url=args.override_base_url, + platform=args.platform, + file_prefix_filter=args.file_prefix, + file_exclude_filter=args.file_exclude_prefix, + file_install_tag=args.install_tag, + dl_optimizations=args.order_opt, + dl_timeout=args.dl_timeout, + repair=args.repair_mode, + repair_use_latest=args.repair_and_update, + disable_delta=args.disable_delta, + override_delta_manifest=args.override_delta_manifest, + preferred_cdn=args.preferred_cdn, + disable_https=args.disable_https) + + # game is either up-to-date or hasn't changed, so we have nothing to do + if not analysis.dl_size: + logger.info('Download size is 0, the game is either already up to date or has not changed. Exiting...') + self.install_game_cleanup(game, igame, args.repair_mode, repair_file) + return + + res = self.core.check_installation_conditions(analysis=analysis, install=igame, game=game, + updating=self.core.is_installed(args.app_name), + ignore_space_req=args.ignore_space) + + return dlm, analysis, igame, game, args.repair_mode, repair_file, res + + # Rare: This is currently handled in DownloadThread, this is a trial + def install_game_real(self, args: LgndrInstallGameRealArgs, dlm: DLManager, game: Game, igame: InstalledGame) -> LgndrInstallGameRealRet: + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(args.indirect_status, self.logger) + ret = LgndrInstallGameRealRet(game.app_name) + + start_t = time.time() + + try: + # set up logging stuff (should be moved somewhere else later) + dlm.logging_queue = self.logging_queue + dlm.proc_debug = args.dlm_debug + + dlm.start() + while dlm.is_alive(): + try: + args.ui_update(dlm.status_queue.get(timeout=1.0)) + except queue.Empty: + pass + if args.dlm_signals.update: + try: + dlm.signals_queue.put(args.dlm_signals, block=False, timeout=1.0) + except queue.Full: + pass + time.sleep(dlm.update_interval / 10) + dlm.join() + except Exception as e: + end_t = time.time() + logger.info(f'Installation failed after {end_t - start_t:.02f} seconds.') + logger.warning(f'The following exception occurred while waiting for the downloader to finish: {e!r}. ' + f'Try restarting the process, the resume file will be used to start where it failed. ' + f'If it continues to fail please open an issue on GitHub.') + ret.ret_code = ret.ReturnCode.ERROR + ret.message = f"{e!r}" + return ret + else: + end_t = time.time() + if args.dlm_signals.kill is True: + logger.info(f"Download stopped after {end_t - start_t:.02f} seconds.") + ret.exit_code = ret.ReturnCode.STOPPED + return ret + logger.info(f"Download finished in {end_t - start_t:.02f} seconds.") + if not args.no_install: + # Allow setting savegame directory at install time so sync-saves will work immediately + if (game.supports_cloud_saves or game.supports_mac_cloud_saves) and args.save_path: + igame.save_path = args.save_path + + postinstall = self.core.install_game(igame) + if postinstall: + self._handle_postinstall(postinstall, igame, yes=args.yes, choice=args.install_preqs) + + dlcs = self.core.get_dlc_for_game(game.app_name) + if dlcs and not args.skip_dlcs: + for dlc in dlcs: + ret.dlcs.append( + { + "app_name": dlc.app_name, + "app_title": dlc.app_title, + "app_version": dlc.app_version(args.platform) + } + ) + + # Rare: We do not install DLCs automatically, we offer to do so through our downloads tab + + if (game.supports_cloud_saves or game.supports_mac_cloud_saves) and not game.is_dlc: + # todo option to automatically download saves after the installation + # args does not have the required attributes for sync_saves in here, + # not sure how to solve that elegantly. + logger.info(f'This game supports cloud saves, syncing is handled by the "sync-saves" command. ' + f'To download saves for this game run "legendary sync-saves {args.app_name}"') + ret.sync_saves = True + + # show tip again after installation finishes so users hopefully actually see it + if tip_url := self.core.get_game_tip(igame.app_name): + ret.tip_url = tip_url + + self.install_game_cleanup(game, igame, args.repair_mode, args.repair_file) + + logger.info(f'Finished installation process in {end_t - start_t:.02f} seconds.') + + return ret + + def install_game_cleanup(self, game: Game, igame: InstalledGame, repair_mode: bool = False, repair_file: str = '') -> None: + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(LgndrIndirectStatus(), self.logger) + + old_igame = self.core.get_installed_game(game.app_name) + if old_igame and repair_mode and os.path.exists(repair_file): + if old_igame.needs_verification: + old_igame.needs_verification = False + self.core.install_game(old_igame) + + logger.debug('Removing repair file.') + os.remove(repair_file) + + # check if install tags have changed, if they did; try deleting files that are no longer required. + if old_igame and old_igame.install_tags != igame.install_tags: + old_igame.install_tags = igame.install_tags + logger.info('Deleting now untagged files.') + self.core.uninstall_tag(old_igame) + self.core.install_game(old_igame) + + def _handle_postinstall(self, postinstall, igame, yes=False, choice=True): + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(LgndrIndirectStatus(), self.logger) + # noinspection PyShadowingBuiltins + def print(x): self.logger.info(x) if x else None + # noinspection PyShadowingBuiltins + def input(x): return 'y' if choice else 'i' + + print('\nThis game lists the following prequisites to be installed:') + print(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}') + print('') + + if os.name == 'nt': + if yes: + c = 'n' # we don't want to launch anything, just silent install. + else: + choice = input('Do you wish to install the prerequisites? ([y]es, [n]o, [i]gnore): ') + c = choice.lower()[0] + print('') + + if c == 'i': # just set it to installed + logger.info('Marking prerequisites as installed...') + self.core.prereq_installed(igame.app_name) + elif c == 'y': # set to installed and launch installation + logger.info('Launching prerequisite executable..') + self.core.prereq_installed(igame.app_name) + req_path, req_exec = os.path.split(postinstall['path']) + work_dir = os.path.join(igame.install_path, req_path) + fullpath = os.path.join(work_dir, req_exec) + try: + p = subprocess.Popen([fullpath, postinstall['args']], cwd=work_dir, shell=True) + p.wait() + except Exception as e: + logger.error(f'Failed to run prereq executable with: {e!r}') + else: + logger.info('Automatic installation not available on Linux.') + + def uninstall_game(self, args: LgndrUninstallGameArgs) -> None: + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(args.indirect_status, self.logger, logging.WARNING) + get_boolean_choice = args.get_boolean_choice + + args.app_name = self._resolve_aliases(args.app_name) + igame = self.core.get_installed_game(args.app_name) + if not igame: + logger.error(f'Game {args.app_name} not installed, cannot uninstall!') + return + + if not args.yes: + if not get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): + return + + try: + if not igame.is_dlc: + # Remove DLC first so directory is empty when game uninstall runs + dlcs = self.core.get_dlc_for_game(igame.app_name) + for dlc in dlcs: + if (idlc := self.core.get_installed_game(dlc.app_name)) is not None: + logger.info(f'Uninstalling DLC "{dlc.app_name}"...') + self.core.uninstall_game(idlc, delete_files=not args.keep_files) + + logger.info(f'Removing "{igame.title}" from "{igame.install_path}"...') + self.core.uninstall_game(igame, delete_files=not args.keep_files, + delete_root_directory=not igame.is_dlc) + logger.info('Game has been uninstalled.') + return + except Exception as e: + logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.') + return + + def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False) -> Optional[Tuple[int, int]]: + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(args.indirect_status, self.logger) + + args.app_name = self._resolve_aliases(args.app_name) + if not self.core.is_installed(args.app_name): + logger.error(f'Game "{args.app_name}" is not installed') + return + + logger.info(f'Loading installed manifest for "{args.app_name}"') + igame = self.core.get_installed_game(args.app_name) + if not os.path.exists(igame.install_path): + logger.error(f'Install path "{igame.install_path}" does not exist, make sure all necessary mounts ' + f'are available. If you previously deleted the game folder without uninstalling, run ' + f'"legendary uninstall -y {igame.app_name}" and reinstall from scratch.') + return + + manifest_data, _ = self.core.get_installed_manifest(args.app_name) + if manifest_data is None: + if repair_mode: + if not repair_online: + logger.critical('No manifest could be loaded, the manifest file may be missing!') + raise ValueError('Local manifest is missing') + + logger.warning('No manifest could be loaded, the file may be missing. Downloading the latest manifest.') + game = self.core.get_game(args.app_name, platform=igame.platform) + manifest_data, _ = self.core.get_cdn_manifest(game, igame.platform) + # Rare: Save the manifest if we downloaded it because it was missing + self.core.lgd.save_manifest(game.app_name, manifest_data, + version=self.core.load_manifest(manifest_data).meta.build_version, + platform=igame.platform) + else: + logger.critical(f'Manifest appears to be missing! To repair, run "legendary repair ' + f'{args.app_name} --repair-and-update", this will however redownload all files ' + f'that do not match the latest manifest in their entirety.') + return + + manifest = self.core.load_manifest(manifest_data) + + files = sorted(manifest.file_manifest_list.elements, + key=lambda a: a.filename.lower()) + + # build list of hashes + if config_tags := self.core.lgd.config.get(args.app_name, 'install_tags', fallback=None): + install_tags = set(i.strip() for i in config_tags.split(',')) + file_list = [ + (f.filename, f.sha_hash.hex()) + for f in files + if any(it in install_tags for it in f.install_tags) or not f.install_tags + ] + else: + file_list = [(f.filename, f.sha_hash.hex()) for f in files] + + total = len(file_list) + total_size = sum(manifest.file_manifest_list.get_file_by_path(fm[0]).file_size + for fm in file_list) + num = processed = last_processed = 0 + speed = 0.0 + percentage = 0.0 + failed = [] + missing = [] + + last_update = time.time() + + logger.info(f'Verifying "{igame.title}" version "{manifest.meta.build_version}"') + repair_file = [] + for result, path, result_hash, bytes_read in validate_files(igame.install_path, file_list): + processed += bytes_read + percentage = (processed / total_size) * 100.0 + num += 1 + + if (delta := ((current_time := time.time()) - last_update)) > 1 or (not last_processed and delta > 1): + last_update = current_time + speed = (processed - last_processed) / 1024 / 1024 / delta + last_processed = processed + + if args.verify_stdout: + args.verify_stdout(num, total, percentage, speed) + + if result == VerifyResult.HASH_MATCH: + repair_file.append(f'{result_hash}:{path}') + continue + elif result == VerifyResult.HASH_MISMATCH: + logger.error(f'File does not match hash: "{path}"') + repair_file.append(f'{result_hash}:{path}') + failed.append(path) + elif result == VerifyResult.FILE_MISSING: + logger.error(f'File is missing: "{path}"') + missing.append(path) + else: + logger.error(f'Other failure (see log), treating file as missing: "{path}"') + missing.append(path) + + if args.verify_stdout: + args.verify_stdout(num, total, percentage, speed) + + # always write repair file, even if all match + if repair_file: + repair_filename = os.path.join(self.core.lgd.get_tmp_path(), f'{args.app_name}.repair') + with open(repair_filename, 'w', encoding='utf-8') as f: + f.write('\n'.join(repair_file)) + logger.debug(f'Written repair file to "{repair_filename}"') + + if not missing and not failed: + logger.info('Verification finished successfully.') + return 0, 0 + else: + logger.error(f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') + if print_command: + logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.') + return len(failed), len(missing) + + def import_game(self, args: LgndrImportGameArgs) -> None: + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(args.indirect_status, self.logger) + get_boolean_choice = args.get_boolean_choice + + # make sure path is absolute + args.app_path = os.path.abspath(args.app_path) + args.app_name = self._resolve_aliases(args.app_name) + + if not os.path.exists(args.app_path): + logger.error(f'Specified path "{args.app_path}" does not exist!') + return + + if self.core.is_installed(args.app_name): + logger.error('Game is already installed!') + return + + if not self.core.login(): + logger.error('Log in failed!') + return + + # do some basic checks + game = self.core.get_game(args.app_name, update_meta=True, platform=args.platform) + if not game: + logger.fatal(f'Did not find game "{args.app_name}" on account.') + return + + if game.is_dlc: + release_info = game.metadata.get('mainGameItem', {}).get('releaseInfo') + if release_info: + main_game_appname = release_info[0]['appId'] + main_game_title = game.metadata['mainGameItem']['title'] + if not self.core.is_installed(main_game_appname): + logger.error(f'Import candidate is DLC but base game "{main_game_title}" ' + f'(App name: "{main_game_appname}") is not installed!') + return + else: + logger.fatal(f'Unable to get base game information for DLC, cannot continue.') + return + + # get everything needed for import from core, then run additional checks. + manifest, igame = self.core.import_game(game, args.app_path, platform=args.platform) + exe_path = os.path.join(args.app_path, manifest.meta.launch_exe.lstrip('/')) + # check if most files at least exist or if user might have specified the wrong directory + total = len(manifest.file_manifest_list.elements) + found = sum(os.path.exists(os.path.join(args.app_path, f.filename)) + for f in manifest.file_manifest_list.elements) + ratio = found / total + + if not found: + logger.error(f'No files belonging to {"DLC" if game.is_dlc else "Game"} "{game.app_title}" ' + f'({game.app_name}) found in the specified location, please verify that the path is correct.') + if not game.is_dlc: + # check if game folder is in path, suggest alternative + folder = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', game.app_name) + if folder and folder not in args.app_path: + new_path = os.path.join(args.app_path, folder) + logger.info(f'Did you mean "{new_path}"?') + return + + if not game.is_dlc and not os.path.exists(exe_path) and not args.disable_check: + logger.error(f'Game executable could not be found at "{exe_path}", ' + f'please verify that the specified path is correct.') + return + + if ratio < 0.95: + logger.warning('Some files are missing from the game installation, install may not ' + 'match latest Epic Games Store version or might be corrupted.') + else: + logger.info(f'{"DLC" if game.is_dlc else "Game"} install appears to be complete.') + + self.core.install_game(igame) + if igame.needs_verification: + logger.info(f'NOTE: The {"DLC" if game.is_dlc else "Game"} installation will have to be ' + f'verified before it can be updated with legendary.') + logger.info(f'Run "legendary repair {args.app_name}" to do so.') + else: + logger.info(f'Installation had Epic Games Launcher metadata for version "{igame.version}", ' + f'verification will not be required.') + + # check for importable DLC + if not args.skip_dlcs: + dlcs = self.core.get_dlc_for_game(game.app_name) + if dlcs: + logger.info(f'Found {len(dlcs)} items of DLC that could be imported.') + import_dlc = True + if not args.yes and not args.with_dlcs: + if not get_boolean_choice(f'Do you wish to automatically attempt to import all DLCs?'): + import_dlc = False + + if import_dlc: + for dlc in dlcs: + args.app_name = dlc.app_name + self.import_game(args) + + logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.') + return + + def move(self, args): + # Override logger for the local context to use message as part of the indirect return value + logger = LgndrIndirectLogger(args.indirect_status, self.logger) + + app_name = self._resolve_aliases(args.app_name) + igame = self.core.get_installed_game(app_name, skip_sync=True) + if not igame: + logger.error(f'No installed game found for "{app_name}"') + return + + old_base, game_folder = os.path.split(igame.install_path.replace('\\', '/')) + new_path = os.path.join(args.new_path, game_folder) + logger.info(f'Moving "{game_folder}" from "{old_base}" to "{args.new_path}"') + + if not args.skip_move: + try: + if not os.path.exists(args.new_path): + os.makedirs(args.new_path) + + os.rename(igame.install_path, new_path) + except Exception as e: + if isinstance(e, OSError) and e.errno == 18: + logger.error(f'Moving to a different drive is not supported. Move the folder manually to ' + f'"{new_path}" and run "legendary move {app_name} "{args.new_path}" --skip-move"') + elif isinstance(e, FileExistsError): + logger.error(f'The target path already contains a folder called "{game_folder}", ' + f'please remove or rename it first.') + else: + logger.error(f'Moving failed with unknown error {e!r}.') + logger.info(f'Try moving the folder manually to "{new_path}" and running ' + f'"legendary move {app_name} "{args.new_path}" --skip-move"') + return + else: + logger.info(f'Not moving, just rewriting legendary metadata...') + + igame.install_path = new_path + self.core.install_game(igame) + logger.info('Finished.') + +# fmt: on diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py new file mode 100644 index 00000000..2055697b --- /dev/null +++ b/rare/lgndr/core.py @@ -0,0 +1,97 @@ +from multiprocessing import Queue + +from legendary.core import LegendaryCore as LegendaryCoreReal +from legendary.models.downloading import AnalysisResult +from legendary.models.game import Game, InstalledGame +from legendary.models.manifest import ManifestMeta + +from .api_exception import LgndrException, LgndrCoreLogHandler +from .manager import DLManager + +# import legendary.core +# legendary.core.DLManager = DLManager + + +# fmt: off +class LegendaryCore(LegendaryCoreReal): + + def __init__(self, override_config=None, timeout=10.0): + super(LegendaryCore, self).__init__(override_config=override_config, timeout=timeout) + self.handler = LgndrCoreLogHandler() + self.log.addHandler(self.handler) + + # skip_sync defaults to false but since Rare is persistent, skip by default + # def get_installed_game(self, app_name, skip_sync=True) -> InstalledGame: + # return super(LegendaryCore, self).get_installed_game(app_name, skip_sync) + + def prepare_download(self, game: Game, base_game: Game = None, base_path: str = '', + status_q: Queue = None, max_shm: int = 0, max_workers: int = 0, + force: bool = False, disable_patching: bool = False, + game_folder: str = '', override_manifest: str = '', + override_old_manifest: str = '', override_base_url: str = '', + platform: str = 'Windows', file_prefix_filter: list = None, + file_exclude_filter: list = None, file_install_tag: list = None, + dl_optimizations: bool = False, dl_timeout: int = 10, + repair: bool = False, repair_use_latest: bool = False, + disable_delta: bool = False, override_delta_manifest: str = '', + egl_guid: str = '', preferred_cdn: str = None, + disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta): + dlm, analysis, igame = super(LegendaryCore, self).prepare_download( + game=game, base_game=base_game, base_path=base_path, + status_q=status_q, max_shm=max_shm, max_workers=max_workers, + force=force, disable_patching=disable_patching, + game_folder=game_folder, override_manifest=override_manifest, + override_old_manifest=override_old_manifest, override_base_url=override_base_url, + platform=platform, file_prefix_filter=file_prefix_filter, + file_exclude_filter=file_exclude_filter, file_install_tag=file_install_tag, + dl_optimizations=dl_optimizations, dl_timeout=dl_timeout, + repair=repair, repair_use_latest=repair_use_latest, + disable_delta=disable_delta, override_delta_manifest=override_delta_manifest, + egl_guid=egl_guid, preferred_cdn=preferred_cdn, + disable_https=disable_https + ) + # lk: monkeypatch run_real (the method that emits the stats) into DLManager + dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) + # lk: set the queue for reporting statistics back the UI + dlm.status_queue = Queue() + # lk: set the queue to send control signals to the DLManager + # lk: this doesn't exist in the original class, but it is monkeypatched in + dlm.signals_queue = Queue() + return dlm, analysis, igame + + def uninstall_game(self, installed_game: InstalledGame, delete_files=True, delete_root_directory=False): + try: + super(LegendaryCore, self).uninstall_game(installed_game, delete_files, delete_root_directory) + except Exception as e: + raise e + finally: + pass + + def egl_import(self, app_name): + try: + super(LegendaryCore, self).egl_import(app_name) + except LgndrException as ret: + raise ret + finally: + pass + + def egl_export(self, app_name): + try: + super(LegendaryCore, self).egl_export(app_name) + except LgndrException as ret: + raise ret + finally: + pass + + def prepare_overlay_install(self, path=None): + dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) + # lk: monkeypatch status_q (the queue for download stats) + dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) + # lk: set the queue for reporting statistics back the UI + dlm.status_queue = Queue() + # lk: set the queue to send control signals to the DLManager + # lk: this doesn't exist in the original class, but it is monkeypatched in + dlm.signals_queue = Queue() + return dlm, analysis_result, igame + +# fmt: on diff --git a/rare/lgndr/downloading.py b/rare/lgndr/downloading.py new file mode 100644 index 00000000..2b5dc37b --- /dev/null +++ b/rare/lgndr/downloading.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class UIUpdate: + """ + Status update object sent from the manager to the CLI/GUI to update status indicators + Inheritance doesn't work due to optional arguments in UIUpdate proper + """ + progress: float + download_speed: float + write_speed: float + read_speed: float + memory_usage: float + runtime: float + estimated_time_left: float + processed_chunks: int + chunk_tasks: int + total_downloaded: float + total_written: float + cache_usage: float + active_tasks: int + download_compressed_speed: float + current_filename: Optional[str] = None diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py new file mode 100644 index 00000000..b9eb32d2 --- /dev/null +++ b/rare/lgndr/manager.py @@ -0,0 +1,229 @@ +import logging +import os +import queue +import time +from multiprocessing import Queue as MPQueue +from multiprocessing.shared_memory import SharedMemory +from sys import exit +from threading import Condition, Thread + +from legendary.downloader.mp.manager import DLManager as DLManagerReal +from legendary.downloader.mp.workers import DLWorker, FileWorker +from legendary.models.downloading import ChunkTask, SharedMemorySegment, TerminateWorkerTask + +from .downloading import UIUpdate +from .api_monkeys import DLManagerSignals + + +# fmt: off +class DLManager(DLManagerReal): + # Rare: prototype to avoid undefined variable in type checkers + signals_queue: MPQueue + + # @staticmethod + def run_real(self): + self.shared_memory = SharedMemory(create=True, size=self.max_shared_memory) + self.log.debug(f'Created shared memory of size: {self.shared_memory.size / 1024 / 1024:.02f} MiB') + + # create the shared memory segments and add them to their respective pools + for i in range(int(self.shared_memory.size / self.analysis.biggest_chunk)): + _sms = SharedMemorySegment(offset=i * self.analysis.biggest_chunk, + end=i * self.analysis.biggest_chunk + self.analysis.biggest_chunk) + self.sms.append(_sms) + + self.log.debug(f'Created {len(self.sms)} shared memory segments.') + + # Create queues + self.dl_worker_queue = MPQueue(-1) + self.writer_queue = MPQueue(-1) + self.dl_result_q = MPQueue(-1) + self.writer_result_q = MPQueue(-1) + + self.log.info(f'Starting download workers...') + for i in range(self.max_workers): + w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, self.dl_result_q, + self.shared_memory.name, logging_queue=self.logging_queue, + dl_timeout=self.dl_timeout) + self.children.append(w) + w.start() + + self.log.info('Starting file writing worker...') + writer_p = FileWorker(self.writer_queue, self.writer_result_q, self.dl_dir, + self.shared_memory.name, self.cache_dir, self.logging_queue) + self.children.append(writer_p) + writer_p.start() + + num_chunk_tasks = sum(isinstance(t, ChunkTask) for t in self.tasks) + num_dl_tasks = len(self.chunks_to_dl) + num_tasks = len(self.tasks) + num_shared_memory_segments = len(self.sms) + self.log.debug(f'Chunks to download: {num_dl_tasks}, File tasks: {num_tasks}, Chunk tasks: {num_chunk_tasks}') + + # active downloader tasks + self.active_tasks = 0 + processed_chunks = 0 + processed_tasks = 0 + total_dl = 0 + total_write = 0 + + # synchronization conditions + shm_cond = Condition() + task_cond = Condition() + self.conditions = [shm_cond, task_cond] + + # start threads + s_time = time.time() + self.threads.append(Thread(target=self.download_job_manager, args=(task_cond, shm_cond))) + self.threads.append(Thread(target=self.dl_results_handler, args=(task_cond,))) + self.threads.append(Thread(target=self.fw_results_handler, args=(shm_cond,))) + + for t in self.threads: + t.start() + + last_update = time.time() + + # Rare: kill requested + kill_request = False + + while processed_tasks < num_tasks: + delta = time.time() - last_update + if not delta: + time.sleep(self.update_interval) + continue + + # update all the things + processed_chunks += self.num_processed_since_last + processed_tasks += self.num_tasks_processed_since_last + + total_dl += self.bytes_downloaded_since_last + total_write += self.bytes_written_since_last + + dl_speed = self.bytes_downloaded_since_last / delta + dl_unc_speed = self.bytes_decompressed_since_last / delta + w_speed = self.bytes_written_since_last / delta + r_speed = self.bytes_read_since_last / delta + # c_speed = self.num_processed_since_last / delta + + # set temporary counters to 0 + self.bytes_read_since_last = self.bytes_written_since_last = 0 + self.bytes_downloaded_since_last = self.num_processed_since_last = 0 + self.bytes_decompressed_since_last = self.num_tasks_processed_since_last = 0 + last_update = time.time() + + perc = (processed_chunks / num_chunk_tasks) * 100 + runtime = time.time() - s_time + total_avail = len(self.sms) + total_used = (num_shared_memory_segments - total_avail) * (self.analysis.biggest_chunk / 1024 / 1024) + + if runtime and processed_chunks: + average_speed = processed_chunks / runtime + estimate = (num_chunk_tasks - processed_chunks) / average_speed + hours, estimate = int(estimate // 3600), estimate % 3600 + minutes, seconds = int(estimate // 60), int(estimate % 60) + + rt_hours, runtime = int(runtime // 3600), runtime % 3600 + rt_minutes, rt_seconds = int(runtime // 60), int(runtime % 60) + else: + estimate = 0 + hours = minutes = seconds = 0 + rt_hours = rt_minutes = rt_seconds = 0 + + # Rare: Disable up to INFO logging level for the segment below + log_level = self.log.level + self.log.setLevel(logging.ERROR) + self.log.info(f'= Progress: {perc:.02f}% ({processed_chunks}/{num_chunk_tasks}), ' + f'Running for {rt_hours:02d}:{rt_minutes:02d}:{rt_seconds:02d}, ' + f'ETA: {hours:02d}:{minutes:02d}:{seconds:02d}') + self.log.info(f' - Downloaded: {total_dl / 1024 / 1024:.02f} MiB, ' + f'Written: {total_write / 1024 / 1024:.02f} MiB') + self.log.info(f' - Cache usage: {total_used:.02f} MiB, active tasks: {self.active_tasks}') + self.log.info(f' + Download\t- {dl_speed / 1024 / 1024:.02f} MiB/s (raw) ' + f'/ {dl_unc_speed / 1024 / 1024:.02f} MiB/s (decompressed)') + self.log.info(f' + Disk\t- {w_speed / 1024 / 1024:.02f} MiB/s (write) / ' + f'{r_speed / 1024 / 1024:.02f} MiB/s (read)') + # Rare: Restore previous logging level + self.log.setLevel(log_level) + + # send status update to back to instantiator (if queue exists) + if self.status_queue: + try: + self.status_queue.put(UIUpdate( + progress=perc, download_speed=dl_unc_speed, write_speed=w_speed, read_speed=r_speed, + runtime=round(runtime), + estimated_time_left=round(estimate), + processed_chunks=processed_chunks, + chunk_tasks=num_chunk_tasks, + total_downloaded=total_dl, + total_written=total_write, + cache_usage=total_used, + active_tasks=self.active_tasks, + download_compressed_speed=dl_speed, + memory_usage=total_used * 1024 * 1024 + ), timeout=1.0) + except Exception as e: + self.log.warning(f'Failed to send status update to queue: {e!r}') + + # Rare: queue of control signals + try: + signals: DLManagerSignals = self.signals_queue.get(timeout=0.5) + self.log.warning('Immediate stop requested!') + if signals.kill is True: + # lk: graceful but not what legendary does + self.running = False + # send conditions to unlock threads if they aren't already + for cond in self.conditions: + with cond: + cond.notify() + kill_request = True + break + # # lk: alternative way, but doesn't clean shm + # for i in range(self.max_workers): + # self.dl_worker_queue.put_nowait(TerminateWorkerTask()) + # + # self.log.info('Waiting for installation to finish...') + # self.writer_queue.put_nowait(TerminateWorkerTask()) + # raise KeyboardInterrupt + except queue.Empty: + pass + + time.sleep(self.update_interval) + + for i in range(self.max_workers): + self.dl_worker_queue.put_nowait(TerminateWorkerTask()) + + self.log.info('Waiting for installation to finish...') + self.writer_queue.put_nowait(TerminateWorkerTask()) + + writer_p.join(timeout=10.0) + if writer_p.exitcode is None: + self.log.warning(f'Terminating writer process, no exit code!') + writer_p.terminate() + + # forcibly kill DL workers that are not actually dead yet + for child in self.children: + if child.exitcode is None: + child.terminate() + + # make sure all the threads are dead. + for t in self.threads: + t.join(timeout=5.0) + if t.is_alive(): + self.log.warning(f'Thread did not terminate! {repr(t)}') + + # clean up resume file + if self.resume_file and not kill_request: + try: + os.remove(self.resume_file) + except OSError as e: + self.log.warning(f'Failed to remove resume file: {e!r}') + + # close up shared memory + self.shared_memory.close() + self.shared_memory.unlink() + self.shared_memory = None + + self.log.info('All done! Download manager quitting...') + # finally, exit the process. + exit(0) + +# fmt: on diff --git a/rare/models/install.py b/rare/models/install.py new file mode 100644 index 00000000..b4dedef8 --- /dev/null +++ b/rare/models/install.py @@ -0,0 +1,65 @@ +import os +import platform as pf +from dataclasses import dataclass +from typing import List, Optional, Callable, Dict + +from legendary.models.downloading import AnalysisResult, ConditionCheckResult +from legendary.models.game import Game, InstalledGame + +from rare.lgndr.manager import DLManager + + +@dataclass +class InstallOptionsModel: + app_name: str + base_path: str = "" + shared_memory: int = 1024 + max_workers: int = os.cpu_count() * 2 + force: bool = False + platform: str = "Windows" + install_tag: Optional[List[str]] = None + order_opt: bool = False + repair_mode: bool = False + repair_and_update: bool = False + no_install: bool = False + ignore_space: bool = False + skip_dlcs: bool = False + with_dlcs: bool = False + # Rare's internal arguments + # FIXME: Do we really need all of these? + create_shortcut: bool = True + overlay: bool = False + update: bool = False + silent: bool = False + install_preqs: bool = pf.system() == "Windows" + + def __post_init__(self): + self.sdl_prompt: Callable[[str, str], list] = \ + lambda app_name, title: self.install_tag if self.install_tag is not None else [""] + + def as_install_kwargs(self) -> Dict: + return { + k: getattr(self, k) + for k in self.__dict__ + if k not in ["update", "silent", "create_shortcut", "overlay", "install_preqs"] + } + + +@dataclass +class InstallDownloadModel: + dlm: DLManager + analysis: AnalysisResult + igame: InstalledGame + game: Game + repair: bool + repair_file: str + res: ConditionCheckResult + + +@dataclass +class InstallQueueItemModel: + download: Optional[InstallDownloadModel] = None + options: Optional[InstallOptionsModel] = None + + def __bool__(self): + return (self.download is not None) and (self.options is not None) diff --git a/rare/models/signals.py b/rare/models/signals.py index 97119b09..7ae32cdb 100644 --- a/rare/models/signals.py +++ b/rare/models/signals.py @@ -1,6 +1,6 @@ from PyQt5.QtCore import QObject, pyqtSignal -from rare.utils.models import InstallOptionsModel +from .install import InstallOptionsModel class GlobalSignals(QObject): diff --git a/rare/shared/__init__.py b/rare/shared/__init__.py index c0fff881..6bbbf05a 100644 --- a/rare/shared/__init__.py +++ b/rare/shared/__init__.py @@ -8,7 +8,7 @@ and only ONCE! from argparse import Namespace from typing import Optional -from legendary.core import LegendaryCore +from rare.lgndr.core import LegendaryCore from rare.models.apiresults import ApiResults from rare.models.signals import GlobalSignals diff --git a/rare/ui/components/tabs/games/import_sync/import_group.py b/rare/ui/components/tabs/games/import_sync/import_group.py index a7b7bc1d..91289ce5 100644 --- a/rare/ui/components/tabs/games/import_sync/import_group.py +++ b/rare/ui/components/tabs/games/import_sync/import_group.py @@ -14,10 +14,11 @@ from PyQt5 import QtCore, QtGui, QtWidgets class Ui_ImportGroup(object): def setupUi(self, ImportGroup): ImportGroup.setObjectName("ImportGroup") - ImportGroup.resize(501, 136) + ImportGroup.resize(501, 162) ImportGroup.setWindowTitle("ImportGroup") ImportGroup.setWindowFilePath("") self.formLayout = QtWidgets.QFormLayout(ImportGroup) + self.formLayout.setLabelAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.formLayout.setObjectName("formLayout") self.path_edit_label = QtWidgets.QLabel(ImportGroup) self.path_edit_label.setObjectName("path_edit_label") @@ -40,6 +41,15 @@ class Ui_ImportGroup(object): self.import_folder_check.setFont(font) self.import_folder_check.setObjectName("import_folder_check") self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.import_folder_check) + self.import_dlcs_label = QtWidgets.QLabel(ImportGroup) + self.import_dlcs_label.setObjectName("import_dlcs_label") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.import_dlcs_label) + self.import_dlcs_check = QtWidgets.QCheckBox(ImportGroup) + font = QtGui.QFont() + font.setItalic(True) + self.import_dlcs_check.setFont(font) + self.import_dlcs_check.setObjectName("import_dlcs_check") + self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.import_dlcs_check) self.button_info_layout = QtWidgets.QHBoxLayout() self.button_info_layout.setObjectName("button_info_layout") self.import_button = QtWidgets.QPushButton(ImportGroup) @@ -50,11 +60,7 @@ class Ui_ImportGroup(object): self.import_button.setSizePolicy(sizePolicy) self.import_button.setObjectName("import_button") self.button_info_layout.addWidget(self.import_button) - self.info_label = QtWidgets.QLabel(ImportGroup) - self.info_label.setText("") - self.info_label.setObjectName("info_label") - self.button_info_layout.addWidget(self.info_label) - self.formLayout.setLayout(3, QtWidgets.QFormLayout.FieldRole, self.button_info_layout) + self.formLayout.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.button_info_layout) self.retranslateUi(ImportGroup) QtCore.QMetaObject.connectSlotsByName(ImportGroup) @@ -66,6 +72,8 @@ class Ui_ImportGroup(object): self.app_name_label.setText(_translate("ImportGroup", "Override app name")) self.import_folder_label.setText(_translate("ImportGroup", "Import all folders")) self.import_folder_check.setText(_translate("ImportGroup", "Scan the installation path for game folders and import them")) + self.import_dlcs_label.setText(_translate("ImportGroup", "Import DLCs")) + self.import_dlcs_check.setText(_translate("ImportGroup", "If a game has DLCs, try to import them too")) self.import_button.setText(_translate("ImportGroup", "Import Game")) diff --git a/rare/ui/components/tabs/games/import_sync/import_group.ui b/rare/ui/components/tabs/games/import_sync/import_group.ui index f85fb386..60a77630 100644 --- a/rare/ui/components/tabs/games/import_sync/import_group.ui +++ b/rare/ui/components/tabs/games/import_sync/import_group.ui @@ -7,7 +7,7 @@ 0 0 501 - 136 + 162 @@ -20,6 +20,9 @@ Import EGL game from a directory + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + @@ -59,7 +62,26 @@ + + + + Import DLCs + + + + + + + true + + + + If a game has DLCs, try to import them too + + + + @@ -74,13 +96,6 @@ - - - - - - - diff --git a/rare/utils/extra_widgets.py b/rare/utils/extra_widgets.py index f75ad5ff..bb95e09a 100644 --- a/rare/utils/extra_widgets.py +++ b/rare/utils/extra_widgets.py @@ -36,7 +36,7 @@ from PyQt5.QtWidgets import ( from rare.utils.paths import tmp_dir from rare.utils.qt_requests import QtRequestManager -from rare.utils.utils import icon as qta_icon +from rare.utils.misc import icon as qta_icon logger = getLogger("ExtraWidgets") diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index b6971c69..77fccfe6 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -2,20 +2,19 @@ import os import platform from logging import getLogger -from PyQt5.QtCore import pyqtSignal, QCoreApplication, QObject, QRunnable, QStandardPaths +from PyQt5.QtCore import pyqtSignal, QObject, QRunnable, QStandardPaths from legendary.core import LegendaryCore -from legendary.models.game import VerifyResult -from legendary.utils.lfs import validate_files -from rare.shared import LegendaryCoreSingleton +from rare.lgndr.api_arguments import LgndrVerifyGameArgs, LgndrUninstallGameArgs +from rare.lgndr.api_monkeys import LgndrIndirectStatus +from rare.lgndr.cli import LegendaryCLI +from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton from rare.utils import config_helper logger = getLogger("Legendary Utils") -def uninstall(app_name: str, core: LegendaryCore, options=None): - if not options: - options = {"keep_files": False} +def uninstall_game(core: LegendaryCore, app_name: str, keep_files=False, keep_config=False): igame = core.get_installed_game(app_name) # remove shortcuts link @@ -40,31 +39,24 @@ def uninstall(app_name: str, core: LegendaryCore, options=None): if os.path.exists(start_menu_shortcut): os.remove(start_menu_shortcut) - try: - # Remove DLC first so directory is empty when game uninstall runs - dlcs = core.get_dlc_for_game(app_name) - for dlc in dlcs: - if (idlc := core.get_installed_game(dlc.app_name)) is not None: - logger.info(f'Uninstalling DLC "{dlc.app_name}"...') - core.uninstall_game(idlc, delete_files=not options["keep_files"]) - - logger.info(f'Removing "{igame.title}" from "{igame.install_path}"...') - core.uninstall_game( - igame, delete_files=not options["keep_files"], delete_root_directory=True + status = LgndrIndirectStatus() + LegendaryCLI(core).uninstall_game( + LgndrUninstallGameArgs( + app_name=app_name, + keep_files=keep_files, + indirect_status=status, + yes=True, ) - logger.info("Game has been uninstalled.") - - except Exception as e: - logger.warning( - f"Removing game failed: {e!r}, please remove {igame.install_path} manually." - ) - if not options["keep_files"]: + ) + if not keep_config: logger.info("Removing sections in config file") config_helper.remove_section(app_name) config_helper.remove_section(f"{app_name}.env") config_helper.save_config() + return status.success, status.message + def update_manifest(app_name: str, core: LegendaryCore): game = core.get_game(app_name) @@ -78,146 +70,65 @@ def update_manifest(app_name: str, core: LegendaryCore): new_manifest = core.load_manifest(new_manifest_data) logger.debug(f"Base urls: {base_urls}") # save manifest with version name as well for testing/downgrading/etc. - core.lgd.save_manifest( - game.app_name, new_manifest_data, version=new_manifest.meta.build_version - ) - - -class VerifySignals(QObject): - status = pyqtSignal(int, int, str) - summary = pyqtSignal(int, int, str) + core.lgd.save_manifest(game.app_name, new_manifest_data, version=new_manifest.meta.build_version) class VerifyWorker(QRunnable): + class Signals(QObject): + status = pyqtSignal(str, int, int, float, float) + result = pyqtSignal(str, bool, int, int) + error = pyqtSignal(str, str) + num: int = 0 total: int = 1 # set default to 1 to avoid DivisionByZero before it is initialized def __init__(self, app_name): super(VerifyWorker, self).__init__() - self.signals = VerifySignals() + self.signals = VerifyWorker.Signals() self.setAutoDelete(True) self.core = LegendaryCoreSingleton() + self.args = ArgumentsSingleton() self.app_name = app_name + def status_callback(self, num: int, total: int, percentage: float, speed: float): + self.signals.status.emit(self.app_name, num, total, percentage, speed) + def run(self): - if not self.core.is_installed(self.app_name): - logger.error(f'Game "{self.app_name}" is not installed') - return - - logger.info(f'Loading installed manifest for "{self.app_name}"') - igame = self.core.get_installed_game(self.app_name) - manifest_data, _ = self.core.get_installed_manifest(self.app_name) - manifest = self.core.load_manifest(manifest_data) - - files = sorted(manifest.file_manifest_list.elements, - key=lambda a: a.filename.lower()) - - # build list of hashes - file_list = [(f.filename, f.sha_hash.hex()) for f in files] - total = len(file_list) - num = 0 - failed = [] - missing = [] - - logger.info(f'Verifying "{igame.title}" version "{manifest.meta.build_version}"') - repair_file = [] - for result, path, result_hash, _ in validate_files(igame.install_path, file_list): - num += 1 - self.signals.status.emit(num, total, self.app_name) - - if result == VerifyResult.HASH_MATCH: - repair_file.append(f"{result_hash}:{path}") - continue - elif result == VerifyResult.HASH_MISMATCH: - logger.error(f'File does not match hash: "{path}"') - repair_file.append(f"{result_hash}:{path}") - failed.append(path) - elif result == VerifyResult.FILE_MISSING: - logger.error(f'File is missing: "{path}"') - missing.append(path) - else: - logger.error(f'Other failure (see log), treating file as missing: "{path}"') - missing.append(path) - - # always write repair file, even if all match - if repair_file: - repair_filename = os.path.join(self.core.lgd.get_tmp_path(), f'{self.app_name}.repair') - with open(repair_filename, 'w') as f: - f.write('\n'.join(repair_file)) - logger.debug(f'Written repair file to "{repair_filename}"') - - if not missing and not failed: - logger.info('Verification finished successfully.') - else: - logger.warning( - f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') - self.signals.summary.emit(len(failed), len(missing), self.app_name) - - -# FIXME: lk: ah ef me sideways, we can't even import this thing properly -# FIXME: lk: so copy it here -def resolve_aliases(core: LegendaryCore, name): - # make sure aliases exist if not yet created - core.update_aliases(force=False) - name = name.strip() - # resolve alias (if any) to real app name - return core.lgd.config.get( - section='Legendary.aliases', option=name, - fallback=core.lgd.aliases.get(name.lower(), name) - ) - - -def import_game(core: LegendaryCore, app_name: str, path: str) -> str: - _tr = QCoreApplication.translate - logger.info(f"Import {app_name}") - game = core.get_game(app_name, update_meta=False) - if not game: - return _tr("LgdUtils", "Could not get game for {}").format(app_name) - - if core.is_installed(app_name): - logger.error(f"{game.app_title} is already installed") - return _tr("LgdUtils", "{} is already installed").format(game.app_title) - - if not os.path.exists(path): - logger.error("Path does not exist") - return _tr("LgdUtils", "Path does not exist") - - manifest, igame = core.import_game(game, path) - exe_path = os.path.join(path, manifest.meta.launch_exe.lstrip("/")) - - if not os.path.exists(exe_path): - logger.error(f"Launch Executable of {game.app_title} does not exist") - return _tr("LgdUtils", "Launch executable of {} does not exist").format( - game.app_title + cli = LegendaryCLI(self.core) + status = LgndrIndirectStatus() + args = LgndrVerifyGameArgs( + app_name=self.app_name, indirect_status=status, verify_stdout=self.status_callback ) - if game.is_dlc: - release_info = game.metadata.get("mainGameItem", {}).get("releaseInfo") - if release_info: - main_game_appname = release_info[0]["appId"] - main_game_title = game.metadata["mainGameItem"]["title"] - if not core.is_installed(main_game_appname): - return _tr("LgdUtils", "Game is a DLC, but {} is not installed").format( - main_game_title + # lk: first pass, verify with the current manifest + repair_mode = False + result = cli.verify_game( + args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline + ) + if result is None: + # lk: second pass with downloading the latest manifest + # lk: this happens if the manifest was not found and repair_mode was not requested + # lk: we already have checked if the directory exists before starting the worker + try: + # lk: this try-except block handles the exception caused by a missing manifest + # lk: and is raised only in the case we are offline + repair_mode = True + result = cli.verify_game( + args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline ) - else: - return _tr("LgdUtils", "Unable to get base game information for DLC") + if result is None: + raise ValueError + except ValueError: + self.signals.error.emit(self.app_name, status.message) + return - total = len(manifest.file_manifest_list.elements) - found = sum( - os.path.exists(os.path.join(path, f.filename)) - for f in manifest.file_manifest_list.elements - ) - ratio = found / total + success = result is not None and not any(result) + if success: + # lk: if verification was successful we delete the repair file and run the clean procedure + # lk: this could probably be cut down to what is relevant for this use-case and skip the `cli` call + igame = self.core.get_installed_game(self.app_name) + game = self.core.get_game(self.app_name, platform=igame.platform) + repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.app_name}.repair") + cli.install_game_cleanup(game=game, igame=igame, repair_mode=True, repair_file=repair_file) - if ratio < 0.9: - logger.warning( - "Game files are missing. It may be not the latest version or it is corrupt" - ) - # return False - core.install_game(igame) - if igame.needs_verification: - logger.info(f"{igame.title} needs verification") - - logger.info(f"Successfully imported Game: {game.app_title}") - return "" + self.signals.result.emit(self.app_name, success, *result) diff --git a/rare/utils/utils.py b/rare/utils/misc.py similarity index 98% rename from rare/utils/utils.py rename to rare/utils/misc.py index 6b56d5b9..a6826571 100644 --- a/rare/utils/utils.py +++ b/rare/utils/misc.py @@ -4,7 +4,7 @@ import shlex import subprocess import sys from logging import getLogger -from typing import List +from typing import List, Union import qtawesome import requests @@ -157,7 +157,7 @@ def get_latest_version(): return "0.0.0" -def get_size(b: int) -> str: +def get_size(b: Union[int, float]) -> str: for i in ["", "K", "M", "G", "T", "P", "E"]: if b < 1024: return f"{b:.2f}{i}B" @@ -165,7 +165,10 @@ def get_size(b: int) -> str: def get_rare_executable() -> List[str]: - if platform.system() == "Linux" or platform.system() == "Darwin": + # lk: detech if nuitka + if "__compiled__" in globals(): + executable = [sys.executable] + elif platform.system() == "Linux" or platform.system() == "Darwin": # TODO flatpak if p := os.environ.get("APPIMAGE"): executable = [p] diff --git a/rare/utils/models.py b/rare/utils/models.py index 16f457d2..24c9d1a5 100644 --- a/rare/utils/models.py +++ b/rare/utils/models.py @@ -1,61 +1,7 @@ import os -import platform as pf -from dataclasses import field, dataclass -from multiprocessing import Queue -from typing import Union, List, Optional +from typing import Union, List from legendary.core import LegendaryCore -from legendary.downloader.mp.manager import DLManager -from legendary.models.downloading import AnalysisResult, ConditionCheckResult -from legendary.models.game import Game, InstalledGame - - -@dataclass -class InstallOptionsModel: - app_name: str - base_path: str = "" - max_shm: int = 1024 - max_workers: int = os.cpu_count() * 2 - repair: bool = False - no_install: bool = False - ignore_space_req: bool = False - force: bool = False - sdl_list: list = field(default_factory=lambda: [""]) - update: bool = False - silent: bool = False - platform: str = "" - dl_optimizations: bool = False - overlay: bool = False - create_shortcut: bool = True - install_preqs: bool = pf.system() == "Windows" - - def set_no_install(self, enabled: bool) -> None: - self.no_install = enabled - - -@dataclass -class InstallDownloadModel: - dlmanager: DLManager - analysis: AnalysisResult - game: Game - igame: InstalledGame - repair: bool - repair_file: str - res: ConditionCheckResult - - -@dataclass -class InstallQueueItemModel: - status_q: Optional[Queue] = None - download: Optional[InstallDownloadModel] = None - options: Optional[InstallOptionsModel] = None - - def __bool__(self): - return ( - (self.status_q is not None) - and (self.download is not None) - and (self.options is not None) - ) class PathSpec: @@ -80,9 +26,7 @@ class PathSpec: @property def wine_egl_programdata(self): - return self.egl_programdata.replace("\\", "/").replace( - "%PROGRAMDATA%", self.wine_programdata - ) + return self.egl_programdata.replace("\\", "/").replace("%PROGRAMDATA%", self.wine_programdata) def wine_egl_prefixes(self, results: int = 0) -> Union[List[str], str]: possible_prefixes = [ diff --git a/rare/widgets/rare_app.py b/rare/widgets/rare_app.py index 0e0904e8..a6964350 100644 --- a/rare/widgets/rare_app.py +++ b/rare/widgets/rare_app.py @@ -11,7 +11,7 @@ from legendary.core import LegendaryCore import rare.resources.resources from rare.utils.paths import resources_path -from rare.utils.utils import set_color_pallete, set_style_sheet +from rare.utils.misc import set_color_pallete, set_style_sheet class RareApp(QApplication): diff --git a/requirements.txt b/requirements.txt index 1a564d96..36151228 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ +typing_extensions requests PyQt5 QtAwesome psutil pypresence +setuptools +legendary-gl pywin32; platform_system == "Windows"