diff --git a/rare/lgndr/cli.py b/rare/lgndr/cli.py index 54c9262db..8c3835f31 100644 --- a/rare/lgndr/cli.py +++ b/rare/lgndr/cli.py @@ -1,3 +1,4 @@ +import functools import logging import os import queue @@ -6,6 +7,7 @@ from typing import Optional, Union, Tuple from legendary.cli import LegendaryCLI as LegendaryCLIReal +from legendary.lfs.wine_helpers import case_insensitive_file_search from legendary.models.downloading import AnalysisResult, ConditionCheckResult from legendary.models.game import Game, InstalledGame, VerifyResult from legendary.lfs.utils import validate_files @@ -30,21 +32,35 @@ class LegendaryCLI(LegendaryCLIReal): # noinspection PyMissingConstructor def __init__(self, core: LegendaryCore): self.core = core - self.logger = logging.getLogger('Cli') + self.logger = logging.getLogger('cli') self.logging_queue = None self.ql = self.setup_threaded_logging() def __del__(self): self.ql.stop() + @staticmethod + def unlock_installed(func): + @functools.wraps(func) + def unlock(self, *args, **kwargs): + ret = func(self, *args, **kwargs) + self.core.lgd._installed_lock.release(force=True) + return ret + return unlock + def resolve_aliases(self, name): return super(LegendaryCLI, self)._resolve_aliases(name) + @unlock_installed 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 + if not self.core.lgd.lock_installed(): + logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may ' + 'install/import/move applications at a time.') + return args.app_name = self._resolve_aliases(args.app_name) if self.core.is_installed(args.app_name): @@ -131,7 +147,7 @@ def install_game(self, args: LgndrInstallGameArgs) -> Optional[Tuple[DLManager, 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) + 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: @@ -185,7 +201,8 @@ def install_game(self, args: LgndrInstallGameArgs) -> Optional[Tuple[DLManager, disable_delta=args.disable_delta, override_delta_manifest=args.override_delta_manifest, preferred_cdn=args.preferred_cdn, - disable_https=args.disable_https) + disable_https=args.disable_https, + bind_ip=args.bind_ip) # game is either up-to-date or hasn't changed, so we have nothing to do if not analysis.dl_size and not game.is_dlc: @@ -205,10 +222,15 @@ def install_game(self, args: LgndrInstallGameArgs) -> Optional[Tuple[DLManager, return dlm, analysis, igame, game, args.repair_mode, repair_file, res # Rare: This is currently handled in DownloadThread, this is a trial + @unlock_installed def install_game_real(self, args: LgndrInstallGameRealArgs, dlm: DLManager, game: Game, igame: InstalledGame) -> LgndrInstallGameRealRet: # Override logger for the local context to use message as part of the indirect return value logger = LgndrIndirectLogger(args.indirect_status, self.logger) ret = LgndrInstallGameRealRet(game.app_name) + if not self.core.lgd.lock_installed(): + logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may ' + 'install/import/move applications at a time.') + return ret start_t = time.time() @@ -286,9 +308,14 @@ def install_game_real(self, args: LgndrInstallGameRealArgs, dlm: DLManager, game return ret + @unlock_installed 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) + if not self.core.lgd.lock_installed(): + logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may ' + 'install/import/move applications at a time.') + return old_igame = self.core.get_installed_game(game.app_name) if old_igame and repair_mode and os.path.exists(repair_file): @@ -310,6 +337,11 @@ def install_game_cleanup(self, game: Game, igame: InstalledGame, repair_mode: bo self.core.lgd.config.set(game.app_name, 'install_tags', ','.join(old_igame.install_tags)) self.core.lgd.save_config() + # check if the version changed, this can happen for DLC that gets a version bump with no actual file changes + if old_igame and old_igame.version != igame.version: + old_igame.version = igame.version + self.core.install_game(old_igame) + def _handle_postinstall(self, postinstall, igame, skip_prereqs=False, choice=True): # Override logger for the local context to use message as part of the indirect return value logger = LgndrIndirectLogger(LgndrIndirectStatus(), self.logger) @@ -347,10 +379,16 @@ def input(x): return 'y' if choice else 'n' else: logger.info('Automatic installation not available on Linux.') + @unlock_installed 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 + get_boolean_choice = args.get_boolean_choice_main + # def get_boolean_choice(x, default): return True + if not self.core.lgd.lock_installed(): + logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may ' + 'install/import/move applications at a time.') + return args.app_name = self._resolve_aliases(args.app_name) igame = self.core.get_installed_game(args.app_name) @@ -362,6 +400,9 @@ def uninstall_game(self, args: LgndrUninstallGameArgs) -> None: if not get_boolean_choice(f'Do you wish to uninstall "{igame.title}"?', default=False): return + if os.name == 'nt' and igame.uninstaller and not args.skip_uninstaller: + self._handle_uninstaller(igame, args) + try: if not igame.is_dlc: # Remove DLC first so directory is empty when game uninstall runs @@ -380,6 +421,31 @@ def uninstall_game(self, args: LgndrUninstallGameArgs) -> None: logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.') return + def _handle_uninstaller(self, igame: InstalledGame, args: LgndrUninstallGameArgs): + # 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) + yes = args.yes + get_boolean_choice = args.get_boolean_choice_handler + # def get_boolean_choice(x, default): return True + # noinspection PyShadowingBuiltins + def print(x): self.logger.info(x) if x else None + + uninstaller = igame.uninstaller + + print('\nThis game provides the following uninstaller:') + print(f'- {uninstaller["path"]} {uninstaller["args"]}\n') + + if yes or get_boolean_choice('Do you wish to run the uninstaller?', default=True): + logger.info('Running uninstaller...') + req_path, req_exec = os.path.split(uninstaller['path']) + work_dir = os.path.join(igame.install_path, req_path) + fullpath = os.path.join(work_dir, req_exec) + try: + p = subprocess.Popen([fullpath, uninstaller['args']], cwd=work_dir, shell=True) + p.wait() + except Exception as e: + logger.error(f'Failed to run uninstaller: {e!r}') + 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) @@ -492,10 +558,15 @@ def verify_game(self, args: Union[LgndrVerifyGameArgs, LgndrInstallGameArgs], pr logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.') return len(failed), len(missing) + @unlock_installed 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 + if not self.core.lgd.lock_installed(): + logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may ' + 'install/import/move applications at a time.') + return # make sure path is absolute args.app_path = os.path.abspath(args.app_path) @@ -535,6 +606,8 @@ def import_game(self, args: LgndrImportGameArgs) -> None: # 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('/')) + if os.name != 'nt': + exe_path = case_insensitive_file_search(exe_path) # 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)) @@ -590,9 +663,18 @@ def import_game(self, args: LgndrImportGameArgs) -> None: logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.') return + @unlock_installed + def egs_sync(self, args): + return super(LegendaryCLI, self).egs_sync(args) + + @unlock_installed 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) + if not self.core.lgd.lock_installed(): + logger.fatal('Failed to acquire installed data lock, only one instance of Legendary may ' + 'install/import/move applications at a time.') + return app_name = self._resolve_aliases(args.app_name) igame = self.core.get_installed_game(app_name, skip_sync=True) diff --git a/rare/lgndr/core.py b/rare/lgndr/core.py index 9a1662866..bac740602 100644 --- a/rare/lgndr/core.py +++ b/rare/lgndr/core.py @@ -1,3 +1,4 @@ +import functools import json import os from multiprocessing import Queue @@ -28,6 +29,15 @@ def __init__(self, override_config=None, timeout=10.0): self.handler = LgndrCoreLogHandler() self.log.addHandler(self.handler) + @staticmethod + def unlock_installed(func): + @functools.wraps(func) + def unlock(self, *args, **kwargs): + ret = func(self, *args, **kwargs) + self.lgd._installed_lock.release(force=True) + return ret + return unlock + # 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) @@ -43,7 +53,7 @@ def prepare_download(self, game: Game, base_game: Game = None, base_path: str = repair: bool = False, repair_use_latest: bool = False, disable_delta: bool = False, override_delta_manifest: str = '', egl_guid: str = '', preferred_cdn: str = None, - disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta): + disable_https: bool = False, bind_ip: str = None) -> (DLManager, AnalysisResult, ManifestMeta): dlm, analysis, igame = super(LegendaryCore, self).prepare_download( game=game, base_game=base_game, base_path=base_path, status_q=status_q, max_shm=max_shm, max_workers=max_workers, @@ -56,7 +66,7 @@ def prepare_download(self, game: Game, base_game: Game = None, base_path: str = 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 + disable_https=disable_https, #bind_ip=bind_ip, ) # lk: monkeypatch run_real (the method that emits the stats) into DLManager # pylint: disable=E1111 @@ -76,6 +86,7 @@ def uninstall_game(self, installed_game: InstalledGame, delete_files=True, delet finally: pass + @unlock_installed def egl_import(self, app_name): try: super(LegendaryCore, self).egl_import(app_name) @@ -121,6 +132,7 @@ def egstore_delete(self, igame: InstalledGame, delete_files=True): if delete_files: delete_folder(os.path.join(igame.install_path, '.egstore')) + @unlock_installed def egl_export(self, app_name): try: super(LegendaryCore, self).egl_export(app_name) diff --git a/rare/lgndr/downloader/mp/manager.py b/rare/lgndr/downloader/mp/manager.py index 2168f4b4f..b667a5d91 100644 --- a/rare/lgndr/downloader/mp/manager.py +++ b/rare/lgndr/downloader/mp/manager.py @@ -40,10 +40,15 @@ def run_real(self): self.writer_result_q = MPQueue(-1) self.log.info(f'Starting download workers...') + + bind_ip = None for i in range(self.max_workers): + if self.bind_ips: + bind_ip = self.bind_ips[i % len(self.bind_ips)] + 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) + dl_timeout=self.dl_timeout, bind_addr=bind_ip) self.children.append(w) w.start() diff --git a/rare/lgndr/glue/arguments.py b/rare/lgndr/glue/arguments.py index 12d74d42f..36e1a46b1 100644 --- a/rare/lgndr/glue/arguments.py +++ b/rare/lgndr/glue/arguments.py @@ -40,10 +40,12 @@ class LgndrImportGameArgs: class LgndrUninstallGameArgs: app_name: str keep_files: bool = False + skip_uninstaller: bool = False yes: bool = False # Rare: Extra arguments indirect_status: LgndrIndirectStatus = field(default_factory=LgndrIndirectStatus) - get_boolean_choice: GetBooleanChoiceProtocol = get_boolean_choice + get_boolean_choice_main: GetBooleanChoiceProtocol = get_boolean_choice + get_boolean_choice_handler: GetBooleanChoiceProtocol = get_boolean_choice @dataclass @@ -84,6 +86,7 @@ class LgndrInstallGameArgs: reset_sdl: bool = False skip_sdl: bool = False disable_https: bool = False + bind_ip: str = "" # FIXME: move to LgndrInstallGameRealArgs skip_dlcs: bool = False with_dlcs: bool = False diff --git a/rare/shared/workers/uninstall.py b/rare/shared/workers/uninstall.py index a8f7c97e9..76207fe76 100644 --- a/rare/shared/workers/uninstall.py +++ b/rare/shared/workers/uninstall.py @@ -14,6 +14,7 @@ logger = getLogger("UninstallWorker") + # TODO: You can use RareGame directly here once this is called inside RareCore and skip metadata fetch def uninstall_game(core: LegendaryCore, app_name: str, keep_files=False, keep_config=False): game = core.get_game(app_name) @@ -32,8 +33,9 @@ def uninstall_game(core: LegendaryCore, app_name: str, keep_files=False, keep_co LgndrUninstallGameArgs( app_name=app_name, keep_files=keep_files, - indirect_status=status, + skip_uninstaller=False, yes=True, + indirect_status=status, ) ) if not keep_config: