mirror of
https://github.com/derrod/legendary.git
synced 2024-09-29 08:52:11 +13:00
Compare commits
No commits in common. "master" and "0.20.30" have entirely different histories.
17 changed files with 143 additions and 587 deletions
57
.github/workflows/python.yml
vendored
57
.github/workflows/python.yml
vendored
|
@ -11,23 +11,26 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: ['ubuntu-24.04', 'windows-latest', 'macos-13']
|
||||
os: ['ubuntu-20.04', 'windows-2019', 'macos-11']
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Legendary dependencies and build tools
|
||||
- name: Python components
|
||||
run: pip3 install --upgrade
|
||||
setuptools
|
||||
wheel
|
||||
|
||||
- name: Legendary dependencies and build tools
|
||||
run: pip3 install --upgrade
|
||||
pyinstaller
|
||||
requests
|
||||
filelock
|
||||
|
||||
- name: Optional dependencies (WebView)
|
||||
run: pip3 install --upgrade pywebview
|
||||
|
@ -44,37 +47,45 @@ jobs:
|
|||
--onefile
|
||||
--name legendary
|
||||
${{ steps.strip.outputs.option }}
|
||||
-i ../assets/windows_icon.ico
|
||||
cli.py
|
||||
env:
|
||||
PYTHONOPTIMIZE: 1
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ runner.os }}-package
|
||||
path: legendary/dist/*
|
||||
|
||||
deb:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: ['ubuntu-20.04', 'ubuntu-22.04']
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Dependencies
|
||||
run: |
|
||||
sudo apt install ruby
|
||||
sudo gem install fpm
|
||||
run: sudo apt install
|
||||
python3-all
|
||||
python3-stdeb
|
||||
dh-python
|
||||
python3-requests
|
||||
python3-setuptools
|
||||
python3-wheel
|
||||
|
||||
- name: Webview Dependencies
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: sudo apt install
|
||||
python3-webview
|
||||
python3-gi
|
||||
python3-gi-cairo
|
||||
gir1.2-gtk-3.0
|
||||
|
||||
- name: Build
|
||||
run: fpm
|
||||
--input-type python
|
||||
--output-type deb
|
||||
--python-package-name-prefix python3
|
||||
--deb-suggests python3-webview
|
||||
--maintainer "Rodney <rodney@rodney.io>"
|
||||
--category python
|
||||
--depends "python3 >= 3.9"
|
||||
setup.py
|
||||
run: python3 setup.py --command-packages=stdeb.command bdist_deb
|
||||
|
||||
- name: Os version
|
||||
id: os_version
|
||||
|
@ -82,7 +93,7 @@ jobs:
|
|||
source /etc/os-release
|
||||
echo ::set-output name=version::$NAME-$VERSION_ID
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ steps.os_version.outputs.version }}-deb-package
|
||||
path: ./*.deb
|
||||
path: deb_dist/*.deb
|
||||
|
|
|
@ -35,6 +35,7 @@ it has to be run from a terminal (e.g. PowerShell)
|
|||
- Linux, Windows (8.1+), or macOS (12.0+)
|
||||
+ 32-bit operating systems are not supported
|
||||
- python 3.9+ (64-bit)
|
||||
+ Currently, only features up to Python 3.8 are used, but support for 3.8 may be dropped at any point
|
||||
+ (Windows) `pythonnet` is not yet compatible with 3.10+, use 3.9 if you plan to install `pywebview`
|
||||
- PyPI packages:
|
||||
+ `requests`
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 25 KiB |
|
@ -1,4 +1,4 @@
|
|||
"""Legendary!"""
|
||||
|
||||
__version__ = '0.20.35'
|
||||
__codename__ = 'Lowlife'
|
||||
__version__ = '0.20.30'
|
||||
__codename__ = 'Dark Energy (hotfix #4)'
|
||||
|
|
|
@ -15,6 +15,7 @@ from legendary.models.gql import *
|
|||
|
||||
class EPCAPI:
|
||||
_user_agent = 'UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit'
|
||||
# ToDo figure out why updating this past 14.0.8 causes a CF captcha page :/
|
||||
_store_user_agent = 'EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live'
|
||||
# required for the oauth request
|
||||
_user_basic = '34a02cf8f4414e29b15921876da36f9a'
|
||||
|
@ -28,10 +29,7 @@ class EPCAPI:
|
|||
_ecommerce_host = 'ecommerceintegration-public-service-ecomprod02.ol.epicgames.com'
|
||||
_datastorage_host = 'datastorage-public-service-liveegs.live.use1a.on.epicgames.com'
|
||||
_library_host = 'library-service.live.use1a.on.epicgames.com'
|
||||
# Using the actual store host with a user-agent newer than 14.0.8 leads to a CF verification page,
|
||||
# but the dedicated graphql host works fine.
|
||||
# _store_gql_host = 'launcher.store.epicgames.com'
|
||||
_store_gql_host = 'graphql.epicgames.com'
|
||||
_store_gql_host = 'launcher.store.epicgames.com'
|
||||
_artifact_service_host = 'artifact-public-service-prod.beee.live.use1a.on.epicgames.com'
|
||||
|
||||
def __init__(self, lc='en', cc='US', timeout=10.0):
|
||||
|
@ -64,7 +62,7 @@ class EPCAPI:
|
|||
# update user-agent
|
||||
if version := egs_params['version']:
|
||||
self._user_agent = f'UELauncher/{version} Windows/10.0.19041.1.256.64bit'
|
||||
self._store_user_agent = f'EpicGamesLauncher/{version}'
|
||||
# self._store_user_agent = f'EpicGamesLauncher/{version}'
|
||||
self.session.headers['User-Agent'] = self._user_agent
|
||||
self.unauth_session.headers['User-Agent'] = self._user_agent
|
||||
# update label
|
||||
|
@ -121,16 +119,9 @@ class EPCAPI:
|
|||
r.raise_for_status()
|
||||
|
||||
j = r.json()
|
||||
if 'errorCode' in j:
|
||||
if j['errorCode'] == 'errors.com.epicgames.oauth.corrective_action_required':
|
||||
self.log.error(f'{j["errorMessage"]} ({j["correctiveAction"]}), '
|
||||
f'open the following URL to take action: {j["continuationUrl"]}')
|
||||
else:
|
||||
self.log.error(f'Login to EGS API failed with errorCode: {j["errorCode"]}')
|
||||
if 'error' in j:
|
||||
self.log.warning(f'Login to EGS API failed with errorCode: {j["errorCode"]}')
|
||||
raise InvalidCredentialsError(j['errorCode'])
|
||||
elif r.status_code >= 400:
|
||||
self.log.error(f'EGS API responded with status {r.status_code} but no error in response: {j}')
|
||||
raise InvalidCredentialsError('Unknown error')
|
||||
|
||||
self.session.headers['Authorization'] = f'bearer {j["access_token"]}'
|
||||
# only set user info when using non-anonymous login
|
||||
|
@ -186,24 +177,13 @@ class EPCAPI:
|
|||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_user_entitlements(self, start=0):
|
||||
def get_user_entitlements(self):
|
||||
user_id = self.user.get('account_id')
|
||||
r = self.session.get(f'https://{self._entitlements_host}/entitlement/api/account/{user_id}/entitlements',
|
||||
params=dict(start=start, count=1000), timeout=self.request_timeout)
|
||||
params=dict(start=0, count=5000), timeout=self.request_timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_user_entitlements_full(self):
|
||||
ret = []
|
||||
|
||||
while True:
|
||||
resp = self.get_user_entitlements(start=len(ret))
|
||||
ret.extend(resp)
|
||||
if len(resp) < 1000:
|
||||
break
|
||||
|
||||
return ret
|
||||
|
||||
def get_game_info(self, namespace, catalog_item_id, timeout=None):
|
||||
r = self.session.get(f'https://{self._catalog_host}/catalog/api/shared/namespace/{namespace}/bulk/items',
|
||||
params=dict(id=catalog_item_id, includeDLCDetails=True, includeMainGameDetails=True,
|
||||
|
|
174
legendary/cli.py
174
legendary/cli.py
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env python
|
||||
# coding: utf-8
|
||||
|
||||
import argparse
|
||||
|
@ -28,7 +28,7 @@ from legendary.utils.env import is_windows_mac_or_pyi
|
|||
from legendary.lfs.eos import add_registry_entries, query_registry_entries, remove_registry_entries
|
||||
from legendary.lfs.utils import validate_files, clean_filename
|
||||
from legendary.utils.selective_dl import get_sdl_appname
|
||||
from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_file_search
|
||||
from legendary.lfs.wine_helpers import read_registry, get_shell_folders
|
||||
|
||||
# todo custom formatter for cli logger (clean info, highlighted error/warning)
|
||||
logging.basicConfig(
|
||||
|
@ -104,11 +104,11 @@ class LegendaryCLI:
|
|||
|
||||
if not egl_wine_pfx:
|
||||
logger.info('Please enter the path to the Wine prefix that has EGL installed')
|
||||
egl_wine_pfx = input('Path [empty input to quit]: ').strip()
|
||||
if not egl_wine_pfx:
|
||||
wine_pfx = input('Path [empty input to quit]: ').strip()
|
||||
if not wine_pfx:
|
||||
print('Empty input, quitting...')
|
||||
exit(0)
|
||||
if not os.path.exists(egl_wine_pfx) and os.path.isdir(egl_wine_pfx):
|
||||
if not os.path.exists(wine_pfx) and os.path.isdir(wine_pfx):
|
||||
print('Path is invalid (does not exist)!')
|
||||
exit(1)
|
||||
|
||||
|
@ -144,7 +144,7 @@ class LegendaryCLI:
|
|||
|
||||
exchange_token = ''
|
||||
auth_code = ''
|
||||
if not args.auth_code and not args.session_id and not args.ex_token:
|
||||
if not args.auth_code and not args.session_id:
|
||||
# only import here since pywebview import is slow
|
||||
from legendary.utils.webview_login import webview_available, do_webview_login
|
||||
|
||||
|
@ -162,8 +162,7 @@ class LegendaryCLI:
|
|||
else:
|
||||
auth_code = auth_code.strip('"')
|
||||
else:
|
||||
if do_webview_login(callback_code=self.core.auth_ex_token,
|
||||
user_agent=f'EpicGamesLauncher/{self.core.get_egl_version()}'):
|
||||
if do_webview_login(callback_code=self.core.auth_ex_token):
|
||||
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}" via WebView')
|
||||
else:
|
||||
logger.error('WebView login attempt failed, please see log for details.')
|
||||
|
@ -242,7 +241,7 @@ class LegendaryCLI:
|
|||
# a third-party application (such as Origin).
|
||||
if not version:
|
||||
_store = game.third_party_store
|
||||
if game.is_origin_game:
|
||||
if _store == 'Origin':
|
||||
print(f' - This game has to be activated, installed, and launched via Origin, use '
|
||||
f'"legendary launch --origin {game.app_name}" to activate and/or run the game.')
|
||||
elif _store:
|
||||
|
@ -255,8 +254,7 @@ class LegendaryCLI:
|
|||
_type = game.partner_link_type
|
||||
if _type == 'ubisoft':
|
||||
print(' - This game can be activated directly on your Ubisoft account and does not require '
|
||||
'legendary to install/run. This game requires Ubisoft Connect to be installed. '
|
||||
'Use "legendary activate --uplay" and follow the instructions.')
|
||||
'legendary to install/run. Use "legendary activate --uplay" and follow the instructions.')
|
||||
else:
|
||||
print(f' ! This app requires linking to a third-party account (name: "{_type}", not supported)')
|
||||
|
||||
|
@ -317,7 +315,7 @@ class LegendaryCLI:
|
|||
|
||||
print('\nInstalled games:')
|
||||
for game in games:
|
||||
if game.install_size == 0 and self.core.lgd.lock_installed():
|
||||
if game.install_size == 0:
|
||||
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)
|
||||
|
@ -373,8 +371,6 @@ class LegendaryCLI:
|
|||
|
||||
if args.install_tag:
|
||||
files = [fm for fm in files if args.install_tag in fm.install_tags]
|
||||
elif args.install_tag is not None:
|
||||
files = [fm for fm in files if not fm.install_tags]
|
||||
|
||||
if args.hashlist:
|
||||
for fm in files:
|
||||
|
@ -419,11 +415,7 @@ class LegendaryCLI:
|
|||
print('Save games:')
|
||||
for save in sorted(saves, key=lambda a: a.app_name + a.manifest_name):
|
||||
if save.app_name != last_app:
|
||||
if game := self.core.get_game(save.app_name):
|
||||
game_title = game.app_title
|
||||
else:
|
||||
game_title = 'Unknown'
|
||||
|
||||
game_title = self.core.get_game(save.app_name).app_title
|
||||
last_app = save.app_name
|
||||
print(f'- {game_title} ("{save.app_name}")')
|
||||
print(' +', save.manifest_name)
|
||||
|
@ -457,7 +449,7 @@ class LegendaryCLI:
|
|||
igames = [igame]
|
||||
|
||||
# check available saves
|
||||
saves = self.core.get_save_games(args.app_name if args.app_name else '')
|
||||
saves = self.core.get_save_games()
|
||||
latest_save = {
|
||||
save.app_name: save for save in sorted(saves, key=lambda a: a.datetime)
|
||||
}
|
||||
|
@ -476,16 +468,13 @@ 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, 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:
|
||||
# if there is no saved save path, try to get one
|
||||
if not igame.save_path:
|
||||
if args.yes:
|
||||
logger.info('Save path for this title has not been set, skipping due to --yes')
|
||||
continue
|
||||
|
||||
|
@ -497,11 +486,6 @@ class LegendaryCLI:
|
|||
if '%' in save_path or '{' in save_path:
|
||||
logger.warning('Path contains unprocessed variables, please enter the correct path manually.')
|
||||
yn = False
|
||||
# When accept_path is set we don't want to fall back to interactive mode
|
||||
if args.accept_path:
|
||||
continue
|
||||
elif args.accept_path:
|
||||
yn = True
|
||||
else:
|
||||
yn = get_boolean_choice('Is this correct?')
|
||||
|
||||
|
@ -569,7 +553,6 @@ class LegendaryCLI:
|
|||
|
||||
def launch_game(self, args, extra):
|
||||
app_name = self._resolve_aliases(args.app_name)
|
||||
addon_app_name = None
|
||||
|
||||
# Interactive CrossOver setup
|
||||
if args.crossover and sys_platform == 'darwin':
|
||||
|
@ -580,19 +563,12 @@ class LegendaryCLI:
|
|||
return self._launch_origin(args)
|
||||
|
||||
igame = self.core.get_installed_game(app_name)
|
||||
if (not igame or not igame.executable) and (game := self.core.get_game(app_name)) is not None:
|
||||
# override installed game with base title
|
||||
if game.is_launchable_addon:
|
||||
addon_app_name = app_name
|
||||
app_name = game.metadata['mainGameItem']['releaseInfo'][0]['appId']
|
||||
igame = self.core.get_installed_game(app_name)
|
||||
|
||||
if not igame:
|
||||
logger.error(f'Game {app_name} is not currently installed!')
|
||||
exit(1)
|
||||
|
||||
if igame.is_dlc and not igame.executable:
|
||||
logger.error(f'{app_name} is DLC without an executable; please launch the base game instead!')
|
||||
if igame.is_dlc:
|
||||
logger.error(f'{app_name} is DLC; please launch the base game instead!')
|
||||
exit(1)
|
||||
|
||||
if not os.path.exists(igame.install_path):
|
||||
|
@ -631,8 +607,7 @@ class LegendaryCLI:
|
|||
disable_wine=args.no_wine,
|
||||
executable_override=args.executable_override,
|
||||
crossover_app=args.crossover_app,
|
||||
crossover_bottle=args.crossover_bottle,
|
||||
addon_app_name=addon_app_name)
|
||||
crossover_bottle=args.crossover_bottle)
|
||||
|
||||
if args.set_defaults:
|
||||
self.core.lgd.config[app_name] = dict()
|
||||
|
@ -723,7 +698,7 @@ class LegendaryCLI:
|
|||
f'to fetch data for Origin titles before using this command.')
|
||||
return
|
||||
|
||||
if not game.is_origin_game:
|
||||
if not game.third_party_store or game.third_party_store != 'Origin':
|
||||
logger.error(f'The specified game is not an Origin title.')
|
||||
return
|
||||
|
||||
|
@ -792,8 +767,6 @@ class LegendaryCLI:
|
|||
f'wrapper in the configuration file or command line. See the README for details.')
|
||||
return
|
||||
|
||||
# You cannot launch a URI without start.exe
|
||||
command.append('start')
|
||||
command.append(origin_uri)
|
||||
if args.dry_run:
|
||||
if cmd:
|
||||
|
@ -814,11 +787,6 @@ 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)
|
||||
|
@ -857,7 +825,7 @@ class LegendaryCLI:
|
|||
|
||||
if store := game.third_party_store:
|
||||
logger.error(f'The selected title has to be installed via a third-party store: {store}')
|
||||
if game.is_origin_game:
|
||||
if store == 'Origin':
|
||||
logger.info(f'For Origin games use "legendary launch --origin {args.app_name}" to '
|
||||
f'activate and/or run the game.')
|
||||
exit(0)
|
||||
|
@ -924,7 +892,7 @@ class LegendaryCLI:
|
|||
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:
|
||||
|
@ -978,8 +946,7 @@ class LegendaryCLI:
|
|||
disable_delta=args.disable_delta,
|
||||
override_delta_manifest=args.override_delta_manifest,
|
||||
preferred_cdn=args.preferred_cdn,
|
||||
disable_https=args.disable_https,
|
||||
bind_ip=args.bind_ip)
|
||||
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:
|
||||
|
@ -1000,15 +967,6 @@ class LegendaryCLI:
|
|||
self.core.uninstall_tag(old_igame)
|
||||
self.core.install_game(old_igame)
|
||||
|
||||
if old_igame.install_tags:
|
||||
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)
|
||||
|
||||
exit(0)
|
||||
|
||||
logger.info(f'Install size: {analysis.install_size / 1024 / 1024:.02f} MiB')
|
||||
|
@ -1157,11 +1115,6 @@ 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:
|
||||
|
@ -1173,9 +1126,6 @@ class LegendaryCLI:
|
|||
print('Aborting...')
|
||||
exit(0)
|
||||
|
||||
if os.name == 'nt' and igame.uninstaller and not args.skip_uninstaller:
|
||||
self._handle_uninstaller(igame, args.yes)
|
||||
|
||||
try:
|
||||
if not igame.is_dlc:
|
||||
# Remove DLC first so directory is empty when game uninstall runs
|
||||
|
@ -1192,23 +1142,6 @@ class LegendaryCLI:
|
|||
except Exception as e:
|
||||
logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.')
|
||||
|
||||
def _handle_uninstaller(self, igame, yes=False):
|
||||
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, 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):
|
||||
|
@ -1245,7 +1178,7 @@ class LegendaryCLI:
|
|||
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)) is not None:
|
||||
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())
|
||||
|
@ -1312,11 +1245,6 @@ 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)
|
||||
|
@ -1355,8 +1283,6 @@ class LegendaryCLI:
|
|||
# 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))
|
||||
|
@ -1412,11 +1338,6 @@ 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')
|
||||
|
@ -1658,7 +1579,7 @@ class LegendaryCLI:
|
|||
else:
|
||||
logger.info('Game not installed and offline mode enabled, cannot load manifest.')
|
||||
elif game:
|
||||
entitlements = self.core.egs.get_user_entitlements_full()
|
||||
entitlements = self.core.egs.get_user_entitlements()
|
||||
egl_meta = self.core.egs.get_game_info(game.namespace, game.catalog_item_id)
|
||||
game.metadata = egl_meta
|
||||
# Get manifest if asset exists for current platform
|
||||
|
@ -1696,7 +1617,7 @@ class LegendaryCLI:
|
|||
# Find custom launch options, if available
|
||||
launch_options = []
|
||||
i = 1
|
||||
while f'extraLaunchOption_{i:03d}_Name' in game.metadata.get('customAttributes', {}):
|
||||
while f'extraLaunchOption_{i:03d}_Name' in game.metadata['customAttributes']:
|
||||
launch_options.append((
|
||||
game.metadata['customAttributes'][f'extraLaunchOption_{i:03d}_Name']['value'],
|
||||
game.metadata['customAttributes'][f'extraLaunchOption_{i:03d}_Args']['value']
|
||||
|
@ -1714,9 +1635,6 @@ class LegendaryCLI:
|
|||
else:
|
||||
game_infos.append(InfoItem('Extra launch options', 'launch_options', None, []))
|
||||
|
||||
game_infos.append(InfoItem('Command Line', 'command_line', game.additional_command_line,
|
||||
game.additional_command_line))
|
||||
|
||||
# list all owned DLC based on entitlements
|
||||
if entitlements and not game.is_dlc:
|
||||
owned_entitlements = {i['entitlementName'] for i in entitlements}
|
||||
|
@ -1727,18 +1645,18 @@ class LegendaryCLI:
|
|||
if dlc['entitlementName'] in owned_entitlements:
|
||||
owned_dlc.append((installable, None, dlc['title'], dlc['id']))
|
||||
elif installable:
|
||||
dlc_app_name = dlc['releaseInfo'][0]['appId']
|
||||
if dlc_app_name in owned_app_names:
|
||||
owned_dlc.append((installable, dlc_app_name, dlc['title'], dlc['id']))
|
||||
app_name = dlc['releaseInfo'][0]['appId']
|
||||
if app_name in owned_app_names:
|
||||
owned_dlc.append((installable, app_name, dlc['title'], dlc['id']))
|
||||
|
||||
if owned_dlc:
|
||||
human_list = []
|
||||
json_list = []
|
||||
for installable, dlc_app_name, title, dlc_id in owned_dlc:
|
||||
json_list.append(dict(app_name=dlc_app_name, title=title,
|
||||
for installable, app_name, title, dlc_id in owned_dlc:
|
||||
json_list.append(dict(app_name=app_name, title=title,
|
||||
installable=installable, id=dlc_id))
|
||||
if installable:
|
||||
human_list.append(f'App name: {dlc_app_name}, Title: "{title}"')
|
||||
human_list.append(f'App name: {app_name}, Title: "{title}"')
|
||||
else:
|
||||
human_list.append(f'Title: "{title}" (no installation required)')
|
||||
game_infos.append(InfoItem('Owned DLC', 'owned_dlc', human_list, json_list))
|
||||
|
@ -1823,17 +1741,6 @@ class LegendaryCLI:
|
|||
else:
|
||||
manifest_info.append(InfoItem('Prerequisites', 'prerequisites', None, None))
|
||||
|
||||
if manifest.meta.uninstall_action_path:
|
||||
human_list = [
|
||||
f'Uninstaller path: {manifest.meta.uninstall_action_path}',
|
||||
f'Uninstaller args: {manifest.meta.uninstall_action_args or "(None)"}',
|
||||
]
|
||||
manifest_info.append(InfoItem('Uninstaller', 'uninstaller', human_list,
|
||||
dict(path=manifest.meta.uninstall_action_path,
|
||||
args=manifest.meta.uninstall_action_args)))
|
||||
else:
|
||||
manifest_info.append(InfoItem('Uninstaller', 'uninstaller', None, None))
|
||||
|
||||
install_tags = {''}
|
||||
for fm in manifest.file_manifest_list.elements:
|
||||
for tag in fm.install_tags:
|
||||
|
@ -2047,7 +1954,7 @@ class LegendaryCLI:
|
|||
redeemed = {k['gameId'] for k in key_list if k['redeemedOnUplay']}
|
||||
|
||||
games = self.core.get_game_list()
|
||||
entitlements = self.core.egs.get_user_entitlements_full()
|
||||
entitlements = self.core.egs.get_user_entitlements()
|
||||
owned_entitlements = {i['entitlementName'] for i in entitlements}
|
||||
|
||||
uplay_games = []
|
||||
|
@ -2126,7 +2033,7 @@ class LegendaryCLI:
|
|||
logger.info('Redeemed all outstanding Uplay codes.')
|
||||
elif args.origin:
|
||||
na_games, _ = self.core.get_non_asset_library_items(skip_ue=True)
|
||||
origin_games = [game for game in na_games if game.is_origin_game]
|
||||
origin_games = [game for game in na_games if game.third_party_store == 'Origin']
|
||||
|
||||
if not origin_games:
|
||||
logger.info('No redeemable games found.')
|
||||
|
@ -2586,11 +2493,6 @@ 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:
|
||||
|
@ -2628,10 +2530,6 @@ class LegendaryCLI:
|
|||
|
||||
|
||||
def main():
|
||||
# Set output encoding to UTF-8 if not outputting to a terminal
|
||||
if not stdout.isatty():
|
||||
stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
parser = argparse.ArgumentParser(description=f'Legendary v{__version__} - "{__codename__}"')
|
||||
parser.register('action', 'parsers', HiddenAliasSubparsersAction)
|
||||
|
||||
|
@ -2797,13 +2695,9 @@ def main():
|
|||
help='Automatically install all DLCs with the base game')
|
||||
install_parser.add_argument('--skip-dlcs', dest='skip_dlcs', action='store_true',
|
||||
help='Do not ask about installing DLCs.')
|
||||
install_parser.add_argument('--bind', dest='bind_ip', action='store', metavar='<IPs>', type=str,
|
||||
help='Comma-separated list of IPs to bind to for downloading')
|
||||
|
||||
uninstall_parser.add_argument('--keep-files', dest='keep_files', action='store_true',
|
||||
help='Keep files but remove game from Legendary database')
|
||||
uninstall_parser.add_argument('--skip-uninstaller', dest='skip_uninstaller', action='store_true',
|
||||
help='Skip running the uninstaller')
|
||||
|
||||
launch_parser.add_argument('--offline', dest='offline', action='store_true',
|
||||
default=False, help='Skip login and launch game without online authentication')
|
||||
|
@ -2911,8 +2805,6 @@ def main():
|
|||
help='Override savegame path (requires single app name to be specified)')
|
||||
sync_saves_parser.add_argument('--disable-filters', dest='disable_filters', action='store_true',
|
||||
help='Disable save game file filtering')
|
||||
sync_saves_parser.add_argument('--accept-path', dest='accept_path', action='store_true',
|
||||
help=argparse.SUPPRESS)
|
||||
|
||||
clean_saves_parser.add_argument('--delete-incomplete', dest='delete_incomplete', action='store_true',
|
||||
help='Delete incomplete save files')
|
||||
|
|
|
@ -131,8 +131,7 @@ class LegendaryCore:
|
|||
Handles authentication via authorization code (either retrieved manually or automatically)
|
||||
"""
|
||||
try:
|
||||
with self.lgd.userdata_lock as lock:
|
||||
lock.data = self.egs.start_session(authorization_code=code)
|
||||
self.lgd.userdata = 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.')
|
||||
|
@ -143,8 +142,7 @@ class LegendaryCore:
|
|||
Handles authentication via exchange token (either retrieved manually or automatically)
|
||||
"""
|
||||
try:
|
||||
with self.lgd.userdata_lock as lock:
|
||||
lock.data = self.egs.start_session(exchange_token=code)
|
||||
self.lgd.userdata = 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.')
|
||||
|
@ -173,23 +171,22 @@ class LegendaryCore:
|
|||
raise ValueError('No login session in config')
|
||||
refresh_token = re_data['Token']
|
||||
try:
|
||||
with self.lgd.userdata_lock as lock:
|
||||
lock.data = self.egs.start_session(refresh_token=refresh_token)
|
||||
self.lgd.userdata = 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, lock, force_refresh=False) -> bool:
|
||||
def login(self, 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 lock.data:
|
||||
if not self.lgd.userdata:
|
||||
raise ValueError('No saved credentials')
|
||||
elif self.logged_in and lock.data['expires_at']:
|
||||
dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
|
||||
elif self.logged_in and self.lgd.userdata['expires_at']:
|
||||
dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1])
|
||||
dt_now = datetime.utcnow()
|
||||
td = dt_now - dt_exp
|
||||
|
||||
|
@ -215,8 +212,8 @@ class LegendaryCore:
|
|||
except Exception as e:
|
||||
self.log.warning(f'Checking for EOS Overlay updates failed: {e!r}')
|
||||
|
||||
if lock.data['expires_at'] and not force_refresh:
|
||||
dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
|
||||
if self.lgd.userdata['expires_at'] and not force_refresh:
|
||||
dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1])
|
||||
dt_now = datetime.utcnow()
|
||||
td = dt_now - dt_exp
|
||||
|
||||
|
@ -224,7 +221,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(lock.data)
|
||||
self.egs.resume_session(self.lgd.userdata)
|
||||
self.logged_in = True
|
||||
return True
|
||||
except InvalidCredentialsError as e:
|
||||
|
@ -236,23 +233,19 @@ class LegendaryCore:
|
|||
|
||||
try:
|
||||
self.log.info('Logging in...')
|
||||
userdata = self.egs.start_session(lock.data['refresh_token'])
|
||||
userdata = self.egs.start_session(self.lgd.userdata['refresh_token'])
|
||||
except InvalidCredentialsError:
|
||||
self.log.error('Stored credentials are no longer valid! Please login again.')
|
||||
lock.clear()
|
||||
self.lgd.invalidate_userdata()
|
||||
return False
|
||||
except (HTTPError, ConnectionError) as e:
|
||||
self.log.error(f'HTTP request for login failed: {e!r}, please try again later.')
|
||||
return False
|
||||
|
||||
lock.data = userdata
|
||||
self.lgd.userdata = 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)
|
||||
|
||||
|
@ -301,9 +294,6 @@ class LegendaryCore:
|
|||
if lgd_config := version_info.get('legendary_config'):
|
||||
self.webview_killswitch = lgd_config.get('webview_killswitch', False)
|
||||
|
||||
def get_egl_version(self):
|
||||
return self._egl_version
|
||||
|
||||
def get_update_info(self):
|
||||
return self.lgd.get_cached_version()['data'].get('release_info')
|
||||
|
||||
|
@ -431,46 +421,25 @@ class LegendaryCore:
|
|||
continue
|
||||
|
||||
game = self.lgd.get_game_meta(app_name)
|
||||
asset_updated = sidecar_updated = False
|
||||
asset_updated = False
|
||||
if game:
|
||||
asset_updated = any(game.app_version(_p) != app_assets[_p].build_version for _p in app_assets.keys())
|
||||
# assuming sidecar data is the same for all platforms, just check the baseline (Windows) for updates.
|
||||
sidecar_updated = (app_assets['Windows'].sidecar_rev > 0 and
|
||||
(not game.sidecar or game.sidecar.rev != app_assets['Windows'].sidecar_rev))
|
||||
games[app_name] = game
|
||||
|
||||
if update_assets and (not game or force_refresh or (game and (asset_updated or sidecar_updated))):
|
||||
if update_assets and (not game or force_refresh or (game and asset_updated)):
|
||||
self.log.debug(f'Scheduling metadata update for {app_name}')
|
||||
# namespace/catalog item are the same for all platforms, so we can just use the first one
|
||||
_ga = next(iter(app_assets.values()))
|
||||
fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id, sidecar_updated))
|
||||
fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id))
|
||||
meta_updated = True
|
||||
|
||||
def fetch_game_meta(args):
|
||||
app_name, namespace, catalog_item_id, update_sidecar = args
|
||||
app_name, namespace, catalog_item_id = args
|
||||
eg_meta = self.egs.get_game_info(namespace, catalog_item_id, timeout=10.0)
|
||||
if not eg_meta:
|
||||
self.log.warning(f'App {app_name} does not have any metadata!')
|
||||
eg_meta = dict(title='Unknown')
|
||||
|
||||
sidecar = None
|
||||
if update_sidecar:
|
||||
self.log.debug(f'Updating sidecar information for {app_name}...')
|
||||
manifest_api_response = self.egs.get_game_manifest(namespace, catalog_item_id, app_name)
|
||||
# sidecar data is a JSON object encoded as a string for some reason
|
||||
manifest_info = manifest_api_response['elements'][0]
|
||||
if 'sidecar' in manifest_info:
|
||||
sidecar_json = json.loads(manifest_info['sidecar']['config'])
|
||||
sidecar = Sidecar(config=sidecar_json, rev=manifest_info['sidecar']['rvn'])
|
||||
|
||||
game = Game(app_name=app_name, app_title=eg_meta['title'], metadata=eg_meta, asset_infos=assets[app_name],
|
||||
sidecar=sidecar)
|
||||
game = Game(app_name=app_name, app_title=eg_meta['title'], metadata=eg_meta, asset_infos=assets[app_name])
|
||||
self.lgd.set_game_meta(game.app_name, game)
|
||||
games[app_name] = game
|
||||
try:
|
||||
still_needs_update.remove(app_name)
|
||||
except KeyError:
|
||||
pass
|
||||
still_needs_update.remove(app_name)
|
||||
|
||||
# setup and teardown of thread pool takes some time, so only do it when it makes sense.
|
||||
still_needs_update = {e[0] for e in fetch_list}
|
||||
|
@ -491,7 +460,7 @@ class LegendaryCore:
|
|||
if use_threads:
|
||||
self.log.warning(f'Fetching metadata for {app_name} failed, retrying')
|
||||
_ga = next(iter(app_assets.values()))
|
||||
fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id, True))
|
||||
fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id))
|
||||
game = games[app_name]
|
||||
|
||||
if game.is_dlc and platform in app_assets:
|
||||
|
@ -538,18 +507,12 @@ class LegendaryCore:
|
|||
_dlc = defaultdict(list)
|
||||
# get all the appnames we have to ignore
|
||||
ignore = set(i.app_name for i in self.get_assets())
|
||||
# broken old app name that we should always ignore
|
||||
ignore |= {'1'}
|
||||
|
||||
for libitem in self.egs.get_library_items():
|
||||
if libitem['namespace'] == 'ue' and skip_ue:
|
||||
continue
|
||||
if 'appName' not in libitem:
|
||||
continue
|
||||
if libitem['appName'] in ignore:
|
||||
continue
|
||||
if libitem['sandboxType'] == 'PRIVATE':
|
||||
continue
|
||||
|
||||
game = self.lgd.get_game_meta(libitem['appName'])
|
||||
if not game or force_refresh:
|
||||
|
@ -709,10 +672,9 @@ class LegendaryCore:
|
|||
disable_wine: bool = False,
|
||||
executable_override: str = None,
|
||||
crossover_app: str = None,
|
||||
crossover_bottle: str = None,
|
||||
addon_app_name: str = None) -> LaunchParameters:
|
||||
crossover_bottle: str = None) -> LaunchParameters:
|
||||
install = self.lgd.get_installed_game(app_name)
|
||||
game = self.lgd.get_game_meta(addon_app_name if addon_app_name else app_name)
|
||||
game = self.lgd.get_game_meta(app_name)
|
||||
|
||||
# Disable wine for non-Windows executables (e.g. native macOS)
|
||||
if not install.platform.startswith('Win'):
|
||||
|
@ -754,13 +716,6 @@ class LegendaryCore:
|
|||
self.log.warning(f'Parsing predefined launch parameters failed with: {e!r}, '
|
||||
f'input: {install.launch_parameters}')
|
||||
|
||||
if meta_args := game.additional_command_line:
|
||||
try:
|
||||
params.game_parameters.extend(shlex.split(meta_args.strip(), posix=False))
|
||||
except ValueError as e:
|
||||
self.log.warning(f'Parsing metadata launch parameters failed with: {e!r}, '
|
||||
f'input: {install.launch_parameters}')
|
||||
|
||||
game_token = ''
|
||||
if not offline:
|
||||
self.log.info('Getting authentication token...')
|
||||
|
@ -798,10 +753,6 @@ class LegendaryCore:
|
|||
f'-epicsandboxid={game.namespace}'
|
||||
])
|
||||
|
||||
if sidecar := game.sidecar:
|
||||
if deployment_id := sidecar.config.get('deploymentId', None):
|
||||
params.egl_parameters.append(f'-epicdeploymentid={deployment_id}')
|
||||
|
||||
if extra_args:
|
||||
params.user_parameters.extend(extra_args)
|
||||
|
||||
|
@ -875,8 +826,6 @@ class LegendaryCore:
|
|||
}
|
||||
elif sys_platform == 'darwin' and platform == 'Mac':
|
||||
path_vars |= {
|
||||
# Note: EGL actually resolves this to "~/Library/Application Support/Epic", but the only game
|
||||
# I could find using this (Loop Hero) expects it to be "~/Library/Application Support".
|
||||
'{appdata}': os.path.expanduser('~/Library/Application Support'),
|
||||
'{userdir}': os.path.expanduser('~/Documents'),
|
||||
'{userlibrary}': os.path.expanduser('~/Library'),
|
||||
|
@ -1048,22 +997,9 @@ class LegendaryCore:
|
|||
if not os.path.exists(_save_dir):
|
||||
os.makedirs(_save_dir)
|
||||
|
||||
if app_name and clean_dir:
|
||||
game = self.lgd.get_game_meta(app_name)
|
||||
custom_attr = game.metadata['customAttributes']
|
||||
include_f = exclude_f = None
|
||||
|
||||
# Make sure to only delete files that match the include/exclude filters.
|
||||
# This is particularly import for games that store save games in their install dir...
|
||||
if (_include := custom_attr.get('CloudIncludeList', {}).get('value', None)) is not None:
|
||||
include_f = _include.split(',')
|
||||
if (_exclude := custom_attr.get('CloudExcludeList', {}).get('value', None)) is not None:
|
||||
exclude_f = _exclude.split(',')
|
||||
|
||||
sgh = SaveGameHelper()
|
||||
save_files = sgh.get_deletion_list(_save_dir, include_f, exclude_f)
|
||||
if clean_dir:
|
||||
self.log.info('Deleting old save files...')
|
||||
delete_filelist(_save_dir, save_files, silent=True)
|
||||
delete_folder(_save_dir)
|
||||
|
||||
self.log.info(f'Downloading "{fname.split("/", 2)[2]}"...')
|
||||
# download manifest
|
||||
|
@ -1278,13 +1214,7 @@ class LegendaryCore:
|
|||
|
||||
for url in manifest_urls:
|
||||
self.log.debug(f'Trying to download manifest from "{url}"...')
|
||||
try:
|
||||
r = self.egs.unauth_session.get(url, timeout=10.0)
|
||||
except Exception as e:
|
||||
self.log.warning(f'Unable to download manifest from "{urlparse(url).netloc}" '
|
||||
f'(Exception: {e!r}), trying next URL...')
|
||||
continue
|
||||
|
||||
r = self.egs.unauth_session.get(url)
|
||||
if r.status_code == 200:
|
||||
manifest_bytes = r.content
|
||||
break
|
||||
|
@ -1331,7 +1261,7 @@ class LegendaryCore:
|
|||
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, bind_ip: str = None) -> (DLManager, AnalysisResult, ManifestMeta):
|
||||
disable_https: bool = False) -> (DLManager, AnalysisResult, ManifestMeta):
|
||||
# load old manifest
|
||||
old_manifest = None
|
||||
|
||||
|
@ -1432,7 +1362,7 @@ class LegendaryCore:
|
|||
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.strip()))
|
||||
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)
|
||||
|
@ -1498,7 +1428,7 @@ class LegendaryCore:
|
|||
|
||||
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, bind_ip=bind_ip)
|
||||
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,
|
||||
|
@ -1511,13 +1441,8 @@ class LegendaryCore:
|
|||
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)
|
||||
|
||||
uninstaller = None
|
||||
if new_manifest.meta.uninstall_action_path:
|
||||
uninstaller = dict(path=new_manifest.meta.uninstall_action_path,
|
||||
args=new_manifest.meta.uninstall_action_args)
|
||||
|
||||
offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
|
||||
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false').lower()
|
||||
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
|
||||
|
||||
if file_install_tag is None:
|
||||
file_install_tag = []
|
||||
|
@ -1539,7 +1464,7 @@ class LegendaryCore:
|
|||
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, uninstaller=uninstaller)
|
||||
platform=platform)
|
||||
|
||||
return dlm, anlres, igame
|
||||
|
||||
|
@ -1640,21 +1565,6 @@ class LegendaryCore:
|
|||
results.warnings.add('You may want to consider trying one of the following executables '
|
||||
f'(see README for launch parameter/config option usage):\n{alt_str}')
|
||||
|
||||
# Detect EOS service
|
||||
eos_installer = next((f for f in analysis.manifest_comparison.added
|
||||
if 'epiconlineservicesinstaller' in f.lower()), None)
|
||||
has_bootstrapper = any('eosbootstrapper' in f.lower() for f in analysis.manifest_comparison.added)
|
||||
|
||||
if eos_installer:
|
||||
results.warnings.add('This game ships the Epic Online Services Windows service, '
|
||||
'it may have to be installed for the game to work properly. '
|
||||
f'To do so, run "{eos_installer}" inside the game directory '
|
||||
f'after the install has finished.')
|
||||
elif has_bootstrapper:
|
||||
results.warnings.add('This game ships the Epic Online Services bootstrapper. '
|
||||
'The Epic Online Services Windows service may have to be '
|
||||
'installed manually for the game to function properly.')
|
||||
|
||||
return results
|
||||
|
||||
def get_default_install_dir(self, platform='Windows'):
|
||||
|
@ -1783,7 +1693,7 @@ class LegendaryCore:
|
|||
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').lower()
|
||||
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
|
||||
igame = InstalledGame(app_name=game.app_name, title=game.app_title, prereq_info=prereq, base_urls=base_urls,
|
||||
install_path=app_path, version=new_manifest.meta.build_version, is_dlc=game.is_dlc,
|
||||
executable=new_manifest.meta.launch_exe, can_run_offline=offline == 'true',
|
||||
|
@ -1808,9 +1718,6 @@ 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
|
||||
|
@ -1858,12 +1765,9 @@ 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)
|
||||
|
@ -1925,10 +1829,6 @@ 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)
|
||||
|
|
|
@ -22,7 +22,7 @@ from legendary.models.manifest import ManifestComparison, Manifest
|
|||
class DLManager(Process):
|
||||
def __init__(self, download_dir, base_url, cache_dir=None, status_q=None,
|
||||
max_workers=0, update_interval=1.0, dl_timeout=10, resume_file=None,
|
||||
max_shared_memory=1024 * 1024 * 1024, bind_ip=None):
|
||||
max_shared_memory=1024 * 1024 * 1024):
|
||||
super().__init__(name='DLManager')
|
||||
self.log = logging.getLogger('DLM')
|
||||
self.proc_debug = False
|
||||
|
@ -37,11 +37,8 @@ class DLManager(Process):
|
|||
self.writer_queue = None
|
||||
self.dl_result_q = None
|
||||
self.writer_result_q = None
|
||||
|
||||
# Worker stuff
|
||||
self.max_workers = max_workers or min(cpu_count() * 2, 16)
|
||||
self.dl_timeout = dl_timeout
|
||||
self.bind_ips = [] if not bind_ip else bind_ip.split(',')
|
||||
|
||||
# Analysis stuff
|
||||
self.analysis = None
|
||||
|
@ -140,24 +137,6 @@ class DLManager(Process):
|
|||
except Exception as e:
|
||||
self.log.warning(f'Reading resume file failed: {e!r}, continuing as normal...')
|
||||
|
||||
elif resume:
|
||||
# Basic check if files exist locally, put all missing files into "added"
|
||||
# This allows new SDL tags to be installed without having to do a repair as well.
|
||||
missing_files = set()
|
||||
|
||||
for fm in manifest.file_manifest_list.elements:
|
||||
if fm.filename in mc.added:
|
||||
continue
|
||||
|
||||
local_path = os.path.join(self.dl_dir, fm.filename)
|
||||
if not os.path.exists(local_path):
|
||||
missing_files.add(fm.filename)
|
||||
|
||||
self.log.info(f'Found {len(missing_files)} missing files.')
|
||||
mc.added |= missing_files
|
||||
mc.changed -= missing_files
|
||||
mc.unchanged -= missing_files
|
||||
|
||||
# Install tags are used for selective downloading, e.g. for language packs
|
||||
additional_deletion_tasks = []
|
||||
if file_install_tag is not None:
|
||||
|
@ -658,15 +637,10 @@ class DLManager(Process):
|
|||
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, bind_addr=bind_ip)
|
||||
dl_timeout=self.dl_timeout)
|
||||
self.children.append(w)
|
||||
w.start()
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# coding: utf-8
|
||||
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
import logging
|
||||
|
||||
|
@ -9,9 +10,6 @@ from multiprocessing import Process
|
|||
from multiprocessing.shared_memory import SharedMemory
|
||||
from queue import Empty
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
|
||||
|
||||
from legendary.models.chunk import Chunk
|
||||
from legendary.models.downloading import (
|
||||
DownloaderTask, DownloaderTaskResult,
|
||||
|
@ -20,22 +18,9 @@ from legendary.models.downloading import (
|
|||
)
|
||||
|
||||
|
||||
class BindingHTTPAdapter(HTTPAdapter):
|
||||
def __init__(self, addr):
|
||||
self.__attrs__.append('addr')
|
||||
self.addr = addr
|
||||
super().__init__()
|
||||
|
||||
def init_poolmanager(
|
||||
self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs
|
||||
):
|
||||
pool_kwargs['source_address'] = (self.addr, 0)
|
||||
super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
|
||||
|
||||
|
||||
class DLWorker(Process):
|
||||
def __init__(self, name, queue, out_queue, shm, max_retries=7,
|
||||
logging_queue=None, dl_timeout=10, bind_addr=None):
|
||||
logging_queue=None, dl_timeout=10):
|
||||
super().__init__(name=name)
|
||||
self.q = queue
|
||||
self.o_q = out_queue
|
||||
|
@ -49,12 +34,6 @@ class DLWorker(Process):
|
|||
self.logging_queue = logging_queue
|
||||
self.dl_timeout = float(dl_timeout) if dl_timeout else 10.0
|
||||
|
||||
# optionally bind an address
|
||||
if bind_addr:
|
||||
adapter = BindingHTTPAdapter(bind_addr)
|
||||
self.session.mount('https://', adapter)
|
||||
self.session.mount('http://', adapter)
|
||||
|
||||
def run(self):
|
||||
# we have to fix up the logger before we can start
|
||||
_root = logging.getLogger()
|
||||
|
@ -126,12 +105,11 @@ class DLWorker(Process):
|
|||
|
||||
# decompress stuff
|
||||
try:
|
||||
data = chunk.data
|
||||
size = len(data)
|
||||
size = len(chunk.data)
|
||||
if size > job.shm.size:
|
||||
logger.fatal('Downloaded chunk is longer than SharedMemorySegment!')
|
||||
|
||||
self.shm.buf[job.shm.offset:job.shm.offset + size] = data
|
||||
self.shm.buf[job.shm.offset:job.shm.offset + size] = bytes(chunk.data)
|
||||
del chunk
|
||||
self.o_q.put(DownloaderTaskResult(success=True, size_decompressed=size,
|
||||
size_downloaded=compressed, **job.__dict__))
|
||||
|
@ -272,7 +250,7 @@ class FileWorker(Process):
|
|||
if j.shared_memory:
|
||||
shm_offset = j.shared_memory.offset + j.chunk_offset
|
||||
shm_end = shm_offset + j.chunk_size
|
||||
current_file.write(self.shm.buf[shm_offset:shm_end])
|
||||
current_file.write(self.shm.buf[shm_offset:shm_end].tobytes())
|
||||
elif j.cache_file:
|
||||
with open(os.path.join(self.cache_path, j.cache_file), 'rb') as f:
|
||||
if j.chunk_offset:
|
||||
|
|
|
@ -4,14 +4,11 @@ import json
|
|||
import os
|
||||
import logging
|
||||
|
||||
from contextlib import contextmanager
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
from .utils import clean_filename, LockedJSONData
|
||||
from .utils import clean_filename
|
||||
|
||||
from legendary.models.game import *
|
||||
from legendary.utils.aliasing import generate_aliases
|
||||
|
@ -19,16 +16,11 @@ 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')
|
||||
|
||||
if config_path := os.environ.get('LEGENDARY_CONFIG_PATH'):
|
||||
self.path = config_path
|
||||
elif config_path := os.environ.get('XDG_CONFIG_HOME'):
|
||||
if config_path := os.environ.get('XDG_CONFIG_HOME'):
|
||||
self.path = os.path.join(config_path, 'legendary')
|
||||
else:
|
||||
self.path = os.path.expanduser('~/.config/legendary')
|
||||
|
@ -92,11 +84,6 @@ 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)
|
||||
|
@ -118,8 +105,6 @@ 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:
|
||||
|
@ -145,35 +130,31 @@ 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:
|
||||
with self.userdata_lock as locked:
|
||||
return locked.data
|
||||
self._user_data = json.load(open(os.path.join(self.path, 'user.json')))
|
||||
return self._user_data
|
||||
except Exception as e:
|
||||
self.log.debug(f'Failed to load user data: {e!r}')
|
||||
return None
|
||||
|
||||
@userdata.setter
|
||||
def userdata(self, userdata):
|
||||
raise NotImplementedError('The setter has been removed, use the locked userdata instead.')
|
||||
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)
|
||||
|
||||
def invalidate_userdata(self):
|
||||
with self.userdata_lock as lock:
|
||||
lock.clear()
|
||||
self._user_data = None
|
||||
if os.path.exists(os.path.join(self.path, 'user.json')):
|
||||
os.remove(os.path.join(self.path, 'user.json'))
|
||||
|
||||
@property
|
||||
def entitlements(self):
|
||||
|
@ -299,27 +280,6 @@ 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:
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import os
|
||||
import shutil
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
@ -11,8 +10,6 @@ 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')
|
||||
|
@ -156,45 +153,3 @@ 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, lock_file: str):
|
||||
super().__init__(lock_file + '.lock')
|
||||
|
||||
self._file_path = lock_file
|
||||
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
|
||||
|
|
|
@ -20,37 +20,6 @@ def get_shell_folders(registry, wine_pfx):
|
|||
return folders
|
||||
|
||||
|
||||
def case_insensitive_file_search(path: str) -> str:
|
||||
"""
|
||||
Similar to case_insensitive_path_search: Finds a file case-insensitively
|
||||
Note that this *does* work on Windows, although it's rather pointless
|
||||
"""
|
||||
path_parts = os.path.normpath(path).split(os.sep)
|
||||
# If path_parts[0] is empty, we're on Unix and thus start searching at /
|
||||
if not path_parts[0]:
|
||||
path_parts[0] = '/'
|
||||
|
||||
computed_path = path_parts[0]
|
||||
for part in path_parts[1:]:
|
||||
# If the computed directory does not exist, add all remaining parts as-is to at least return a valid path
|
||||
# at the end
|
||||
if not os.path.exists(computed_path):
|
||||
computed_path = os.path.join(computed_path, part)
|
||||
continue
|
||||
|
||||
# First try to find an exact match
|
||||
actual_file_or_dirname = part if os.path.exists(os.path.join(computed_path, part)) else None
|
||||
|
||||
# If there is no case-sensitive match, find a case-insensitive one
|
||||
if not actual_file_or_dirname:
|
||||
actual_file_or_dirname = next((
|
||||
x for x in os.listdir(computed_path)
|
||||
if x.lower() == part.lower()
|
||||
), part)
|
||||
computed_path = os.path.join(computed_path, actual_file_or_dirname)
|
||||
return computed_path
|
||||
|
||||
|
||||
def case_insensitive_path_search(path):
|
||||
"""
|
||||
Attempts to find a path case-insensitively
|
||||
|
|
|
@ -18,7 +18,6 @@ class GameAsset:
|
|||
label_name: str = ''
|
||||
namespace: str = ''
|
||||
metadata: Dict = field(default_factory=dict)
|
||||
sidecar_rev: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_egs_json(cls, json):
|
||||
|
@ -30,7 +29,6 @@ class GameAsset:
|
|||
tmp.label_name = json.get('labelName', '')
|
||||
tmp.namespace = json.get('namespace', '')
|
||||
tmp.metadata = json.get('metadata', {})
|
||||
tmp.sidecar_rev = json.get('sidecarRvn', 0)
|
||||
return tmp
|
||||
|
||||
@classmethod
|
||||
|
@ -43,26 +41,9 @@ class GameAsset:
|
|||
tmp.label_name = json.get('label_name', '')
|
||||
tmp.namespace = json.get('namespace', '')
|
||||
tmp.metadata = json.get('metadata', {})
|
||||
tmp.sidecar_rev = json.get('sidecar_rev', 0)
|
||||
return tmp
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sidecar:
|
||||
"""
|
||||
App sidecar data
|
||||
"""
|
||||
config: Dict
|
||||
rev: int
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json):
|
||||
return cls(
|
||||
config=json.get('config', {}),
|
||||
rev=json.get('rev', 0)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Game:
|
||||
"""
|
||||
|
@ -74,7 +55,6 @@ class Game:
|
|||
asset_infos: Dict[str, GameAsset] = field(default_factory=dict)
|
||||
base_urls: List[str] = field(default_factory=list)
|
||||
metadata: Dict = field(default_factory=dict)
|
||||
sidecar: Optional[Sidecar] = None
|
||||
|
||||
def app_version(self, platform='Windows'):
|
||||
if platform not in self.asset_infos:
|
||||
|
@ -86,11 +66,7 @@ class Game:
|
|||
return self.metadata and 'mainGameItem' in self.metadata
|
||||
|
||||
@property
|
||||
def is_origin_game(self) -> bool:
|
||||
return self.third_party_store and self.third_party_store.lower() in ['origin', 'the ea app']
|
||||
|
||||
@property
|
||||
def third_party_store(self) -> Optional[str]:
|
||||
def third_party_store(self):
|
||||
if not self.metadata:
|
||||
return None
|
||||
return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None)
|
||||
|
@ -115,18 +91,6 @@ class Game:
|
|||
def supports_mac_cloud_saves(self):
|
||||
return self.metadata and (self.metadata.get('customAttributes', {}).get('CloudSaveFolder_MAC') is not None)
|
||||
|
||||
@property
|
||||
def additional_command_line(self):
|
||||
if not self.metadata:
|
||||
return None
|
||||
return self.metadata.get('customAttributes', {}).get('AdditionalCommandLine', {}).get('value', None)
|
||||
|
||||
@property
|
||||
def is_launchable_addon(self):
|
||||
if not self.metadata:
|
||||
return False
|
||||
return any(m['path'] == 'addons/launchable' for m in self.metadata.get('categories', []))
|
||||
|
||||
@property
|
||||
def catalog_item_id(self):
|
||||
if not self.metadata:
|
||||
|
@ -152,9 +116,6 @@ class Game:
|
|||
# Migrate old asset_info to new asset_infos
|
||||
tmp.asset_infos['Windows'] = GameAsset.from_json(json.get('asset_info', dict()))
|
||||
|
||||
if sidecar := json.get('sidecar', None):
|
||||
tmp.sidecar = Sidecar.from_json(sidecar)
|
||||
|
||||
tmp.base_urls = json.get('base_urls', list())
|
||||
return tmp
|
||||
|
||||
|
@ -162,9 +123,8 @@ class Game:
|
|||
def __dict__(self):
|
||||
"""This is just here so asset_infos gets turned into a dict as well"""
|
||||
assets_dictified = {k: v.__dict__ for k, v in self.asset_infos.items()}
|
||||
sidecar_dictified = self.sidecar.__dict__ if self.sidecar else None
|
||||
return dict(metadata=self.metadata, asset_infos=assets_dictified, app_name=self.app_name,
|
||||
app_title=self.app_title, base_urls=self.base_urls, sidecar=sidecar_dictified)
|
||||
app_title=self.app_title, base_urls=self.base_urls)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -189,7 +149,6 @@ class InstalledGame:
|
|||
needs_verification: bool = False
|
||||
platform: str = 'Windows'
|
||||
prereq_info: Optional[Dict] = None
|
||||
uninstaller: Optional[Dict] = None
|
||||
requires_ot: bool = False
|
||||
save_path: Optional[str] = None
|
||||
|
||||
|
@ -206,7 +165,6 @@ class InstalledGame:
|
|||
tmp.executable = json.get('executable', '')
|
||||
tmp.launch_parameters = json.get('launch_parameters', '')
|
||||
tmp.prereq_info = json.get('prereq_info', None)
|
||||
tmp.uninstaller = json.get('uninstaller', None)
|
||||
|
||||
tmp.can_run_offline = json.get('can_run_offline', False)
|
||||
tmp.requires_ot = json.get('requires_ot', False)
|
||||
|
|
|
@ -22,14 +22,11 @@ def _filename_matches(filename, patterns):
|
|||
"""
|
||||
|
||||
for pattern in patterns:
|
||||
# Pattern is a directory, just check if path starts with it
|
||||
if pattern.endswith('/') and filename.startswith(pattern):
|
||||
return True
|
||||
# Check if pattern is a suffix of filename
|
||||
if filename.endswith(pattern):
|
||||
return True
|
||||
# Check if pattern with wildcards ('*') matches
|
||||
if fnmatch(filename, pattern):
|
||||
if pattern.endswith('/'):
|
||||
# pat is a directory, check if path starts with it
|
||||
if filename.startswith(pattern):
|
||||
return True
|
||||
elif fnmatch(filename, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
@ -170,21 +167,3 @@ class SaveGameHelper:
|
|||
|
||||
# return dict with created files for uploading/whatever
|
||||
return self.files
|
||||
|
||||
def get_deletion_list(self, save_folder, include_filter=None, exclude_filter=None):
|
||||
files = []
|
||||
for _dir, _, _files in os.walk(save_folder):
|
||||
for _file in _files:
|
||||
_file_path = os.path.join(_dir, _file)
|
||||
_file_path_rel = os.path.relpath(_file_path, save_folder).replace('\\', '/')
|
||||
|
||||
if include_filter and not _filename_matches(_file_path_rel, include_filter):
|
||||
self.log.debug(f'Excluding "{_file_path_rel}" (does not match include filter)')
|
||||
continue
|
||||
elif exclude_filter and _filename_matches(_file_path_rel, exclude_filter):
|
||||
self.log.debug(f'Excluding "{_file_path_rel}" (does match exclude filter)')
|
||||
continue
|
||||
|
||||
files.append(_file_path_rel)
|
||||
|
||||
return files
|
||||
|
|
|
@ -124,7 +124,7 @@ class MockLauncher:
|
|||
self.window.load_url(logout_url)
|
||||
|
||||
|
||||
def do_webview_login(callback_sid=None, callback_code=None, user_agent=None):
|
||||
def do_webview_login(callback_sid=None, callback_code=None):
|
||||
api = MockLauncher(callback_sid=callback_sid, callback_code=callback_code)
|
||||
url = login_url
|
||||
|
||||
|
@ -143,7 +143,7 @@ def do_webview_login(callback_sid=None, callback_code=None, user_agent=None):
|
|||
window.events.loaded += api.on_loaded
|
||||
|
||||
try:
|
||||
webview.start(user_agent=user_agent)
|
||||
webview.start()
|
||||
except Exception as we:
|
||||
logger.error(f'Running webview failed with {we!r}. If this error persists try the manual '
|
||||
f'login process by adding --disable-webview to your command line.')
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
requests<3.0
|
||||
filelock
|
||||
|
|
10
setup.py
10
setup.py
|
@ -8,8 +8,8 @@ from setuptools import setup
|
|||
|
||||
from legendary import __version__ as legendary_version
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
sys.exit('python 3.9 or higher is required for legendary')
|
||||
if sys.version_info < (3, 8):
|
||||
sys.exit('python 3.8 or higher is required for legendary')
|
||||
|
||||
with open("README.md", "r") as fh:
|
||||
long_description_l = fh.readlines()
|
||||
|
@ -37,8 +37,7 @@ setup(
|
|||
install_requires=[
|
||||
'requests<3.0',
|
||||
'setuptools',
|
||||
'wheel',
|
||||
'filelock'
|
||||
'wheel'
|
||||
],
|
||||
extras_require=dict(
|
||||
webview=['pywebview>=3.4'],
|
||||
|
@ -48,10 +47,11 @@ setup(
|
|||
description='Free and open-source replacement for the Epic Games Launcher application',
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
python_requires='>=3.9',
|
||||
python_requires='>=3.8',
|
||||
classifiers=[
|
||||
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Operating System :: Microsoft',
|
||||
|
|
Loading…
Reference in a new issue