From f4743d99b6cb1f114a50f1f3f5fc7654fc16349b Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Mon, 18 Apr 2022 16:55:23 +0300 Subject: [PATCH 01/61] Remove submodules --- .gitmodules | 4 ---- rare/__main__.py | 6 +++--- rare/legendary | 1 - 3 files changed, 3 insertions(+), 8 deletions(-) delete mode 160000 rare/legendary 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/rare/__main__.py b/rare/__main__.py index 79a1ef69..add40aa5 100644 --- a/rare/__main__.py +++ b/rare/__main__.py @@ -123,9 +123,9 @@ 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())) 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 From e58d33ee5db3e2910ec13666c937902e09d2696e Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Mon, 18 Apr 2022 17:51:51 +0300 Subject: [PATCH 02/61] Create shim legendary classes for overloaded functions --- rare/__main__.py | 4 +- rare/components/dialogs/install_dialog.py | 2 +- .../tabs/downloads/download_thread.py | 3 +- rare/lgndr/__init__.py | 0 rare/lgndr/core.py | 365 ++++++++++++++++++ rare/lgndr/downloading.py | 26 ++ rare/lgndr/manager.py | 175 +++++++++ rare/shared/__init__.py | 2 +- 8 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 rare/lgndr/__init__.py create mode 100644 rare/lgndr/core.py create mode 100644 rare/lgndr/downloading.py create mode 100644 rare/lgndr/manager.py diff --git a/rare/__main__.py b/rare/__main__.py index add40aa5..e2f6f975 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() diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 20b00e3a..6aeafb37 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -7,7 +7,7 @@ from typing import Tuple 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 rare.lgndr.core import LegendaryCore from legendary.models.downloading import ConditionCheckResult from legendary.models.game import Game from legendary.utils.selective_dl import games diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index 914e3f1a..09b763d0 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -9,11 +9,12 @@ from queue import Empty import psutil from PyQt5.QtCore import QThread, pyqtSignal, QProcess from legendary.core import LegendaryCore -from legendary.models.downloading import UIUpdate, WriterTask +from legendary.models.downloading import WriterTask from rare.shared import GlobalSignalsSingleton from rare.utils.models import InstallQueueItemModel from rare.utils.utils import create_desktop_link +from rare.lgndr.downloading import UIUpdate logger = getLogger("DownloadThread") diff --git a/rare/lgndr/__init__.py b/rare/lgndr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py new file mode 100644 index 00000000..7887199e --- /dev/null +++ b/rare/lgndr/core.py @@ -0,0 +1,365 @@ +import os + +from multiprocessing import Queue +from typing import Callable + +from legendary.utils.lfs import clean_filename, validate_files +from legendary.models.downloading import AnalysisResult +from legendary.models.game import * +from legendary.utils.game_workarounds import is_opt_enabled +from legendary.utils.selective_dl import get_sdl_appname +from legendary.utils.manifests import combine_manifests + +from legendary.core import LegendaryCore as LegendaryCoreReal +from .manager import DLManager + + +class LegendaryCore(LegendaryCoreReal): + + def prepare_download(self, app_name: str, base_path: str = '', no_install: bool = False, + 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, ignore_space_req: bool = False, reset_sdl: bool = False, + sdl_prompt: Callable[[str, str], List[str]] = list) \ + -> (DLManager, AnalysisResult, Game, InstalledGame): + if self.is_installed(app_name): + igame = self.get_installed_game(app_name) + platform = igame.platform + if igame.needs_verification and not repair: + self.log.info('Game needs to be verified before updating, switching to repair mode...') + repair = True + + repair_file = '' + if repair: + repair = True + no_install = repair_use_latest is False + repair_file = os.path.join(self.lgd.get_tmp_path(), f'{app_name}.repair') + + if not self.login(): + raise RuntimeError('Login failed! Cannot continue with download process.') + + if file_prefix_filter or file_exclude_filter or file_install_tag: + no_install = True + + game = self.get_game(app_name, update_meta=True) + + if not game: + raise RuntimeError(f'Could not find "{app_name}" in list of available games,' + f'did you type the name correctly?') + + if game.is_dlc: + self.log.info('Install candidate is DLC') + app_name = game.metadata['mainGameItem']['releaseInfo'][0]['appId'] + base_game = self.get_game(app_name) + # check if base_game is actually installed + if not self.is_installed(app_name): + # download mode doesn't care about whether or not something's installed + if not no_install: + raise RuntimeError(f'Base game "{app_name}" is not installed!') + else: + base_game = None + + if repair: + if not self.is_installed(game.app_name): + raise RuntimeError(f'Game "{game.app_title}" ({game.app_name}) is not installed!') + + if not os.path.exists(repair_file): + self.log.info('Verifing game...') + self.verify_game(app_name) + else: + self.log.info(f'Using existing repair file: {repair_file}') + + # Workaround for Cyberpunk 2077 preload + if not file_install_tag and not game.is_dlc and ((sdl_name := get_sdl_appname(game.app_name)) is not None): + config_tags = self.lgd.config.get(game.app_name, 'install_tags', fallback=None) + if not self.is_installed(game.app_name) or config_tags is None or reset_sdl: + file_install_tag = sdl_prompt(sdl_name, game.app_title) + if game.app_name not in self.lgd.config: + self.lgd.config[game.app_name] = dict() + self.lgd.config.set(game.app_name, 'install_tags', ','.join(file_install_tag)) + else: + file_install_tag = config_tags.split(',') + + # load old manifest + old_manifest = None + + # load old manifest if we have one + if override_old_manifest: + self.log.info(f'Overriding old manifest with "{override_old_manifest}"') + old_bytes, _ = self.get_uri_manifest(override_old_manifest) + old_manifest = self.load_manifest(old_bytes) + elif not disable_patching and not force and self.is_installed(game.app_name): + old_bytes, _base_urls = self.get_installed_manifest(game.app_name) + if _base_urls and not game.base_urls: + game.base_urls = _base_urls + + if not old_bytes: + self.log.error(f'Could not load old manifest, patching will not work!') + else: + old_manifest = self.load_manifest(old_bytes) + + base_urls = game.base_urls + + # The EGS client uses plaintext HTTP by default for the purposes of enabling simple DNS based + # CDN redirection to a (local) cache. In Legendary this will be a config option. + disable_https = disable_https or self.lgd.config.getboolean('Legendary', 'disable_https', fallback=False) + + if override_manifest: + self.log.info(f'Overriding manifest with "{override_manifest}"') + new_manifest_data, _base_urls = self.get_uri_manifest(override_manifest) + # if override manifest has a base URL use that instead + if _base_urls: + base_urls = _base_urls + else: + new_manifest_data, base_urls = self.get_cdn_manifest(game, platform, disable_https=disable_https) + # overwrite base urls in metadata with current ones to avoid using old/dead CDNs + game.base_urls = base_urls + # save base urls to game metadata + self.lgd.set_game_meta(game.app_name, game) + + self.log.info('Parsing game manifest...') + new_manifest = self.load_manifest(new_manifest_data) + self.log.debug(f'Base urls: {base_urls}') + # save manifest with version name as well for testing/downgrading/etc. + self.lgd.save_manifest(game.app_name, new_manifest_data, + version=new_manifest.meta.build_version, + platform=platform) + + # check if we should use a delta manifest or not + disable_delta = disable_delta or ((override_old_manifest or override_manifest) and not override_delta_manifest) + if old_manifest and new_manifest: + disable_delta = disable_delta or (old_manifest.meta.build_id == new_manifest.meta.build_id) + if old_manifest and new_manifest and not disable_delta: + if override_delta_manifest: + self.log.info(f'Overriding delta manifest with "{override_delta_manifest}"') + delta_manifest_data, _ = self.get_uri_manifest(override_delta_manifest) + else: + delta_manifest_data = self.get_delta_manifest(base_urls[0], + old_manifest.meta.build_id, + new_manifest.meta.build_id) + if delta_manifest_data: + delta_manifest = self.load_manifest(delta_manifest_data) + self.log.info(f'Using optimized delta manifest to upgrade from build ' + f'"{old_manifest.meta.build_id}" to ' + f'"{new_manifest.meta.build_id}"...') + combine_manifests(new_manifest, delta_manifest) + else: + self.log.debug(f'No Delta manifest received from CDN.') + + # reuse existing installation's directory + if igame := self.get_installed_game(base_game.app_name if base_game else game.app_name): + install_path = igame.install_path + # make sure to re-use the epic guid we assigned on first install + if not game.is_dlc and igame.egl_guid: + egl_guid = igame.egl_guid + else: + if not game_folder: + if game.is_dlc: + game_folder = base_game.metadata.get('customAttributes', {}). \ + get('FolderName', {}).get('value', base_game.app_name) + else: + game_folder = game.metadata.get('customAttributes', {}). \ + get('FolderName', {}).get('value', game.app_name) + + if not base_path: + base_path = self.get_default_install_dir(platform=platform) + + if platform == 'Mac': + # if we're on mac and the path to the binary does not start with .app, + # treat it as if it were a Windows game instead and install it to the default folder. + if '.app' not in new_manifest.meta.launch_exe.partition('/')[0].lower(): + base_path = self.get_default_install_dir(platform='Windows') + else: + # If it is a .app omit the game folder + game_folder = '' + + # make sure base directory actually exists (but do not create game dir) + if not os.path.exists(base_path): + self.log.info(f'"{base_path}" does not exist, creating...') + os.makedirs(base_path) + + install_path = os.path.normpath(os.path.join(base_path, game_folder)) + + # check for write access on the install path or its parent directory if it doesn't exist yet + base_path = os.path.dirname(install_path) + if os.path.exists(install_path) and not os.access(install_path, os.W_OK): + raise PermissionError(f'No write access to "{install_path}"') + elif not os.access(base_path, os.W_OK): + raise PermissionError(f'No write access to "{base_path}"') + + self.log.info(f'Install path: {install_path}') + + if repair: + if not repair_use_latest and old_manifest: + # use installed manifest for repairs instead of updating + new_manifest = old_manifest + old_manifest = None + + filename = clean_filename(f'{game.app_name}.repair') + resume_file = os.path.join(self.lgd.get_tmp_path(), filename) + force = False + elif not force: + filename = clean_filename(f'{game.app_name}.resume') + resume_file = os.path.join(self.lgd.get_tmp_path(), filename) + else: + resume_file = None + + # Use user-specified base URL or preferred CDN first, otherwise fall back to + # EGS's behaviour of just selecting the first CDN in the list. + base_url = None + if override_base_url: + self.log.info(f'Overriding base URL with "{override_base_url}"') + base_url = override_base_url + elif preferred_cdn or (preferred_cdn := self.lgd.config.get('Legendary', 'preferred_cdn', fallback=None)): + for url in base_urls: + if preferred_cdn in url: + base_url = url + break + else: + self.log.warning(f'Preferred CDN "{preferred_cdn}" unavailable, using default selection.') + # Use first, fail if none known + if not base_url: + if not base_urls: + raise ValueError('No base URLs found, please try again.') + base_url = base_urls[0] + + if disable_https: + base_url = base_url.replace('https://', 'http://') + + self.log.debug(f'Using base URL: {base_url}') + scheme, cdn_host = base_url.split('/')[0:3:2] + self.log.info(f'Selected CDN: {cdn_host} ({scheme.strip(":")})') + + if not max_shm: + max_shm = self.lgd.config.getint('Legendary', 'max_memory', fallback=2048) + + if dl_optimizations or is_opt_enabled(game.app_name, new_manifest.meta.build_version): + self.log.info('Download order optimizations are enabled.') + process_opt = True + else: + process_opt = False + + if not max_workers: + max_workers = self.lgd.config.getint('Legendary', 'max_workers', fallback=0) + + dlm = DLManager(install_path, base_url, resume_file=resume_file, status_q=status_q, + max_shared_memory=max_shm * 1024 * 1024, max_workers=max_workers, + dl_timeout=dl_timeout) + anlres = dlm.run_analysis(manifest=new_manifest, old_manifest=old_manifest, + patch=not disable_patching, resume=not force, + file_prefix_filter=file_prefix_filter, + file_exclude_filter=file_exclude_filter, + file_install_tag=file_install_tag, + processing_optimization=process_opt) + + prereq = None + if new_manifest.meta.prereq_ids: + prereq = dict(ids=new_manifest.meta.prereq_ids, name=new_manifest.meta.prereq_name, + path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args) + + offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true') + ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false') + + if file_install_tag is None: + file_install_tag = [] + igame = InstalledGame(app_name=game.app_name, title=game.app_title, + version=new_manifest.meta.build_version, prereq_info=prereq, + manifest_path=override_manifest, base_urls=base_urls, + install_path=install_path, executable=new_manifest.meta.launch_exe, + launch_parameters=new_manifest.meta.launch_command, + can_run_offline=offline == 'true', requires_ot=ot == 'true', + is_dlc=base_game is not None, install_size=anlres.install_size, + egl_guid=egl_guid, install_tags=file_install_tag, + platform=platform) + + # game is either up to date or hasn't changed, so we have nothing to do + if not anlres.dl_size: + self.log.info('Download size is 0, the game is either already up to date or has not changed. Exiting...') + self.clean_post_install(game, igame, repair, repair_file) + + raise RuntimeError('Nothing to do.') + + res = self.check_installation_conditions(analysis=anlres, install=igame, game=game, + updating=self.is_installed(app_name), + ignore_space_req=ignore_space_req) + + return dlm, anlres, game, igame, repair, repair_file, res + + def verify_game(self, app_name: str, callback: Callable[[int, int], None] = print): + if not self.is_installed(app_name): + self.log.error(f'Game "{app_name}" is not installed') + return + + self.log.info(f'Loading installed manifest for "{app_name}"') + igame = self.get_installed_game(app_name) + manifest_data, _ = self.get_installed_manifest(app_name) + manifest = self.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 = [] + + self.log.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): + if callback: + num += 1 + callback(num, total) + + if result == VerifyResult.HASH_MATCH: + repair_file.append(f'{result_hash}:{path}') + continue + elif result == VerifyResult.HASH_MISMATCH: + self.log.error(f'File does not match hash: "{path}"') + repair_file.append(f'{result_hash}:{path}') + failed.append(path) + elif result == VerifyResult.FILE_MISSING: + self.log.error(f'File is missing: "{path}"') + missing.append(path) + else: + self.log.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.lgd.get_tmp_path(), f'{app_name}.repair') + with open(repair_filename, 'w') as f: + f.write('\n'.join(repair_file)) + self.log.debug(f'Written repair file to "{repair_filename}"') + + if not missing and not failed: + self.log.info('Verification finished successfully.') + else: + raise RuntimeError( + f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') + + def clean_post_install(self, game: Game, igame: InstalledGame, repair: bool = False, repair_file: str = ''): + old_igame = self.get_installed_game(game.app_name) + if old_igame and repair and os.path.exists(repair_file): + if old_igame.needs_verification: + old_igame.needs_verification = False + self.install_game(old_igame) + + self.log.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 + self.log.info('Deleting now untagged files.') + self.uninstall_tag(old_igame) + self.install_game(old_igame) diff --git a/rare/lgndr/downloading.py b/rare/lgndr/downloading.py new file mode 100644 index 00000000..c6c472d4 --- /dev/null +++ b/rare/lgndr/downloading.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +@dataclass +class UIUpdate: + """ + Status update object sent from the manager to the CLI/GUI to update status indicators + """ + + def __init__(self, progress, runtime, estimated_time_left, processed_chunks, chunk_tasks, + total_downloaded, total_written, cache_usage, active_tasks, download_speed, + download_decompressed_speed, write_speed, read_speed, memory_usage, current_filename=''): + self.progress = progress + self.runtime = runtime + self.estimated_time_left = estimated_time_left + self.processed_chunks = processed_chunks + self.chunk_tasks = chunk_tasks + self.total_downloaded = total_downloaded + self.total_written = total_written + self.cache_usage = cache_usage + self.active_tasks = active_tasks + self.download_speed = download_speed + self.download_decompressed_speed = download_decompressed_speed + self.write_speed = write_speed + self.read_speed = read_speed + self.memory_usage = memory_usage + self.current_filename = current_filename diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py new file mode 100644 index 00000000..25dca0e7 --- /dev/null +++ b/rare/lgndr/manager.py @@ -0,0 +1,175 @@ +import os +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.workers import DLWorker, FileWorker +from legendary.models.downloading import ChunkTask, SharedMemorySegment, TerminateWorkerTask + +from legendary.downloader.mp.manager import DLManager as DLManagerReal +from .downloading import UIUpdate + + +class DLManager(DLManagerReal): + + 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() + + 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) + + try: + average_speed = processed_chunks / runtime + estimate = (num_chunk_tasks - processed_chunks) / average_speed + except ZeroDivisionError: + average_speed = estimate = 0 + + # send status update to back to instantiator (if queue exists) + if self.status_queue: + try: + self.status_queue.put(UIUpdate( + progress=perc, + 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_speed=dl_speed, + download_decompressed_speed=dl_unc_speed, + write_speed=w_speed, + memory_usage=total_used * 1024 * 1024, + read_speed=r_speed, + ), timeout=1.0) + except Exception as e: + self.log.warning(f'Failed to send status update to queue: {e!r}') + + 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: + 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) 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 From 04a76ed2ff91c03fb5c298f48b4d4c4574240cda Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Wed, 20 Apr 2022 16:49:16 +0300 Subject: [PATCH 03/61] Include PoC implementation of `LegendaryCLI` for the `import_game` method --- .../tabs/games/import_sync/import_group.py | 17 ++++++++ rare/lgndr/cli.py | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 rare/lgndr/cli.py diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 3726aa62..7b525134 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -1,5 +1,6 @@ import json import os +from argparse import Namespace from dataclasses import dataclass from enum import IntEnum from logging import getLogger @@ -10,6 +11,7 @@ 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.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup from rare.utils import legendary_utils @@ -295,3 +297,18 @@ class ImportGroup(QGroupBox): def import_progress(self, progress: int): pass + + def __import_game(self, app_name, path): + args = Namespace( + app_path=path, + app_name=app_name, + platform='Windows', + disable_check=False, + skip_dlcs=False, + with_dlcs=False, + yes=False, + log_dest=self.ui.info_label, + ) + cli = LegendaryCLI() + cli.core = LegendaryCoreSingleton() + cli.import_game(args) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py new file mode 100644 index 00000000..42a0f323 --- /dev/null +++ b/rare/lgndr/cli.py @@ -0,0 +1,40 @@ +import logging + +import legendary.cli +from PyQt5.QtWidgets import QLabel, QMessageBox +from legendary.cli import LegendaryCLI as LegendaryCLIReal + +from .core import LegendaryCore + +logger = logging.getLogger('cli') + + +def get_boolean_choice(message): + choice = QMessageBox.question(None, "Import DLCs?", message) + return True if choice == QMessageBox.StandardButton.Yes else False + + +class UILogHandler(logging.Handler): + def __init__(self, dest: QLabel): + super(UILogHandler, self).__init__() + self.widget = dest + + def emit(self, record: logging.LogRecord) -> None: + self.widget.setText(record.getMessage()) + + +class LegendaryCLI(LegendaryCLIReal): + + def __init__(self): + self.core = None + self.logger = logging.getLogger('cli') + self.logging_queue = None + + def import_game(self, args): + handler = UILogHandler(args.log_dest) + logger.addHandler(handler) + old_choice = legendary.cli.get_boolean_choice + legendary.cli.get_boolean_choice = get_boolean_choice + super(LegendaryCLI, self).import_game(args) + legendary.cli.get_boolean_choice = old_choice + logger.removeHandler(handler) From a9954a98f6ae217ca380b5d8abd554ce4ac6cf3b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 25 Jun 2022 19:44:35 +0300 Subject: [PATCH 04/61] LegendaryCore: Re-use super's `prepare_download()` instead of copying it. --- rare/lgndr/core.py | 280 ++++++++++++--------------------------------- requirements.txt | 2 + 2 files changed, 73 insertions(+), 209 deletions(-) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 7887199e..e512bc8a 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -3,12 +3,10 @@ import os from multiprocessing import Queue from typing import Callable -from legendary.utils.lfs import clean_filename, validate_files +from legendary.utils.lfs import validate_files from legendary.models.downloading import AnalysisResult from legendary.models.game import * -from legendary.utils.game_workarounds import is_opt_enabled from legendary.utils.selective_dl import get_sdl_appname -from legendary.utils.manifests import combine_manifests from legendary.core import LegendaryCore as LegendaryCoreReal from .manager import DLManager @@ -16,7 +14,7 @@ from .manager import DLManager class LegendaryCore(LegendaryCoreReal): - def prepare_download(self, app_name: str, base_path: str = '', no_install: bool = False, + def prepare_download(self, app_name: str, 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 = '', @@ -27,9 +25,10 @@ class LegendaryCore(LegendaryCoreReal): 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, ignore_space_req: bool = False, reset_sdl: bool = False, - sdl_prompt: Callable[[str, str], List[str]] = list) \ - -> (DLManager, AnalysisResult, Game, InstalledGame): + no_install: bool = False, ignore_space_req: bool = False, + disable_sdl: bool = False, reset_sdl: bool = False, skip_sdl: bool = False, + sdl_prompt: Callable[[str, str], List[str]] = list, + disable_https: bool = False) -> (DLManager, AnalysisResult, InstalledGame, Game): if self.is_installed(app_name): igame = self.get_installed_game(app_name) platform = igame.platform @@ -37,7 +36,7 @@ class LegendaryCore(LegendaryCoreReal): self.log.info('Game needs to be verified before updating, switching to repair mode...') repair = True - repair_file = '' + repair_file = None if repair: repair = True no_install = repair_use_latest is False @@ -77,221 +76,84 @@ class LegendaryCore(LegendaryCoreReal): else: self.log.info(f'Using existing repair file: {repair_file}') - # Workaround for Cyberpunk 2077 preload - if not file_install_tag and not game.is_dlc and ((sdl_name := get_sdl_appname(game.app_name)) is not None): - config_tags = self.lgd.config.get(game.app_name, 'install_tags', fallback=None) + # check if SDL should be disabled + sdl_enabled = not file_install_tag and not game.is_dlc + config_tags = self.lgd.config.get(game.app_name, 'install_tags', fallback=None) + config_disable_sdl = self.lgd.config.getboolean(game.app_name, 'disable_sdl', fallback=False) + # remove config flag if SDL is reset + if config_disable_sdl and reset_sdl and not disable_sdl: + self.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 disable_sdl: + self.log.info('Clearing install tags from config and disabling SDL for title.') + if config_tags: + self.lgd.config.remove_option(game.app_name, 'install_tags') + config_tags = None + self.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 disable_sdl: + sdl_enabled = False + + if sdl_enabled and ((sdl_name := get_sdl_appname(game.app_name)) is not None): if not self.is_installed(game.app_name) or config_tags is None or reset_sdl: - file_install_tag = sdl_prompt(sdl_name, game.app_title) - if game.app_name not in self.lgd.config: - self.lgd.config[game.app_name] = dict() - self.lgd.config.set(game.app_name, 'install_tags', ','.join(file_install_tag)) + sdl_data = self.get_sdl_data(sdl_name, platform=platform) + if sdl_data: + if skip_sdl: + file_install_tag = [''] + if '__required' in sdl_data: + file_install_tag.extend(sdl_data['__required']['tags']) + else: + file_install_tag = sdl_prompt(sdl_data, game.app_title) + self.lgd.config.set(game.app_name, 'install_tags', ','.join(file_install_tag)) + else: + self.log.error(f'Unable to get SDL data for {sdl_name}') else: file_install_tag = config_tags.split(',') + elif file_install_tag and not game.is_dlc and not no_install: + config_tags = ','.join(file_install_tag) + self.log.info(f'Saving install tags for "{game.app_name}" to config: {config_tags}') + self.lgd.config.set(game.app_name, 'install_tags', config_tags) + elif not game.is_dlc: + if config_tags and reset_sdl: + self.log.info('Clearing install tags from config.') + self.lgd.config.remove_option(game.app_name, 'install_tags') + elif config_tags: + self.log.info(f'Using install tags from config: {config_tags}') + file_install_tag = config_tags.split(',') - # load old manifest - old_manifest = None - - # load old manifest if we have one - if override_old_manifest: - self.log.info(f'Overriding old manifest with "{override_old_manifest}"') - old_bytes, _ = self.get_uri_manifest(override_old_manifest) - old_manifest = self.load_manifest(old_bytes) - elif not disable_patching and not force and self.is_installed(game.app_name): - old_bytes, _base_urls = self.get_installed_manifest(game.app_name) - if _base_urls and not game.base_urls: - game.base_urls = _base_urls - - if not old_bytes: - self.log.error(f'Could not load old manifest, patching will not work!') - else: - old_manifest = self.load_manifest(old_bytes) - - base_urls = game.base_urls - - # The EGS client uses plaintext HTTP by default for the purposes of enabling simple DNS based - # CDN redirection to a (local) cache. In Legendary this will be a config option. - disable_https = disable_https or self.lgd.config.getboolean('Legendary', 'disable_https', fallback=False) - - if override_manifest: - self.log.info(f'Overriding manifest with "{override_manifest}"') - new_manifest_data, _base_urls = self.get_uri_manifest(override_manifest) - # if override manifest has a base URL use that instead - if _base_urls: - base_urls = _base_urls - else: - new_manifest_data, base_urls = self.get_cdn_manifest(game, platform, disable_https=disable_https) - # overwrite base urls in metadata with current ones to avoid using old/dead CDNs - game.base_urls = base_urls - # save base urls to game metadata - self.lgd.set_game_meta(game.app_name, game) - - self.log.info('Parsing game manifest...') - new_manifest = self.load_manifest(new_manifest_data) - self.log.debug(f'Base urls: {base_urls}') - # save manifest with version name as well for testing/downgrading/etc. - self.lgd.save_manifest(game.app_name, new_manifest_data, - version=new_manifest.meta.build_version, - platform=platform) - - # check if we should use a delta manifest or not - disable_delta = disable_delta or ((override_old_manifest or override_manifest) and not override_delta_manifest) - if old_manifest and new_manifest: - disable_delta = disable_delta or (old_manifest.meta.build_id == new_manifest.meta.build_id) - if old_manifest and new_manifest and not disable_delta: - if override_delta_manifest: - self.log.info(f'Overriding delta manifest with "{override_delta_manifest}"') - delta_manifest_data, _ = self.get_uri_manifest(override_delta_manifest) - else: - delta_manifest_data = self.get_delta_manifest(base_urls[0], - old_manifest.meta.build_id, - new_manifest.meta.build_id) - if delta_manifest_data: - delta_manifest = self.load_manifest(delta_manifest_data) - self.log.info(f'Using optimized delta manifest to upgrade from build ' - f'"{old_manifest.meta.build_id}" to ' - f'"{new_manifest.meta.build_id}"...') - combine_manifests(new_manifest, delta_manifest) - else: - self.log.debug(f'No Delta manifest received from CDN.') - - # reuse existing installation's directory - if igame := self.get_installed_game(base_game.app_name if base_game else game.app_name): - install_path = igame.install_path - # make sure to re-use the epic guid we assigned on first install - if not game.is_dlc and igame.egl_guid: - egl_guid = igame.egl_guid - else: - if not game_folder: - if game.is_dlc: - game_folder = base_game.metadata.get('customAttributes', {}). \ - get('FolderName', {}).get('value', base_game.app_name) - else: - game_folder = game.metadata.get('customAttributes', {}). \ - get('FolderName', {}).get('value', game.app_name) - - if not base_path: - base_path = self.get_default_install_dir(platform=platform) - - if platform == 'Mac': - # if we're on mac and the path to the binary does not start with .app, - # treat it as if it were a Windows game instead and install it to the default folder. - if '.app' not in new_manifest.meta.launch_exe.partition('/')[0].lower(): - base_path = self.get_default_install_dir(platform='Windows') - else: - # If it is a .app omit the game folder - game_folder = '' - - # make sure base directory actually exists (but do not create game dir) - if not os.path.exists(base_path): - self.log.info(f'"{base_path}" does not exist, creating...') - os.makedirs(base_path) - - install_path = os.path.normpath(os.path.join(base_path, game_folder)) - - # check for write access on the install path or its parent directory if it doesn't exist yet - base_path = os.path.dirname(install_path) - if os.path.exists(install_path) and not os.access(install_path, os.W_OK): - raise PermissionError(f'No write access to "{install_path}"') - elif not os.access(base_path, os.W_OK): - raise PermissionError(f'No write access to "{base_path}"') - - self.log.info(f'Install path: {install_path}') - - if repair: - if not repair_use_latest and old_manifest: - # use installed manifest for repairs instead of updating - new_manifest = old_manifest - old_manifest = None - - filename = clean_filename(f'{game.app_name}.repair') - resume_file = os.path.join(self.lgd.get_tmp_path(), filename) - force = False - elif not force: - filename = clean_filename(f'{game.app_name}.resume') - resume_file = os.path.join(self.lgd.get_tmp_path(), filename) - else: - resume_file = None - - # Use user-specified base URL or preferred CDN first, otherwise fall back to - # EGS's behaviour of just selecting the first CDN in the list. - base_url = None - if override_base_url: - self.log.info(f'Overriding base URL with "{override_base_url}"') - base_url = override_base_url - elif preferred_cdn or (preferred_cdn := self.lgd.config.get('Legendary', 'preferred_cdn', fallback=None)): - for url in base_urls: - if preferred_cdn in url: - base_url = url - break - else: - self.log.warning(f'Preferred CDN "{preferred_cdn}" unavailable, using default selection.') - # Use first, fail if none known - if not base_url: - if not base_urls: - raise ValueError('No base URLs found, please try again.') - base_url = base_urls[0] - - if disable_https: - base_url = base_url.replace('https://', 'http://') - - self.log.debug(f'Using base URL: {base_url}') - scheme, cdn_host = base_url.split('/')[0:3:2] - self.log.info(f'Selected CDN: {cdn_host} ({scheme.strip(":")})') - - if not max_shm: - max_shm = self.lgd.config.getint('Legendary', 'max_memory', fallback=2048) - - if dl_optimizations or is_opt_enabled(game.app_name, new_manifest.meta.build_version): - self.log.info('Download order optimizations are enabled.') - process_opt = True - else: - process_opt = False - - if not max_workers: - max_workers = self.lgd.config.getint('Legendary', 'max_workers', fallback=0) - - dlm = DLManager(install_path, base_url, resume_file=resume_file, status_q=status_q, - max_shared_memory=max_shm * 1024 * 1024, max_workers=max_workers, - dl_timeout=dl_timeout) - anlres = dlm.run_analysis(manifest=new_manifest, old_manifest=old_manifest, - patch=not disable_patching, resume=not force, - file_prefix_filter=file_prefix_filter, - file_exclude_filter=file_exclude_filter, - file_install_tag=file_install_tag, - processing_optimization=process_opt) - - prereq = None - if new_manifest.meta.prereq_ids: - prereq = dict(ids=new_manifest.meta.prereq_ids, name=new_manifest.meta.prereq_name, - path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args) - - offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true') - ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false') - - if file_install_tag is None: - file_install_tag = [] - igame = InstalledGame(app_name=game.app_name, title=game.app_title, - version=new_manifest.meta.build_version, prereq_info=prereq, - manifest_path=override_manifest, base_urls=base_urls, - install_path=install_path, executable=new_manifest.meta.launch_exe, - launch_parameters=new_manifest.meta.launch_command, - can_run_offline=offline == 'true', requires_ot=ot == 'true', - is_dlc=base_game is not None, install_size=anlres.install_size, - egl_guid=egl_guid, install_tags=file_install_tag, - platform=platform) + dlm, analysis, igame = super(LegendaryCore, self).prepare_download(game=game, base_game=base_game, base_path=base_path, + force=force, max_shm=max_shm, + max_workers=max_workers, game_folder=game_folder, + disable_patching=disable_patching, + 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, + preferred_cdn=preferred_cdn, + disable_https=disable_https) # game is either up to date or hasn't changed, so we have nothing to do - if not anlres.dl_size: + if not analysis.dl_size: self.log.info('Download size is 0, the game is either already up to date or has not changed. Exiting...') self.clean_post_install(game, igame, repair, repair_file) raise RuntimeError('Nothing to do.') - res = self.check_installation_conditions(analysis=anlres, install=igame, game=game, + res = self.check_installation_conditions(analysis=analysis, install=igame, game=game, updating=self.is_installed(app_name), ignore_space_req=ignore_space_req) - return dlm, anlres, game, igame, repair, repair_file, res + return dlm, analysis, igame, game, repair, repair_file, res def verify_game(self, app_name: str, callback: Callable[[int, int], None] = print): if not self.is_installed(app_name): diff --git a/requirements.txt b/requirements.txt index 1a564d96..a2034e62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ PyQt5 QtAwesome psutil pypresence +setuptools +legendary-gl pywin32; platform_system == "Windows" From 3ec8973de83557b9cb03d33f596355a8192e5e0c Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 25 Jun 2022 21:48:25 +0300 Subject: [PATCH 05/61] Update shim UIUpdate and DLManager to the cleanup changes from the submodule --- rare/lgndr/downloading.py | 36 +++++++++++++++++------------------- rare/lgndr/manager.py | 34 +++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/rare/lgndr/downloading.py b/rare/lgndr/downloading.py index c6c472d4..58fa342b 100644 --- a/rare/lgndr/downloading.py +++ b/rare/lgndr/downloading.py @@ -1,26 +1,24 @@ 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 """ - - def __init__(self, progress, runtime, estimated_time_left, processed_chunks, chunk_tasks, - total_downloaded, total_written, cache_usage, active_tasks, download_speed, - download_decompressed_speed, write_speed, read_speed, memory_usage, current_filename=''): - self.progress = progress - self.runtime = runtime - self.estimated_time_left = estimated_time_left - self.processed_chunks = processed_chunks - self.chunk_tasks = chunk_tasks - self.total_downloaded = total_downloaded - self.total_written = total_written - self.cache_usage = cache_usage - self.active_tasks = active_tasks - self.download_speed = download_speed - self.download_decompressed_speed = download_decompressed_speed - self.write_speed = write_speed - self.read_speed = read_speed - self.memory_usage = memory_usage - self.current_filename = current_filename + 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 index 25dca0e7..a712ed27 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -106,17 +106,36 @@ class DLManager(DLManagerReal): total_avail = len(self.sms) total_used = (num_shared_memory_segments - total_avail) * (self.analysis.biggest_chunk / 1024 / 1024) - try: + if runtime and processed_chunks: average_speed = processed_chunks / runtime estimate = (num_chunk_tasks - processed_chunks) / average_speed - except ZeroDivisionError: - average_speed = estimate = 0 + 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: + hours = minutes = seconds = 0 + rt_hours = rt_minutes = rt_seconds = 0 + + logging.disable(logging.INFO) # lk: Disable INFO logging channel for the segment below + 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)') + logging.enable(logging.INFO) # lk: Enable INFO logging channel again # send status update to back to instantiator (if queue exists) if self.status_queue: try: self.status_queue.put(UIUpdate( - progress=perc, + 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, @@ -125,11 +144,8 @@ class DLManager(DLManagerReal): total_written=total_write, cache_usage=total_used, active_tasks=self.active_tasks, - download_speed=dl_speed, - download_decompressed_speed=dl_unc_speed, - write_speed=w_speed, - memory_usage=total_used * 1024 * 1024, - read_speed=r_speed, + 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}') From d4718f823f0f10cf4babae330d680ae24b7bdcf2 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 25 Jun 2022 21:51:32 +0300 Subject: [PATCH 06/61] Use custom exception to overload `log.error` and `log.fatal` in `egl_import` and `egl_export` --- rare/lgndr/core.py | 20 ++++++++++++++++++++ rare/lgndr/exception.py | 4 ++++ 2 files changed, 24 insertions(+) create mode 100644 rare/lgndr/exception.py diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index e512bc8a..0635cc85 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -10,10 +10,14 @@ from legendary.utils.selective_dl import get_sdl_appname from legendary.core import LegendaryCore as LegendaryCoreReal from .manager import DLManager +from .exception import LgndrException class LegendaryCore(LegendaryCoreReal): + def __log_exception(self, error): + raise LgndrException(error) + def prepare_download(self, app_name: str, base_path: str = '', status_q: Queue = None, max_shm: int = 0, max_workers: int = 0, force: bool = False, disable_patching: bool = False, @@ -225,3 +229,19 @@ class LegendaryCore(LegendaryCoreReal): self.log.info('Deleting now untagged files.') self.uninstall_tag(old_igame) self.install_game(old_igame) + + def egl_import(self, app_name): + __log_error = self.log.error + __log_fatal = self.log.fatal + self.log.error = self.__log_exception + self.log.fatal = self.__log_exception + super(LegendaryCore, self).egl_import(app_name) + self.log.error = __log_error + self.log.fatal = __log_fatal + + def egl_export(self, app_name): + __log_error = self.log.error + self.log.error = self.__log_exception + super(LegendaryCore, self).egl_export(app_name) + self.log.error = __log_error + diff --git a/rare/lgndr/exception.py b/rare/lgndr/exception.py new file mode 100644 index 00000000..13f08aca --- /dev/null +++ b/rare/lgndr/exception.py @@ -0,0 +1,4 @@ +class LgndrException(RuntimeError): + def __init__(self, message="Error in Legendary"): + self.message = message + super(LgndrException, self).__init__(self.message) From c302775acdd3f5cdb29fc80e97d84129fb4995d2 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 25 Jun 2022 21:55:38 +0300 Subject: [PATCH 07/61] Remove dead code from EGLSyncGroup --- .../tabs/games/import_sync/egl_sync_group.py | 82 ------------------- 1 file changed, 82 deletions(-) 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..7c400095 100644 --- a/rare/components/tabs/games/import_sync/egl_sync_group.py +++ b/rare/components/tabs/games/import_sync/egl_sync_group.py @@ -307,85 +307,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() -""" From ad81546b3595c4ff08c02853d5426ce46ed7a578 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 01:15:30 +0300 Subject: [PATCH 08/61] Adjust EGLSyncGroup to use LgndrException --- .../tabs/games/import_sync/egl_sync_group.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 7c400095..3c92a734 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.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 ( @@ -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 From 82015f21d44a0c3135429e88a33026e6933d07a4 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 01:25:43 +0300 Subject: [PATCH 09/61] Fix ordering in InstallDownloadModel to match `prepare_download` --- rare/utils/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rare/utils/models.py b/rare/utils/models.py index 16f457d2..0931bd96 100644 --- a/rare/utils/models.py +++ b/rare/utils/models.py @@ -37,8 +37,8 @@ class InstallOptionsModel: class InstallDownloadModel: dlmanager: DLManager analysis: AnalysisResult - game: Game igame: InstalledGame + game: Game repair: bool repair_file: str res: ConditionCheckResult From 9698bf445be530663a4183c569a1f862b8a5687a Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 01:26:11 +0300 Subject: [PATCH 10/61] Re-enable logging in DLManager correctly --- rare/lgndr/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index a712ed27..a444b951 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -1,5 +1,6 @@ import os import time +import logging from multiprocessing import Queue as MPQueue from multiprocessing.shared_memory import SharedMemory @@ -15,6 +16,7 @@ from .downloading import UIUpdate class DLManager(DLManagerReal): + @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') @@ -129,7 +131,7 @@ class DLManager(DLManagerReal): 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)') - logging.enable(logging.INFO) # lk: Enable INFO logging channel again + logging.disable(logging.NOTSET) # lk: Enable INFO logging channel again # send status update to back to instantiator (if queue exists) if self.status_queue: From 99ab48882007061701d4ba096d123ec70a5d30c1 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 01:27:18 +0300 Subject: [PATCH 11/61] Pass the correct status queue and monkeypatch run_real in `prepare_download` --- rare/components/tabs/downloads/__init__.py | 2 +- rare/lgndr/core.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 17225e39..cd6c3d23 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -13,8 +13,8 @@ from PyQt5.QtWidgets import ( ) from legendary.core import LegendaryCore -from legendary.models.downloading import UIUpdate from legendary.models.game import Game, InstalledGame +from rare.lgndr.downloading import UIUpdate 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 diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 0635cc85..d6691af5 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -1,4 +1,5 @@ import os +import sys from multiprocessing import Queue from typing import Callable @@ -9,6 +10,7 @@ from legendary.models.game import * from legendary.utils.selective_dl import get_sdl_appname from legendary.core import LegendaryCore as LegendaryCoreReal + from .manager import DLManager from .exception import LgndrException @@ -144,8 +146,10 @@ class LegendaryCore(LegendaryCoreReal): disable_delta=disable_delta, override_delta_manifest=override_delta_manifest, preferred_cdn=preferred_cdn, + status_q=status_q, disable_https=disable_https) + dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) # game is either up to date or hasn't changed, so we have nothing to do if not analysis.dl_size: self.log.info('Download size is 0, the game is either already up to date or has not changed. Exiting...') From 78fb13830e3a18ab857553cd19d626d28cf354a3 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 14:29:20 +0300 Subject: [PATCH 12/61] Set logging level instead of disabling it in DLManager --- rare/lgndr/manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index a444b951..d50bf48e 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -120,7 +120,9 @@ class DLManager(DLManagerReal): hours = minutes = seconds = 0 rt_hours = rt_minutes = rt_seconds = 0 - logging.disable(logging.INFO) # lk: Disable INFO logging channel for the segment below + log_level = self.log.level + # lk: Disable up to INFO logging level for the segment below + 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}') @@ -131,7 +133,8 @@ class DLManager(DLManagerReal): 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)') - logging.disable(logging.NOTSET) # lk: Enable INFO logging channel again + # lk: Restore previous logging level + self.log.setLevel(log_level) # send status update to back to instantiator (if queue exists) if self.status_queue: From 2c2f44c97cb3dd92e2641bc55feb8f99fdd9c5c1 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 14:30:17 +0300 Subject: [PATCH 13/61] Use a loggin handler in stead of monkeypatching for raising LgndrException --- .../tabs/games/import_sync/import_group.py | 8 +++-- rare/lgndr/cli.py | 17 +++++---- rare/lgndr/core.py | 35 ++++++++++--------- rare/lgndr/exception.py | 19 ++++++++++ 4 files changed, 54 insertions(+), 25 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 7b525134..27ce6b1c 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -12,6 +12,7 @@ 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.exception import LgndrException 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 @@ -307,8 +308,11 @@ class ImportGroup(QGroupBox): skip_dlcs=False, with_dlcs=False, yes=False, - log_dest=self.ui.info_label, ) cli = LegendaryCLI() cli.core = LegendaryCoreSingleton() - cli.import_game(args) + try: + cli.import_game(args) + except LgndrException as ret: + self.ui.info_label.setText(ret.message) + diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 42a0f323..ad89574a 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -2,11 +2,10 @@ import logging import legendary.cli from PyQt5.QtWidgets import QLabel, QMessageBox -from legendary.cli import LegendaryCLI as LegendaryCLIReal +from legendary.cli import LegendaryCLI as LegendaryCLIReal, logger from .core import LegendaryCore - -logger = logging.getLogger('cli') +from .exception import LgndrException, LgndrLogHandler def get_boolean_choice(message): @@ -31,10 +30,14 @@ class LegendaryCLI(LegendaryCLIReal): self.logging_queue = None def import_game(self, args): - handler = UILogHandler(args.log_dest) + handler = LgndrLogHandler() logger.addHandler(handler) old_choice = legendary.cli.get_boolean_choice legendary.cli.get_boolean_choice = get_boolean_choice - super(LegendaryCLI, self).import_game(args) - legendary.cli.get_boolean_choice = old_choice - logger.removeHandler(handler) + try: + super(LegendaryCLI, self).import_game(args) + except LgndrException as e: + raise e + finally: + legendary.cli.get_boolean_choice = old_choice + logger.removeHandler(handler) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index d6691af5..20e357ee 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -1,3 +1,4 @@ +import logging import os import sys @@ -12,14 +13,11 @@ from legendary.utils.selective_dl import get_sdl_appname from legendary.core import LegendaryCore as LegendaryCoreReal from .manager import DLManager -from .exception import LgndrException +from .exception import LgndrException, LgndrLogHandler class LegendaryCore(LegendaryCoreReal): - def __log_exception(self, error): - raise LgndrException(error) - def prepare_download(self, app_name: str, base_path: str = '', status_q: Queue = None, max_shm: int = 0, max_workers: int = 0, force: bool = False, disable_patching: bool = False, @@ -148,8 +146,8 @@ class LegendaryCore(LegendaryCoreReal): preferred_cdn=preferred_cdn, status_q=status_q, disable_https=disable_https) - dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) + # game is either up to date or hasn't changed, so we have nothing to do if not analysis.dl_size: self.log.info('Download size is 0, the game is either already up to date or has not changed. Exiting...') @@ -235,17 +233,22 @@ class LegendaryCore(LegendaryCoreReal): self.install_game(old_igame) def egl_import(self, app_name): - __log_error = self.log.error - __log_fatal = self.log.fatal - self.log.error = self.__log_exception - self.log.fatal = self.__log_exception - super(LegendaryCore, self).egl_import(app_name) - self.log.error = __log_error - self.log.fatal = __log_fatal + handler = LgndrLogHandler() + self.log.addHandler(handler) + try: + super(LegendaryCore, self).egl_import(app_name) + except LgndrException as ret: + raise ret + finally: + self.log.removeHandler(handler) def egl_export(self, app_name): - __log_error = self.log.error - self.log.error = self.__log_exception - super(LegendaryCore, self).egl_export(app_name) - self.log.error = __log_error + handler = LgndrLogHandler() + self.log.addHandler(handler) + try: + super(LegendaryCore, self).egl_export(app_name) + except LgndrException as ret: + raise ret + finally: + self.log.removeHandler(handler) diff --git a/rare/lgndr/exception.py b/rare/lgndr/exception.py index 13f08aca..03005c76 100644 --- a/rare/lgndr/exception.py +++ b/rare/lgndr/exception.py @@ -1,4 +1,23 @@ +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 LgndrLogHandler(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.INFO or record.levelno == logging.WARNING: + warnings.warn(record.getMessage()) From 9af4e7ead81d899001c95c4d5252a87e86aad2d0 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 14:40:16 +0300 Subject: [PATCH 14/61] Specify correct return type for `prepare_download()` and disable formatting for copied code segments --- rare/lgndr/core.py | 7 ++++--- rare/lgndr/manager.py | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 20e357ee..2f990177 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -6,7 +6,7 @@ from multiprocessing import Queue from typing import Callable from legendary.utils.lfs import validate_files -from legendary.models.downloading import AnalysisResult +from legendary.models.downloading import AnalysisResult, ConditionCheckResult from legendary.models.game import * from legendary.utils.selective_dl import get_sdl_appname @@ -17,7 +17,7 @@ from .exception import LgndrException, LgndrLogHandler class LegendaryCore(LegendaryCoreReal): - + # fmt: off def prepare_download(self, app_name: str, base_path: str = '', status_q: Queue = None, max_shm: int = 0, max_workers: int = 0, force: bool = False, disable_patching: bool = False, @@ -32,7 +32,7 @@ class LegendaryCore(LegendaryCoreReal): no_install: bool = False, ignore_space_req: bool = False, disable_sdl: bool = False, reset_sdl: bool = False, skip_sdl: bool = False, sdl_prompt: Callable[[str, str], List[str]] = list, - disable_https: bool = False) -> (DLManager, AnalysisResult, InstalledGame, Game): + disable_https: bool = False) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): if self.is_installed(app_name): igame = self.get_installed_game(app_name) platform = igame.platform @@ -231,6 +231,7 @@ class LegendaryCore(LegendaryCoreReal): self.log.info('Deleting now untagged files.') self.uninstall_tag(old_igame) self.install_game(old_igame) + # fmt: on def egl_import(self, app_name): handler = LgndrLogHandler() diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index d50bf48e..304141df 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -15,7 +15,7 @@ from .downloading import UIUpdate class DLManager(DLManagerReal): - + # fmt: off @staticmethod def run_real(self): self.shared_memory = SharedMemory(create=True, size=self.max_shared_memory) @@ -194,3 +194,4 @@ class DLManager(DLManagerReal): self.log.info('All done! Download manager quitting...') # finally, exit the process. exit(0) + # fmt: on From fd2c094dc2f252dc2817e08569fd805923d92892 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 15:31:57 +0300 Subject: [PATCH 15/61] Add override for `prepare_overlay_install()` --- rare/lgndr/core.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 2f990177..a748e1e7 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -253,3 +253,10 @@ class LegendaryCore(LegendaryCoreReal): finally: self.log.removeHandler(handler) + def prepare_overlay_install(self, path=None, status_q: Queue = None): + dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) + # lk: monkeypatch status_q (the queue for download statistic) + # lk: and run_real (the download function that emits the statistics) into DLManager + dlm.status_queue = status_q + dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) + return dlm, analysis_result, igame From d4bf783018a4bb8d91f38b9bc241afc1d96ab5f9 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 19:06:44 +0300 Subject: [PATCH 16/61] Update core to the submodule rebase --- rare/lgndr/core.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index a748e1e7..ed749824 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -46,9 +46,6 @@ class LegendaryCore(LegendaryCoreReal): no_install = repair_use_latest is False repair_file = os.path.join(self.lgd.get_tmp_path(), f'{app_name}.repair') - if not self.login(): - raise RuntimeError('Login failed! Cannot continue with download process.') - if file_prefix_filter or file_exclude_filter or file_install_tag: no_install = True @@ -146,6 +143,7 @@ class LegendaryCore(LegendaryCoreReal): preferred_cdn=preferred_cdn, status_q=status_q, 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) # game is either up to date or hasn't changed, so we have nothing to do @@ -255,8 +253,8 @@ class LegendaryCore(LegendaryCoreReal): def prepare_overlay_install(self, path=None, status_q: Queue = None): dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) - # lk: monkeypatch status_q (the queue for download statistic) - # lk: and run_real (the download function that emits the statistics) into DLManager + # lk: monkeypatch status_q (the queue for download stats) + # lk: and run_real (the method that emits the stats) into DLManager dlm.status_queue = status_q dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) return dlm, analysis_result, igame From 51c45015a47bd90687ced99709db04cf1663572e Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 26 Jun 2022 19:07:16 +0300 Subject: [PATCH 17/61] InstallDialog: Remove unused arguments --- rare/components/dialogs/install_dialog.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 6aeafb37..053e5801 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -342,16 +342,13 @@ class InstallInfoWorker(QRunnable): 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, ) download = InstallDownloadModel( dlmanager=dlm, analysis=analysis, - game=self.game, igame=igame, + game=self.game, repair=False, repair_file="", res=ConditionCheckResult(), # empty From 3892f4a59466018ce589d256d8a299903f2a531b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Wed, 6 Jul 2022 14:37:05 +0300 Subject: [PATCH 18/61] ImportGroup: Update the shim stuff for the import worker --- .../tabs/games/import_sync/import_group.py | 62 +++++++++++-------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 27ce6b1c..1a3f3d50 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -87,22 +87,48 @@ class ImportWorker(QRunnable): 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) + app_title = self.core.get_game(app_name).app_title if err: result.result = ImportResult.FAILED - result.message = err + result.message = f"{app_title} - {err}" else: result.result = ImportResult.SUCCESS + result.message = self.tr("{} - Imported successfully").format(app_title) 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}") + # 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 + + # TODO: This should be moved into RareCore and wrap import_game + def import_game_args(self, app_path: str, app_name: str, platfrom: str = "Windows", + disable_check: bool = False, skip_dlcs: bool = False, with_dlcs: bool = False, yes: bool = False): + args = Namespace( + app_path=app_path, + app_name=app_name, + platform=platfrom, + disable_check=disable_check, + skip_dlcs=skip_dlcs, + with_dlcs=with_dlcs, + yes=yes, + ) + return args + + def __import_game(self, app_name: str, path: Path): + cli = LegendaryCLI() + cli.core = LegendaryCoreSingleton() + args = self.import_game_args(str(path), app_name) + try: + cli.import_game(args) return "" - else: - return err + except LgndrException as ret: + return ret.message class AppNameCompleter(QCompleter): @@ -255,7 +281,7 @@ class ImportGroup(QGroupBox): 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.tr("Success: {}").format(res.message) ) elif res.result == ImportResult.FAILED: self.ui.info_label.setText( @@ -283,7 +309,7 @@ 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: {}").format(res.message) ) for res in failure: details.append( @@ -298,21 +324,3 @@ class ImportGroup(QGroupBox): def import_progress(self, progress: int): pass - - def __import_game(self, app_name, path): - args = Namespace( - app_path=path, - app_name=app_name, - platform='Windows', - disable_check=False, - skip_dlcs=False, - with_dlcs=False, - yes=False, - ) - cli = LegendaryCLI() - cli.core = LegendaryCoreSingleton() - try: - cli.import_game(args) - except LgndrException as ret: - self.ui.info_label.setText(ret.message) - From 883bd268ff8bf678878c01315960dfa114e64ca1 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Thu, 7 Jul 2022 20:19:52 +0300 Subject: [PATCH 19/61] Mirror Legendary classes structure in the shim. Lgndr: Move code segments copied from `prepare_download` back to their original location in `install_game` Lgndr: Add the LgndrLogHandler at initialization instead of every function. Lgndr: Move `verify_game` to its original place in `LegendaryCLI` Lgndr: Change the way DLManager is patched into LegendaryCore proper Shared: Add singleton for LegendaryCLI, LegendaryCoreSignleton returns core from LegendaryCLI VerifyWorker: Update to use `verify_game` from `LegendaryCLI` directly PreLaunchThread: Initialize LegendaryCLI to get LegendaryCore from it InstallDialog: Update `prepare_install` argument names --- rare/app.py | 8 +- rare/components/dialogs/install_dialog.py | 18 +- .../tabs/games/import_sync/import_group.py | 6 +- rare/game_launch_helper/__init__.py | 5 +- rare/lgndr/cli.py | 355 +++++++++++++++++- rare/lgndr/core.py | 281 +++----------- rare/lgndr/manager.py | 5 +- rare/shared/__init__.py | 23 +- rare/utils/legendary_utils.py | 86 ++--- 9 files changed, 466 insertions(+), 321 deletions(-) diff --git a/rare/app.py b/rare/app.py index 9ef19f97..b23aa185 100644 --- a/rare/app.py +++ b/rare/app.py @@ -20,7 +20,7 @@ import rare from rare.components.dialogs.launch_dialog import LaunchDialog from rare.components.main_window import MainWindow from rare.components.tray_icon import TrayIcon -from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton +from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton from rare.shared.image_manager import ImageManagerSingleton from rare.utils import legendary_utils, config_helper from rare.utils.paths import cache_dir, tmp_dir @@ -64,7 +64,8 @@ class App(RareApp): # init Legendary try: - self.core = LegendaryCoreSingleton(init=True) + LegendaryCLISingleton(init=True) + self.core = LegendaryCoreSingleton() except configparser.MissingSectionHeaderError as e: logger.warning(f"Config is corrupt: {e}") if config_path := os.environ.get("XDG_CONFIG_HOME"): @@ -73,7 +74,8 @@ class App(RareApp): path = os.path.expanduser("~/.config/legendary") with open(os.path.join(path, "config.ini"), "w") as config_file: config_file.write("[Legendary]") - self.core = LegendaryCoreSingleton(init=True) + LegendaryCLISingleton(init=True) + self.core = LegendaryCoreSingleton() if "Legendary" not in self.core.lgd.config.sections(): self.core.lgd.config.add_section("Legendary") self.core.lgd.save_config() diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 053e5801..f756dd86 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -7,12 +7,14 @@ from typing import Tuple from PyQt5.QtCore import Qt, QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot from PyQt5.QtGui import QCloseEvent, QKeyEvent from PyQt5.QtWidgets import QDialog, QFileDialog, QCheckBox + +from rare.lgndr.cli import LegendaryCLI from rare.lgndr.core import LegendaryCore from legendary.models.downloading import ConditionCheckResult from legendary.models.game import Game from legendary.utils.selective_dl import games -from rare.shared import LegendaryCoreSingleton, ApiResultsSingleton, ArgumentsSingleton +from rare.shared import LegendaryCLISingleton, 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 @@ -306,14 +308,16 @@ class InstallInfoWorker(QRunnable): def run(self): try: if not self.is_overlay_install: + cli = LegendaryCLISingleton() download = InstallDownloadModel( - *self.core.prepare_download( + # *self.core.prepare_download( + *cli.prepare_install( 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, + shared_memory=self.dl_item.options.max_shm, max_workers=self.dl_item.options.max_workers, # game_folder=, # disable_patching=, @@ -324,11 +328,11 @@ class InstallInfoWorker(QRunnable): # file_prefix_filter=, # file_exclude_filter=, # file_install_tag=, - dl_optimizations=self.dl_item.options.dl_optimizations, + order_opt=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, + repair_mode=self.dl_item.options.repair, + # repair_and_update=True, + ignore_space=self.dl_item.options.ignore_space_req, # disable_delta=, # override_delta_manifest=, # reset_sdl=, diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 1a3f3d50..7f78acf4 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -11,9 +11,8 @@ 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.exception import LgndrException -from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton +from rare.shared import LegendaryCLISingleton, 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 @@ -121,8 +120,7 @@ class ImportWorker(QRunnable): return args def __import_game(self, app_name: str, path: Path): - cli = LegendaryCLI() - cli.core = LegendaryCoreSingleton() + cli = LegendaryCLISingleton() args = self.import_game_args(str(path), app_name) try: cli.import_game(args) diff --git a/rare/game_launch_helper/__init__.py b/rare/game_launch_helper/__init__.py index 12aa226c..092d2be2 100644 --- a/rare/game_launch_helper/__init__.py +++ b/rare/game_launch_helper/__init__.py @@ -14,7 +14,7 @@ from PyQt5.QtNetwork import QLocalServer, QLocalSocket from .console import Console from .lgd_helper import get_launch_args, InitArgs, get_configured_process, LaunchArgs, GameArgsError from .message_models import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel -from ..shared import LegendaryCoreSingleton +from ..shared import LegendaryCLISingleton, LegendaryCoreSingleton from ..widgets.rare_app import RareApp @@ -69,7 +69,8 @@ class GameProcessApp(RareApp): self.game_process = QProcess() self.app_name = app_name self.logger = getLogger(self.app_name) - self.core = LegendaryCoreSingleton(True) + LegendaryCLISingleton(init=True) + self.core = LegendaryCoreSingleton() lang = self.settings.value("language", self.core.language_code, type=str) self.load_translator(lang) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index ad89574a..9fe4639c 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,11 +1,21 @@ +import os import logging +import time +from argparse import Namespace +from multiprocessing import Queue +from typing import Optional, Callable, List import legendary.cli from PyQt5.QtWidgets import QLabel, QMessageBox -from legendary.cli import LegendaryCLI as LegendaryCLIReal, logger +from legendary.cli import logger +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 .core import LegendaryCore from .exception import LgndrException, LgndrLogHandler +from .manager import DLManager def get_boolean_choice(message): @@ -22,22 +32,347 @@ class UILogHandler(logging.Handler): self.widget.setText(record.getMessage()) -class LegendaryCLI(LegendaryCLIReal): - - def __init__(self): - self.core = None +class LegendaryCLI(legendary.cli.LegendaryCLI): + def __init__(self, override_config=None, api_timeout=None): + self.core = LegendaryCore(override_config) self.logger = logging.getLogger('cli') self.logging_queue = None + self.handler = LgndrLogHandler() + self.logger.addHandler(self.handler) + + # def __init__(self, core: LegendaryCore): + # self.core = core + # self.logger = logging.getLogger('cli') + # self.logging_queue = None + # self.handler = LgndrLogHandler() + # self.logger.addHandler(self.handler) + + # app_name, repair, repair_mode, no_install, repair_and_update, file_prefix, file_exclude_prefix, platform + # sdl_prompt, status_q + def prepare_install(self, app_name: str, base_path: str = '', + status_q: Queue = None, 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: list = 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, + sdl_prompt: Callable[[str, str], List[str]] = list, + yes: bool = True, verify_callback: Callable[[int, int, float, float], None] = None, + disable_https: bool = False) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): + args = Namespace( + app_name=app_name, base_path=base_path, + status_q=status_q, shared_memory=shared_memory, 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=file_prefix, + file_exclude_prefix=file_exclude_prefix, install_tag=install_tag, + order_opt=order_opt, dl_timeout=dl_timeout, + repair_mode=repair_mode, repair_and_update=repair_and_update, + disable_delta=disable_delta, override_delta_manifest=override_delta_manifest, + preferred_cdn=preferred_cdn, + no_install=no_install, ignore_space=ignore_space, + disable_sdl=disable_sdl, reset_sdl=reset_sdl, skip_sdl=skip_sdl, + sdl_prompt=sdl_prompt, + yes=yes, callback=verify_callback, + disable_https=disable_https + ) + old_choice = legendary.cli.get_boolean_choice + legendary.cli.get_boolean_choice = get_boolean_choice + try: + return self.install_game(args) + except LgndrException as ret: + raise ret + finally: + legendary.cli.get_boolean_choice = old_choice + + def install_game(self, args): + 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 + 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') + + if args.file_prefix or args.file_exclude_prefix: + args.no_install = True + + 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?') + + 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!') + 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!') + + if not os.path.exists(repair_file): + logger.info('Game has not been verified yet.') + if not args.yes: + if not get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): + return + try: + self.verify_game(args, print_command=False, repair_mode=True, repair_online=args.repair_and_update) + except ValueError: + logger.error('To repair a game with a missing manifest you must run the command with ' + '"--repair-and-update". However this will redownload any file that does ' + 'not match the current hash in its entirety.') + 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 = args.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, + status_q=args.status_q, + 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.clean_post_install(game, igame, args.repair_mode, repair_file) + + logger.error('Nothing to do.') + + 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 + + def clean_post_install(self, game: Game, igame: InstalledGame, repair: bool = False, repair_file: str = ''): + old_igame = self.core.get_installed_game(game.app_name) + if old_igame and repair 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 verify_game(self, args, print_command=True, repair_mode=False, repair_online=False): + if not hasattr(args, 'callback') or args.callback is None: + args.callback = print + + 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) + 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.callback: + args.callback(num, total, percentage, speed) + + if result == VerifyResult.HASH_MATCH: + repair_file.append(f'{result_hash}:{path}') + continue + elif result == VerifyResult.HASH_MISMATCH: + logger.info(f'File does not match hash: "{path}"') + repair_file.append(f'{result_hash}:{path}') + failed.append(path) + elif result == VerifyResult.FILE_MISSING: + logger.info(f'File is missing: "{path}"') + missing.append(path) + else: + logger.info(f'Other failure (see log), treating file as missing: "{path}"') + missing.append(path) + + if args.callback: + args.callback(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.') + 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.') def import_game(self, args): - handler = LgndrLogHandler() - logger.addHandler(handler) old_choice = legendary.cli.get_boolean_choice legendary.cli.get_boolean_choice = get_boolean_choice try: super(LegendaryCLI, self).import_game(args) - except LgndrException as e: - raise e + except LgndrException as ret: + raise ret finally: legendary.cli.get_boolean_choice = old_choice - logger.removeHandler(handler) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index ed749824..7434e1a5 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -1,24 +1,22 @@ -import logging -import os -import sys - from multiprocessing import Queue -from typing import Callable -from legendary.utils.lfs import validate_files -from legendary.models.downloading import AnalysisResult, ConditionCheckResult -from legendary.models.game import * -from legendary.utils.selective_dl import get_sdl_appname +import legendary.core +from legendary.models.downloading import AnalysisResult +from legendary.models.game import Game +from legendary.models.manifest import ManifestMeta -from legendary.core import LegendaryCore as LegendaryCoreReal - -from .manager import DLManager from .exception import LgndrException, LgndrLogHandler +from .manager import DLManager -class LegendaryCore(LegendaryCoreReal): - # fmt: off - def prepare_download(self, app_name: str, base_path: str = '', +class LegendaryCore(legendary.core.LegendaryCore): + + def __init__(self, override_config=None, timeout=10.0): + super(LegendaryCore, self).__init__(override_config=override_config, timeout=timeout) + self.handler = LgndrLogHandler() + self.log.addHandler(self.handler) + + 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 = '', @@ -29,232 +27,59 @@ class LegendaryCore(LegendaryCoreReal): repair: bool = False, repair_use_latest: bool = False, disable_delta: bool = False, override_delta_manifest: str = '', egl_guid: str = '', preferred_cdn: str = None, - no_install: bool = False, ignore_space_req: bool = False, - disable_sdl: bool = False, reset_sdl: bool = False, skip_sdl: bool = False, - sdl_prompt: Callable[[str, str], List[str]] = list, - disable_https: bool = False) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): - if self.is_installed(app_name): - igame = self.get_installed_game(app_name) - platform = igame.platform - if igame.needs_verification and not repair: - self.log.info('Game needs to be verified before updating, switching to repair mode...') - repair = True - - repair_file = None - if repair: - repair = True - no_install = repair_use_latest is False - repair_file = os.path.join(self.lgd.get_tmp_path(), f'{app_name}.repair') - - if file_prefix_filter or file_exclude_filter or file_install_tag: - no_install = True - - game = self.get_game(app_name, update_meta=True) - - if not game: - raise RuntimeError(f'Could not find "{app_name}" in list of available games,' - f'did you type the name correctly?') - - if game.is_dlc: - self.log.info('Install candidate is DLC') - app_name = game.metadata['mainGameItem']['releaseInfo'][0]['appId'] - base_game = self.get_game(app_name) - # check if base_game is actually installed - if not self.is_installed(app_name): - # download mode doesn't care about whether or not something's installed - if not no_install: - raise RuntimeError(f'Base game "{app_name}" is not installed!') - else: - base_game = None - - if repair: - if not self.is_installed(game.app_name): - raise RuntimeError(f'Game "{game.app_title}" ({game.app_name}) is not installed!') - - if not os.path.exists(repair_file): - self.log.info('Verifing game...') - self.verify_game(app_name) - else: - self.log.info(f'Using existing repair file: {repair_file}') - - # check if SDL should be disabled - sdl_enabled = not file_install_tag and not game.is_dlc - config_tags = self.lgd.config.get(game.app_name, 'install_tags', fallback=None) - config_disable_sdl = self.lgd.config.getboolean(game.app_name, 'disable_sdl', fallback=False) - # remove config flag if SDL is reset - if config_disable_sdl and reset_sdl and not disable_sdl: - self.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 disable_sdl: - self.log.info('Clearing install tags from config and disabling SDL for title.') - if config_tags: - self.lgd.config.remove_option(game.app_name, 'install_tags') - config_tags = None - self.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 disable_sdl: - sdl_enabled = False - - if sdl_enabled and ((sdl_name := get_sdl_appname(game.app_name)) is not None): - if not self.is_installed(game.app_name) or config_tags is None or reset_sdl: - sdl_data = self.get_sdl_data(sdl_name, platform=platform) - if sdl_data: - if skip_sdl: - file_install_tag = [''] - if '__required' in sdl_data: - file_install_tag.extend(sdl_data['__required']['tags']) - else: - file_install_tag = sdl_prompt(sdl_data, game.app_title) - self.lgd.config.set(game.app_name, 'install_tags', ','.join(file_install_tag)) - else: - self.log.error(f'Unable to get SDL data for {sdl_name}') - else: - file_install_tag = config_tags.split(',') - elif file_install_tag and not game.is_dlc and not no_install: - config_tags = ','.join(file_install_tag) - self.log.info(f'Saving install tags for "{game.app_name}" to config: {config_tags}') - self.lgd.config.set(game.app_name, 'install_tags', config_tags) - elif not game.is_dlc: - if config_tags and reset_sdl: - self.log.info('Clearing install tags from config.') - self.lgd.config.remove_option(game.app_name, 'install_tags') - elif config_tags: - self.log.info(f'Using install tags from config: {config_tags}') - file_install_tag = config_tags.split(',') - - dlm, analysis, igame = super(LegendaryCore, self).prepare_download(game=game, base_game=base_game, base_path=base_path, - force=force, max_shm=max_shm, - max_workers=max_workers, game_folder=game_folder, - disable_patching=disable_patching, - 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, - preferred_cdn=preferred_cdn, - status_q=status_q, - 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) - - # game is either up to date or hasn't changed, so we have nothing to do - if not analysis.dl_size: - self.log.info('Download size is 0, the game is either already up to date or has not changed. Exiting...') - self.clean_post_install(game, igame, repair, repair_file) - - raise RuntimeError('Nothing to do.') - - res = self.check_installation_conditions(analysis=analysis, install=igame, game=game, - updating=self.is_installed(app_name), - ignore_space_req=ignore_space_req) - - return dlm, analysis, igame, game, repair, repair_file, res - - def verify_game(self, app_name: str, callback: Callable[[int, int], None] = print): - if not self.is_installed(app_name): - self.log.error(f'Game "{app_name}" is not installed') - return - - self.log.info(f'Loading installed manifest for "{app_name}"') - igame = self.get_installed_game(app_name) - manifest_data, _ = self.get_installed_manifest(app_name) - manifest = self.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 = [] - - self.log.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): - if callback: - num += 1 - callback(num, total) - - if result == VerifyResult.HASH_MATCH: - repair_file.append(f'{result_hash}:{path}') - continue - elif result == VerifyResult.HASH_MISMATCH: - self.log.error(f'File does not match hash: "{path}"') - repair_file.append(f'{result_hash}:{path}') - failed.append(path) - elif result == VerifyResult.FILE_MISSING: - self.log.error(f'File is missing: "{path}"') - missing.append(path) - else: - self.log.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.lgd.get_tmp_path(), f'{app_name}.repair') - with open(repair_filename, 'w') as f: - f.write('\n'.join(repair_file)) - self.log.debug(f'Written repair file to "{repair_filename}"') - - if not missing and not failed: - self.log.info('Verification finished successfully.') - else: - raise RuntimeError( - f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') - - def clean_post_install(self, game: Game, igame: InstalledGame, repair: bool = False, repair_file: str = ''): - old_igame = self.get_installed_game(game.app_name) - if old_igame and repair and os.path.exists(repair_file): - if old_igame.needs_verification: - old_igame.needs_verification = False - self.install_game(old_igame) - - self.log.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 - self.log.info('Deleting now untagged files.') - self.uninstall_tag(old_igame) - self.install_game(old_igame) - # fmt: on + disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta): + _dlmanager = legendary.core.DLManager + legendary.core.DLManager = DLManager + try: + 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) + return dlm, analysis, igame + except LgndrException as ret: + raise ret + finally: + legendary.core.DLManager = _dlmanager def egl_import(self, app_name): - handler = LgndrLogHandler() - self.log.addHandler(handler) try: super(LegendaryCore, self).egl_import(app_name) except LgndrException as ret: raise ret finally: - self.log.removeHandler(handler) + pass def egl_export(self, app_name): - handler = LgndrLogHandler() - self.log.addHandler(handler) try: super(LegendaryCore, self).egl_export(app_name) except LgndrException as ret: raise ret finally: - self.log.removeHandler(handler) + pass def prepare_overlay_install(self, path=None, status_q: Queue = None): - dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) - # lk: monkeypatch status_q (the queue for download stats) - # lk: and run_real (the method that emits the stats) into DLManager - dlm.status_queue = status_q - dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) - return dlm, analysis_result, igame + _dlmanager = legendary.core.DLManager + legendary.core.DLManager = DLManager + try: + dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) + # lk: monkeypatch status_q (the queue for download stats) + # lk: and run_real (the method that emits the stats) into DLManager + dlm.status_queue = status_q + # dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) + return dlm, analysis_result, igame + except LgndrException as ret: + raise ret + finally: + legendary.core.DLManager = _dlmanager diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index 304141df..45cdf22f 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -10,13 +10,12 @@ from threading import Condition, Thread from legendary.downloader.mp.workers import DLWorker, FileWorker from legendary.models.downloading import ChunkTask, SharedMemorySegment, TerminateWorkerTask -from legendary.downloader.mp.manager import DLManager as DLManagerReal +import legendary.downloader.mp.manager from .downloading import UIUpdate -class DLManager(DLManagerReal): +class DLManager(legendary.downloader.mp.manager.DLManager): # fmt: off - @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') diff --git a/rare/shared/__init__.py b/rare/shared/__init__.py index 6bbbf05a..ace02987 100644 --- a/rare/shared/__init__.py +++ b/rare/shared/__init__.py @@ -8,24 +8,35 @@ and only ONCE! from argparse import Namespace from typing import Optional +from rare.lgndr.cli import LegendaryCLI from rare.lgndr.core import LegendaryCore from rare.models.apiresults import ApiResults from rare.models.signals import GlobalSignals +_legendary_cli_signleton: Optional[LegendaryCLI] = None _legendary_core_singleton: Optional[LegendaryCore] = None _global_signals_singleton: Optional[GlobalSignals] = None _arguments_singleton: Optional[Namespace] = None _api_results_singleton: Optional[ApiResults] = None +def LegendaryCLISingleton(init: bool = False) -> LegendaryCLI: + global _legendary_cli_signleton + if _legendary_cli_signleton is None and not init: + raise RuntimeError("Uninitialized use of LegendaryCLISingleton") + if _legendary_cli_signleton is None: + _legendary_cli_signleton = LegendaryCLI() + return _legendary_cli_signleton + + def LegendaryCoreSingleton(init: bool = False) -> LegendaryCore: - global _legendary_core_singleton - if _legendary_core_singleton is None and not init: - raise RuntimeError("Uninitialized use of LegendaryCoreSingleton") - if _legendary_core_singleton is None: - _legendary_core_singleton = LegendaryCore() - return _legendary_core_singleton + global _legendary_cli_signleton + if _legendary_cli_signleton is None: + raise RuntimeError("LegendaryCLI is not initialized yet") + # if _legendary_cli_signleton is None: + # _legendary_cli_signleton = LegendaryCLISingleton(init) + return _legendary_cli_signleton.core def GlobalSignalsSingleton(init: bool = False) -> GlobalSignals: diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index b6971c69..90c80963 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -1,14 +1,17 @@ import os import platform +from argparse import Namespace from logging import getLogger from PyQt5.QtCore import pyqtSignal, QCoreApplication, QObject, QRunnable, QStandardPaths from legendary.core import LegendaryCore from legendary.models.game import VerifyResult from legendary.utils.lfs import validate_files +from parse import parse -from rare.shared import LegendaryCoreSingleton +from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton from rare.utils import config_helper +from rare.lgndr.exception import LgndrException logger = getLogger("Legendary Utils") @@ -83,75 +86,42 @@ def update_manifest(app_name: str, core: LegendaryCore): ) -class VerifySignals(QObject): - status = pyqtSignal(int, int, str) - summary = pyqtSignal(int, int, str) - - class VerifyWorker(QRunnable): + class Signals(QObject): + status = pyqtSignal(int, int, str) + summary = pyqtSignal(int, int, 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.cli = LegendaryCLISingleton() self.core = LegendaryCoreSingleton() self.app_name = app_name + def status_callback(self, num: int, total: int, percentage: float, speed: float): + self.signals.status.emit(num, total, self.app_name) + 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) + args = Namespace(app_name=self.app_name, + callback=self.status_callback) + try: + # TODO: offer this as an alternative when manifest doesn't exist + # TODO: requires the client to be online. To do it this way, we need to + # TODO: somehow detect the error and offer a dialog in which case `verify_games` is + # TODO: re-run with `repair_mode` and `repair_online` + self.cli.verify_game(args, print_command=False, repair_mode=True, repair_online=True) + # self.cli.verify_game(args, print_command=False) + self.signals.summary.emit(0, 0, self.app_name) + except LgndrException as ret: + r = parse('Verification failed, {:d} file(s) corrupted, {:d} file(s) are missing.', ret.message) + if r is None: + raise ret 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) + self.signals.summary.emit(r[0], r[1], self.app_name) # FIXME: lk: ah ef me sideways, we can't even import this thing properly From 197f915fb32e4a41f25a41abb258f6e39a60a99b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 02:34:53 +0300 Subject: [PATCH 20/61] InstallOptionsModel: Rename fields to use the same names as Legendary's arguments --- rare/components/dialogs/install_dialog.py | 14 +++++++------- rare/components/tabs/downloads/download_thread.py | 2 +- rare/components/tabs/games/game_info/game_info.py | 4 ++-- rare/lgndr/cli.py | 6 ++++++ rare/lgndr/exception.py | 4 ++-- rare/utils/models.py | 8 ++++---- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index f756dd86..5ef822f7 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -169,10 +169,10 @@ class InstallDialog(QDialog, Ui_InstallDialog): 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 = [""] @@ -317,7 +317,7 @@ class InstallInfoWorker(QRunnable): force=self.dl_item.options.force, no_install=self.dl_item.options.no_install, status_q=self.dl_item.status_q, - shared_memory=self.dl_item.options.max_shm, + shared_memory=self.dl_item.options.shared_memory, max_workers=self.dl_item.options.max_workers, # game_folder=, # disable_patching=, @@ -328,11 +328,11 @@ class InstallInfoWorker(QRunnable): # file_prefix_filter=, # file_exclude_filter=, # file_install_tag=, - order_opt=self.dl_item.options.dl_optimizations, + order_opt=self.dl_item.options.order_opt, # dl_timeout=, - repair_mode=self.dl_item.options.repair, + repair_mode=self.dl_item.options.repair_mode, # repair_and_update=True, - ignore_space=self.dl_item.options.ignore_space_req, + ignore_space=self.dl_item.options.ignore_space, # disable_delta=, # override_delta_manifest=, # reset_sdl=, diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index 09b763d0..d92b35b4 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -188,7 +188,7 @@ class DownloadThread(QThread): 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.") + logger.info("Deleting now untagged files.") self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) diff --git a/rare/components/tabs/games/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py index fa303b2e..771036fd 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -129,7 +129,7 @@ class GameInfo(QWidget, Ui_GameInfo): ) return self.signals.install_game.emit( - InstallOptionsModel(app_name=self.game.app_name, repair=True, update=True) + InstallOptionsModel(app_name=self.game.app_name, repair_mode=True, update=True) ) def verify(self): @@ -182,7 +182,7 @@ class GameInfo(QWidget, Ui_GameInfo): ) if ans == QMessageBox.Yes: self.signals.install_game.emit( - InstallOptionsModel(app_name=app_name, repair=True, update=True) + InstallOptionsModel(app_name=app_name, repair_mode=True, update=True) ) self.verify_widget.setCurrentIndex(0) self.verify_threads.pop(app_name) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 9fe4639c..f2b6bb2a 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -261,6 +261,12 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) + def _handle_postinstall(self, postinstall, igame, yes=False): + super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) + + def uninstall_game(self, args): + super(LegendaryCLI, self).uninstall_game(args) + def verify_game(self, args, print_command=True, repair_mode=False, repair_online=False): if not hasattr(args, 'callback') or args.callback is None: args.callback = print diff --git a/rare/lgndr/exception.py b/rare/lgndr/exception.py index 03005c76..f31b94c0 100644 --- a/rare/lgndr/exception.py +++ b/rare/lgndr/exception.py @@ -19,5 +19,5 @@ class LgndrLogHandler(logging.Handler): # 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.INFO or record.levelno == logging.WARNING: - warnings.warn(record.getMessage()) + # if record.levelno == logging.INFO or record.levelno == logging.WARNING: + # warnings.warn(record.getMessage()) diff --git a/rare/utils/models.py b/rare/utils/models.py index 0931bd96..6a20e06a 100644 --- a/rare/utils/models.py +++ b/rare/utils/models.py @@ -14,17 +14,17 @@ from legendary.models.game import Game, InstalledGame class InstallOptionsModel: app_name: str base_path: str = "" - max_shm: int = 1024 + shared_memory: int = 1024 max_workers: int = os.cpu_count() * 2 - repair: bool = False + repair_mode: bool = False no_install: bool = False - ignore_space_req: bool = False + ignore_space: bool = False force: bool = False sdl_list: list = field(default_factory=lambda: [""]) update: bool = False silent: bool = False platform: str = "" - dl_optimizations: bool = False + order_opt: bool = False overlay: bool = False create_shortcut: bool = True install_preqs: bool = pf.system() == "Windows" From d7aba41aa4896bab5595ad99d9955dda3934865b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 02:36:14 +0300 Subject: [PATCH 21/61] Requirements: Add parse --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index a2034e62..4dc04ff5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ QtAwesome psutil pypresence setuptools +parse legendary-gl pywin32; platform_system == "Windows" From 3aae3887f655a1bfbbef555f9744661d263b201b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 19:32:42 +0300 Subject: [PATCH 22/61] Lgndr: Create argument models for consistent function arguments and cleaner implementation. Lgndr: Prefix files that aren't part of legendary with 'api_' Lgndr: Return statistics from 'verify_game' instead of parsing the exception VerifyWorker: Add 'error' signal for exceptions. --- rare/components/dialogs/install_dialog.py | 5 +- .../tabs/games/game_info/game_info.py | 17 +++-- .../tabs/games/import_sync/egl_sync_group.py | 2 +- .../tabs/games/import_sync/import_group.py | 22 ++---- rare/lgndr/__init__.py | 5 ++ rare/lgndr/api_arguments.py | 71 ++++++++++++++++++ rare/lgndr/{exception.py => api_exception.py} | 0 rare/lgndr/cli.py | 72 ++++--------------- rare/lgndr/core.py | 2 +- rare/utils/legendary_utils.py | 31 ++++---- 10 files changed, 126 insertions(+), 101 deletions(-) create mode 100644 rare/lgndr/api_arguments.py rename rare/lgndr/{exception.py => api_exception.py} (100%) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 5ef822f7..bbf7b36a 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -8,6 +8,7 @@ from PyQt5.QtCore import Qt, QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSl from PyQt5.QtGui import QCloseEvent, QKeyEvent from PyQt5.QtWidgets import QDialog, QFileDialog, QCheckBox +from rare.lgndr.api_arguments import LgndrInstallGameArgs from rare.lgndr.cli import LegendaryCLI from rare.lgndr.core import LegendaryCore from legendary.models.downloading import ConditionCheckResult @@ -311,7 +312,7 @@ class InstallInfoWorker(QRunnable): cli = LegendaryCLISingleton() download = InstallDownloadModel( # *self.core.prepare_download( - *cli.prepare_install( + *cli.prepare_install(LgndrInstallGameArgs( app_name=self.dl_item.options.app_name, base_path=self.dl_item.options.base_path, force=self.dl_item.options.force, @@ -336,7 +337,7 @@ class InstallInfoWorker(QRunnable): # disable_delta=, # override_delta_manifest=, # reset_sdl=, - sdl_prompt=lambda app_name, title: self.dl_item.options.sdl_list, + sdl_prompt=lambda app_name, title: self.dl_item.options.sdl_list,) ) ) diff --git a/rare/components/tabs/games/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py index 771036fd..666555f6 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -143,20 +143,27 @@ class GameInfo(QWidget, Ui_GameInfo): return 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.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_pool.start(verify_worker) self.move_button.setEnabled(False) - def verify_statistics(self, num, total, app_name): + @pyqtSlot(str, str) + def verify_error(self, app_name, message): + pass + + @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): + if success: QMessageBox.information( self, "Summary", 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 3c92a734..fc3b7991 100644 --- a/rare/components/tabs/games/import_sync/egl_sync_group.py +++ b/rare/components/tabs/games/import_sync/egl_sync_group.py @@ -6,7 +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.exception import LgndrException +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 ( diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 7f78acf4..678204ba 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -11,7 +11,8 @@ 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.exception import LgndrException +from lgndr.api_arguments import LgndrImportGameArgs +from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup from rare.utils import legendary_utils @@ -105,23 +106,12 @@ class ImportWorker(QRunnable): # else: # return err - # TODO: This should be moved into RareCore and wrap import_game - def import_game_args(self, app_path: str, app_name: str, platfrom: str = "Windows", - disable_check: bool = False, skip_dlcs: bool = False, with_dlcs: bool = False, yes: bool = False): - args = Namespace( - app_path=app_path, - app_name=app_name, - platform=platfrom, - disable_check=disable_check, - skip_dlcs=skip_dlcs, - with_dlcs=with_dlcs, - yes=yes, - ) - return args - def __import_game(self, app_name: str, path: Path): cli = LegendaryCLISingleton() - args = self.import_game_args(str(path), app_name) + args = LgndrImportGameArgs( + app_path=str(path), + app_name=app_name, + ) try: cli.import_game(args) return "" diff --git a/rare/lgndr/__init__.py b/rare/lgndr/__init__.py index e69de29b..18d65d6b 100644 --- a/rare/lgndr/__init__.py +++ 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..81380493 --- /dev/null +++ b/rare/lgndr/api_arguments.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from multiprocessing import Queue +from typing import Callable, List + + +@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 + + +@dataclass +class LgndrVerifyGameArgs: + app_name: str + # Rare's extra arguments + verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( + f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" + ) + + +@dataclass +class LgndrInstallGameArgs: + app_name: str + base_path: str = "" + status_q: Queue = None + 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: List = 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 + yes: bool = True + # Rare's extra arguments + sdl_prompt: Callable[[str, str], List[str]] = lambda a0, a1: [] + verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( + f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" + ) diff --git a/rare/lgndr/exception.py b/rare/lgndr/api_exception.py similarity index 100% rename from rare/lgndr/exception.py rename to rare/lgndr/api_exception.py diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index f2b6bb2a..bf5f4d01 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,9 +1,7 @@ import os import logging import time -from argparse import Namespace -from multiprocessing import Queue -from typing import Optional, Callable, List +from typing import Optional, Union import legendary.cli from PyQt5.QtWidgets import QLabel, QMessageBox @@ -14,8 +12,9 @@ from legendary.utils.lfs import validate_files from legendary.utils.selective_dl import get_sdl_appname from .core import LegendaryCore -from .exception import LgndrException, LgndrLogHandler from .manager import DLManager +from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs +from .api_exception import LgndrException, LgndrLogHandler def get_boolean_choice(message): @@ -40,49 +39,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.handler = LgndrLogHandler() self.logger.addHandler(self.handler) - # def __init__(self, core: LegendaryCore): - # self.core = core - # self.logger = logging.getLogger('cli') - # self.logging_queue = None - # self.handler = LgndrLogHandler() - # self.logger.addHandler(self.handler) - - # app_name, repair, repair_mode, no_install, repair_and_update, file_prefix, file_exclude_prefix, platform - # sdl_prompt, status_q - def prepare_install(self, app_name: str, base_path: str = '', - status_q: Queue = None, 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: list = 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, - sdl_prompt: Callable[[str, str], List[str]] = list, - yes: bool = True, verify_callback: Callable[[int, int, float, float], None] = None, - disable_https: bool = False) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): - args = Namespace( - app_name=app_name, base_path=base_path, - status_q=status_q, shared_memory=shared_memory, 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=file_prefix, - file_exclude_prefix=file_exclude_prefix, install_tag=install_tag, - order_opt=order_opt, dl_timeout=dl_timeout, - repair_mode=repair_mode, repair_and_update=repair_and_update, - disable_delta=disable_delta, override_delta_manifest=override_delta_manifest, - preferred_cdn=preferred_cdn, - no_install=no_install, ignore_space=ignore_space, - disable_sdl=disable_sdl, reset_sdl=reset_sdl, skip_sdl=skip_sdl, - sdl_prompt=sdl_prompt, - yes=yes, callback=verify_callback, - disable_https=disable_https - ) + def prepare_install(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): old_choice = legendary.cli.get_boolean_choice legendary.cli.get_boolean_choice = get_boolean_choice try: @@ -92,7 +49,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): finally: legendary.cli.get_boolean_choice = old_choice - def install_game(self, args): + def install_game(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): 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) @@ -267,10 +224,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): def uninstall_game(self, args): super(LegendaryCLI, self).uninstall_game(args) - def verify_game(self, args, print_command=True, repair_mode=False, repair_online=False): - if not hasattr(args, 'callback') or args.callback is None: - args.callback = print - + def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False): 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') @@ -339,8 +293,8 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): speed = (processed - last_processed) / 1024 / 1024 / delta last_processed = processed - if args.callback: - args.callback(num, total, percentage, speed) + if args.verify_stdout: + args.verify_stdout(num, total, percentage, speed) if result == VerifyResult.HASH_MATCH: repair_file.append(f'{result_hash}:{path}') @@ -356,8 +310,8 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): logger.info(f'Other failure (see log), treating file as missing: "{path}"') missing.append(path) - if args.callback: - args.callback(num, total, percentage, speed) + if args.verify_stdout: + args.verify_stdout(num, total, percentage, speed) # always write repair file, even if all match if repair_file: @@ -368,12 +322,14 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): if not missing and not failed: logger.info('Verification finished successfully.') + return True, 0, 0 else: - logger.error(f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') + logger.warning(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 False, len(failed), len(missing) - def import_game(self, args): + def import_game(self, args: LgndrImportGameArgs): old_choice = legendary.cli.get_boolean_choice legendary.cli.get_boolean_choice = get_boolean_choice try: diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 7434e1a5..d1e6ad95 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -5,7 +5,7 @@ from legendary.models.downloading import AnalysisResult from legendary.models.game import Game from legendary.models.manifest import ManifestMeta -from .exception import LgndrException, LgndrLogHandler +from .api_exception import LgndrException, LgndrLogHandler from .manager import DLManager diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 90c80963..d56b2841 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -1,17 +1,14 @@ import os import platform -from argparse import Namespace from logging import getLogger from PyQt5.QtCore import pyqtSignal, QCoreApplication, QObject, QRunnable, QStandardPaths from legendary.core import LegendaryCore -from legendary.models.game import VerifyResult -from legendary.utils.lfs import validate_files -from parse import parse +from rare.lgndr.api_arguments import LgndrVerifyGameArgs +from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton from rare.utils import config_helper -from rare.lgndr.exception import LgndrException logger = getLogger("Legendary Utils") @@ -88,8 +85,9 @@ def update_manifest(app_name: str, core: LegendaryCore): class VerifyWorker(QRunnable): class Signals(QObject): - status = pyqtSignal(int, int, str) - summary = pyqtSignal(int, int, str) + 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 @@ -103,25 +101,22 @@ class VerifyWorker(QRunnable): self.app_name = app_name def status_callback(self, num: int, total: int, percentage: float, speed: float): - self.signals.status.emit(num, total, self.app_name) + self.signals.status.emit(self.app_name, num, total, percentage, speed) def run(self): - args = Namespace(app_name=self.app_name, - callback=self.status_callback) + args = LgndrVerifyGameArgs(app_name=self.app_name, + verify_stdout=self.status_callback) try: # TODO: offer this as an alternative when manifest doesn't exist # TODO: requires the client to be online. To do it this way, we need to # TODO: somehow detect the error and offer a dialog in which case `verify_games` is # TODO: re-run with `repair_mode` and `repair_online` - self.cli.verify_game(args, print_command=False, repair_mode=True, repair_online=True) - # self.cli.verify_game(args, print_command=False) - self.signals.summary.emit(0, 0, self.app_name) + success, failed, missing = self.cli.verify_game( + args, print_command=False, repair_mode=True, repair_online=True) + # success, failed, missing = self.cli.verify_game(args, print_command=False) + self.signals.result.emit(self.app_name, success, failed, missing) except LgndrException as ret: - r = parse('Verification failed, {:d} file(s) corrupted, {:d} file(s) are missing.', ret.message) - if r is None: - raise ret - else: - self.signals.summary.emit(r[0], r[1], self.app_name) + self.signals.error.emit(self.app_name, ret.message) # FIXME: lk: ah ef me sideways, we can't even import this thing properly From 545d77ef072c2868a44869e74576b2278e49b56d Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 21:23:54 +0300 Subject: [PATCH 23/61] Lgndr: Expose 'resolve_aliases' for outside usage --- rare/components/tabs/games/import_sync/import_group.py | 3 +-- rare/lgndr/cli.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 678204ba..90e54314 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -28,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 := LegendaryCLISingleton().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 diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index bf5f4d01..9b4d1959 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -39,6 +39,9 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.handler = LgndrLogHandler() self.logger.addHandler(self.handler) + def resolve_aliases(self, name): + super(LegendaryCLI, self)._resolve_aliases(name) + def prepare_install(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): old_choice = legendary.cli.get_boolean_choice legendary.cli.get_boolean_choice = get_boolean_choice From 10b845c0aad284d9ba4045705c0cbff878f39b44 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 21:49:31 +0300 Subject: [PATCH 24/61] Lgndr: Save the manifest if it was downloaded while verifying --- rare/lgndr/cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 9b4d1959..f6fb3918 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -251,6 +251,10 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 ' From 53fba8c705bcc6d889ee41adb1e93c3a22a10f65 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 21:58:29 +0300 Subject: [PATCH 25/61] Lgndr: Set 'estimate' to 0 before it can be calculated --- rare/lgndr/manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index 45cdf22f..a8f0bbed 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -116,6 +116,7 @@ class DLManager(legendary.downloader.mp.manager.DLManager): 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 From 9acfd7a67fcdc8e60bf71969f815fb3e28d02e89 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 22:23:53 +0300 Subject: [PATCH 26/61] Lgndr: Follow comment pattern --- rare/lgndr/api_arguments.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 81380493..eb56aa38 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -26,7 +26,7 @@ class LgndrImportGameArgs: @dataclass class LgndrVerifyGameArgs: app_name: str - # Rare's extra arguments + # Rare: Extra arguments verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" ) @@ -64,7 +64,7 @@ class LgndrInstallGameArgs: skip_sdl: bool = False disable_https: bool = False yes: bool = True - # Rare's extra arguments + # Rare: Extra arguments sdl_prompt: Callable[[str, str], List[str]] = lambda a0, a1: [] verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" From caffd9c51dda6d6239a597829ba95f4e2657587f Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 23:30:48 +0300 Subject: [PATCH 27/61] Lgndr: Return result from resolve_aliases (woops) --- rare/lgndr/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index f6fb3918..172432c8 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -40,7 +40,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.logger.addHandler(self.handler) def resolve_aliases(self, name): - super(LegendaryCLI, self)._resolve_aliases(name) + return super(LegendaryCLI, self)._resolve_aliases(name) def prepare_install(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): old_choice = legendary.cli.get_boolean_choice From a208c9b1d4152c7d21b5e31c1eaf64ce547725d7 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 10 Jul 2022 23:35:44 +0300 Subject: [PATCH 28/61] Revert "Requirements: Add parse" This reverts commit e100d90708a98e29d23826f56e61561db96b60be. --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4dc04ff5..a2034e62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,5 @@ QtAwesome psutil pypresence setuptools -parse legendary-gl pywin32; platform_system == "Windows" From 7aded95a1fadccb43387c297fd0fc9bc1db7916b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 11 Jul 2022 11:41:22 +0300 Subject: [PATCH 29/61] Lgndr: Monkeypatch modified DLManager into LegendaryCore at module import --- rare/lgndr/core.py | 59 +++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index d1e6ad95..1f4e463d 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -8,6 +8,9 @@ from legendary.models.manifest import ManifestMeta from .api_exception import LgndrException, LgndrLogHandler from .manager import DLManager +# lk: Monkeypatch the modified DLManager into LegendaryCore +legendary.core.DLManager = DLManager + class LegendaryCore(legendary.core.LegendaryCore): @@ -28,30 +31,21 @@ class LegendaryCore(legendary.core.LegendaryCore): disable_delta: bool = False, override_delta_manifest: str = '', egl_guid: str = '', preferred_cdn: str = None, disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta): - _dlmanager = legendary.core.DLManager - legendary.core.DLManager = DLManager - try: - 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) - return dlm, analysis, igame - except LgndrException as ret: - raise ret - finally: - legendary.core.DLManager = _dlmanager + 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 + ) + return dlm, analysis, igame def egl_import(self, app_name): try: @@ -70,16 +64,7 @@ class LegendaryCore(legendary.core.LegendaryCore): pass def prepare_overlay_install(self, path=None, status_q: Queue = None): - _dlmanager = legendary.core.DLManager - legendary.core.DLManager = DLManager - try: - dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) - # lk: monkeypatch status_q (the queue for download stats) - # lk: and run_real (the method that emits the stats) into DLManager - dlm.status_queue = status_q - # dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) - return dlm, analysis_result, igame - except LgndrException as ret: - raise ret - finally: - legendary.core.DLManager = _dlmanager + dlm, analysis_result, igame = super(LegendaryCore, self).prepare_overlay_install(path) + # lk: monkeypatch status_q (the queue for download stats) + dlm.status_queue = status_q + return dlm, analysis_result, igame From aeb149a3e9c458bb117feb765c5a083618261fcd Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 11 Jul 2022 13:22:17 +0300 Subject: [PATCH 30/61] Lgndr: Use custom `wrapped` decorator to wrap LegendaryCLI functions Lgndr: Add `get_boolean_choice` to relevant args dataclasses Lgndr: Move mock functions to `api_monkeys` InstallDialog: Add status queue to prepare_overlay_install arguments, fixes missing download stats --- rare/components/dialogs/install_dialog.py | 7 ++- rare/lgndr/api_arguments.py | 5 ++ rare/lgndr/api_monkeys.py | 21 ++++++++ rare/lgndr/cli.py | 63 +++++++++++------------ rare/shared/__init__.py | 2 +- 5 files changed, 62 insertions(+), 36 deletions(-) create mode 100644 rare/lgndr/api_monkeys.py diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index bbf7b36a..17965aba 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -9,7 +9,7 @@ from PyQt5.QtGui import QCloseEvent, QKeyEvent from PyQt5.QtWidgets import QDialog, QFileDialog, QCheckBox from rare.lgndr.api_arguments import LgndrInstallGameArgs -from rare.lgndr.cli import LegendaryCLI +from rare.lgndr.api_exception import LgndrException from rare.lgndr.core import LegendaryCore from legendary.models.downloading import ConditionCheckResult from legendary.models.game import Game @@ -312,7 +312,7 @@ class InstallInfoWorker(QRunnable): cli = LegendaryCLISingleton() download = InstallDownloadModel( # *self.core.prepare_download( - *cli.prepare_install(LgndrInstallGameArgs( + *cli.install_game(LgndrInstallGameArgs( app_name=self.dl_item.options.app_name, base_path=self.dl_item.options.base_path, force=self.dl_item.options.force, @@ -347,6 +347,7 @@ class InstallInfoWorker(QRunnable): dlm, analysis, igame = self.core.prepare_overlay_install( path=self.dl_item.options.base_path, + status_q=self.dl_item.status_q, ) download = InstallDownloadModel( @@ -363,6 +364,8 @@ 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() diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index eb56aa38..9dd0c1d5 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from multiprocessing import Queue from typing import Callable, List +from .api_monkeys import get_boolean_choice + @dataclass(kw_only=True) class LgndrCommonArgs: @@ -21,6 +23,8 @@ class LgndrImportGameArgs: skip_dlcs: bool = False with_dlcs: bool = False yes: bool = False + # Rare: Extra arguments + get_boolean_choice: Callable[[str], bool] = get_boolean_choice @dataclass @@ -65,6 +69,7 @@ class LgndrInstallGameArgs: disable_https: bool = False yes: bool = True # Rare: Extra arguments + get_boolean_choice: Callable[[str], bool] = get_boolean_choice sdl_prompt: Callable[[str, str], List[str]] = lambda a0, a1: [] verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py new file mode 100644 index 00000000..6148bf1a --- /dev/null +++ b/rare/lgndr/api_monkeys.py @@ -0,0 +1,21 @@ +import logging + +from PyQt5.QtWidgets import QMessageBox, QLabel + + +def get_boolean_choice(a0): + choice = QMessageBox.question(None, "Import DLCs?", a0) + return True if choice == QMessageBox.StandardButton.Yes else False + + +def return_exit(__status): + return __status + + +class UILogHandler(logging.Handler): + def __init__(self, dest: QLabel): + super(UILogHandler, self).__init__() + self.widget = dest + + def emit(self, record: logging.LogRecord) -> None: + self.widget.setText(record.getMessage()) \ No newline at end of file diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 172432c8..fde08990 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,7 +1,8 @@ +import functools import os import logging import time -from typing import Optional, Union +from typing import Optional, Union, overload import legendary.cli from PyQt5.QtWidgets import QLabel, QMessageBox @@ -15,25 +16,12 @@ from .core import LegendaryCore from .manager import DLManager from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs from .api_exception import LgndrException, LgndrLogHandler - - -def get_boolean_choice(message): - choice = QMessageBox.question(None, "Import DLCs?", message) - return True if choice == QMessageBox.StandardButton.Yes else False - - -class UILogHandler(logging.Handler): - def __init__(self, dest: QLabel): - super(UILogHandler, self).__init__() - self.widget = dest - - def emit(self, record: logging.LogRecord) -> None: - self.widget.setText(record.getMessage()) +from .api_monkeys import return_exit, get_boolean_choice class LegendaryCLI(legendary.cli.LegendaryCLI): def __init__(self, override_config=None, api_timeout=None): - self.core = LegendaryCore(override_config) + self.core = LegendaryCore(override_config, timeout=api_timeout) self.logger = logging.getLogger('cli') self.logging_queue = None self.handler = LgndrLogHandler() @@ -42,16 +30,30 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): def resolve_aliases(self, name): return super(LegendaryCLI, self)._resolve_aliases(name) - def prepare_install(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): - old_choice = legendary.cli.get_boolean_choice - legendary.cli.get_boolean_choice = get_boolean_choice - try: - return self.install_game(args) - except LgndrException as ret: - raise ret - finally: - legendary.cli.get_boolean_choice = old_choice + @staticmethod + def wrapped(func): + @functools.wraps(func) + def inner(self, args, *oargs, **kwargs): + old_exit = legendary.cli.exit + legendary.cli.exit = return_exit + + old_choice = legendary.cli.get_boolean_choice + if hasattr(args, 'get_boolean_choice') and args.get_boolean_choice is not None: + legendary.cli.get_boolean_choice = args.get_boolean_choice + + try: + return func(self, args, *oargs, **kwargs) + except LgndrException as ret: + print(f'Caught exception in wrapped function {ret.message}') + raise ret + finally: + legendary.cli.get_boolean_choice = old_choice + legendary.cli.exit = old_exit + + return inner + + @wrapped def install_game(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): args.app_name = self._resolve_aliases(args.app_name) if self.core.is_installed(args.app_name): @@ -224,6 +226,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): def _handle_postinstall(self, postinstall, igame, yes=False): super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) + @wrapped def uninstall_game(self, args): super(LegendaryCLI, self).uninstall_game(args) @@ -336,12 +339,6 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.') return False, len(failed), len(missing) + @wrapped def import_game(self, args: LgndrImportGameArgs): - old_choice = legendary.cli.get_boolean_choice - legendary.cli.get_boolean_choice = get_boolean_choice - try: - super(LegendaryCLI, self).import_game(args) - except LgndrException as ret: - raise ret - finally: - legendary.cli.get_boolean_choice = old_choice + super(LegendaryCLI, self).import_game(args) diff --git a/rare/shared/__init__.py b/rare/shared/__init__.py index ace02987..7cc8bb82 100644 --- a/rare/shared/__init__.py +++ b/rare/shared/__init__.py @@ -26,7 +26,7 @@ def LegendaryCLISingleton(init: bool = False) -> LegendaryCLI: if _legendary_cli_signleton is None and not init: raise RuntimeError("Uninitialized use of LegendaryCLISingleton") if _legendary_cli_signleton is None: - _legendary_cli_signleton = LegendaryCLI() + _legendary_cli_signleton = LegendaryCLI(override_config=None, api_timeout=10) return _legendary_cli_signleton From b4a1efa357133eeb75881f8942b7da51f428ce27 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 11 Jul 2022 20:11:34 +0300 Subject: [PATCH 31/61] Lgndr: Add 'repair_and_update' argument in InstallOptionsModel --- rare/components/dialogs/install_dialog.py | 2 +- rare/lgndr/api_arguments.py | 3 ++- rare/lgndr/cli.py | 9 +++++---- rare/utils/models.py | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 17965aba..c1dff40a 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -332,7 +332,7 @@ class InstallInfoWorker(QRunnable): order_opt=self.dl_item.options.order_opt, # dl_timeout=, repair_mode=self.dl_item.options.repair_mode, - # repair_and_update=True, + repair_and_update=self.dl_item.options.repair_and_update, ignore_space=self.dl_item.options.ignore_space, # disable_delta=, # override_delta_manifest=, diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 9dd0c1d5..b3ce7d02 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -4,7 +4,7 @@ from typing import Callable, List from .api_monkeys import get_boolean_choice - +""" @dataclass(kw_only=True) class LgndrCommonArgs: # keep this here for future reference @@ -12,6 +12,7 @@ class LgndrCommonArgs: app_name: str platform: str = "Windows" yes: bool = False +""" @dataclass diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index fde08990..643b8f79 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -2,10 +2,9 @@ import functools import os import logging import time -from typing import Optional, Union, overload +from typing import Optional, Union import legendary.cli -from PyQt5.QtWidgets import QLabel, QMessageBox from legendary.cli import logger from legendary.models.downloading import AnalysisResult, ConditionCheckResult from legendary.models.game import Game, InstalledGame, VerifyResult @@ -16,7 +15,7 @@ from .core import LegendaryCore from .manager import DLManager from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs from .api_exception import LgndrException, LgndrLogHandler -from .api_monkeys import return_exit, get_boolean_choice +from .api_monkeys import return_exit as exit, get_boolean_choice class LegendaryCLI(legendary.cli.LegendaryCLI): @@ -36,7 +35,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): @functools.wraps(func) def inner(self, args, *oargs, **kwargs): old_exit = legendary.cli.exit - legendary.cli.exit = return_exit + legendary.cli.exit = exit old_choice = legendary.cli.get_boolean_choice if hasattr(args, 'get_boolean_choice') and args.get_boolean_choice is not None: @@ -64,6 +63,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 @@ -230,6 +230,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): def uninstall_game(self, args): super(LegendaryCLI, self).uninstall_game(args) + @wrapped def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False): args.app_name = self._resolve_aliases(args.app_name) if not self.core.is_installed(args.app_name): diff --git a/rare/utils/models.py b/rare/utils/models.py index 6a20e06a..926e031b 100644 --- a/rare/utils/models.py +++ b/rare/utils/models.py @@ -17,6 +17,7 @@ class InstallOptionsModel: shared_memory: int = 1024 max_workers: int = os.cpu_count() * 2 repair_mode: bool = False + repair_and_update: bool = False no_install: bool = False ignore_space: bool = False force: bool = False From 9df3355200ae8c90a6f789a67347462c45a16159 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 11 Jul 2022 23:03:17 +0300 Subject: [PATCH 32/61] LegendaryUtils: Remove dead re-implemented functions --- rare/utils/legendary_utils.py | 55 ----------------------------------- 1 file changed, 55 deletions(-) diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index d56b2841..32cf1508 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -131,58 +131,3 @@ def resolve_aliases(core: LegendaryCore, 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 - ) - - 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 - ) - else: - return _tr("LgdUtils", "Unable to get base game information for DLC") - - 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 - - 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 "" From 14087a3c6c7fe8cdc5212cdf6ce9d0196451a978 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Tue, 12 Jul 2022 23:47:23 +0300 Subject: [PATCH 33/61] Lgndr: Add default implementation for `get_boolean_choice` that returns `False` --- rare/components/tabs/games/import_sync/import_group.py | 7 ++++++- rare/lgndr/api_arguments.py | 4 ++-- rare/lgndr/cli.py | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 90e54314..a19a9ed4 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -11,7 +11,7 @@ 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 lgndr.api_arguments import LgndrImportGameArgs +from rare.lgndr.api_arguments import LgndrImportGameArgs from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup @@ -105,11 +105,16 @@ class ImportWorker(QRunnable): # else: # return err + def get_boolean_choice(self, a0): + choice = QMessageBox.question(None, "Import DLCs?", a0) + return True if choice == QMessageBox.StandardButton.Yes else False + def __import_game(self, app_name: str, path: Path): cli = LegendaryCLISingleton() args = LgndrImportGameArgs( app_path=str(path), app_name=app_name, + get_boolean_choice=self.get_boolean_choice ) try: cli.import_game(args) diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index b3ce7d02..d1328ccd 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -25,7 +25,7 @@ class LgndrImportGameArgs: with_dlcs: bool = False yes: bool = False # Rare: Extra arguments - get_boolean_choice: Callable[[str], bool] = get_boolean_choice + get_boolean_choice: Callable[[str], bool] = lambda a0: False @dataclass @@ -70,7 +70,7 @@ class LgndrInstallGameArgs: disable_https: bool = False yes: bool = True # Rare: Extra arguments - get_boolean_choice: Callable[[str], bool] = get_boolean_choice + get_boolean_choice: Callable[[str], bool] = lambda a0: False sdl_prompt: Callable[[str, str], List[str]] = lambda a0, a1: [] verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 643b8f79..4bccd8ea 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -44,7 +44,6 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): try: return func(self, args, *oargs, **kwargs) except LgndrException as ret: - print(f'Caught exception in wrapped function {ret.message}') raise ret finally: legendary.cli.get_boolean_choice = old_choice @@ -111,7 +110,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): if not os.path.exists(repair_file): logger.info('Game has not been verified yet.') if not args.yes: - if not get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): + if not args.get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): return try: self.verify_game(args, print_command=False, repair_mode=True, repair_online=args.repair_and_update) @@ -223,7 +222,8 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - def _handle_postinstall(self, postinstall, igame, yes=False): + @wrapped + def handle_postinstall(self, postinstall, igame, yes=False): super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) @wrapped From d05f74b286d6410200cf6835020cf77ff7e3109b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Thu, 14 Jul 2022 12:01:57 +0300 Subject: [PATCH 34/61] ImportGroup: Add option to import DLCs --- .../tabs/games/import_sync/import_group.py | 64 ++++++++++--------- .../tabs/games/import_sync/import_group.py | 16 ++++- .../tabs/games/import_sync/import_group.ui | 24 ++++++- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index a19a9ed4..3e276367 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -15,7 +15,6 @@ from rare.lgndr.api_arguments import LgndrImportGameArgs from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCLISingleton, 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 logger = getLogger("Import") @@ -56,15 +55,17 @@ 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.path = Path(path) + self.app_name = app_name + self.import_folder = import_folder + self.import_dlcs = import_dlcs + def run(self) -> None: result_list: List = [] if self.import_folder: @@ -85,8 +86,8 @@ class ImportWorker(QRunnable): result = ImportedGame(ImportResult.ERROR, None, None) 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) app_title = self.core.get_game(app_name).app_title + err = self.__import_game(path, app_name, app_title) if err: result.result = ImportResult.FAILED result.message = f"{app_title} - {err}" @@ -97,24 +98,12 @@ class ImportWorker(QRunnable): 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 get_boolean_choice(self, a0): - choice = QMessageBox.question(None, "Import DLCs?", a0) - return True if choice == QMessageBox.StandardButton.Yes else False - - def __import_game(self, app_name: str, path: Path): + def __import_game(self, path: Path, app_name: str, app_title: str): cli = LegendaryCLISingleton() args = LgndrImportGameArgs( app_path=str(path), app_name=app_name, - get_boolean_choice=self.get_boolean_choice + get_boolean_choice=lambda a0: self.import_dlcs ) try: cli.import_game(args) @@ -202,17 +191,13 @@ 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.threadpool = QThreadPool.globalInstance() def path_edit_cb(self, path) -> Tuple[bool, str, str]: @@ -227,7 +212,7 @@ class ImportGroup(QGroupBox): def path_changed(self, path): self.ui.info_label.setText("") - self.ui.import_folder_check.setChecked(False) + 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: @@ -241,17 +226,36 @@ class ImportGroup(QGroupBox): else: return False, text, IndicatorLineEdit.reasons.game_not_installed - def app_name_changed(self, text): + def app_name_changed(self, app_name: str): self.ui.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) 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..fbb0f031 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) @@ -54,7 +64,7 @@ class Ui_ImportGroup(object): 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 +76,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..870d0512 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 + + + + From 99cb7f46fd6471fc3e5c4941f75fe8ffbc4d5019 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 15 Jul 2022 20:58:25 +0300 Subject: [PATCH 35/61] ImportGroup: Set the wordwrap option for the information label --- rare/ui/components/tabs/games/import_sync/import_group.py | 1 + rare/ui/components/tabs/games/import_sync/import_group.ui | 3 +++ 2 files changed, 4 insertions(+) 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 fbb0f031..f0193152 100644 --- a/rare/ui/components/tabs/games/import_sync/import_group.py +++ b/rare/ui/components/tabs/games/import_sync/import_group.py @@ -62,6 +62,7 @@ class Ui_ImportGroup(object): self.button_info_layout.addWidget(self.import_button) self.info_label = QtWidgets.QLabel(ImportGroup) self.info_label.setText("") + self.info_label.setWordWrap(True) self.info_label.setObjectName("info_label") self.button_info_layout.addWidget(self.info_label) self.formLayout.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.button_info_layout) 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 870d0512..9ada1618 100644 --- a/rare/ui/components/tabs/games/import_sync/import_group.ui +++ b/rare/ui/components/tabs/games/import_sync/import_group.ui @@ -101,6 +101,9 @@ + + true + From 2bf41f20d18bfa06b5fd4db88f1ae5e5f442b960 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 15 Jul 2022 21:00:52 +0300 Subject: [PATCH 36/61] Lgndr: Match the arguments of legendary's get_boolean_choice --- .../tabs/games/import_sync/import_group.py | 2 +- rare/lgndr/api_arguments.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 3e276367..9654f6f6 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -103,7 +103,7 @@ class ImportWorker(QRunnable): args = LgndrImportGameArgs( app_path=str(path), app_name=app_name, - get_boolean_choice=lambda a0: self.import_dlcs + get_boolean_choice=lambda prompt, default=True: self.import_dlcs ) try: cli.import_game(args) diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index d1328ccd..273624ab 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -25,7 +25,16 @@ class LgndrImportGameArgs: with_dlcs: bool = False yes: bool = False # Rare: Extra arguments - get_boolean_choice: Callable[[str], bool] = lambda a0: False + get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default + + +@dataclass +class LgndrUninstallGameArgs: + app_name: str + keep_files: bool = False + yes: bool = False + # Rare: Extra arguments + get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default @dataclass @@ -70,8 +79,8 @@ class LgndrInstallGameArgs: disable_https: bool = False yes: bool = True # Rare: Extra arguments - get_boolean_choice: Callable[[str], bool] = lambda a0: False - sdl_prompt: Callable[[str, str], List[str]] = lambda a0, a1: [] + get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default + sdl_prompt: Callable[[str, str], List[str]] = lambda sdl_data, title: [""] verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" ) From 5c1ff813700f3c9473554d7d11d231f889c78678 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 15 Jul 2022 21:02:10 +0300 Subject: [PATCH 37/61] LegendarySettings: clean_manifests expects more values --- rare/components/tabs/settings/legendary.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rare/components/tabs/settings/legendary.py b/rare/components/tabs/settings/legendary.py index 1f7c3435..2d3871a3 100644 --- a/rare/components/tabs/settings/legendary.py +++ b/rare/components/tabs/settings/legendary.py @@ -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) From 654f73497503af79367e426712dec92175016ff9 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 15 Jul 2022 21:06:01 +0300 Subject: [PATCH 38/61] Lgndr: Update test get_boolean_choice to the arguments of its counterpart --- rare/lgndr/api_monkeys.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py index 6148bf1a..92cbb21b 100644 --- a/rare/lgndr/api_monkeys.py +++ b/rare/lgndr/api_monkeys.py @@ -3,8 +3,11 @@ import logging from PyQt5.QtWidgets import QMessageBox, QLabel -def get_boolean_choice(a0): - choice = QMessageBox.question(None, "Import DLCs?", a0) +def get_boolean_choice(prompt, default=True): + choice = QMessageBox.question( + None, "Import DLCs?", prompt, + defaultButton=QMessageBox.Yes if default else QMessageBox.No + ) return True if choice == QMessageBox.StandardButton.Yes else False @@ -18,4 +21,4 @@ class UILogHandler(logging.Handler): self.widget = dest def emit(self, record: logging.LogRecord) -> None: - self.widget.setText(record.getMessage()) \ No newline at end of file + self.widget.setText(record.getMessage()) From 8bbb42a0452696ad3cc671f40efa8e0c0cc41fed Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 15 Jul 2022 21:06:41 +0300 Subject: [PATCH 39/61] Lgndr: cleanup --- rare/lgndr/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index a8f0bbed..b73c855e 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -1,21 +1,21 @@ +import logging import os import time -import logging - from multiprocessing import Queue as MPQueue from multiprocessing.shared_memory import SharedMemory from sys import exit from threading import Condition, Thread +import legendary.downloader.mp.manager from legendary.downloader.mp.workers import DLWorker, FileWorker from legendary.models.downloading import ChunkTask, SharedMemorySegment, TerminateWorkerTask -import legendary.downloader.mp.manager from .downloading import UIUpdate class DLManager(legendary.downloader.mp.manager.DLManager): # fmt: off + # @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') From bbaff5f42c85609af1a3a38c5276ebe243a70753 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 15 Jul 2022 21:16:12 +0300 Subject: [PATCH 40/61] Lgndr: Add re-implemented uinstall_game Lgndr: Change the exception level to CRITICAL for core LegendaryUtils: Use uninstall_game from our Lgndr UninstallDialog: Update to return a tuple of values App: Keep files if the install directory was lost App: Run legendary's exit procedures on exit --- rare/app.py | 5 +- rare/components/dialogs/uninstall_dialog.py | 40 +++++++------ rare/components/tabs/games/game_utils.py | 6 +- rare/lgndr/api_exception.py | 13 +++- rare/lgndr/cli.py | 66 +++++++++++++++------ rare/lgndr/core.py | 18 ++++-- rare/utils/legendary_utils.py | 33 ++++------- 7 files changed, 111 insertions(+), 70 deletions(-) diff --git a/rare/app.py b/rare/app.py index b23aa185..4a0f292a 100644 --- a/rare/app.py +++ b/rare/app.py @@ -151,7 +151,9 @@ 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("/"))): @@ -228,6 +230,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/uninstall_dialog.py b/rare/components/dialogs/uninstall_dialog.py index 13caab00..4ead1380 100644 --- a/rare/components/dialogs/uninstall_dialog.py +++ b/rare/components/dialogs/uninstall_dialog.py @@ -1,3 +1,6 @@ +from enum import Enum, IntEnum +from typing import Tuple + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, @@ -16,21 +19,20 @@ from rare.utils.utils 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.layout.addWidget(self.info_text) + 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) + form_layout = QFormLayout() + form_layout.setContentsMargins(0, 10, 0, 10) + form_layout.addRow(QLabel(self.tr("Do you want to keep files?")), self.keep_files) + 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.addStretch(1) + button_layout.addWidget(self.ok_button) + button_layout.addWidget(self.cancel_button) + layout.addLayout(button_layout) + self.setLayout(layout) - def get_information(self): + self.options: Tuple[bool, bool] = (False, False) + + def get_options(self) -> Tuple[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.close() def cancel(self): - self.info = 0 + self.options = (False, False) self.close() diff --git a/rare/components/tabs/games/game_utils.py b/rare/components/tabs/games/game_utils.py index 56b6bf1c..a31338ad 100644 --- a/rare/components/tabs/games/game_utils.py +++ b/rare/components/tabs/games/game_utils.py @@ -164,10 +164,10 @@ class GameUtils(QObject): else: return False - infos = UninstallDialog(game).get_information() - if infos == 0: + proceed, keep_files = UninstallDialog(game).get_options() + if not proceed: return False - legendary_utils.uninstall(game.app_name, self.core, infos) + legendary_utils.uninstall_game(self.core, game.app_name, keep_files) self.signals.game_uninstalled.emit(app_name) return True diff --git a/rare/lgndr/api_exception.py b/rare/lgndr/api_exception.py index f31b94c0..11779f79 100644 --- a/rare/lgndr/api_exception.py +++ b/rare/lgndr/api_exception.py @@ -14,10 +14,19 @@ class LgndrWarning(RuntimeWarning): super(LgndrWarning, self).__init__(self.message) -class LgndrLogHandler(logging.Handler): +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.INFO or record.levelno == logging.WARNING: + # 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/cli.py b/rare/lgndr/cli.py index 4bccd8ea..9415ecd8 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -13,24 +13,17 @@ from legendary.utils.selective_dl import get_sdl_appname from .core import LegendaryCore from .manager import DLManager -from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs -from .api_exception import LgndrException, LgndrLogHandler +from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs, LgndrUninstallGameArgs +from .api_exception import LgndrException, LgndrCLILogHandler from .api_monkeys import return_exit as exit, get_boolean_choice +legendary.cli.exit = exit + class LegendaryCLI(legendary.cli.LegendaryCLI): - def __init__(self, override_config=None, api_timeout=None): - self.core = LegendaryCore(override_config, timeout=api_timeout) - self.logger = logging.getLogger('cli') - self.logging_queue = None - self.handler = LgndrLogHandler() - self.logger.addHandler(self.handler) - - def resolve_aliases(self, name): - return super(LegendaryCLI, self)._resolve_aliases(name) @staticmethod - def wrapped(func): + def apply_wrap(func): @functools.wraps(func) def inner(self, args, *oargs, **kwargs): @@ -39,6 +32,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): old_choice = legendary.cli.get_boolean_choice if hasattr(args, 'get_boolean_choice') and args.get_boolean_choice is not None: + # g['get_boolean_choice'] = args.get_boolean_choice legendary.cli.get_boolean_choice = args.get_boolean_choice try: @@ -46,12 +40,22 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): except LgndrException as ret: raise ret finally: + # g['get_boolean_choice'] = old_choice legendary.cli.get_boolean_choice = old_choice legendary.cli.exit = old_exit return inner - @wrapped + def __init__(self, override_config=None, api_timeout=None): + self.core = LegendaryCore(override_config, timeout=api_timeout) + self.logger = logging.getLogger('cli') + self.logging_queue = None + self.handler = LgndrCLILogHandler() + self.logger.addHandler(self.handler) + + def resolve_aliases(self, name): + return super(LegendaryCLI, self)._resolve_aliases(name) + def install_game(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): args.app_name = self._resolve_aliases(args.app_name) if self.core.is_installed(args.app_name): @@ -222,15 +226,39 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - @wrapped + @apply_wrap def handle_postinstall(self, postinstall, igame, yes=False): super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) - @wrapped - def uninstall_game(self, args): - super(LegendaryCLI, self).uninstall_game(args) + def uninstall_game(self, args: LgndrUninstallGameArgs): + 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!') + exit(0) + + if not args.yes: + if not get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): + return False + + 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 True + except Exception as e: + logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.') + return False - @wrapped def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False): args.app_name = self._resolve_aliases(args.app_name) if not self.core.is_installed(args.app_name): @@ -340,6 +368,6 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.') return False, len(failed), len(missing) - @wrapped + @apply_wrap def import_game(self, args: LgndrImportGameArgs): super(LegendaryCLI, self).import_game(args) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 1f4e463d..c479855a 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -2,10 +2,10 @@ from multiprocessing import Queue import legendary.core from legendary.models.downloading import AnalysisResult -from legendary.models.game import Game +from legendary.models.game import Game, InstalledGame from legendary.models.manifest import ManifestMeta -from .api_exception import LgndrException, LgndrLogHandler +from .api_exception import LgndrException, LgndrCoreLogHandler from .manager import DLManager # lk: Monkeypatch the modified DLManager into LegendaryCore @@ -16,7 +16,7 @@ class LegendaryCore(legendary.core.LegendaryCore): def __init__(self, override_config=None, timeout=10.0): super(LegendaryCore, self).__init__(override_config=override_config, timeout=timeout) - self.handler = LgndrLogHandler() + self.handler = LgndrCoreLogHandler() self.log.addHandler(self.handler) def prepare_download(self, game: Game, base_game: Game = None, base_path: str = '', @@ -31,7 +31,7 @@ class LegendaryCore(legendary.core.LegendaryCore): 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( + return 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, @@ -45,7 +45,14 @@ class LegendaryCore(legendary.core.LegendaryCore): egl_guid=egl_guid, preferred_cdn=preferred_cdn, disable_https=disable_https ) - 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: @@ -68,3 +75,4 @@ class LegendaryCore(legendary.core.LegendaryCore): # lk: monkeypatch status_q (the queue for download stats) dlm.status_queue = status_q return dlm, analysis_result, igame + diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 32cf1508..358f8c40 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -5,7 +5,7 @@ from logging import getLogger from PyQt5.QtCore import pyqtSignal, QCoreApplication, QObject, QRunnable, QStandardPaths from legendary.core import LegendaryCore -from rare.lgndr.api_arguments import LgndrVerifyGameArgs +from rare.lgndr.api_arguments import LgndrVerifyGameArgs, LgndrUninstallGameArgs from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton from rare.utils import config_helper @@ -13,9 +13,7 @@ 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): igame = core.get_installed_game(app_name) # remove shortcuts link @@ -40,31 +38,22 @@ 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 + result = LegendaryCLISingleton().uninstall_game( + LgndrUninstallGameArgs( + app_name=app_name, + keep_files=keep_files, + 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_files: 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 result + def update_manifest(app_name: str, core: LegendaryCore): game = core.get_game(app_name) From 28531eec386c84b809aa17877d53857bff4ee010 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Wed, 20 Jul 2022 23:33:44 +0300 Subject: [PATCH 41/61] Lgndr: Add logger monkey class The class acts as an intermediate between the logger and the function call It keeps the last message that was sent to the logger. The instance of the class can be returned as a return value from the LegendaryCLI methods to provide return status and the message related to it. The level at which it considers the logged message as an error is configurable. By default it considers logging.ERROR and above as faulty return values --- .../tabs/games/import_sync/import_group.py | 12 +- rare/lgndr/api_monkeys.py | 54 ++++++ rare/lgndr/cli.py | 171 ++++++++++++++---- rare/lgndr/core.py | 13 +- rare/lgndr/manager.py | 4 +- rare/utils/legendary_utils.py | 36 ++-- 6 files changed, 215 insertions(+), 75 deletions(-) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 9654f6f6..93cf52cb 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -87,10 +87,10 @@ class ImportWorker(QRunnable): if app_name or (app_name := find_app_name(str(path), self.core)): result.app_name = app_name app_title = self.core.get_game(app_name).app_title - err = self.__import_game(path, app_name, app_title) - if err: + success, message = self.__import_game(path, app_name, app_title) + if not success: result.result = ImportResult.FAILED - result.message = f"{app_title} - {err}" + result.message = f"{app_title} - {message}" else: result.result = ImportResult.SUCCESS result.message = self.tr("{} - Imported successfully").format(app_title) @@ -105,11 +105,7 @@ class ImportWorker(QRunnable): app_name=app_name, get_boolean_choice=lambda prompt, default=True: self.import_dlcs ) - try: - cli.import_game(args) - return "" - except LgndrException as ret: - return ret.message + return cli.import_game(args) class AppNameCompleter(QCompleter): diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py index 92cbb21b..8c0ddd50 100644 --- a/rare/lgndr/api_monkeys.py +++ b/rare/lgndr/api_monkeys.py @@ -22,3 +22,57 @@ class UILogHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: self.widget.setText(record.getMessage()) + + +class LgndrReturnLogger: + def __init__(self, logger: logging.Logger, level: int = logging.ERROR): + self.logger = logger + self.level = level + self.value = False + self.message = "" + + def _log(self, level: int, msg: str): + self.value = True if level < self.level else False + self.message = msg + 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) + + def __len__(self): + if self.message: + return 2 + else: + return 0 + + def __bool__(self): + return self.value + + def __getitem__(self, item): + if item == 0: + return self.value + elif item == 1: + return self.message + else: + raise IndexError + + def __iter__(self): + return iter((self.value, self.message)) + + def __str__(self): + return self.message diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 9415ecd8..3a4df888 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -4,8 +4,7 @@ import logging import time from typing import Optional, Union -import legendary.cli -from legendary.cli import logger +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 @@ -14,14 +13,12 @@ from legendary.utils.selective_dl import get_sdl_appname from .core import LegendaryCore from .manager import DLManager from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs, LgndrUninstallGameArgs -from .api_exception import LgndrException, LgndrCLILogHandler -from .api_monkeys import return_exit as exit, get_boolean_choice - -legendary.cli.exit = exit +from .api_monkeys import return_exit, get_boolean_choice, LgndrReturnLogger -class LegendaryCLI(legendary.cli.LegendaryCLI): +class LegendaryCLI(LegendaryCLIReal): + """ @staticmethod def apply_wrap(func): @@ -30,33 +27,35 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): old_exit = legendary.cli.exit legendary.cli.exit = exit - old_choice = legendary.cli.get_boolean_choice - if hasattr(args, 'get_boolean_choice') and args.get_boolean_choice is not None: - # g['get_boolean_choice'] = args.get_boolean_choice - legendary.cli.get_boolean_choice = args.get_boolean_choice + # old_choice = legendary.cli.get_boolean_choice + # if hasattr(args, 'get_boolean_choice') and args.get_boolean_choice is not None: + # legendary.cli.get_boolean_choice = args.get_boolean_choice try: return func(self, args, *oargs, **kwargs) except LgndrException as ret: raise ret finally: - # g['get_boolean_choice'] = old_choice - legendary.cli.get_boolean_choice = old_choice + # legendary.cli.get_boolean_choice = old_choice legendary.cli.exit = old_exit return inner + """ def __init__(self, override_config=None, api_timeout=None): self.core = LegendaryCore(override_config, timeout=api_timeout) self.logger = logging.getLogger('cli') self.logging_queue = None - self.handler = LgndrCLILogHandler() - self.logger.addHandler(self.handler) + # self.handler = LgndrCLILogHandler() + # self.logger.addHandler(self.handler) def resolve_aliases(self, name): return super(LegendaryCLI, self)._resolve_aliases(name) def install_game(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): + # Override logger for the local context to use message as part of the return value + logger = LgndrReturnLogger(self.logger) + 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) @@ -80,6 +79,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 logger if args.platform not in game.asset_infos: if not args.no_install: @@ -90,7 +90,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 + return logger else: logger.warning(f'No asset found for platform "{args.platform}", ' f'trying anyway since --no-install is set.') @@ -115,14 +115,14 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): logger.info('Game has not been verified yet.') if not args.yes: if not args.get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): - return + return logger try: self.verify_game(args, print_command=False, repair_mode=True, repair_online=args.repair_and_update) except ValueError: logger.error('To repair a game with a missing manifest you must run the command with ' '"--repair-and-update". However this will redownload any file that does ' 'not match the current hash in its entirety.') - return + return logger else: logger.info(f'Using existing repair file: {repair_file}') @@ -210,6 +210,9 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): return dlm, analysis, igame, game, args.repair_mode, repair_file, res def clean_post_install(self, game: Game, igame: InstalledGame, repair: bool = False, repair_file: str = ''): + # Override logger for the local context to use message as part of the return value + logger = LgndrReturnLogger(self.logger) + old_igame = self.core.get_installed_game(game.app_name) if old_igame and repair and os.path.exists(repair_file): if old_igame.needs_verification: @@ -226,20 +229,24 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - @apply_wrap + return logger + def handle_postinstall(self, postinstall, igame, yes=False): super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) def uninstall_game(self, args: LgndrUninstallGameArgs): + # Override logger for the local context to use message as part of the return value + logger = LgndrReturnLogger(self.logger, level=logging.WARNING) + 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!') - exit(0) + return logger if not args.yes: - if not get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): - return False + if not args.get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): + return logger try: if not igame.is_dlc: @@ -254,16 +261,19 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 True + return logger except Exception as e: logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.') - return False + return logger def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False): + # Override logger for the local context to use message as part of the return value + logger = LgndrReturnLogger(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 + return logger logger.info(f'Loading installed manifest for "{args.app_name}"') igame = self.core.get_installed_game(args.app_name) @@ -271,7 +281,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 + return logger manifest_data, _ = self.core.get_installed_manifest(args.app_name) if manifest_data is None: @@ -291,7 +301,7 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): 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 + return logger manifest = self.core.load_manifest(manifest_data) @@ -339,14 +349,14 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): repair_file.append(f'{result_hash}:{path}') continue elif result == VerifyResult.HASH_MISMATCH: - logger.info(f'File does not match hash: "{path}"') + 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.info(f'File is missing: "{path}"') + logger.error(f'File is missing: "{path}"') missing.append(path) else: - logger.info(f'Other failure (see log), treating file as missing: "{path}"') + logger.error(f'Other failure (see log), treating file as missing: "{path}"') missing.append(path) if args.verify_stdout: @@ -361,13 +371,106 @@ class LegendaryCLI(legendary.cli.LegendaryCLI): if not missing and not failed: logger.info('Verification finished successfully.') - return True, 0, 0 + return logger, 0, 0 else: logger.warning(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 False, len(failed), len(missing) + return logger, len(failed), len(missing) - @apply_wrap def import_game(self, args: LgndrImportGameArgs): - super(LegendaryCLI, self).import_game(args) + # Override logger for the local context to use message as part of the return value + logger = LgndrReturnLogger(self.logger) + + # 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 logger + + if self.core.is_installed(args.app_name): + logger.error('Game is already installed!') + return logger + + if not self.core.login(): + logger.error('Log in failed!') + return logger + + # 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 logger + + 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 logger + else: + logger.fatal(f'Unable to get base game information for DLC, cannot continue.') + return logger + + # 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 logger + + 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 logger + + 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 args.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 logger diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index c479855a..d96314cb 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -1,6 +1,6 @@ from multiprocessing import Queue -import legendary.core +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 @@ -8,11 +8,8 @@ from legendary.models.manifest import ManifestMeta from .api_exception import LgndrException, LgndrCoreLogHandler from .manager import DLManager -# lk: Monkeypatch the modified DLManager into LegendaryCore -legendary.core.DLManager = DLManager - -class LegendaryCore(legendary.core.LegendaryCore): +class LegendaryCore(LegendaryCoreReal): def __init__(self, override_config=None, timeout=10.0): super(LegendaryCore, self).__init__(override_config=override_config, timeout=timeout) @@ -31,7 +28,7 @@ class LegendaryCore(legendary.core.LegendaryCore): disable_delta: bool = False, override_delta_manifest: str = '', egl_guid: str = '', preferred_cdn: str = None, disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta): - return super(LegendaryCore, self).prepare_download( + 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, @@ -45,6 +42,9 @@ class LegendaryCore(legendary.core.LegendaryCore): 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) + return dlm, analysis, igame def uninstall_game(self, installed_game: InstalledGame, delete_files=True, delete_root_directory=False): try: @@ -73,6 +73,7 @@ class LegendaryCore(legendary.core.LegendaryCore): def prepare_overlay_install(self, path=None, status_q: Queue = 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) dlm.status_queue = status_q return dlm, analysis_result, igame diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index b73c855e..ee16a1de 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -6,14 +6,14 @@ from multiprocessing.shared_memory import SharedMemory from sys import exit from threading import Condition, Thread -import legendary.downloader.mp.manager +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 -class DLManager(legendary.downloader.mp.manager.DLManager): +class DLManager(DLManagerReal): # fmt: off # @staticmethod def run_real(self): diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 358f8c40..263f88fc 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -95,28 +95,14 @@ class VerifyWorker(QRunnable): def run(self): args = LgndrVerifyGameArgs(app_name=self.app_name, verify_stdout=self.status_callback) - try: - # TODO: offer this as an alternative when manifest doesn't exist - # TODO: requires the client to be online. To do it this way, we need to - # TODO: somehow detect the error and offer a dialog in which case `verify_games` is - # TODO: re-run with `repair_mode` and `repair_online` - success, failed, missing = self.cli.verify_game( - args, print_command=False, repair_mode=True, repair_online=True) - # success, failed, missing = self.cli.verify_game(args, print_command=False) - self.signals.result.emit(self.app_name, success, failed, missing) - except LgndrException as ret: - self.signals.error.emit(self.app_name, ret.message) - - -# 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) - ) - + # TODO: offer this as an alternative when manifest doesn't exist + # TODO: requires the client to be online. To do it this way, we need to + # TODO: somehow detect the error and offer a dialog in which case `verify_games` is + # TODO: re-run with `repair_mode` and `repair_online` + result, failed, missing = self.cli.verify_game( + args, print_command=False, repair_mode=True, repair_online=True) + # success, failed, missing = self.cli.verify_game(args, print_command=False) + if result: + self.signals.result.emit(self.app_name, not failed and not missing, failed, missing) + else: + self.signals.error.emit(self.app_name, result.message) From ee5adce18b18d23ae6e687a5ce5d5c79629bd85e Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 24 Jul 2022 01:06:35 +0300 Subject: [PATCH 42/61] Implement `LgndrIndirectLogger` to return the last logged message from LegendaryAPI The indirect return is stored in a `LgndrIndirectStatus` object that provides checking and unpacking features Lgndr: `LgndrInstallGameArgs.install_tag` default to None Lgndr: add default `move` method. Lgndr: monkeypatch `dlm.status_queue` after preparing a download to reduce `InstallQueueItemModel` InstallOptionsModel: implement `as_install_kwargs` to pass only relevant arguments to `LgndrInstallGameArgs` in InstallDialog InstallOptionsModel: rename `sdl_list` to `install_tag` as they were the same thing LegendaryUtils: Update to use `LgndrIndirectStatus` UninstallDialog: Add option to keep configuration decoupled from keeping game data GameUtils: Add messagebox to show error messages from legendary after uninstalling a game InstallDialog: Update to use `LgndrIndirectStatus` InstallDialog: Update selectable download handling to match legendary's DownloadThread: Remove multiple instance variables, instead reference them directly from `InstallQueueItemModel` instance ImportGroup: Replace `info_label` with an `ElideLabel` for displaying long messages ImportGroup: Don't translate message strings in the `ImportWorker` GameInfo: Call `repair()` if needed after verification instead of handling it locally GamesTab: Fix string matching for capitalized strings and scroll to top on when searching --- rare/components/dialogs/install_dialog.py | 153 +++++++-------- rare/components/dialogs/uninstall_dialog.py | 24 +-- .../tabs/downloads/download_thread.py | 80 ++++---- rare/components/tabs/games/__init__.py | 16 +- .../tabs/games/game_info/game_info.py | 16 +- rare/components/tabs/games/game_utils.py | 8 +- .../tabs/games/import_sync/import_group.py | 47 +++-- rare/lgndr/api_arguments.py | 11 +- rare/lgndr/api_monkeys.py | 73 ++++--- rare/lgndr/cli.py | 179 ++++++++++-------- rare/lgndr/core.py | 5 +- .../tabs/games/import_sync/import_group.py | 5 - .../tabs/games/import_sync/import_group.ui | 10 - rare/utils/legendary_utils.py | 21 +- rare/utils/models.py | 38 ++-- 15 files changed, 365 insertions(+), 321 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index c1dff40a..a80d920b 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -1,20 +1,19 @@ 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.models.downloading import ConditionCheckResult +from legendary.models.game import Game +from legendary.utils.selective_dl import get_sdl_appname 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 legendary.models.downloading import ConditionCheckResult -from legendary.models.game import Game -from legendary.utils.selective_dl import games - from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, ApiResultsSingleton, ArgumentsSingleton from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog from rare.utils.extra_widgets import PathEdit @@ -34,7 +33,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) @@ -87,18 +85,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)) @@ -112,22 +112,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) @@ -141,7 +129,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")) @@ -156,7 +144,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: @@ -166,6 +154,37 @@ 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 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 @@ -176,11 +195,12 @@ class InstallDialog(QDialog, Ui_InstallDialog): 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 @@ -221,6 +241,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: + self.core.lgd.config.set(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 + self.core.lgd.config.remove_option(self.game.app_name, 'install_tags') + self.dl_item.download = None self.reject_close = False self.close() @@ -246,7 +272,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) @@ -302,52 +328,27 @@ 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: + if not self.dl_item.options.overlay: cli = LegendaryCLISingleton() - download = InstallDownloadModel( - # *self.core.prepare_download( - *cli.install_game(LgndrInstallGameArgs( - 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, - shared_memory=self.dl_item.options.shared_memory, - 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=, - order_opt=self.dl_item.options.order_opt, - # dl_timeout=, - repair_mode=self.dl_item.options.repair_mode, - repair_and_update=self.dl_item.options.repair_and_update, - ignore_space=self.dl_item.options.ignore_space, - # disable_delta=, - # override_delta_manifest=, - # reset_sdl=, - sdl_prompt=lambda app_name, title: self.dl_item.options.sdl_list,) - ) + 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_q=self.dl_item.status_q, + path=self.dl_item.options.base_path ) download = InstallDownloadModel( @@ -371,14 +372,14 @@ class InstallInfoWorker(QRunnable): 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/uninstall_dialog.py b/rare/components/dialogs/uninstall_dialog.py index 4ead1380..c7311b91 100644 --- a/rare/components/dialogs/uninstall_dialog.py +++ b/rare/components/dialogs/uninstall_dialog.py @@ -1,4 +1,3 @@ -from enum import Enum, IntEnum from typing import Tuple from PyQt5.QtCore import Qt @@ -7,7 +6,6 @@ from PyQt5.QtWidgets import ( QLabel, QVBoxLayout, QCheckBox, - QFormLayout, QHBoxLayout, QPushButton, ) @@ -23,13 +21,15 @@ class UninstallDialog(QDialog): 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) ) layout.addWidget(self.info_text) - self.keep_files = QCheckBox(self.tr("Keep Files")) - form_layout = QFormLayout() - form_layout.setContentsMargins(0, 10, 0, 10) - form_layout.addRow(QLabel(self.tr("Do you want to keep files?")), self.keep_files) + 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) button_layout = QHBoxLayout() @@ -41,22 +41,22 @@ class UninstallDialog(QDialog): self.cancel_button = QPushButton(self.tr("Cancel")) self.cancel_button.clicked.connect(self.cancel) - button_layout.addStretch(1) button_layout.addWidget(self.ok_button) + button_layout.addStretch(1) button_layout.addWidget(self.cancel_button) layout.addLayout(button_layout) self.setLayout(layout) - self.options: Tuple[bool, bool] = (False, False) + self.options: Tuple[bool, bool, bool] = (False, False, False) - def get_options(self) -> Tuple[bool, bool]: + def get_options(self) -> Tuple[bool, bool, bool]: self.exec_() return self.options def ok(self): - self.options = (True, self.keep_files.isChecked()) + self.options = (True, self.keep_files.isChecked(), self.keep_config.isChecked()) self.close() def cancel(self): - self.options = (False, False) + self.options = (False, False, False) self.close() diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index d92b35b4..d2dd2f6d 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -23,17 +23,11 @@ class DownloadThread(QThread): status = pyqtSignal(str) statistics = pyqtSignal(UIUpdate) - def __init__(self, core: LegendaryCore, queue_item: InstallQueueItemModel): + 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.core: LegendaryCore = core + self.item: InstallQueueItemModel = item self._kill = False @@ -42,9 +36,9 @@ class DownloadThread(QThread): dl_stopped = False try: - self.dlm.start() + self.item.download.dlmanager.start() time.sleep(1) - while self.dlm.is_alive(): + while self.item.download.dlmanager.is_alive(): if self._kill: self.status.emit("stop") logger.info("Download stopping...") @@ -52,15 +46,15 @@ class DownloadThread(QThread): # The code below is a temporary solution. # It should be removed once legendary supports stopping downloads more gracefully. - self.dlm.running = False + self.item.download.dlmanager.running = False # send conditions to unlock threads if they aren't already - for cond in self.dlm.conditions: + for cond in self.item.download.dlmanager.conditions: with cond: cond.notify() # make sure threads are dead. - for t in self.dlm.threads: + for t in self.item.download.dlmanager.threads: t.join(timeout=5.0) if t.is_alive(): logger.warning(f"Thread did not terminate! {repr(t)}") @@ -74,10 +68,10 @@ class DownloadThread(QThread): "Writer results", ), ( - self.dlm.dl_worker_queue, - self.dlm.writer_queue, - self.dlm.dl_result_q, - self.dlm.writer_result_q, + self.item.download.dlmanager.dl_worker_queue, + self.item.download.dlmanager.writer_queue, + self.item.download.dlmanager.dl_result_q, + self.item.download.dlmanager.writer_result_q, ), ): logger.debug(f'Cleaning up queue "{name}"') @@ -90,22 +84,22 @@ class DownloadThread(QThread): except AttributeError: logger.warning(f"Queue {name} did not close") - if self.dlm.writer_queue: + if self.item.download.dlmanager.writer_queue: # cancel installation - self.dlm.writer_queue.put_nowait(WriterTask("", kill=True)) + self.item.download.dlmanager.writer_queue.put_nowait(WriterTask("", kill=True)) # forcibly kill DL workers that are not actually dead yet - for child in self.dlm.children: + for child in self.item.download.dlmanager.children: if child.exitcode is None: child.terminate() - if self.dlm.shared_memory: + if self.item.download.dlmanager.shared_memory: # close up shared memory - self.dlm.shared_memory.close() - self.dlm.shared_memory.unlink() - self.dlm.shared_memory = None + self.item.download.dlmanager.shared_memory.close() + self.item.download.dlmanager.shared_memory.unlink() + self.item.download.dlmanager.shared_memory = None - self.dlm.kill() + self.item.download.dlmanager.kill() # force kill any threads that are somehow still alive for proc in psutil.process_iter(): @@ -126,11 +120,11 @@ class DownloadThread(QThread): dl_stopped = True try: if not dl_stopped: - self.statistics.emit(self.status_q.get(timeout=1)) + self.statistics.emit(self.item.download.dlmanager.status_queue.get(timeout=1)) except queue.Empty: pass - self.dlm.join() + self.item.download.dlmanager.join() except Exception as e: logger.error( @@ -145,20 +139,20 @@ class DownloadThread(QThread): 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) + game = self.core.get_game(self.item.download.igame.app_name) - if self.queue_item.options.overlay: + if self.item.options.overlay: self.signals.overlay_installation_finished.emit() - self.core.finish_overlay_install(self.igame) + self.core.finish_overlay_install(self.item.download.igame) self.status.emit("finish") 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) + self._handle_postinstall(postinstall, self.item.download.igame) - dlcs = self.core.get_dlc_for_game(self.igame.app_name) + dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name) if dlcs: print("The following DLCs are available for this game:") for dlc in dlcs: @@ -179,21 +173,21 @@ class DownloadThread(QThread): 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 and self.item.download.repair and os.path.exists(self.item.download.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(self.repair_file) - if old_igame and old_igame.install_tags != self.igame.install_tags: - old_igame.install_tags = self.igame.install_tags + os.remove(self.item.download.repair_file) + if old_igame and old_igame.install_tags != self.item.download.igame.install_tags: + old_igame.install_tags = self.item.download.igame.install_tags logger.info("Deleting now untagged files.") self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - 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"): + if not self.item.options.update and self.item.options.create_shortcut: + if not create_desktop_link(self.item.options.app_name, self.core, "desktop"): # maybe add it to download summary, to show in finished downloads pass else: @@ -204,7 +198,7 @@ class DownloadThread(QThread): def _handle_postinstall(self, postinstall, igame): logger.info(f"Postinstall info: {postinstall}") if platform.system() == "Windows": - if self.queue_item.options.install_preqs: + if self.item.options.install_preqs: 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) @@ -218,7 +212,7 @@ class DownloadThread(QThread): proc.start(fullpath, postinstall.get("args", [])) proc.waitForFinished() # wait, because it is inside the thread else: - self.core.prereq_installed(self.igame.app_name) + self.core.prereq_installed(self.item.download.igame.app_name) else: logger.info("Automatic installation not available on Linux.") 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/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py index 666555f6..2e114b55 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -129,7 +129,7 @@ class GameInfo(QWidget, Ui_GameInfo): ) return self.signals.install_game.emit( - InstallOptionsModel(app_name=self.game.app_name, repair_mode=True, update=True) + InstallOptionsModel(app_name=self.game.app_name, repair_mode=True, repair_and_update=True, update=True) ) def verify(self): @@ -163,24 +163,24 @@ class GameInfo(QWidget, Ui_GameInfo): @pyqtSlot(str, bool, int, int) def verify_result(self, app_name, success, failed, missing): + 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("Game 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")) + QMessageBox.warning(self, self.tr("Warning - {}").format(igame.title), self.tr("Something went wrong")) 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?" ).format(failed, missing), @@ -188,9 +188,7 @@ class GameInfo(QWidget, Ui_GameInfo): QMessageBox.Yes, ) if ans == QMessageBox.Yes: - self.signals.install_game.emit( - InstallOptionsModel(app_name=app_name, repair_mode=True, update=True) - ) + self.repair() self.verify_widget.setCurrentIndex(0) self.verify_threads.pop(app_name) self.move_button.setEnabled(True) diff --git a/rare/components/tabs/games/game_utils.py b/rare/components/tabs/games/game_utils.py index a31338ad..8ef8e5a9 100644 --- a/rare/components/tabs/games/game_utils.py +++ b/rare/components/tabs/games/game_utils.py @@ -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 - proceed, keep_files = UninstallDialog(game).get_options() + proceed, keep_files, keep_config = UninstallDialog(game).get_options() if not proceed: return False - legendary_utils.uninstall_game(self.core, game.app_name, keep_files) + 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 diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index 93cf52cb..b3ce4d1e 100644 --- a/rare/components/tabs/games/import_sync/import_group.py +++ b/rare/components/tabs/games/import_sync/import_group.py @@ -1,6 +1,5 @@ import json import os -from argparse import Namespace from dataclasses import dataclass from enum import IntEnum from logging import getLogger @@ -13,9 +12,11 @@ from PyQt5.QtWidgets import QFileDialog, QGroupBox, QCompleter, QTreeView, QHead from rare.lgndr.api_arguments import LgndrImportGameArgs from rare.lgndr.api_exception import LgndrException +from rare.lgndr.api_monkeys import LgndrIndirectStatus from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup from rare.utils.extra_widgets import IndicatorLineEdit, PathEdit +from rare.widgets.elide_label import ElideLabel logger = getLogger("Import") @@ -46,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 @@ -59,7 +62,6 @@ class ImportWorker(QRunnable): super(ImportWorker, self).__init__() self.signals = self.Signals() self.core = LegendaryCoreSingleton() - self.tr = lambda message: qApp.translate("ImportThread", message) self.path = Path(path) self.app_name = app_name @@ -83,29 +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 - app_title = self.core.get_game(app_name).app_title + 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 = f"{app_title} - {message}" + result.message = message else: result.result = ImportResult.SUCCESS - result.message = self.tr("{} - Imported successfully").format(app_title) - else: - result.message = self.tr("Could not find AppName for {}").format(str(path)) return result def __import_game(self, path: Path, app_name: str, app_title: str): cli = LegendaryCLISingleton() + 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 ) - return cli.import_game(args) + cli.import_game(args) + return status.success, status.message class AppNameCompleter(QCompleter): @@ -194,6 +197,10 @@ class ImportGroup(QGroupBox): self.ui.import_button.clicked.connect( lambda: self.import_pressed(self.path_edit.text()) ) + + 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]: @@ -207,7 +214,7 @@ class ImportGroup(QGroupBox): return False, path, "" def path_changed(self, path): - self.ui.info_label.setText("") + 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)) @@ -223,7 +230,7 @@ class ImportGroup(QGroupBox): return False, text, IndicatorLineEdit.reasons.game_not_installed def app_name_changed(self, app_name: str): - self.ui.info_label.setText("") + 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( @@ -272,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("Success: {}").format(res.message) + 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] @@ -301,15 +308,15 @@ class ImportGroup(QGroupBox): details: List = [] for res in success: details.append( - self.tr("Success: {}").format(res.message) + 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/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 273624ab..cdeefb91 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from multiprocessing import Queue -from typing import Callable, List +from typing import Callable, List, Optional -from .api_monkeys import get_boolean_choice +from .api_monkeys import get_boolean_choice, LgndrIndirectStatus """ @dataclass(kw_only=True) @@ -25,6 +25,7 @@ class LgndrImportGameArgs: with_dlcs: bool = False yes: bool = False # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default @@ -34,6 +35,7 @@ class LgndrUninstallGameArgs: keep_files: bool = False yes: bool = False # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default @@ -41,6 +43,7 @@ class LgndrUninstallGameArgs: class LgndrVerifyGameArgs: app_name: str # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" ) @@ -50,7 +53,6 @@ class LgndrVerifyGameArgs: class LgndrInstallGameArgs: app_name: str base_path: str = "" - status_q: Queue = None shared_memory: int = 0 max_workers: int = 0 force: bool = False @@ -62,7 +64,7 @@ class LgndrInstallGameArgs: platform: str = "Windows" file_prefix: List = None file_exclude_prefix: List = None - install_tag: List = None + install_tag: Optional[List[str]] = None order_opt: bool = False dl_timeout: int = 10 repair_mode: bool = False @@ -79,6 +81,7 @@ class LgndrInstallGameArgs: disable_https: bool = False yes: bool = True # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default sdl_prompt: Callable[[str, str], List[str]] = lambda sdl_data, title: [""] verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py index 8c0ddd50..f9a8938c 100644 --- a/rare/lgndr/api_monkeys.py +++ b/rare/lgndr/api_monkeys.py @@ -1,4 +1,5 @@ import logging +from dataclasses import dataclass from PyQt5.QtWidgets import QMessageBox, QLabel @@ -24,17 +25,52 @@ class UILogHandler(logging.Handler): self.widget.setText(record.getMessage()) -class LgndrReturnLogger: - def __init__(self, logger: logging.Logger, level: int = logging.ERROR): +@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.value = False - self.message = "" + 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.value = True if level < self.level else False - self.message = msg - self.logger.log(level, msg) + 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) @@ -53,26 +89,3 @@ class LgndrReturnLogger: def fatal(self, msg: str): self.critical(msg) - - def __len__(self): - if self.message: - return 2 - else: - return 0 - - def __bool__(self): - return self.value - - def __getitem__(self, item): - if item == 0: - return self.value - elif item == 1: - return self.message - else: - raise IndexError - - def __iter__(self): - return iter((self.value, self.message)) - - def __str__(self): - return self.message diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 3a4df888..da66381c 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,8 +1,7 @@ -import functools -import os import logging +import os import time -from typing import Optional, Union +from typing import Optional, Union, Tuple from legendary.cli import LegendaryCLI as LegendaryCLIReal from legendary.models.downloading import AnalysisResult, ConditionCheckResult @@ -10,51 +9,27 @@ 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 +from .api_monkeys import LgndrIndirectStatus, LgndrIndirectLogger from .core import LegendaryCore from .manager import DLManager -from .api_arguments import LgndrInstallGameArgs, LgndrImportGameArgs, LgndrVerifyGameArgs, LgndrUninstallGameArgs -from .api_monkeys import return_exit, get_boolean_choice, LgndrReturnLogger class LegendaryCLI(LegendaryCLIReal): - """ - @staticmethod - def apply_wrap(func): - - @functools.wraps(func) - def inner(self, args, *oargs, **kwargs): - old_exit = legendary.cli.exit - legendary.cli.exit = exit - - # old_choice = legendary.cli.get_boolean_choice - # if hasattr(args, 'get_boolean_choice') and args.get_boolean_choice is not None: - # legendary.cli.get_boolean_choice = args.get_boolean_choice - - try: - return func(self, args, *oargs, **kwargs) - except LgndrException as ret: - raise ret - finally: - # legendary.cli.get_boolean_choice = old_choice - legendary.cli.exit = old_exit - - return inner - """ - def __init__(self, override_config=None, api_timeout=None): self.core = LegendaryCore(override_config, timeout=api_timeout) self.logger = logging.getLogger('cli') self.logging_queue = None - # self.handler = LgndrCLILogHandler() - # self.logger.addHandler(self.handler) def resolve_aliases(self, name): return super(LegendaryCLI, self)._resolve_aliases(name) - def install_game(self, args: LgndrInstallGameArgs) -> (DLManager, AnalysisResult, InstalledGame, Game, bool, Optional[str], ConditionCheckResult): - # Override logger for the local context to use message as part of the return value - logger = LgndrReturnLogger(self.logger) + 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): @@ -71,15 +46,21 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + return + + # Rare: Rare checks this before calling 'install_game' if args.platform not in game.asset_infos: if not args.no_install: @@ -90,7 +71,7 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + return else: logger.warning(f'No asset found for platform "{args.platform}", ' f'trying anyway since --no-install is set.') @@ -104,25 +85,30 @@ class LegendaryCLI(LegendaryCLIReal): # 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: Dodge the path below for now + logger.error('Game has not been verified yet.') + return if not args.yes: - if not args.get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): - return logger + if not get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): + return try: self.verify_game(args, print_command=False, repair_mode=True, repair_online=args.repair_and_update) except ValueError: logger.error('To repair a game with a missing manifest you must run the command with ' '"--repair-and-update". However this will redownload any file that does ' 'not match the current hash in its entirety.') - return logger + return else: logger.info(f'Using existing repair file: {repair_file}') @@ -154,7 +140,7 @@ class LegendaryCLI(LegendaryCLIReal): if '__required' in sdl_data: args.install_tag.extend(sdl_data['__required']['tags']) else: - args.install_tag = args.sdl_prompt(sdl_data, game.app_title) + 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}') @@ -176,7 +162,6 @@ class LegendaryCLI(LegendaryCLIReal): # 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, - status_q=args.status_q, force=args.force, max_shm=args.shared_memory, max_workers=args.max_workers, game_folder=args.game_folder, disable_patching=args.disable_patching, @@ -200,8 +185,7 @@ class LegendaryCLI(LegendaryCLIReal): 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.clean_post_install(game, igame, args.repair_mode, repair_file) - - logger.error('Nothing to do.') + return res = self.core.check_installation_conditions(analysis=analysis, install=igame, game=game, updating=self.core.is_installed(args.app_name), @@ -209,9 +193,9 @@ class LegendaryCLI(LegendaryCLIReal): return dlm, analysis, igame, game, args.repair_mode, repair_file, res - def clean_post_install(self, game: Game, igame: InstalledGame, repair: bool = False, repair_file: str = ''): - # Override logger for the local context to use message as part of the return value - logger = LgndrReturnLogger(self.logger) + def clean_post_install(self, game: Game, igame: InstalledGame, repair: 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 and os.path.exists(repair_file): @@ -229,24 +213,23 @@ class LegendaryCLI(LegendaryCLIReal): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - return logger - def handle_postinstall(self, postinstall, igame, yes=False): super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) - def uninstall_game(self, args: LgndrUninstallGameArgs): - # Override logger for the local context to use message as part of the return value - logger = LgndrReturnLogger(self.logger, level=logging.WARNING) + 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 logger + return if not args.yes: - if not args.get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): - return logger + if not get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): + return try: if not igame.is_dlc: @@ -261,19 +244,19 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + return except Exception as e: logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.') - return logger + return - def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], print_command=True, repair_mode=False, repair_online=False): - # Override logger for the local context to use message as part of the return value - logger = LgndrReturnLogger(self.logger) + 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 + return logger.info(f'Loading installed manifest for "{args.app_name}"') igame = self.core.get_installed_game(args.app_name) @@ -281,7 +264,7 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + return manifest_data, _ = self.core.get_installed_manifest(args.app_name) if manifest_data is None: @@ -301,7 +284,7 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + return manifest = self.core.load_manifest(manifest_data) @@ -371,16 +354,17 @@ class LegendaryCLI(LegendaryCLIReal): if not missing and not failed: logger.info('Verification finished successfully.') - return logger, 0, 0 + return 0, 0 else: - logger.warning(f'Verification failed, {len(failed)} file(s) corrupted, {len(missing)} file(s) are missing.') + 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 logger, len(failed), len(missing) + return len(failed), len(missing) - def import_game(self, args: LgndrImportGameArgs): - # Override logger for the local context to use message as part of the return value - logger = LgndrReturnLogger(self.logger) + 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) @@ -388,21 +372,21 @@ class LegendaryCLI(LegendaryCLIReal): if not os.path.exists(args.app_path): logger.error(f'Specified path "{args.app_path}" does not exist!') - return logger + return if self.core.is_installed(args.app_name): logger.error('Game is already installed!') - return logger + return if not self.core.login(): logger.error('Log in failed!') - return logger + 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 logger + return if game.is_dlc: release_info = game.metadata.get('mainGameItem', {}).get('releaseInfo') @@ -412,10 +396,10 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + return else: logger.fatal(f'Unable to get base game information for DLC, cannot continue.') - return logger + 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) @@ -435,12 +419,12 @@ class LegendaryCLI(LegendaryCLIReal): 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 logger + 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 logger + return if ratio < 0.95: logger.warning('Some files are missing from the game installation, install may not ' @@ -464,7 +448,7 @@ class LegendaryCLI(LegendaryCLIReal): 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 args.get_boolean_choice(f'Do you wish to automatically attempt to import all DLCs?'): + if not get_boolean_choice(f'Do you wish to automatically attempt to import all DLCs?'): import_dlc = False if import_dlc: @@ -473,4 +457,43 @@ class LegendaryCLI(LegendaryCLIReal): self.import_game(args) logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.') - return logger + 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.') diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index d96314cb..c8a606af 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -44,6 +44,7 @@ class LegendaryCore(LegendaryCoreReal): ) # lk: monkeypatch run_real (the method that emits the stats) into DLManager dlm.run_real = DLManager.run_real.__get__(dlm, DLManager) + dlm.status_queue = Queue() return dlm, analysis, igame def uninstall_game(self, installed_game: InstalledGame, delete_files=True, delete_root_directory=False): @@ -70,10 +71,10 @@ class LegendaryCore(LegendaryCoreReal): finally: pass - def prepare_overlay_install(self, path=None, status_q: Queue = None): + 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) - dlm.status_queue = status_q + dlm.status_queue = Queue() return dlm, analysis_result, igame 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 f0193152..91289ce5 100644 --- a/rare/ui/components/tabs/games/import_sync/import_group.py +++ b/rare/ui/components/tabs/games/import_sync/import_group.py @@ -60,11 +60,6 @@ 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.setWordWrap(True) - self.info_label.setObjectName("info_label") - self.button_info_layout.addWidget(self.info_label) self.formLayout.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.button_info_layout) self.retranslateUi(ImportGroup) 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 9ada1618..60a77630 100644 --- a/rare/ui/components/tabs/games/import_sync/import_group.ui +++ b/rare/ui/components/tabs/games/import_sync/import_group.ui @@ -96,16 +96,6 @@ - - - - - - - true - - - diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 263f88fc..c876c3d3 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -2,9 +2,10 @@ 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 rare.lgndr.api_monkeys import LgndrIndirectStatus from rare.lgndr.api_arguments import LgndrVerifyGameArgs, LgndrUninstallGameArgs from rare.lgndr.api_exception import LgndrException from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton @@ -13,7 +14,7 @@ from rare.utils import config_helper logger = getLogger("Legendary Utils") -def uninstall_game(core: LegendaryCore, app_name: str, 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 @@ -38,21 +39,23 @@ def uninstall_game(core: LegendaryCore, app_name: str, keep_files=False): if os.path.exists(start_menu_shortcut): os.remove(start_menu_shortcut) - result = LegendaryCLISingleton().uninstall_game( + status = LgndrIndirectStatus() + LegendaryCLISingleton().uninstall_game( LgndrUninstallGameArgs( app_name=app_name, keep_files=keep_files, + indirect_status=status, yes=True, ) ) - if not 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 result + return status.success, status.message def update_manifest(app_name: str, core: LegendaryCore): @@ -93,16 +96,18 @@ class VerifyWorker(QRunnable): self.signals.status.emit(self.app_name, num, total, percentage, speed) def run(self): + status = LgndrIndirectStatus() args = LgndrVerifyGameArgs(app_name=self.app_name, + indirect_status=status, verify_stdout=self.status_callback) # TODO: offer this as an alternative when manifest doesn't exist # TODO: requires the client to be online. To do it this way, we need to # TODO: somehow detect the error and offer a dialog in which case `verify_games` is # TODO: re-run with `repair_mode` and `repair_online` - result, failed, missing = self.cli.verify_game( + result = self.cli.verify_game( args, print_command=False, repair_mode=True, repair_online=True) # success, failed, missing = self.cli.verify_game(args, print_command=False) if result: - self.signals.result.emit(self.app_name, not failed and not missing, failed, missing) + self.signals.result.emit(self.app_name, not any(result), *result) else: - self.signals.error.emit(self.app_name, result.message) + self.signals.error.emit(self.app_name, status.message) diff --git a/rare/utils/models.py b/rare/utils/models.py index 926e031b..2e3ccd97 100644 --- a/rare/utils/models.py +++ b/rare/utils/models.py @@ -2,7 +2,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, Optional, Callable, Dict from legendary.core import LegendaryCore from legendary.downloader.mp.manager import DLManager @@ -16,22 +16,31 @@ class InstallOptionsModel: 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 - force: bool = False - sdl_list: list = field(default_factory=lambda: [""]) + # 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 - platform: str = "" - order_opt: 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 + def __post_init__(self): + self.sdl_prompt: Callable[[str, str], list] = lambda app_name, title: self.install_tag + + 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 @@ -47,16 +56,11 @@ class InstallDownloadModel: @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) - ) + return (self.download is not None) and (self.options is not None) class PathSpec: @@ -81,9 +85,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 = [ From 7fba2259eda74f0c3fc0f212ca73da0ea3defd2b Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Tue, 26 Jul 2022 17:36:05 +0300 Subject: [PATCH 43/61] LoginDialog: Fix crash due to messagebox parent --- rare/components/dialogs/login/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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)) From f51563ea93524348cdb661713e0356e6a8d29ffa Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Tue, 26 Jul 2022 17:41:23 +0300 Subject: [PATCH 44/61] Models: Move installation related models into their own file --- rare/components/dialogs/install_dialog.py | 2 +- rare/components/tabs/downloads/__init__.py | 2 +- .../tabs/downloads/dl_queue_widget.py | 2 +- .../tabs/downloads/download_thread.py | 2 +- .../tabs/games/game_info/game_dlc.py | 2 +- .../tabs/games/game_info/game_info.py | 2 +- .../tabs/games/game_info/uninstalled_info.py | 2 +- rare/components/tabs/settings/widgets/eos.py | 2 +- rare/models/install.py | 61 +++++++++++++++++++ rare/models/signals.py | 2 +- rare/utils/models.py | 61 +------------------ 11 files changed, 71 insertions(+), 69 deletions(-) create mode 100644 rare/models/install.py diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index a80d920b..eeba257e 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -17,7 +17,7 @@ from rare.lgndr.core import LegendaryCore from rare.shared import LegendaryCLISingleton, 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.models.install import InstallDownloadModel, InstallQueueItemModel from rare.utils.utils import get_size diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index cd6c3d23..d9f5c31e 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -20,7 +20,7 @@ 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.ui.components.tabs.downloads.downloads_tab import Ui_DownloadsTab -from rare.utils.models import InstallOptionsModel, InstallQueueItemModel +from rare.models.install import InstallOptionsModel, InstallQueueItemModel from rare.utils.utils import get_size logger = getLogger("Download") diff --git a/rare/components/tabs/downloads/dl_queue_widget.py b/rare/components/tabs/downloads/dl_queue_widget.py index 2b0455c1..cdae6bb1 100644 --- a/rare/components/tabs/downloads/dl_queue_widget.py +++ b/rare/components/tabs/downloads/dl_queue_widget.py @@ -10,7 +10,7 @@ from PyQt5.QtWidgets import ( QWidget, ) -from rare.utils.models import InstallQueueItemModel +from rare.models.install import InstallQueueItemModel from rare.utils.utils import icon logger = getLogger("QueueWidget") diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index d2dd2f6d..bea27743 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -12,7 +12,7 @@ from legendary.core import LegendaryCore from legendary.models.downloading import WriterTask from rare.shared import GlobalSignalsSingleton -from rare.utils.models import InstallQueueItemModel +from rare.models.install import InstallQueueItemModel from rare.utils.utils import create_desktop_link from rare.lgndr.downloading import UIUpdate 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 2e114b55..8577c6b5 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -37,7 +37,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 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.widgets.image_widget import ImageWidget 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/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/models/install.py b/rare/models/install.py new file mode 100644 index 00000000..b8771242 --- /dev/null +++ b/rare/models/install.py @@ -0,0 +1,61 @@ +import os +import platform as pf +from dataclasses import dataclass +from typing import List, Optional, Callable, Dict + +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 = "" + 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 + # 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 + + 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: + dlmanager: 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/utils/models.py b/rare/utils/models.py index 2e3ccd97..24c9d1a5 100644 --- a/rare/utils/models.py +++ b/rare/utils/models.py @@ -1,66 +1,7 @@ import os -import platform as pf -from dataclasses import field, dataclass -from multiprocessing import Queue -from typing import Union, List, Optional, Callable, Dict +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 = "" - 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 - # 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 - - 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: - dlmanager: 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) class PathSpec: From 3ecbae0564009b4bc1fa7d5a6aed14def82e565e Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Tue, 26 Jul 2022 17:58:17 +0300 Subject: [PATCH 45/61] Utils: Rename `utils.py` to `misc.py` to not confuse PyCharm checker --- rare/__main__.py | 18 ++++++++---------- rare/components/dialogs/install_dialog.py | 2 +- rare/components/dialogs/launch_dialog.py | 2 +- rare/components/dialogs/login/browser_login.py | 2 +- rare/components/dialogs/uninstall_dialog.py | 2 +- rare/components/tabs/__init__.py | 2 +- rare/components/tabs/account/__init__.py | 2 +- rare/components/tabs/downloads/__init__.py | 2 +- .../tabs/downloads/dl_queue_widget.py | 2 +- .../tabs/downloads/download_thread.py | 2 +- rare/components/tabs/games/cloud_save_utils.py | 2 +- .../tabs/games/game_info/game_info.py | 2 +- .../tabs/games/game_info/game_settings.py | 2 +- rare/components/tabs/games/game_utils.py | 2 +- .../game_widgets/base_installed_widget.py | 2 +- .../game_widgets/installed_icon_widget.py | 2 +- .../game_widgets/installed_list_widget.py | 3 +-- rare/components/tabs/games/head_bar.py | 2 +- .../tabs/games/import_sync/egl_sync_group.py | 2 +- rare/components/tabs/settings/__init__.py | 8 +++----- rare/components/tabs/settings/legendary.py | 2 +- rare/components/tabs/settings/rare.py | 6 +++--- .../tabs/settings/widgets/env_vars.py | 2 +- .../settings/widgets/ubisoft_activation.py | 2 +- .../tabs/settings/widgets/wrapper.py | 2 +- rare/components/tabs/shop/game_info.py | 2 +- rare/components/tabs/shop/game_widgets.py | 2 +- rare/components/tabs/shop/wishlist.py | 2 +- rare/components/tabs/tab_utils.py | 2 +- rare/utils/extra_widgets.py | 2 +- rare/utils/{utils.py => misc.py} | 0 rare/widgets/rare_app.py | 2 +- 32 files changed, 42 insertions(+), 47 deletions(-) rename rare/utils/{utils.py => misc.py} (100%) diff --git a/rare/__main__.py b/rare/__main__.py index e2f6f975..7b6663ea 100644 --- a/rare/__main__.py +++ b/rare/__main__.py @@ -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 diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index eeba257e..988f2e43 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -18,7 +18,7 @@ from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, ApiResult from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog from rare.utils.extra_widgets import PathEdit from rare.models.install import InstallDownloadModel, InstallQueueItemModel -from rare.utils.utils import get_size +from rare.utils.misc import get_size class InstallDialog(QDialog, Ui_InstallDialog): 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/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 c7311b91..0d4fb88b 100644 --- a/rare/components/dialogs/uninstall_dialog.py +++ b/rare/components/dialogs/uninstall_dialog.py @@ -11,7 +11,7 @@ from PyQt5.QtWidgets import ( ) from legendary.models.game import Game -from rare.utils.utils import icon +from rare.utils.misc import icon class UninstallDialog(QDialog): diff --git a/rare/components/tabs/__init__.py b/rare/components/tabs/__init__.py index bb213582..50db0ba0 100644 --- a/rare/components/tabs/__init__.py +++ b/rare/components/tabs/__init__.py @@ -9,7 +9,7 @@ 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): diff --git a/rare/components/tabs/account/__init__.py b/rare/components/tabs/account/__init__.py index 8b327b73..19039eec 100644 --- a/rare/components/tabs/account/__init__.py +++ b/rare/components/tabs/account/__init__.py @@ -3,7 +3,7 @@ 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): diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index d9f5c31e..89808f57 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -21,7 +21,7 @@ from rare.components.tabs.downloads.dl_queue_widget import DlQueueWidget, DlWidg from rare.components.tabs.downloads.download_thread import DownloadThread from rare.ui.components.tabs.downloads.downloads_tab import Ui_DownloadsTab from rare.models.install import InstallOptionsModel, InstallQueueItemModel -from rare.utils.utils import get_size +from rare.utils.misc import get_size logger = getLogger("Download") diff --git a/rare/components/tabs/downloads/dl_queue_widget.py b/rare/components/tabs/downloads/dl_queue_widget.py index cdae6bb1..18152273 100644 --- a/rare/components/tabs/downloads/dl_queue_widget.py +++ b/rare/components/tabs/downloads/dl_queue_widget.py @@ -11,7 +11,7 @@ from PyQt5.QtWidgets import ( ) from rare.models.install import InstallQueueItemModel -from rare.utils.utils import icon +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 bea27743..1410ca6d 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -13,7 +13,7 @@ from legendary.models.downloading import WriterTask from rare.shared import GlobalSignalsSingleton from rare.models.install import InstallQueueItemModel -from rare.utils.utils import create_desktop_link +from rare.utils.misc import create_desktop_link from rare.lgndr.downloading import UIUpdate logger = getLogger("DownloadThread") 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_info.py b/rare/components/tabs/games/game_info/game_info.py index 8577c6b5..588b5e75 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -39,7 +39,7 @@ from rare.utils.extra_widgets import PathEdit from rare.utils.legendary_utils import VerifyWorker 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") 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_utils.py b/rare/components/tabs/games/game_utils.py index 8ef8e5a9..8e560d77 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") 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..9904084c 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") 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/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 fc3b7991..79d3ab4d 100644 --- a/rare/components/tabs/games/import_sync/egl_sync_group.py +++ b/rare/components/tabs/games/import_sync/egl_sync_group.py @@ -14,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") 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 2d3871a3..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") diff --git a/rare/components/tabs/settings/rare.py b/rare/components/tabs/settings/rare.py index 187fb94d..27fbaef3 100644 --- a/rare/components/tabs/settings/rare.py +++ b/rare/components/tabs/settings/rare.py @@ -10,14 +10,14 @@ 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, ) logger = getLogger("RareSettings") @@ -148,7 +148,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) 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/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/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/utils.py b/rare/utils/misc.py similarity index 100% rename from rare/utils/utils.py rename to rare/utils/misc.py 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): From 7408116deb49b3ac45c634993c53cf7b20e9a124 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Tue, 26 Jul 2022 18:45:27 +0300 Subject: [PATCH 46/61] Lgndr: Don' use lambda with default argument --- rare/components/dialogs/install_dialog.py | 5 +-- rare/lgndr/api_arguments.py | 38 ++++++++++++++++------- rare/models/install.py | 3 +- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 988f2e43..7bd6d493 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -19,6 +19,7 @@ from rare.ui.components.dialogs.install_dialog import Ui_InstallDialog from rare.utils.extra_widgets import PathEdit 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): @@ -242,10 +243,10 @@ class InstallDialog(QDialog, Ui_InstallDialog): def cancel_clicked(self): if self.config_tags: - self.core.lgd.config.set(self.game.app_name, 'install_tags', ','.join(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 - self.core.lgd.config.remove_option(self.game.app_name, 'install_tags') + config_helper.remove_option(self.game.app_name, 'install_tags') self.dl_item.download = None self.reject_close = False diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index cdeefb91..12ae63d1 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from multiprocessing import Queue from typing import Callable, List, Optional +from typing_extensions import Protocol -from .api_monkeys import get_boolean_choice, LgndrIndirectStatus +from .api_monkeys import LgndrIndirectStatus """ @dataclass(kw_only=True) @@ -15,6 +15,19 @@ class LgndrCommonArgs: """ +class _GetBooleanChoiceP(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") + + @dataclass class LgndrImportGameArgs: app_path: str @@ -26,7 +39,7 @@ class LgndrImportGameArgs: yes: bool = False # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default + get_boolean_choice: _GetBooleanChoiceP = _get_boolean_choice @dataclass @@ -36,7 +49,7 @@ class LgndrUninstallGameArgs: yes: bool = False # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default + get_boolean_choice: _GetBooleanChoiceP = _get_boolean_choice @dataclass @@ -44,9 +57,7 @@ class LgndrVerifyGameArgs: app_name: str # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( - f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" - ) + verify_stdout: Callable[[int, int, float, float], None] = _verify_stdout @dataclass @@ -82,8 +93,11 @@ class LgndrInstallGameArgs: yes: bool = True # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: Callable[[str, bool], bool] = lambda prompt, default=True: default - sdl_prompt: Callable[[str, str], List[str]] = lambda sdl_data, title: [""] - verify_stdout: Callable[[int, int, float, float], None] = lambda a0, a1, a2, a3: print( - f"Verification progress: {a0}/{a1} ({a2:.01f}%) [{a3:.1f} MiB/s]\t\r" - ) + get_boolean_choice: _GetBooleanChoiceP = _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 [""] diff --git a/rare/models/install.py b/rare/models/install.py index b8771242..560d8823 100644 --- a/rare/models/install.py +++ b/rare/models/install.py @@ -31,7 +31,8 @@ class InstallOptionsModel: install_preqs: bool = pf.system() == "Windows" def __post_init__(self): - self.sdl_prompt: Callable[[str, str], list] = lambda app_name, title: self.install_tag + 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 { From f375357a2ce7fc8fa0eca17a6d07e5cb3ab33f45 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 30 Jul 2022 09:02:08 +0300 Subject: [PATCH 47/61] Utils: fix unresolved references --- rare/components/tabs/games/game_utils.py | 4 ++-- rare/components/tabs/settings/rare.py | 14 ++++++++------ rare/lgndr/api_arguments.py | 2 +- requirements.txt | 1 + 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/rare/components/tabs/games/game_utils.py b/rare/components/tabs/games/game_utils.py index 8e560d77..81394eb3 100644 --- a/rare/components/tabs/games/game_utils.py +++ b/rare/components/tabs/games/game_utils.py @@ -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 @@ -208,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/settings/rare.py b/rare/components/tabs/settings/rare.py index 27fbaef3..48bdc573 100644 --- a/rare/components/tabs/settings/rare.py +++ b/rare/components/tabs/settings/rare.py @@ -18,6 +18,7 @@ from rare.utils.misc import ( get_style_sheets, set_style_sheet, get_size, + create_desktop_link, ) logger = getLogger("RareSettings") @@ -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/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 12ae63d1..5bb22289 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -17,7 +17,7 @@ class LgndrCommonArgs: class _GetBooleanChoiceP(Protocol): def __call__(self, prompt: str, default: bool = ...) -> bool: - ... + pass def _get_boolean_choice(prompt: str, default: bool = True) -> bool: diff --git a/requirements.txt b/requirements.txt index a2034e62..36151228 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +typing_extensions requests PyQt5 QtAwesome From 3501741c03783d418af717536f8e6e5a0e529831 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 30 Jul 2022 09:18:49 +0300 Subject: [PATCH 48/61] DownloadsTab: invalidate and recreate update widgets on import Previously on game import, multiple update widgets for the same game were created in the downloads tab. Instead of checking if a widget already exists, invalidate the delete the existing and add a new one with the current information about the installed game. --- rare/components/tabs/downloads/__init__.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 89808f57..22d1ab21 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -11,16 +11,16 @@ from PyQt5.QtWidgets import ( QPushButton, QGroupBox, ) - from legendary.core import LegendaryCore from legendary.models.game import Game, InstalledGame -from rare.lgndr.downloading import UIUpdate -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.ui.components.tabs.downloads.downloads_tab import Ui_DownloadsTab +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.misc import get_size 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,7 +77,11 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): w.update_button.setDisabled(False) w.update_with_settings.setDisabled(False) - def add_update(self, igame: InstalledGame): + 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) + igame: InstalledGame = self.core.get_installed_game(app_name) widget = UpdateWidget(self.core, igame, self) self.update_layout.addWidget(widget) self.update_widgets[igame.app_name] = widget From c388f99c46f3de214b282d26634f1c4a992d7b73 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 30 Jul 2022 09:32:55 +0300 Subject: [PATCH 49/61] Lgndr: Move generic function implementations to `api_monkeys` --- rare/lgndr/api_arguments.py | 26 ++++++-------------------- rare/lgndr/api_monkeys.py | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 5bb22289..02753b77 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -1,8 +1,7 @@ from dataclasses import dataclass from typing import Callable, List, Optional -from typing_extensions import Protocol -from .api_monkeys import LgndrIndirectStatus +from .api_monkeys import LgndrIndirectStatus, GetBooleanChoiceProtocol, get_boolean_choice, verify_stdout """ @dataclass(kw_only=True) @@ -15,19 +14,6 @@ class LgndrCommonArgs: """ -class _GetBooleanChoiceP(Protocol): - def __call__(self, prompt: str, default: bool = ...) -> bool: - pass - - -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") - - @dataclass class LgndrImportGameArgs: app_path: str @@ -39,7 +25,7 @@ class LgndrImportGameArgs: yes: bool = False # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: _GetBooleanChoiceP = _get_boolean_choice + get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice @dataclass @@ -49,7 +35,7 @@ class LgndrUninstallGameArgs: yes: bool = False # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: _GetBooleanChoiceP = _get_boolean_choice + get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice @dataclass @@ -57,7 +43,7 @@ class LgndrVerifyGameArgs: app_name: str # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - verify_stdout: Callable[[int, int, float, float], None] = _verify_stdout + verify_stdout: Callable[[int, int, float, float], None] = verify_stdout @dataclass @@ -93,9 +79,9 @@ class LgndrInstallGameArgs: yes: bool = True # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() - get_boolean_choice: _GetBooleanChoiceP = _get_boolean_choice + 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 + verify_stdout: Callable[[int, int, float, float], None] = verify_stdout # def __post_init__(self): # if self.sdl_prompt is None: diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py index f9a8938c..b6babe65 100644 --- a/rare/lgndr/api_monkeys.py +++ b/rare/lgndr/api_monkeys.py @@ -1,19 +1,21 @@ import logging from dataclasses import dataclass -from PyQt5.QtWidgets import QMessageBox, QLabel +from PyQt5.QtWidgets import QLabel +from typing_extensions import Protocol -def get_boolean_choice(prompt, default=True): - choice = QMessageBox.question( - None, "Import DLCs?", prompt, - defaultButton=QMessageBox.Yes if default else QMessageBox.No - ) - return True if choice == QMessageBox.StandardButton.Yes else False +class GetBooleanChoiceProtocol(Protocol): + def __call__(self, prompt: str, default: bool = ...) -> bool: + pass -def return_exit(__status): - return __status +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 UILogHandler(logging.Handler): @@ -55,7 +57,9 @@ class LgndrIndirectStatus: class LgndrIndirectLogger: - def __init__(self, status: LgndrIndirectStatus, logger: logging.Logger = None, level: int = logging.ERROR): + def __init__( + self, status: LgndrIndirectStatus, logger: logging.Logger = None, level: int = logging.ERROR + ): self.logger = logger self.level = level self.status = status From 785aaf648e6cc42347876e4cee9708ec3d6bb52c Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 30 Jul 2022 11:19:06 +0300 Subject: [PATCH 50/61] GameInfo: Detect `repair_and_update` requirement If a game was partially installed and it was imported through the import functionality, if `repair_and_update` is specified it will report `0` download size if there is no real update to be done. Fix it by detecting the need for an update explicitly. This will also force games that have failed verification to also update while repairing them, fixing the long-standing issue of repairing an older version of a game and then doing the update in a separate step. --- rare/components/tabs/downloads/__init__.py | 51 ++++++++++--------- .../tabs/games/game_info/game_info.py | 13 +++-- rare/lgndr/core.py | 4 ++ rare/utils/legendary_utils.py | 1 + 4 files changed, 40 insertions(+), 29 deletions(-) diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 22d1ab21..28cf8c01 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -81,14 +81,13 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): if old_widget := self.update_widgets.get(app_name, False): old_widget.deleteLater() self.update_widgets.pop(app_name) - igame: InstalledGame = self.core.get_installed_game(app_name) - widget = UpdateWidget(self.core, igame, self) + 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) @@ -101,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() @@ -228,6 +227,10 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): 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): @@ -266,37 +269,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 + + " \u2B9E " + + 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/games/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py index 588b5e75..1cbce502 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -85,7 +85,7 @@ class GameInfo(QWidget, Ui_GameInfo): if self.args.offline: self.repair_button.setDisabled(True) else: - self.repair_button.clicked.connect(self.repair) + self.repair_button.clicked.connect(lambda: self.repair(self.igame)) self.install_button.clicked.connect(lambda: self.game_utils.launch_game(self.game.app_name)) @@ -117,8 +117,10 @@ class GameInfo(QWidget, Ui_GameInfo): self.game_utils.update_list.emit(self.game.app_name) self.uninstalled.emit(self.game.app_name) - def repair(self): - repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.game.app_name}.repair") + @pyqtSlot(InstalledGame) + def repair(self, igame: InstalledGame): + game = self.core.get_game(igame.app_name) + repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{igame.app_name}.repair") if not os.path.exists(repair_file): QMessageBox.warning( self, @@ -128,8 +130,9 @@ class GameInfo(QWidget, Ui_GameInfo): ), ) return + update = igame.version != game.app_version(igame.platform) self.signals.install_game.emit( - InstallOptionsModel(app_name=self.game.app_name, repair_mode=True, repair_and_update=True, update=True) + InstallOptionsModel(app_name=self.game.app_name, repair_mode=True, repair_and_update=update, update=True) ) def verify(self): @@ -188,7 +191,7 @@ class GameInfo(QWidget, Ui_GameInfo): QMessageBox.Yes, ) if ans == QMessageBox.Yes: - self.repair() + self.repair(igame) self.verify_widget.setCurrentIndex(0) self.verify_threads.pop(app_name) self.move_button.setEnabled(True) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index c8a606af..7aba3a10 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -16,6 +16,10 @@ class LegendaryCore(LegendaryCoreReal): 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, diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index c876c3d3..787e7b51 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -104,6 +104,7 @@ class VerifyWorker(QRunnable): # TODO: requires the client to be online. To do it this way, we need to # TODO: somehow detect the error and offer a dialog in which case `verify_games` is # TODO: re-run with `repair_mode` and `repair_online` + # FIXME: This will crash in offline mode. Offline mode needs a re-thinking in general. result = self.cli.verify_game( args, print_command=False, repair_mode=True, repair_online=True) # success, failed, missing = self.cli.verify_game(args, print_command=False) From e5c7b029ffc925574c9822efd7a2ba3032cd7e37 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sun, 31 Jul 2022 23:00:50 +0300 Subject: [PATCH 51/61] GameInfo: Offer to also update the game after a verification if there is one --- rare/components/dialogs/install_dialog.py | 3 +- .../tabs/games/game_info/game_info.py | 86 ++++++++++++------- .../game_widgets/installed_icon_widget.py | 2 +- .../game_widgets/uninstalled_icon_widget.py | 2 +- rare/utils/legendary_utils.py | 47 +++++++--- 5 files changed, 96 insertions(+), 44 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 7bd6d493..05f6763c 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -25,7 +25,7 @@ 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) @@ -44,6 +44,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 diff --git a/rare/components/tabs/games/game_info/game_info.py b/rare/components/tabs/games/game_info/game_info.py index 1cbce502..9f8fa904 100644 --- a/rare/components/tabs/games/game_info/game_info.py +++ b/rare/components/tabs/games/game_info/game_info.py @@ -85,7 +85,7 @@ class GameInfo(QWidget, Ui_GameInfo): if self.args.offline: self.repair_button.setDisabled(True) else: - self.repair_button.clicked.connect(lambda: self.repair(self.igame)) + self.repair_button.clicked.connect(self.repair) self.install_button.clicked.connect(lambda: self.game_utils.launch_game(self.game.app_name)) @@ -117,46 +117,78 @@ class GameInfo(QWidget, Ui_GameInfo): self.game_utils.update_list.emit(self.game.app_name) self.uninstalled.emit(self.game.app_name) - @pyqtSlot(InstalledGame) - def repair(self, igame: InstalledGame): - game = self.core.get_game(igame.app_name) - repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{igame.app_name}.repair") + @pyqtSlot() + def repair(self): + """ 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 - update = igame.version != game.app_version(igame.platform) + 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_mode=True, repair_and_update=update, 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 = 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_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): - pass + 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): @@ -166,36 +198,30 @@ class GameInfo(QWidget, Ui_GameInfo): @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, self.tr("Summary - {}").format(igame.title), - self.tr("Game has been verified successfully. No missing or corrupt files found").format(igame.title), + self.tr("{} has been verified successfully. " + "No missing or corrupt files found").format(igame.title), ) - 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, self.tr("Warning - {}").format(igame.title), self.tr("Something went wrong")) - + self.verification_finished.emit(igame) else: ans = QMessageBox.question( self, 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.repair(igame) - 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): @@ -326,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_widgets/installed_icon_widget.py b/rare/components/tabs/games/game_widgets/installed_icon_widget.py index 9904084c..f6b84680 100644 --- a/rare/components/tabs/games/game_widgets/installed_icon_widget.py +++ b/rare/components/tabs/games/game_widgets/installed_icon_widget.py @@ -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/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/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 787e7b51..a3805a65 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -8,7 +8,7 @@ from legendary.core import LegendaryCore from rare.lgndr.api_monkeys import LgndrIndirectStatus from rare.lgndr.api_arguments import LgndrVerifyGameArgs, LgndrUninstallGameArgs from rare.lgndr.api_exception import LgndrException -from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton +from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, ArgumentsSingleton from rare.utils import config_helper logger = getLogger("Legendary Utils") @@ -90,6 +90,7 @@ class VerifyWorker(QRunnable): self.setAutoDelete(True) self.cli = LegendaryCLISingleton() self.core = LegendaryCoreSingleton() + self.args = ArgumentsSingleton() self.app_name = app_name def status_callback(self, num: int, total: int, percentage: float, speed: float): @@ -100,15 +101,37 @@ class VerifyWorker(QRunnable): args = LgndrVerifyGameArgs(app_name=self.app_name, indirect_status=status, verify_stdout=self.status_callback) - # TODO: offer this as an alternative when manifest doesn't exist - # TODO: requires the client to be online. To do it this way, we need to - # TODO: somehow detect the error and offer a dialog in which case `verify_games` is - # TODO: re-run with `repair_mode` and `repair_online` - # FIXME: This will crash in offline mode. Offline mode needs a re-thinking in general. + + # lk: first pass, verify with the current manifest + repair_mode = False result = self.cli.verify_game( - args, print_command=False, repair_mode=True, repair_online=True) - # success, failed, missing = self.cli.verify_game(args, print_command=False) - if result: - self.signals.result.emit(self.app_name, not any(result), *result) - else: - self.signals.error.emit(self.app_name, status.message) + args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline + ) + if result is None: + # lk: second pass with 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 = self.cli.verify_game( + args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline + ) + if result is None: + raise ValueError + except ValueError: + self.signals.error.emit(self.app_name, status.message) + return + + 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') + self.cli.clean_post_install(game=game, igame=igame, repair=True, repair_file=repair_file) + + self.signals.result.emit(self.app_name, success, *result) + From 5ac71e99f0f80e258af1352869ab90ca12d42174 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 1 Aug 2022 10:30:31 +0300 Subject: [PATCH 52/61] DownloadThread: Use `clean_post_install` instead of local copy InstallDialog: Handly empty `config_tags` Lgndr: Cleanup --- rare/components/dialogs/install_dialog.py | 5 ++-- .../tabs/downloads/download_thread.py | 25 ++++++------------- rare/lgndr/api_monkeys.py | 11 +------- rare/utils/legendary_utils.py | 2 +- 4 files changed, 12 insertions(+), 31 deletions(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 05f6763c..40d19b0b 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -176,8 +176,9 @@ class InstallDialog(QDialog, Ui_InstallDialog): if tag == "__required": cb.setChecked(True) cb.setDisabled(True) - if all(elem in self.config_tags for elem in info["tags"]): - cb.setChecked(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()) diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index 1410ca6d..ab8f9e53 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -11,7 +11,7 @@ from PyQt5.QtCore import QThread, pyqtSignal, QProcess from legendary.core import LegendaryCore from legendary.models.downloading import WriterTask -from rare.shared import GlobalSignalsSingleton +from rare.shared import GlobalSignalsSingleton, LegendaryCLISingleton from rare.models.install import InstallQueueItemModel from rare.utils.misc import create_desktop_link from rare.lgndr.downloading import UIUpdate @@ -139,7 +139,6 @@ class DownloadThread(QThread): 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.item.download.igame.app_name) if self.item.options.overlay: self.signals.overlay_installation_finished.emit() @@ -163,28 +162,18 @@ class DownloadThread(QThread): "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: + if self.item.download.game.supports_cloud_saves and not self.item.download.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}"' + f'To download saves for this game run "legendary sync-saves {self.item.download.game.app_name}"' ) - old_igame = self.core.get_installed_game(game.app_name) - if old_igame and self.item.download.repair and os.path.exists(self.item.download.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(self.item.download.repair_file) - if old_igame and old_igame.install_tags != self.item.download.igame.install_tags: - old_igame.install_tags = self.item.download.igame.install_tags - logger.info("Deleting now untagged files.") - self.core.uninstall_tag(old_igame) - self.core.install_game(old_igame) + LegendaryCLISingleton().clean_post_install( + self.item.download.game, self.item.download.igame, + self.item.download.repair, self.item.download.repair_file + ) if not self.item.options.update and self.item.options.create_shortcut: if not create_desktop_link(self.item.options.app_name, self.core, "desktop"): diff --git a/rare/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py index b6babe65..7696f917 100644 --- a/rare/lgndr/api_monkeys.py +++ b/rare/lgndr/api_monkeys.py @@ -7,7 +7,7 @@ from typing_extensions import Protocol class GetBooleanChoiceProtocol(Protocol): def __call__(self, prompt: str, default: bool = ...) -> bool: - pass + ... def get_boolean_choice(prompt: str, default: bool = True) -> bool: @@ -18,15 +18,6 @@ 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 UILogHandler(logging.Handler): - def __init__(self, dest: QLabel): - super(UILogHandler, self).__init__() - self.widget = dest - - def emit(self, record: logging.LogRecord) -> None: - self.widget.setText(record.getMessage()) - - @dataclass class LgndrIndirectStatus: success: bool = False diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index a3805a65..7a224832 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -108,7 +108,7 @@ class VerifyWorker(QRunnable): args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline ) if result is None: - # lk: second pass with the latest manifest + # 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: From 38dfdc8bc20ff20c08fe5162ee9b880b76d87f77 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 1 Aug 2022 14:31:07 +0300 Subject: [PATCH 53/61] Lgndr: Remove `LegendaryCLISingleton` Since `LegendaryCLI` isn't stateful, we can instantiate it when needed --- rare/app.py | 8 +++---- rare/components/dialogs/install_dialog.py | 5 ++-- .../tabs/downloads/download_thread.py | 5 ++-- .../tabs/games/import_sync/import_group.py | 8 +++---- rare/game_launch_helper/__init__.py | 5 ++-- rare/lgndr/cli.py | 18 ++++----------- rare/shared/__init__.py | 23 +++++-------------- rare/utils/legendary_utils.py | 14 +++++------ 8 files changed, 33 insertions(+), 53 deletions(-) diff --git a/rare/app.py b/rare/app.py index 4a0f292a..a9aa7bca 100644 --- a/rare/app.py +++ b/rare/app.py @@ -20,7 +20,7 @@ import rare from rare.components.dialogs.launch_dialog import LaunchDialog from rare.components.main_window import MainWindow from rare.components.tray_icon import TrayIcon -from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton +from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ArgumentsSingleton from rare.shared.image_manager import ImageManagerSingleton from rare.utils import legendary_utils, config_helper from rare.utils.paths import cache_dir, tmp_dir @@ -64,8 +64,7 @@ class App(RareApp): # init Legendary try: - LegendaryCLISingleton(init=True) - self.core = LegendaryCoreSingleton() + self.core = LegendaryCoreSingleton(init=True) except configparser.MissingSectionHeaderError as e: logger.warning(f"Config is corrupt: {e}") if config_path := os.environ.get("XDG_CONFIG_HOME"): @@ -74,8 +73,7 @@ class App(RareApp): path = os.path.expanduser("~/.config/legendary") with open(os.path.join(path, "config.ini"), "w") as config_file: config_file.write("[Legendary]") - LegendaryCLISingleton(init=True) - self.core = LegendaryCoreSingleton() + self.core = LegendaryCoreSingleton(init=True) if "Legendary" not in self.core.lgd.config.sections(): self.core.lgd.config.add_section("Legendary") self.core.lgd.save_config() diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index 40d19b0b..e6834742 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -10,11 +10,12 @@ from legendary.models.downloading import ConditionCheckResult from legendary.models.game import Game 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 LegendaryCLISingleton, LegendaryCoreSingleton, ApiResultsSingleton, ArgumentsSingleton +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.models.install import InstallDownloadModel, InstallQueueItemModel @@ -337,7 +338,7 @@ class InstallInfoWorker(QRunnable): def run(self): try: if not self.dl_item.options.overlay: - cli = LegendaryCLISingleton() + cli = LegendaryCLI(self.core) status = LgndrIndirectStatus() result = cli.install_game( LgndrInstallGameArgs(**self.dl_item.options.as_install_kwargs(), indirect_status=status) diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index ab8f9e53..67de371d 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -11,7 +11,8 @@ from PyQt5.QtCore import QThread, pyqtSignal, QProcess from legendary.core import LegendaryCore from legendary.models.downloading import WriterTask -from rare.shared import GlobalSignalsSingleton, LegendaryCLISingleton +from rare.lgndr.cli import LegendaryCLI +from rare.shared import GlobalSignalsSingleton from rare.models.install import InstallQueueItemModel from rare.utils.misc import create_desktop_link from rare.lgndr.downloading import UIUpdate @@ -170,7 +171,7 @@ class DownloadThread(QThread): f'To download saves for this game run "legendary sync-saves {self.item.download.game.app_name}"' ) - LegendaryCLISingleton().clean_post_install( + LegendaryCLI(self.core).clean_post_install( self.item.download.game, self.item.download.igame, self.item.download.repair, self.item.download.repair_file ) diff --git a/rare/components/tabs/games/import_sync/import_group.py b/rare/components/tabs/games/import_sync/import_group.py index b3ce4d1e..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,10 @@ 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_exception import LgndrException from rare.lgndr.api_monkeys import LgndrIndirectStatus -from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton +from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton, ApiResultsSingleton from rare.ui.components.tabs.games.import_sync.import_group import Ui_ImportGroup from rare.utils.extra_widgets import IndicatorLineEdit, PathEdit from rare.widgets.elide_label import ElideLabel @@ -28,7 +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 := LegendaryCLISingleton().resolve_aliases(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 @@ -99,7 +99,7 @@ class ImportWorker(QRunnable): return result def __import_game(self, path: Path, app_name: str, app_title: str): - cli = LegendaryCLISingleton() + cli = LegendaryCLI(self.core) status = LgndrIndirectStatus() args = LgndrImportGameArgs( app_path=str(path), diff --git a/rare/game_launch_helper/__init__.py b/rare/game_launch_helper/__init__.py index 092d2be2..6b26435d 100644 --- a/rare/game_launch_helper/__init__.py +++ b/rare/game_launch_helper/__init__.py @@ -14,7 +14,7 @@ from PyQt5.QtNetwork import QLocalServer, QLocalSocket from .console import Console from .lgd_helper import get_launch_args, InitArgs, get_configured_process, LaunchArgs, GameArgsError from .message_models import ErrorModel, Actions, FinishedModel, BaseModel, StateChangedModel -from ..shared import LegendaryCLISingleton, LegendaryCoreSingleton +from ..shared import LegendaryCoreSingleton from ..widgets.rare_app import RareApp @@ -69,8 +69,7 @@ class GameProcessApp(RareApp): self.game_process = QProcess() self.app_name = app_name self.logger = getLogger(self.app_name) - LegendaryCLISingleton(init=True) - self.core = LegendaryCoreSingleton() + self.core = LegendaryCoreSingleton(init=True) lang = self.settings.value("language", self.core.language_code, type=str) self.load_translator(lang) diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index da66381c..ef85dce4 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -17,8 +17,9 @@ from .manager import DLManager class LegendaryCLI(LegendaryCLIReal): - def __init__(self, override_config=None, api_timeout=None): - self.core = LegendaryCore(override_config, timeout=api_timeout) + # noinspection PyMissingConstructor + def __init__(self, core: LegendaryCore): + self.core = core self.logger = logging.getLogger('cli') self.logging_queue = None @@ -96,19 +97,10 @@ class LegendaryCLI(LegendaryCLIReal): if not os.path.exists(repair_file): logger.info('Game has not been verified yet.') - # Rare: Dodge the path below for now + # 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 - if not args.yes: - if not get_boolean_choice(f'Verify "{game.app_name}" now ("no" will abort repair)?'): - return - try: - self.verify_game(args, print_command=False, repair_mode=True, repair_online=args.repair_and_update) - except ValueError: - logger.error('To repair a game with a missing manifest you must run the command with ' - '"--repair-and-update". However this will redownload any file that does ' - 'not match the current hash in its entirety.') - return else: logger.info(f'Using existing repair file: {repair_file}') diff --git a/rare/shared/__init__.py b/rare/shared/__init__.py index 7cc8bb82..6bbbf05a 100644 --- a/rare/shared/__init__.py +++ b/rare/shared/__init__.py @@ -8,35 +8,24 @@ and only ONCE! from argparse import Namespace from typing import Optional -from rare.lgndr.cli import LegendaryCLI from rare.lgndr.core import LegendaryCore from rare.models.apiresults import ApiResults from rare.models.signals import GlobalSignals -_legendary_cli_signleton: Optional[LegendaryCLI] = None _legendary_core_singleton: Optional[LegendaryCore] = None _global_signals_singleton: Optional[GlobalSignals] = None _arguments_singleton: Optional[Namespace] = None _api_results_singleton: Optional[ApiResults] = None -def LegendaryCLISingleton(init: bool = False) -> LegendaryCLI: - global _legendary_cli_signleton - if _legendary_cli_signleton is None and not init: - raise RuntimeError("Uninitialized use of LegendaryCLISingleton") - if _legendary_cli_signleton is None: - _legendary_cli_signleton = LegendaryCLI(override_config=None, api_timeout=10) - return _legendary_cli_signleton - - def LegendaryCoreSingleton(init: bool = False) -> LegendaryCore: - global _legendary_cli_signleton - if _legendary_cli_signleton is None: - raise RuntimeError("LegendaryCLI is not initialized yet") - # if _legendary_cli_signleton is None: - # _legendary_cli_signleton = LegendaryCLISingleton(init) - return _legendary_cli_signleton.core + global _legendary_core_singleton + if _legendary_core_singleton is None and not init: + raise RuntimeError("Uninitialized use of LegendaryCoreSingleton") + if _legendary_core_singleton is None: + _legendary_core_singleton = LegendaryCore() + return _legendary_core_singleton def GlobalSignalsSingleton(init: bool = False) -> GlobalSignals: diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 7a224832..6a1080bf 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -5,10 +5,10 @@ from logging import getLogger from PyQt5.QtCore import pyqtSignal, QObject, QRunnable, QStandardPaths from legendary.core import LegendaryCore +from rare.lgndr.cli import LegendaryCLI from rare.lgndr.api_monkeys import LgndrIndirectStatus from rare.lgndr.api_arguments import LgndrVerifyGameArgs, LgndrUninstallGameArgs -from rare.lgndr.api_exception import LgndrException -from rare.shared import LegendaryCLISingleton, LegendaryCoreSingleton, ArgumentsSingleton +from rare.shared import LegendaryCoreSingleton, ArgumentsSingleton from rare.utils import config_helper logger = getLogger("Legendary Utils") @@ -40,7 +40,7 @@ def uninstall_game(core: LegendaryCore, app_name: str, keep_files=False, keep_co os.remove(start_menu_shortcut) status = LgndrIndirectStatus() - LegendaryCLISingleton().uninstall_game( + LegendaryCLI(core).uninstall_game( LgndrUninstallGameArgs( app_name=app_name, keep_files=keep_files, @@ -88,7 +88,6 @@ class VerifyWorker(QRunnable): super(VerifyWorker, self).__init__() self.signals = VerifyWorker.Signals() self.setAutoDelete(True) - self.cli = LegendaryCLISingleton() self.core = LegendaryCoreSingleton() self.args = ArgumentsSingleton() self.app_name = app_name @@ -97,6 +96,7 @@ class VerifyWorker(QRunnable): self.signals.status.emit(self.app_name, num, total, percentage, speed) def run(self): + cli = LegendaryCLI(self.core) status = LgndrIndirectStatus() args = LgndrVerifyGameArgs(app_name=self.app_name, indirect_status=status, @@ -104,7 +104,7 @@ class VerifyWorker(QRunnable): # lk: first pass, verify with the current manifest repair_mode = False - result = self.cli.verify_game( + result = cli.verify_game( args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline ) if result is None: @@ -115,7 +115,7 @@ class VerifyWorker(QRunnable): # 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 = self.cli.verify_game( + result = cli.verify_game( args, print_command=False, repair_mode=repair_mode, repair_online=not self.args.offline ) if result is None: @@ -131,7 +131,7 @@ class VerifyWorker(QRunnable): 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') - self.cli.clean_post_install(game=game, igame=igame, repair=True, repair_file=repair_file) + cli.clean_post_install(game=game, igame=igame, repair=True, repair_file=repair_file) self.signals.result.emit(self.app_name, success, *result) From 3ee789a6956444024a1f999314b4dc94236a9a7e Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Wed, 3 Aug 2022 02:33:50 +0300 Subject: [PATCH 54/61] Update `DownloadThread` --- rare/app.py | 9 +- rare/components/tabs/downloads/__init__.py | 24 +- .../tabs/downloads/download_thread.py | 219 +++++++----------- rare/lgndr/api_monkeys.py | 22 +- rare/lgndr/cli.py | 42 +++- rare/lgndr/core.py | 13 ++ rare/lgndr/downloading.py | 3 +- rare/lgndr/manager.py | 42 +++- rare/models/install.py | 3 +- rare/utils/legendary_utils.py | 17 +- 10 files changed, 225 insertions(+), 169 deletions(-) diff --git a/rare/app.py b/rare/app.py index a9aa7bca..b5405b37 100644 --- a/rare/app.py +++ b/rare/app.py @@ -154,7 +154,14 @@ class App(RareApp): 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") diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 28cf8c01..2d959b0d 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -2,7 +2,7 @@ import datetime from logging import getLogger from typing import List, Dict -from PyQt5.QtCore import QThread, pyqtSignal, QSettings +from PyQt5.QtCore import QThread, pyqtSignal, QSettings, pyqtSlot from PyQt5.QtWidgets import ( QWidget, QMessageBox, @@ -21,7 +21,7 @@ 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.misc import get_size +from rare.utils.misc import get_size, create_desktop_link logger = getLogger("Download") @@ -137,7 +137,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.status.connect(self.status) + self.thread.exit_status.connect(self.status) self.thread.statistics.connect(self.statistics) self.thread.start() self.kill_button.setDisabled(False) @@ -146,8 +146,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.ExitStatus) + def status(self, result: DownloadThread.ExitStatus): + if result.exit_code == DownloadThread.ExitCode.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}") @@ -182,10 +190,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.exit_code == DownloadThread.ExitCode.ERROR: + QMessageBox.warning(self, self.tr("Error"), f"Download error: {result.message}") - elif text == "stop": + elif result.exit_code == DownloadThread.ExitCode.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 67de371d..bcbddc72 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -1,27 +1,40 @@ 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 WriterTask +from rare.lgndr.api_monkeys import DLManagerSignals from rare.lgndr.cli import LegendaryCLI -from rare.shared import GlobalSignalsSingleton -from rare.models.install import InstallQueueItemModel -from rare.utils.misc import create_desktop_link from rare.lgndr.downloading import UIUpdate +from rare.models.install import InstallQueueItemModel +from rare.shared import GlobalSignalsSingleton logger = getLogger("DownloadThread") class DownloadThread(QThread): - status = pyqtSignal(str) + class ExitCode(IntEnum): + ERROR = 1 + STOPPED = 2 + FINISHED = 3 + + @dataclass + class ExitStatus: + app_name: str + exit_code: int + message: str = "" + dlcs: Optional[List[Dict]] = None + sync_saves: bool = False + shortcuts: bool = False + + exit_status = pyqtSignal(ExitStatus) statistics = pyqtSignal(UIUpdate) def __init__(self, core: LegendaryCore, item: InstallQueueItemModel): @@ -29,166 +42,93 @@ class DownloadThread(QThread): self.signals = GlobalSignalsSingleton() self.core: LegendaryCore = core self.item: InstallQueueItemModel = item - - self._kill = False + self.dlm_signals: DLManagerSignals = DLManagerSignals() def run(self): - start_time = time.time() - dl_stopped = False + _exit_status = DownloadThread.ExitStatus(self.item.download.game.app_name, DownloadThread.ExitCode.ERROR) + start_t = time.time() try: - self.item.download.dlmanager.start() time.sleep(1) while self.item.download.dlmanager.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.item.download.dlmanager.running = False - - # send conditions to unlock threads if they aren't already - for cond in self.item.download.dlmanager.conditions: - with cond: - cond.notify() - - # make sure threads are dead. - for t in self.item.download.dlmanager.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.item.download.dlmanager.dl_worker_queue, - self.item.download.dlmanager.writer_queue, - self.item.download.dlmanager.dl_result_q, - self.item.download.dlmanager.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.item.download.dlmanager.writer_queue: - # cancel installation - self.item.download.dlmanager.writer_queue.put_nowait(WriterTask("", kill=True)) - - # forcibly kill DL workers that are not actually dead yet - for child in self.item.download.dlmanager.children: - if child.exitcode is None: - child.terminate() - - if self.item.download.dlmanager.shared_memory: - # close up shared memory - self.item.download.dlmanager.shared_memory.close() - self.item.download.dlmanager.shared_memory.unlink() - self.item.download.dlmanager.shared_memory = None - - self.item.download.dlmanager.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 try: - if not dl_stopped: - self.statistics.emit(self.item.download.dlmanager.status_queue.get(timeout=1)) + self.statistics.emit(self.item.download.dlmanager.status_queue.get(timeout=1.0)) except queue.Empty: pass - + if self.dlm_signals.update: + try: + self.item.download.dlmanager.signals_queue.put(self.dlm_signals, block=False, timeout=1.0) + except queue.Full: + pass + time.sleep(self.item.download.dlmanager.update_interval/10) self.item.download.dlmanager.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") + 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) + 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) + return + logger.info(f"Download finished in {end_t - start_t:.02f} seconds.") + + _exit_status.exit_code = DownloadThread.ExitCode.FINISHED if self.item.options.overlay: self.signals.overlay_installation_finished.emit() self.core.finish_overlay_install(self.item.download.igame) - self.status.emit("finish") + self.exit_status.emit(_exit_status) return if not self.item.options.no_install: postinstall = self.core.install_game(self.item.download.igame) if postinstall: + # 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.item.download.igame.app_name) if dlcs: - print("The following DLCs are available for this game:") for dlc in dlcs: - print( - f" - {dlc.app_title} (App name: {dlc.app_name}, version: {dlc.app_version})" + _exit_status.dlcs.append( + {"app_name": dlc.app_name, "app_title": dlc.app_title, "app_version": dlc.app_version} ) - print( - "Manually installing DLCs works the same; just use the DLC app name instead." - ) if self.item.download.game.supports_cloud_saves and not self.item.download.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 {self.item.download.game.app_name}"' - ) + _exit_status.sync_saves = True - LegendaryCLI(self.core).clean_post_install( - self.item.download.game, self.item.download.igame, - self.item.download.repair, self.item.download.repair_file - ) + LegendaryCLI(self.core).clean_post_install( + self.item.download.game, + self.item.download.igame, + self.item.download.repair, + self.item.download.repair_file, + ) - if not self.item.options.update and self.item.options.create_shortcut: - if not create_desktop_link(self.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") + if not self.item.options.update and self.item.options.create_shortcut: + _exit_status.shortcuts = True - self.status.emit("finish") + self.exit_status.emit(_exit_status) 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.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) @@ -196,15 +136,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.item.download.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/lgndr/api_monkeys.py b/rare/lgndr/api_monkeys.py index 7696f917..b764b23d 100644 --- a/rare/lgndr/api_monkeys.py +++ b/rare/lgndr/api_monkeys.py @@ -1,7 +1,6 @@ import logging from dataclasses import dataclass -from PyQt5.QtWidgets import QLabel from typing_extensions import Protocol @@ -18,6 +17,27 @@ 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 diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index ef85dce4..3064302e 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,6 +1,7 @@ import logging import os import time +import subprocess from typing import Optional, Union, Tuple from legendary.cli import LegendaryCLI as LegendaryCLIReal @@ -15,6 +16,7 @@ from .core import LegendaryCore from .manager import DLManager +# fmt: off class LegendaryCLI(LegendaryCLIReal): # noinspection PyMissingConstructor @@ -205,8 +207,42 @@ class LegendaryCLI(LegendaryCLIReal): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - def handle_postinstall(self, postinstall, igame, yes=False): - super(LegendaryCLI, self)._handle_postinstall(postinstall, igame, yes) + def _handle_postinstall(self, postinstall, igame, yes=False, choice=False): + # 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 @@ -489,3 +525,5 @@ class LegendaryCLI(LegendaryCLIReal): 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 index 7aba3a10..2055697b 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -8,7 +8,11 @@ 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): @@ -48,7 +52,11 @@ class LegendaryCore(LegendaryCoreReal): ) # 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): @@ -79,6 +87,11 @@ class LegendaryCore(LegendaryCoreReal): 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 index 58fa342b..2b5dc37b 100644 --- a/rare/lgndr/downloading.py +++ b/rare/lgndr/downloading.py @@ -5,7 +5,8 @@ from typing import Optional @dataclass class UIUpdate: """ - Status update object sent from the manager to the CLI/GUI to update status indicators + 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 diff --git a/rare/lgndr/manager.py b/rare/lgndr/manager.py index ee16a1de..80f199a4 100644 --- a/rare/lgndr/manager.py +++ b/rare/lgndr/manager.py @@ -1,5 +1,6 @@ import logging import os +import queue import time from multiprocessing import Queue as MPQueue from multiprocessing.shared_memory import SharedMemory @@ -11,10 +12,14 @@ 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): - # fmt: off + # 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) @@ -77,6 +82,9 @@ class DLManager(DLManagerReal): last_update = time.time() + # Rare: kill requested + kill_request = False + while processed_tasks < num_tasks: delta = time.time() - last_update if not delta: @@ -121,7 +129,7 @@ class DLManager(DLManagerReal): rt_hours = rt_minutes = rt_seconds = 0 log_level = self.log.level - # lk: Disable up to INFO logging level for the segment below + # Rare: Disable up to INFO logging level for the segment below 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}, ' @@ -133,7 +141,7 @@ class DLManager(DLManagerReal): 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)') - # lk: Restore previous logging level + # Rare: Restore previous logging level self.log.setLevel(log_level) # send status update to back to instantiator (if queue exists) @@ -155,6 +163,29 @@ class DLManager(DLManagerReal): 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): @@ -180,7 +211,7 @@ class DLManager(DLManagerReal): self.log.warning(f'Thread did not terminate! {repr(t)}') # clean up resume file - if self.resume_file: + if self.resume_file and not kill_request: try: os.remove(self.resume_file) except OSError as e: @@ -194,4 +225,5 @@ class DLManager(DLManagerReal): self.log.info('All done! Download manager quitting...') # finally, exit the process. exit(0) - # fmt: on + +# fmt: on diff --git a/rare/models/install.py b/rare/models/install.py index 560d8823..c44b47ab 100644 --- a/rare/models/install.py +++ b/rare/models/install.py @@ -3,10 +3,11 @@ import platform as pf from dataclasses import dataclass from typing import List, Optional, Callable, Dict -from legendary.downloader.mp.manager import DLManager from legendary.models.downloading import AnalysisResult, ConditionCheckResult from legendary.models.game import Game, InstalledGame +from rare.lgndr.manager import DLManager + @dataclass class InstallOptionsModel: diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 6a1080bf..272ccf65 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -5,9 +5,9 @@ from logging import getLogger from PyQt5.QtCore import pyqtSignal, QObject, QRunnable, QStandardPaths from legendary.core import LegendaryCore -from rare.lgndr.cli import LegendaryCLI -from rare.lgndr.api_monkeys import LgndrIndirectStatus 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 @@ -70,9 +70,7 @@ 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 - ) + core.lgd.save_manifest(game.app_name, new_manifest_data, version=new_manifest.meta.build_version) class VerifyWorker(QRunnable): @@ -98,9 +96,9 @@ class VerifyWorker(QRunnable): def run(self): cli = LegendaryCLI(self.core) status = LgndrIndirectStatus() - args = LgndrVerifyGameArgs(app_name=self.app_name, - indirect_status=status, - verify_stdout=self.status_callback) + args = LgndrVerifyGameArgs( + app_name=self.app_name, indirect_status=status, verify_stdout=self.status_callback + ) # lk: first pass, verify with the current manifest repair_mode = False @@ -130,8 +128,7 @@ class VerifyWorker(QRunnable): # 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') + repair_file = os.path.join(self.core.lgd.get_tmp_path(), f"{self.app_name}.repair") cli.clean_post_install(game=game, igame=igame, repair=True, repair_file=repair_file) self.signals.result.emit(self.app_name, success, *result) - From e97e9fe8412a888fbd1540d2a5160d2ded39b248 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 8 Aug 2022 17:25:50 +0300 Subject: [PATCH 55/61] Downloads: Add `skip_dlcs` and `with_dlcs` arguments. --- rare/components/tabs/downloads/download_thread.py | 2 +- rare/lgndr/api_arguments.py | 2 ++ rare/models/install.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index bcbddc72..f6f85ead 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -99,7 +99,7 @@ class DownloadThread(QThread): self._handle_postinstall(postinstall, self.item.download.igame) dlcs = self.core.get_dlc_for_game(self.item.download.igame.app_name) - if dlcs: + 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} diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 02753b77..4856b3b3 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -76,6 +76,8 @@ class LgndrInstallGameArgs: reset_sdl: bool = False skip_sdl: bool = False disable_https: bool = False + skip_dlcs: bool = False + with_dlcs: bool = False yes: bool = True # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() diff --git a/rare/models/install.py b/rare/models/install.py index c44b47ab..97320294 100644 --- a/rare/models/install.py +++ b/rare/models/install.py @@ -23,6 +23,8 @@ class InstallOptionsModel: 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 From 5e022e6d28dfa881f9cd36c3a2220c41c7df568a Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 8 Aug 2022 21:06:58 +0300 Subject: [PATCH 56/61] Lgndr: Rename a few fields to reflect legendary's names --- rare/components/tabs/downloads/__init__.py | 4 +- .../tabs/downloads/download_thread.py | 45 ++++----- rare/lgndr/api_arguments.py | 19 +++- rare/lgndr/cli.py | 92 +++++++++++++++++-- rare/models/install.py | 2 +- rare/utils/legendary_utils.py | 2 +- 6 files changed, 131 insertions(+), 33 deletions(-) diff --git a/rare/components/tabs/downloads/__init__.py b/rare/components/tabs/downloads/__init__.py index 2d959b0d..58a98caa 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -138,7 +138,7 @@ class DownloadsTab(QWidget, Ui_DownloadsTab): self.active_game = queue_item.download.game self.thread = DownloadThread(self.core, queue_item) self.thread.exit_status.connect(self.status) - self.thread.statistics.connect(self.statistics) + self.thread.ui_update.connect(self.progress_update) self.thread.start() self.kill_button.setDisabled(False) self.analysis = queue_item.download.analysis @@ -213,7 +213,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 ) diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index f6f85ead..3bb279cc 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -14,7 +14,7 @@ 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 +from rare.shared import GlobalSignalsSingleton, ArgumentsSingleton logger = getLogger("DownloadThread") @@ -35,7 +35,7 @@ class DownloadThread(QThread): shortcuts: bool = False exit_status = pyqtSignal(ExitStatus) - statistics = pyqtSignal(UIUpdate) + ui_update = pyqtSignal(UIUpdate) def __init__(self, core: LegendaryCore, item: InstallQueueItemModel): super(DownloadThread, self).__init__() @@ -45,46 +45,49 @@ class DownloadThread(QThread): self.dlm_signals: DLManagerSignals = DLManagerSignals() def run(self): - _exit_status = DownloadThread.ExitStatus(self.item.download.game.app_name, DownloadThread.ExitCode.ERROR) + 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) start_t = time.time() try: - self.item.download.dlmanager.start() + self.item.download.dlm.start() time.sleep(1) - while self.item.download.dlmanager.is_alive(): + while self.item.download.dlm.is_alive(): try: - self.statistics.emit(self.item.download.dlmanager.status_queue.get(timeout=1.0)) + self.ui_update.emit(self.item.download.dlm.status_queue.get(timeout=1.0)) except queue.Empty: pass if self.dlm_signals.update: try: - self.item.download.dlmanager.signals_queue.put(self.dlm_signals, block=False, timeout=1.0) + self.item.download.dlm.signals_queue.put(self.dlm_signals, block=False, timeout=1.0) except queue.Full: pass - time.sleep(self.item.download.dlmanager.update_interval/10) - self.item.download.dlmanager.join() + time.sleep(self.item.download.dlm.update_interval / 10) + self.item.download.dlm.join() except Exception as e: 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) + exit_status.exit_code = DownloadThread.ExitCode.ERROR + exit_status.message = f"{e!r}" + self.exit_status.emit(exit_status) 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) + exit_status.exit_code = DownloadThread.ExitCode.STOPPED + self.exit_status.emit(exit_status) return logger.info(f"Download finished in {end_t - start_t:.02f} seconds.") - _exit_status.exit_code = DownloadThread.ExitCode.FINISHED + exit_status.exit_code = DownloadThread.ExitCode.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.exit_status.emit(exit_status) return if not self.item.options.no_install: @@ -101,14 +104,14 @@ 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( + exit_status.dlcs.append( {"app_name": dlc.app_name, "app_title": dlc.app_title, "app_version": dlc.app_version} ) if self.item.download.game.supports_cloud_saves and not self.item.download.game.is_dlc: - _exit_status.sync_saves = True + exit_status.sync_saves = True - LegendaryCLI(self.core).clean_post_install( + LegendaryCLI(self.core).install_game_cleanup( self.item.download.game, self.item.download.igame, self.item.download.repair, @@ -116,9 +119,9 @@ class DownloadThread(QThread): ) if not self.item.options.update and self.item.options.create_shortcut: - _exit_status.shortcuts = True + exit_status.shortcuts = True - self.exit_status.emit(_exit_status) + self.exit_status.emit(exit_status) def _handle_postinstall(self, postinstall, igame): logger.info("This game lists the following prequisites to be installed:") diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index 4856b3b3..e9f74283 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -76,8 +76,6 @@ class LgndrInstallGameArgs: reset_sdl: bool = False skip_sdl: bool = False disable_https: bool = False - skip_dlcs: bool = False - with_dlcs: bool = False yes: bool = True # Rare: Extra arguments indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() @@ -89,3 +87,20 @@ class LgndrInstallGameArgs: # 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 = True + # Rare: Extra arguments + indirect_status: LgndrIndirectStatus = LgndrIndirectStatus() + get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 3064302e..2b0e79ed 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,7 +1,7 @@ import logging import os -import time import subprocess +import time from typing import Optional, Union, Tuple from legendary.cli import LegendaryCLI as LegendaryCLIReal @@ -10,7 +10,13 @@ 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 +from .api_arguments import ( + LgndrInstallGameArgs, + LgndrImportGameArgs, + LgndrVerifyGameArgs, + LgndrUninstallGameArgs, + LgndrInstallGameRealArgs, +) from .api_monkeys import LgndrIndirectStatus, LgndrIndirectLogger from .core import LegendaryCore from .manager import DLManager @@ -24,6 +30,10 @@ class LegendaryCLI(LegendaryCLIReal): 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) @@ -178,7 +188,7 @@ class LegendaryCLI(LegendaryCLIReal): # 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.clean_post_install(game, igame, args.repair_mode, repair_file) + self.install_game_cleanup(game, igame, args.repair_mode, repair_file) return res = self.core.check_installation_conditions(analysis=analysis, install=igame, game=game, @@ -187,12 +197,82 @@ class LegendaryCLI(LegendaryCLIReal): return dlm, analysis, igame, game, args.repair_mode, repair_file, res - def clean_post_install(self, game: Game, igame: InstalledGame, repair: bool = False, repair_file: str = '') -> None: + # 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: + # 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 + + 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() + 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.') + else: + end_t = time.time() + 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) + + 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.') + + 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('') + + 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}"') + + # 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') + + 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.') + + 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 and os.path.exists(repair_file): + 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) @@ -207,7 +287,7 @@ class LegendaryCLI(LegendaryCLIReal): self.core.uninstall_tag(old_igame) self.core.install_game(old_igame) - def _handle_postinstall(self, postinstall, igame, yes=False, choice=False): + 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 diff --git a/rare/models/install.py b/rare/models/install.py index 97320294..b4dedef8 100644 --- a/rare/models/install.py +++ b/rare/models/install.py @@ -47,7 +47,7 @@ class InstallOptionsModel: @dataclass class InstallDownloadModel: - dlmanager: DLManager + dlm: DLManager analysis: AnalysisResult igame: InstalledGame game: Game diff --git a/rare/utils/legendary_utils.py b/rare/utils/legendary_utils.py index 272ccf65..77fccfe6 100644 --- a/rare/utils/legendary_utils.py +++ b/rare/utils/legendary_utils.py @@ -129,6 +129,6 @@ class VerifyWorker(QRunnable): 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.clean_post_install(game=game, igame=igame, repair=True, repair_file=repair_file) + cli.install_game_cleanup(game=game, igame=igame, repair_mode=True, repair_file=repair_file) self.signals.result.emit(self.app_name, success, *result) From dffd76893409615d89e58092030c494800c12d43 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Mon, 8 Aug 2022 21:32:31 +0300 Subject: [PATCH 57/61] Re-add dlc arguments --- rare/lgndr/api_arguments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rare/lgndr/api_arguments.py b/rare/lgndr/api_arguments.py index e9f74283..be4b1f18 100644 --- a/rare/lgndr/api_arguments.py +++ b/rare/lgndr/api_arguments.py @@ -76,6 +76,10 @@ class LgndrInstallGameArgs: 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() From c40fef0595759dedb6bfbcd23daa134958ddba9a Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 12 Aug 2022 13:17:53 +0300 Subject: [PATCH 58/61] Lgndr: Transfer what DownloadThread does to `install_game_real` (not used yet) DownloadThread: Refactor names to match Lgndr --- rare/components/tabs/downloads/__init__.py | 13 ++-- .../tabs/downloads/download_thread.py | 60 +++++++++++-------- rare/lgndr/api_arguments.py | 34 +++++++++-- rare/lgndr/cli.py | 58 +++++++++++------- rare/lgndr/manager.py | 2 +- 5 files changed, 110 insertions(+), 57 deletions(-) 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}, ' From 50a37be433639a25117b20ada4658b422ff598ac Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Sat, 13 Aug 2022 18:53:00 +0300 Subject: [PATCH 59/61] DownloadsTab: Remove dead code --- CONTRIBUTING.md | 10 ++++++++++ rare/app.py | 3 +-- rare/components/tabs/__init__.py | 6 +++--- rare/components/tabs/account/__init__.py | 19 +++++++++---------- rare/components/tabs/downloads/__init__.py | 19 +++---------------- .../tabs/downloads/download_thread.py | 1 + rare/utils/misc.py | 4 ++-- 7 files changed, 29 insertions(+), 33 deletions(-) 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/rare/app.py b/rare/app.py index b5405b37..46be17fd 100644 --- a/rare/app.py +++ b/rare/app.py @@ -167,8 +167,7 @@ class App(RareApp): 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( diff --git a/rare/components/tabs/__init__.py b/rare/components/tabs/__init__.py index 50db0ba0..a8e62e82 100644 --- a/rare/components/tabs/__init__.py +++ b/rare/components/tabs/__init__.py @@ -2,7 +2,7 @@ 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 @@ -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 19039eec..2f522b67 100644 --- a/rare/components/tabs/account/__init__.py +++ b/rare/components/tabs/account/__init__.py @@ -6,31 +6,30 @@ from rare.shared import LegendaryCoreSingleton, GlobalSignalsSingleton 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 45661376..26739a26 100644 --- a/rare/components/tabs/downloads/__init__.py +++ b/rare/components/tabs/downloads/__init__.py @@ -1,6 +1,6 @@ import datetime from logging import getLogger -from typing import List, Dict +from typing import List, Dict, Union from PyQt5.QtCore import QThread, pyqtSignal, QSettings, pyqtSlot from PyQt5.QtWidgets import ( @@ -230,7 +230,7 @@ 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): @@ -257,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 @@ -298,7 +285,7 @@ class UpdateWidget(QWidget): QLabel( self.tr("Version: ") + self.igame.version - + " \u2B9E " + + " >> " + self.game.app_version(self.igame.platform) + "" ) diff --git a/rare/components/tabs/downloads/download_thread.py b/rare/components/tabs/downloads/download_thread.py index 576d5740..5f672558 100644 --- a/rare/components/tabs/downloads/download_thread.py +++ b/rare/components/tabs/downloads/download_thread.py @@ -104,6 +104,7 @@ 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: + ret.dlcs = [] for dlc in dlcs: ret.dlcs.append( { diff --git a/rare/utils/misc.py b/rare/utils/misc.py index 6b56d5b9..4dbe590e 100644 --- a/rare/utils/misc.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" From b15a5b70076ace52829d8684ece64b38702ad59d Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Thu, 18 Aug 2022 02:24:51 +0300 Subject: [PATCH 60/61] Fix game launching if compiled with Nuitka --- pyproject.toml | 10 ++++++++++ rare/__main__.py | 2 +- rare/utils/misc.py | 5 ++++- 3 files changed, 15 insertions(+), 2 deletions(-) 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 7b6663ea..c510ed8d 100644 --- a/rare/__main__.py +++ b/rare/__main__.py @@ -125,6 +125,6 @@ if __name__ == "__main__": # 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/utils/misc.py b/rare/utils/misc.py index 4dbe590e..a6826571 100644 --- a/rare/utils/misc.py +++ b/rare/utils/misc.py @@ -165,7 +165,10 @@ def get_size(b: Union[int, float]) -> 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] From 2b141c276fbadb2772a37f89bcaf9ffe40438705 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Fri, 26 Aug 2022 11:38:13 +0300 Subject: [PATCH 61/61] InstallDialog: Fix `dlm` keyword argument --- rare/components/dialogs/install_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rare/components/dialogs/install_dialog.py b/rare/components/dialogs/install_dialog.py index e6834742..65501956 100644 --- a/rare/components/dialogs/install_dialog.py +++ b/rare/components/dialogs/install_dialog.py @@ -356,7 +356,7 @@ class InstallInfoWorker(QRunnable): ) download = InstallDownloadModel( - dlmanager=dlm, + dlm=dlm, analysis=analysis, igame=igame, game=self.game,