diff --git a/legendary/core.py b/legendary/core.py index bd3ad7d..7490c8f 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -131,7 +131,8 @@ class LegendaryCore: Handles authentication via authorization code (either retrieved manually or automatically) """ try: - self.lgd.userdata = self.egs.start_session(authorization_code=code) + with self.lgd.userdata_lock as lock: + lock.data = self.egs.start_session(authorization_code=code) return True except Exception as e: self.log.error(f'Logging in failed with {e!r}, please try again.') @@ -142,7 +143,8 @@ class LegendaryCore: Handles authentication via exchange token (either retrieved manually or automatically) """ try: - self.lgd.userdata = self.egs.start_session(exchange_token=code) + with self.lgd.userdata_lock as lock: + lock.data = self.egs.start_session(exchange_token=code) return True except Exception as e: self.log.error(f'Logging in failed with {e!r}, please try again.') @@ -171,22 +173,23 @@ class LegendaryCore: raise ValueError('No login session in config') refresh_token = re_data['Token'] try: - self.lgd.userdata = self.egs.start_session(refresh_token=refresh_token) + with self.lgd.userdata_lock as lock: + lock.data = self.egs.start_session(refresh_token=refresh_token) return True except Exception as e: self.log.error(f'Logging in failed with {e!r}, please try again.') return False - def login(self, force_refresh=False) -> bool: + def _login(self, lock, force_refresh=False) -> bool: """ Attempts logging in with existing credentials. raises ValueError if no existing credentials or InvalidCredentialsError if the API return an error """ - if not self.lgd.userdata: + if not lock.data: raise ValueError('No saved credentials') - elif self.logged_in and self.lgd.userdata['expires_at']: - dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1]) + elif self.logged_in and lock.data['expires_at']: + dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1]) dt_now = datetime.utcnow() td = dt_now - dt_exp @@ -212,8 +215,8 @@ class LegendaryCore: except Exception as e: self.log.warning(f'Checking for EOS Overlay updates failed: {e!r}') - if self.lgd.userdata['expires_at'] and not force_refresh: - dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1]) + if lock.data['expires_at'] and not force_refresh: + dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1]) dt_now = datetime.utcnow() td = dt_now - dt_exp @@ -221,7 +224,7 @@ class LegendaryCore: if dt_exp > dt_now and abs(td.total_seconds()) > 600: self.log.info('Trying to re-use existing login session...') try: - self.egs.resume_session(self.lgd.userdata) + self.egs.resume_session(lock.data) self.logged_in = True return True except InvalidCredentialsError as e: @@ -233,7 +236,7 @@ class LegendaryCore: try: self.log.info('Logging in...') - userdata = self.egs.start_session(self.lgd.userdata['refresh_token']) + userdata = self.egs.start_session(lock.data['refresh_token']) except InvalidCredentialsError: self.log.error('Stored credentials are no longer valid! Please login again.') self.lgd.invalidate_userdata() @@ -242,10 +245,14 @@ class LegendaryCore: self.log.error(f'HTTP request for login failed: {e!r}, please try again later.') return False - self.lgd.userdata = userdata + lock.data = userdata self.logged_in = True return True + def login(self, force_refresh=False) -> bool: + with self.lgd.userdata_lock as lock: + return self._login(lock, force_refresh=force_refresh) + def update_check_enabled(self): return not self.lgd.config.getboolean('Legendary', 'disable_update_check', fallback=False) diff --git a/legendary/lfs/lgndry.py b/legendary/lfs/lgndry.py index bc75263..c8ad482 100644 --- a/legendary/lfs/lgndry.py +++ b/legendary/lfs/lgndry.py @@ -4,11 +4,12 @@ import json import os import logging +from contextlib import contextmanager from collections import defaultdict from pathlib import Path from time import time -from .utils import clean_filename +from .utils import clean_filename, LockedJSONData from legendary.models.game import * from legendary.utils.aliasing import generate_aliases @@ -16,6 +17,9 @@ from legendary.models.config import LGDConf from legendary.utils.env import is_windows_mac_or_pyi +FILELOCK_DEBUG = False + + class LGDLFS: def __init__(self, config_file=None): self.log = logging.getLogger('LGDLFS') @@ -84,6 +88,11 @@ class LGDLFS: self.log.warning(f'Removing "{os.path.join(self.path, "manifests", "old")}" folder failed: ' f'{e!r}, please remove manually') + if not FILELOCK_DEBUG: + # Prevent filelock logger from spamming Legendary debug output + filelock_logger = logging.getLogger('filelock') + filelock_logger.setLevel(logging.INFO) + # try loading config try: self.config.read(self.config_path) @@ -130,31 +139,35 @@ class LGDLFS: except Exception as e: self.log.debug(f'Loading aliases failed with {e!r}') + @property + @contextmanager + def userdata_lock(self) -> LockedJSONData: + """Wrapper around the lock to automatically update user data when it is released""" + with LockedJSONData(os.path.join(self.path, 'user.json')) as lock: + try: + yield lock + finally: + self._user_data = lock.data + @property def userdata(self): if self._user_data is not None: return self._user_data try: - self._user_data = json.load(open(os.path.join(self.path, 'user.json'))) - return self._user_data + with self.userdata_lock as locked: + return locked.data except Exception as e: self.log.debug(f'Failed to load user data: {e!r}') return None @userdata.setter def userdata(self, userdata): - if userdata is None: - raise ValueError('Userdata is none!') - - self._user_data = userdata - json.dump(userdata, open(os.path.join(self.path, 'user.json'), 'w'), - indent=2, sort_keys=True) + raise NotImplementedError('The setter has been removed, use the locked userdata instead.') def invalidate_userdata(self): - self._user_data = None - if os.path.exists(os.path.join(self.path, 'user.json')): - os.remove(os.path.join(self.path, 'user.json')) + with self.userdata_lock as lock: + lock.clear() @property def entitlements(self): diff --git a/legendary/lfs/utils.py b/legendary/lfs/utils.py index 95043b9..ed69466 100644 --- a/legendary/lfs/utils.py +++ b/legendary/lfs/utils.py @@ -3,6 +3,7 @@ import os import shutil import hashlib +import json import logging from pathlib import Path @@ -10,6 +11,8 @@ from sys import stdout from time import perf_counter from typing import List, Iterator +from filelock import FileLock + from legendary.models.game import VerifyResult logger = logging.getLogger('LFS Utils') @@ -153,3 +156,45 @@ def clean_filename(filename): def get_dir_size(path): return sum(f.stat().st_size for f in Path(path).glob('**/*') if f.is_file()) + + +class LockedJSONData(FileLock): + def __init__(self, file_path: str): + super().__init__(file_path + '.lock') + + self._file_path = file_path + self._data = None + self._initial_data = None + + def __enter__(self): + super().__enter__() + + if os.path.exists(self._file_path): + with open(self._file_path, 'r', encoding='utf-8') as f: + self._data = json.load(f) + self._initial_data = self._data + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + super().__exit__(exc_type, exc_val, exc_tb) + + if self._data != self._initial_data: + if self._data is not None: + with open(self._file_path, 'w', encoding='utf-8') as f: + json.dump(self._data, f, indent=2, sort_keys=True) + else: + if os.path.exists(self._file_path): + os.remove(self._file_path) + + @property + def data(self): + return self._data + + @data.setter + def data(self, new_data): + if new_data is None: + raise ValueError('Invalid new data, use clear() explicitly to reset file data') + self._data = new_data + + def clear(self): + self._data = None diff --git a/requirements.txt b/requirements.txt index 011ac80..481d045 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests<3.0 +filelock diff --git a/setup.py b/setup.py index efa10b9..d4f7e05 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ setup( install_requires=[ 'requests<3.0', 'setuptools', - 'wheel' + 'wheel', + 'filelock' ], extras_require=dict( webview=['pywebview>=3.4'],