diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 58a98caa..45661376 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -123,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: @@ -137,7 +138,7 @@ 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.exit_status.connect(self.status) + self.thread.ret_status.connect(self.status) self.thread.ui_update.connect(self.progress_update) self.thread.start() self.kill_button.setDisabled(False) @@ -146,9 +147,9 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.signals.installation_started.emit(self.active_game.app_name) - @pyqtSlot(DownloadThread.ExitStatus) - def status(self, result: DownloadThread.ExitStatus): - if result.exit_code == DownloadThread.ExitCode.FINISHED: + @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 @@ -190,10 +191,10 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): else: self.queue_widget.update_queue(self.dl_queue) - elif result.exit_code == DownloadThread.ExitCode.ERROR: + elif result.ret_code == result.ReturnCode.ERROR: QMessageBox.warning(self, self.tr("Error"), f"Download error: {result.message}") - elif result.exit_code == DownloadThread.ExitCode.STOPPED: + 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) diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index 3bb279cc..576d5740 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -20,21 +20,22 @@ logger = getLogger("DownloadThread") class DownloadThread(QThread): - class ExitCode(IntEnum): - ERROR = 1 - STOPPED = 2 - FINISHED = 3 - @dataclass - class ExitStatus: + class ReturnStatus: + class ReturnCode(IntEnum): + ERROR = 1 + STOPPED = 2 + FINISHED = 3 + app_name: str - exit_code: int + ret_code: ReturnCode = ReturnCode.ERROR message: str = "" dlcs: Optional[List[Dict]] = None sync_saves: bool = False + tip_url: str = "" shortcuts: bool = False - exit_status = pyqtSignal(ExitStatus) + ret_status = pyqtSignal(ReturnStatus) ui_update = pyqtSignal(UIUpdate) def __init__(self, core: LegendaryCore, item: InstallQueueItemModel): @@ -48,7 +49,7 @@ class DownloadThread(QThread): cli = LegendaryCLI(self.core) self.item.download.dlm.logging_queue = cli.logging_queue self.item.download.dlm.proc_debug = ArgumentsSingleton().debug - exit_status = DownloadThread.ExitStatus(self.item.download.game.app_name, DownloadThread.ExitCode.ERROR) + ret = DownloadThread.ReturnStatus(self.item.download.game.app_name) start_t = time.time() try: self.item.download.dlm.start() @@ -69,25 +70,25 @@ class DownloadThread(QThread): end_t = time.time() 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}.") - exit_status.exit_code = DownloadThread.ExitCode.ERROR - exit_status.message = f"{e!r}" - self.exit_status.emit(exit_status) + 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.") - exit_status.exit_code = DownloadThread.ExitCode.STOPPED - self.exit_status.emit(exit_status) + ret.ret_code = ret.ReturnCode.STOPPED + self.ret_status.emit(ret) return logger.info(f"Download finished in {end_t - start_t:.02f} seconds.") - exit_status.exit_code = DownloadThread.ExitCode.FINISHED + ret.ret_code = ret.ReturnCode.FINISHED if self.item.options.overlay: self.signals.overlay_installation_finished.emit() self.core.finish_overlay_install(self.item.download.igame) - self.exit_status.emit(exit_status) + self.ret_status.emit(ret) return if not self.item.options.no_install: @@ -104,12 +105,23 @@ class DownloadThread(QThread): dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name) if dlcs and not self.item.options.skip_dlcs: for dlc in dlcs: - exit_status.dlcs.append( - {"app_name": dlc.app_name, "app_title": dlc.app_title, "app_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), + } ) - if self.item.download.game.supports_cloud_saves and not self.item.download.game.is_dlc: - exit_status.sync_saves = True + 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 + + # 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 LegendaryCLI(self.core).install_game_cleanup( self.item.download.game, @@ -119,19 +131,19 @@ class DownloadThread(QThread): ) if not self.item.options.update and self.item.options.create_shortcut: - exit_status.shortcuts = True + ret.shortcuts = True - self.exit_status.emit(exit_status) + self.ret_status.emit(ret) def _handle_postinstall(self, postinstall, igame): 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 not self.item.options.install_preqs: - logger.info('Marking prerequisites as installed...') + logger.info("Marking prerequisites as installed...") self.core.prereq_installed(self.item.download.igame.app_name) else: - logger.info('Launching prerequisite executable..') + 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) diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index be4b1f18..abbe9bba 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -1,7 +1,15 @@ from dataclasses import dataclass -from typing import Callable, List, Optional +from enum import IntEnum +from typing import Callable, List, Optional, Dict -from .api_monkeys import LgndrIndirectStatus, GetBooleanChoiceProtocol, get_boolean_choice, verify_stdout +from .api_monkeys import ( + LgndrIndirectStatus, + GetBooleanChoiceProtocol, + get_boolean_choice, + verify_stdout, + DLManagerSignals +) +from .downloading import UIUpdate """ @dataclass(kw_only=True) @@ -104,7 +112,25 @@ class LgndrInstallGameRealArgs: skip_dlcs: bool = False with_dlcs: bool = False dlm_debug: bool = False - yes: bool = True + yes: bool = False # Rare: Extra arguments + install_preqs: bool = False indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice + 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/cli.py b/rare/lgndr/cli.py index 2b0e79ed..d0331e1e 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,5 +1,6 @@ import logging import os +import queue import subprocess import time from typing import Optional, Union, Tuple @@ -16,6 +17,7 @@ from .api_arguments import ( LgndrVerifyGameArgs, LgndrUninstallGameArgs, LgndrInstallGameRealArgs, + LgndrInstallGameRealRet, ) from .api_monkeys import LgndrIndirectStatus, LgndrIndirectLogger from .core import LegendaryCore @@ -198,10 +200,10 @@ class LegendaryCLI(LegendaryCLIReal): 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) -> None: + 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) - get_boolean_choice = args.get_boolean_choice + ret = LgndrInstallGameRealRet(game.app_name) start_t = time.time() @@ -211,6 +213,17 @@ class LegendaryCLI(LegendaryCLIReal): 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() @@ -218,8 +231,16 @@ class LegendaryCLI(LegendaryCLIReal): 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: @@ -227,30 +248,20 @@ class LegendaryCLI(LegendaryCLIReal): postinstall = self.core.install_game(igame) if postinstall: - self._handle_postinstall(postinstall, igame, yes=args.yes) + 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: - print('\nThe following DLCs are available for this game:') for dlc in dlcs: - print(f' - {dlc.app_title} (App name: {dlc.app_name}, version: ' - f'{dlc.app_version(args.platform)})') - print('\nYou can manually install these later by running this command with the DLC\'s app name.') + ret.dlcs.append( + { + "app_name": dlc.app_name, + "app_title": dlc.app_title, + "app_version": dlc.app_version(args.platform) + } + ) - install_dlcs = not args.skip_dlcs - if not args.yes and not args.with_dlcs and not args.skip_dlcs: - if not get_boolean_choice(f'Do you wish to automatically install DLCs?'): - install_dlcs = False - - if install_dlcs: - _yes, _app_name = args.yes, args.app_name - args.yes = True - for dlc in dlcs: - args.app_name = dlc.app_name - self.install_game(args) - args.yes, args.app_name = _yes, _app_name - else: - print('') + # 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 @@ -258,15 +269,18 @@ class LegendaryCLI(LegendaryCLIReal): # 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): - print(f'\nThis game may require additional setup, see: {tip_url}\n') + 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) diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index 80f199a4..b9eb32d2 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -128,8 +128,8 @@ class DLManager(DLManagerReal): hours = minutes = seconds = 0 rt_hours = rt_minutes = rt_seconds = 0 - log_level = self.log.level # 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}, '