From 4145381b93c7223f5ca9936cd11d2bd8e53304ec Mon Sep 17 00:00:00 2001 From: derrod Date: Sat, 17 Jun 2023 23:39:59 +0200 Subject: [PATCH] [cli/core/lfs] Add slightly janky lock for installed game data In order to prevent multiple instances of Legendary mucking with installed game data acquire a lock as soon as it is required and only release it (implicitly) when Legendary exits. This is a bit jank, but should prevent people from messing up their local data by running two install commands at a time. EGL sync is technically also affected by this, but in its case we simply skip the sync/import/export and leave it to the next instance with a lock to do. --- legendary/cli.py | 34 +++++++++++++++++++++++++++++++--- legendary/core.py | 12 +++++++++++- legendary/lfs/lgndry.py | 25 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/legendary/cli.py b/legendary/cli.py index 83e06be..ccbf7d2 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -315,7 +315,7 @@ class LegendaryCLI: print('\nInstalled games:') for game in games: - if game.install_size == 0: + if game.install_size == 0 and self.core.lgd.lock_installed(): logger.debug(f'Updating missing size for {game.app_name}') m = self.core.load_manifest(self.core.get_installed_manifest(game.app_name)[0]) game.install_size = sum(fm.file_size for fm in m.file_manifest_list.elements) @@ -472,12 +472,15 @@ class LegendaryCLI: logger.info(f'Checking "{igame.title}" ({igame.app_name})') # override save path only if app name is specified if args.app_name and args.save_path: + if not self.core.lgd.lock_installed(): + logger.error('Unable to lock install data, cannot modify save path.') + break logger.info(f'Overriding save path with "{args.save_path}"...') igame.save_path = args.save_path self.core.lgd.set_installed_game(igame.app_name, igame) - # if there is no saved save path, try to get one - if not igame.save_path: + # if there is no saved save path, try to get one, skip if we cannot get a install data lock + if not igame.save_path and self.core.lgd.lock_installed(): if args.yes and not args.accept_path: logger.info('Save path for this title has not been set, skipping due to --yes') continue @@ -796,6 +799,11 @@ class LegendaryCLI: subprocess.Popen(command, env=full_env) def install_game(self, args): + 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): igame = self.core.get_installed_game(args.app_name) @@ -1129,6 +1137,11 @@ class LegendaryCLI: logger.info('Automatic installation not available on Linux.') def uninstall_game(self, args): + 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) if not igame: @@ -1259,6 +1272,11 @@ class LegendaryCLI: logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.') def import_game(self, args): + 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) args.app_name = self._resolve_aliases(args.app_name) @@ -1354,6 +1372,11 @@ class LegendaryCLI: logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.') def egs_sync(self, args): + 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 + if args.unlink: logger.info('Unlinking and resetting EGS and LGD sync...') self.core.lgd.config.remove_option('Legendary', 'egl_programdata') @@ -2509,6 +2532,11 @@ class LegendaryCLI: logger.info('Saved choices to configuration.') def move(self, args): + 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) if not igame: diff --git a/legendary/core.py b/legendary/core.py index 7490c8f..a1bb9e9 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -1749,6 +1749,9 @@ class LegendaryCore: def egl_import(self, app_name): if not self.asset_valid(app_name): raise ValueError(f'To-be-imported game {app_name} not in game asset database!') + if not self.lgd.lock_installed(): + self.log.warning('Could not acquire lock for EGL import') + return self.log.debug(f'Importing "{app_name}" from EGL') # load egl json file @@ -1796,9 +1799,12 @@ class LegendaryCore: # mark game as installed _ = self._install_game(lgd_igame) - return def egl_export(self, app_name): + if not self.lgd.lock_installed(): + self.log.warning('Could not acquire lock for EGL import') + return + self.log.debug(f'Exporting "{app_name}" to EGL') # load igame/game lgd_game = self.get_game(app_name) @@ -1860,6 +1866,10 @@ class LegendaryCore: """ Sync game installs between Legendary and the Epic Games Launcher """ + if not self.lgd.lock_installed(): + self.log.warning('Could not acquire lock for EGL sync') + return + # read egl json files if app_name: lgd_igame = self._get_installed_game(app_name) diff --git a/legendary/lfs/lgndry.py b/legendary/lfs/lgndry.py index c8ad482..2d5154c 100644 --- a/legendary/lfs/lgndry.py +++ b/legendary/lfs/lgndry.py @@ -9,6 +9,8 @@ from collections import defaultdict from pathlib import Path from time import time +from filelock import FileLock + from .utils import clean_filename, LockedJSONData from legendary.models.game import * @@ -114,6 +116,8 @@ class LGDLFS: self.config.set('Legendary', '; Disables the notice about an available update on exit') self.config.set('Legendary', 'disable_update_notice', 'false' if is_windows_mac_or_pyi() else 'true') + self._installed_lock = FileLock(os.path.join(self.path, 'installed.json') + '.lock') + try: self._installed = json.load(open(os.path.join(self.path, 'installed.json'))) except Exception as e: @@ -293,6 +297,27 @@ class LGDLFS: except Exception as e: self.log.warning(f'Failed to delete file "{f}": {e!r}') + def lock_installed(self) -> bool: + """ + Locks the install data. We do not care about releasing this lock. + If it is acquired by a Legendary instance it should own the lock until it exits. + Some operations such as egl sync may be simply skipped if a lock cannot be acquired + """ + if self._installed_lock.is_locked: + return True + + try: + self._installed_lock.acquire(blocking=False) + # reload data in case it has been updated elsewhere + try: + self._installed = json.load(open(os.path.join(self.path, 'installed.json'))) + except Exception as e: + self.log.debug(f'Failed to load installed game data: {e!r}') + + return True + except TimeoutError: + return False + def get_installed_game(self, app_name): if self._installed is None: try: