mirror of
https://github.com/derrod/legendary.git
synced 2024-09-29 08:52:11 +13:00
Compare commits
94 commits
Author | SHA1 | Date | |
---|---|---|---|
|
3963382b3f | ||
|
49dcdf1a59 | ||
|
56a2314e40 | ||
|
4d63dcc188 | ||
|
f1f5cc07f6 | ||
|
08c64ebca1 | ||
|
09d280f476 | ||
|
9395eb94ab | ||
|
90e5f75af0 | ||
|
7fefdc4973 | ||
|
96e07ff453 | ||
|
ac6290627c | ||
|
691048d481 | ||
|
837c166187 | ||
|
1841da51f0 | ||
|
56d439ed2d | ||
|
2fdacb75d3 | ||
|
d2963db5b2 | ||
|
f1d815797f | ||
|
591039eaf3 | ||
|
9131f32c22 | ||
|
450784283d | ||
|
c56a81ab64 | ||
|
488d14c6e0 | ||
|
4c765325af | ||
|
c6e622f3ae | ||
|
013f7d4bde | ||
|
03b21f49de | ||
|
bd2e7ca0cd | ||
|
20b121bdb9 | ||
|
b759d9dbb1 | ||
|
51377e8548 | ||
|
07a16f7b84 | ||
|
c69301212c | ||
|
865dd51e2b | ||
|
6536473063 | ||
|
6d7909c311 | ||
|
0e35b70941 | ||
|
e0428b497e | ||
|
6500ea73af | ||
|
96b155800a | ||
|
4145381b93 | ||
|
e26b9e60ff | ||
|
bdd53fb8f8 | ||
|
bbb19d6cb6 | ||
|
175168adcb | ||
|
8b2809779f | ||
|
4bed49e7e1 | ||
|
f97d799e87 | ||
|
09d39b3fe3 | ||
|
a70ac2d1f9 | ||
|
362287543b | ||
|
ae05b4c1e5 | ||
|
6b8273f983 | ||
|
00f025dcc9 | ||
|
87b01b77d8 | ||
|
f19a1ba69d | ||
|
c8a6e68bf4 | ||
|
2ed9557b2c | ||
|
da23690510 | ||
|
c3eb6b4fe6 | ||
|
032b7fc64f | ||
|
29086276ee | ||
|
4c99bf8987 | ||
|
6709e8aa4f | ||
|
4722e38081 | ||
|
2ffd183554 | ||
|
d59e973816 | ||
|
f80ceb50f3 | ||
|
cf22de2bcf | ||
|
ddb7e1c3ca | ||
|
36e6e5f08a | ||
|
0e23b8e4f0 | ||
|
85f6bd3220 | ||
|
9e5fbaf21a | ||
|
ecb405172b | ||
|
c053860f25 | ||
|
3ab31561bf | ||
|
66ef0f3d5e | ||
|
c0d67882bb | ||
|
338fef2fac | ||
|
075f446add | ||
|
0eec8472a4 | ||
|
abd3a9d496 | ||
|
53e2accbb0 | ||
|
e111ae56fc | ||
|
88d30322b5 | ||
|
b136748168 | ||
|
5a20f12461 | ||
|
f26c8ab0a1 | ||
|
0d23775337 | ||
|
d8af06c936 | ||
|
a73d0694f6 | ||
|
f9a2dae282 |
27 changed files with 931 additions and 421 deletions
55
.github/workflows/python.yml
vendored
55
.github/workflows/python.yml
vendored
|
@ -11,26 +11,23 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ['ubuntu-20.04', 'windows-2019', 'macos-11']
|
os: ['ubuntu-24.04', 'windows-latest', 'macos-13']
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 3
|
max-parallel: 3
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.9'
|
||||||
|
|
||||||
- name: Python components
|
|
||||||
run: pip3 install --upgrade
|
|
||||||
setuptools
|
|
||||||
wheel
|
|
||||||
|
|
||||||
- name: Legendary dependencies and build tools
|
- name: Legendary dependencies and build tools
|
||||||
run: pip3 install --upgrade
|
run: pip3 install --upgrade
|
||||||
|
setuptools
|
||||||
pyinstaller
|
pyinstaller
|
||||||
requests
|
requests
|
||||||
|
filelock
|
||||||
|
|
||||||
- name: Optional dependencies (WebView)
|
- name: Optional dependencies (WebView)
|
||||||
run: pip3 install --upgrade pywebview
|
run: pip3 install --upgrade pywebview
|
||||||
|
@ -47,43 +44,37 @@ jobs:
|
||||||
--onefile
|
--onefile
|
||||||
--name legendary
|
--name legendary
|
||||||
${{ steps.strip.outputs.option }}
|
${{ steps.strip.outputs.option }}
|
||||||
|
-i ../assets/windows_icon.ico
|
||||||
cli.py
|
cli.py
|
||||||
env:
|
env:
|
||||||
PYTHONOPTIMIZE: 1
|
PYTHONOPTIMIZE: 1
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ runner.os }}-package
|
name: ${{ runner.os }}-package
|
||||||
path: legendary/dist/*
|
path: legendary/dist/*
|
||||||
|
|
||||||
deb:
|
deb:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: ['ubuntu-20.04']
|
|
||||||
|
|
||||||
fail-fast: true
|
|
||||||
max-parallel: 3
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Dependencies
|
- name: Dependencies
|
||||||
run: sudo apt install
|
run: |
|
||||||
python3-all
|
sudo apt install ruby
|
||||||
python3-stdeb
|
sudo gem install fpm
|
||||||
dh-python
|
|
||||||
python3-requests
|
|
||||||
python3-setuptools
|
|
||||||
python3-wheel
|
|
||||||
# pywebview is too outdated on 20.04, re-enable this on 22.04
|
|
||||||
# python3-webview
|
|
||||||
# python3-gi
|
|
||||||
# python3-gi-cairo
|
|
||||||
# gir1.2-gtk-3.0
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: python3 setup.py --command-packages=stdeb.command bdist_deb
|
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
|
||||||
|
|
||||||
- name: Os version
|
- name: Os version
|
||||||
id: os_version
|
id: os_version
|
||||||
|
@ -91,7 +82,7 @@ jobs:
|
||||||
source /etc/os-release
|
source /etc/os-release
|
||||||
echo ::set-output name=version::$NAME-$VERSION_ID
|
echo ::set-output name=version::$NAME-$VERSION_ID
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.os_version.outputs.version }}-deb-package
|
name: ${{ steps.os_version.outputs.version }}-deb-package
|
||||||
path: deb_dist/*.deb
|
path: ./*.deb
|
||||||
|
|
10
README.md
10
README.md
|
@ -35,7 +35,6 @@ it has to be run from a terminal (e.g. PowerShell)
|
||||||
- Linux, Windows (8.1+), or macOS (12.0+)
|
- Linux, Windows (8.1+), or macOS (12.0+)
|
||||||
+ 32-bit operating systems are not supported
|
+ 32-bit operating systems are not supported
|
||||||
- python 3.9+ (64-bit)
|
- 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`
|
+ (Windows) `pythonnet` is not yet compatible with 3.10+, use 3.9 if you plan to install `pywebview`
|
||||||
- PyPI packages:
|
- PyPI packages:
|
||||||
+ `requests`
|
+ `requests`
|
||||||
|
@ -141,7 +140,7 @@ legendary auth
|
||||||
When using the prebuilt Windows executables of version 0.20.14 or higher this should open a new window with the Epic Login.
|
When using the prebuilt Windows executables of version 0.20.14 or higher this should open a new window with the Epic Login.
|
||||||
|
|
||||||
Otherwise, authentication is a little finicky since we have to go through the Epic website and manually copy a code.
|
Otherwise, authentication is a little finicky since we have to go through the Epic website and manually copy a code.
|
||||||
The login page should open in your browser and after logging in you should be presented with a JSON response that contains a code ("sid"), just copy the code into the terminal and hit enter.
|
The login page should open in your browser and after logging in you should be presented with a JSON response that contains a code ("authorizationCode"), just copy the code into the terminal and hit enter.
|
||||||
|
|
||||||
Alternatively you can use the `--import` flag to import the authentication from the Epic Games Launcher (manually specifying the used WINE prefix may be required on Linux).
|
Alternatively you can use the `--import` flag to import the authentication from the Epic Games Launcher (manually specifying the used WINE prefix may be required on Linux).
|
||||||
Note that this will log you out of the Epic Launcher.
|
Note that this will log you out of the Epic Launcher.
|
||||||
|
@ -269,9 +268,10 @@ optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
--import Import Epic Games Launcher authentication data (logs
|
--import Import Epic Games Launcher authentication data (logs
|
||||||
out of EGL)
|
out of EGL)
|
||||||
--code <exchange code>
|
--code <authorization code>
|
||||||
Use specified exchange code instead of interactive
|
Use specified authorization code instead of interactive authentication
|
||||||
authentication
|
--token <exchange token>
|
||||||
|
Use specified exchange token instead of interactive authentication
|
||||||
--sid <session id> Use specified session id instead of interactive
|
--sid <session id> Use specified session id instead of interactive
|
||||||
authentication
|
authentication
|
||||||
--delete Remove existing authentication (log out)
|
--delete Remove existing authentication (log out)
|
||||||
|
|
BIN
assets/windows_icon.ico
Normal file
BIN
assets/windows_icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -1,4 +1,4 @@
|
||||||
"""Legendary!"""
|
"""Legendary!"""
|
||||||
|
|
||||||
__version__ = '0.20.27'
|
__version__ = '0.20.35'
|
||||||
__codename__ = 'Dark Energy (hotfix)'
|
__codename__ = 'Lowlife'
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
# !/usr/bin/env python
|
# !/usr/bin/env python
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests.adapters
|
import requests.adapters
|
||||||
import logging
|
import logging
|
||||||
|
@ -13,7 +15,7 @@ from legendary.models.gql import *
|
||||||
|
|
||||||
class EPCAPI:
|
class EPCAPI:
|
||||||
_user_agent = 'UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit'
|
_user_agent = 'UELauncher/11.0.1-14907503+++Portal+Release-Live Windows/10.0.19041.1.256.64bit'
|
||||||
_store_user_agent = 'EpicGamesLauncher/11.0.1-14907503+++Portal+Release-Live'
|
_store_user_agent = 'EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live'
|
||||||
# required for the oauth request
|
# required for the oauth request
|
||||||
_user_basic = '34a02cf8f4414e29b15921876da36f9a'
|
_user_basic = '34a02cf8f4414e29b15921876da36f9a'
|
||||||
_pw_basic = 'daafbccc737745039dffe53d94fc76cf'
|
_pw_basic = 'daafbccc737745039dffe53d94fc76cf'
|
||||||
|
@ -26,7 +28,10 @@ class EPCAPI:
|
||||||
_ecommerce_host = 'ecommerceintegration-public-service-ecomprod02.ol.epicgames.com'
|
_ecommerce_host = 'ecommerceintegration-public-service-ecomprod02.ol.epicgames.com'
|
||||||
_datastorage_host = 'datastorage-public-service-liveegs.live.use1a.on.epicgames.com'
|
_datastorage_host = 'datastorage-public-service-liveegs.live.use1a.on.epicgames.com'
|
||||||
_library_host = 'library-service.live.use1a.on.epicgames.com'
|
_library_host = 'library-service.live.use1a.on.epicgames.com'
|
||||||
_store_gql_host = 'launcher.store.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'
|
||||||
_artifact_service_host = 'artifact-public-service-prod.beee.live.use1a.on.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):
|
def __init__(self, lc='en', cc='US', timeout=10.0):
|
||||||
|
@ -48,10 +53,12 @@ class EPCAPI:
|
||||||
self.language_code = lc
|
self.language_code = lc
|
||||||
self.country_code = cc
|
self.country_code = cc
|
||||||
|
|
||||||
if timeout > 0:
|
self.request_timeout = timeout if timeout > 0 else None
|
||||||
self.request_timeout = timeout
|
|
||||||
else:
|
def get_auth_url(self):
|
||||||
self.request_timeout = None
|
login_url = 'https://www.epicgames.com/id/login?redirectUrl='
|
||||||
|
redirect_url = f'https://www.epicgames.com/id/api/redirect?clientId={self._user_basic}&responseType=code'
|
||||||
|
return login_url + urllib.parse.quote(redirect_url)
|
||||||
|
|
||||||
def update_egs_params(self, egs_params):
|
def update_egs_params(self, egs_params):
|
||||||
# update user-agent
|
# update user-agent
|
||||||
|
@ -87,7 +94,7 @@ class EPCAPI:
|
||||||
return self.user
|
return self.user
|
||||||
|
|
||||||
def start_session(self, refresh_token: str = None, exchange_token: str = None,
|
def start_session(self, refresh_token: str = None, exchange_token: str = None,
|
||||||
client_credentials: bool = False) -> dict:
|
authorization_code: str = None, client_credentials: bool = False) -> dict:
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
params = dict(grant_type='refresh_token',
|
params = dict(grant_type='refresh_token',
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
|
@ -96,6 +103,10 @@ class EPCAPI:
|
||||||
params = dict(grant_type='exchange_code',
|
params = dict(grant_type='exchange_code',
|
||||||
exchange_code=exchange_token,
|
exchange_code=exchange_token,
|
||||||
token_type='eg1')
|
token_type='eg1')
|
||||||
|
elif authorization_code:
|
||||||
|
params = dict(grant_type='authorization_code',
|
||||||
|
code=authorization_code,
|
||||||
|
token_type='eg1')
|
||||||
elif client_credentials:
|
elif client_credentials:
|
||||||
params = dict(grant_type='client_credentials',
|
params = dict(grant_type='client_credentials',
|
||||||
token_type='eg1')
|
token_type='eg1')
|
||||||
|
@ -110,9 +121,16 @@ class EPCAPI:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
j = r.json()
|
j = r.json()
|
||||||
if 'error' in j:
|
if 'errorCode' in j:
|
||||||
self.log.warning(f'Login to EGS API failed with errorCode: {j["errorCode"]}')
|
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"]}')
|
||||||
raise InvalidCredentialsError(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"]}'
|
self.session.headers['Authorization'] = f'bearer {j["access_token"]}'
|
||||||
# only set user info when using non-anonymous login
|
# only set user info when using non-anonymous login
|
||||||
|
@ -168,13 +186,24 @@ class EPCAPI:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_user_entitlements(self):
|
def get_user_entitlements(self, start=0):
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
r = self.session.get(f'https://{self._entitlements_host}/entitlement/api/account/{user_id}/entitlements',
|
r = self.session.get(f'https://{self._entitlements_host}/entitlement/api/account/{user_id}/entitlements',
|
||||||
params=dict(start=0, count=5000), timeout=self.request_timeout)
|
params=dict(start=start, count=1000), timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
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):
|
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',
|
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,
|
params=dict(id=catalog_item_id, includeDLCDetails=True, includeMainGameDetails=True,
|
||||||
|
@ -184,7 +213,7 @@ class EPCAPI:
|
||||||
return r.json().get(catalog_item_id, None)
|
return r.json().get(catalog_item_id, None)
|
||||||
|
|
||||||
def get_artifact_service_ticket(self, sandbox_id: str, artifact_id: str, label='Live', platform='Windows'):
|
def get_artifact_service_ticket(self, sandbox_id: str, artifact_id: str, label='Live', platform='Windows'):
|
||||||
# based on EOS windows service implementation, untested as it's not live yet (just 403s)
|
# Based on EOS Helper Windows service implementation. Only works with anonymous EOSH session.
|
||||||
# sandbox_id is the same as the namespace, artifact_id is the same as the app name
|
# sandbox_id is the same as the namespace, artifact_id is the same as the app name
|
||||||
r = self.session.post(f'https://{self._artifact_service_host}/artifact-service/api/public/v1/dependency/'
|
r = self.session.post(f'https://{self._artifact_service_host}/artifact-service/api/public/v1/dependency/'
|
||||||
f'sandbox/{sandbox_id}/artifact/{artifact_id}/ticket',
|
f'sandbox/{sandbox_id}/artifact/{artifact_id}/ticket',
|
||||||
|
@ -194,11 +223,11 @@ class EPCAPI:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_game_manifest_by_ticket(self, artifact_id: str, ticket: dict):
|
def get_game_manifest_by_ticket(self, artifact_id: str, signed_ticket: str, label='Live', platform='Windows'):
|
||||||
# Untested as get_artifact_service_ticket is not working yet either
|
# Based on EOS Helper Windows service implementation.
|
||||||
r = self.session.post(f'https://{self._launcher_host}/launcher/api/public/assets/v2/'
|
r = self.session.post(f'https://{self._launcher_host}/launcher/api/public/assets/v2/'
|
||||||
f'by-ticket/app/{artifact_id}',
|
f'by-ticket/app/{artifact_id}',
|
||||||
headers=dict(authorization=f'bearer {ticket["signedTicket"]}'),
|
json=dict(platform=platform, label=label, signedTicket=signed_ticket),
|
||||||
timeout=self.request_timeout)
|
timeout=self.request_timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
@ -224,10 +253,8 @@ class EPCAPI:
|
||||||
return records
|
return records
|
||||||
|
|
||||||
def get_user_cloud_saves(self, app_name='', manifests=False, filenames=None):
|
def get_user_cloud_saves(self, app_name='', manifests=False, filenames=None):
|
||||||
if app_name and manifests:
|
if app_name:
|
||||||
app_name += '/manifests/'
|
app_name += '/manifests/' if manifests else '/'
|
||||||
elif app_name:
|
|
||||||
app_name += '/'
|
|
||||||
|
|
||||||
user_id = self.user.get('account_id')
|
user_id = self.user.get('account_id')
|
||||||
|
|
||||||
|
|
276
legendary/cli.py
276
legendary/cli.py
|
@ -1,4 +1,4 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
@ -22,13 +22,13 @@ from legendary.core import LegendaryCore
|
||||||
from legendary.models.exceptions import InvalidCredentialsError
|
from legendary.models.exceptions import InvalidCredentialsError
|
||||||
from legendary.models.game import SaveGameStatus, VerifyResult, Game
|
from legendary.models.game import SaveGameStatus, VerifyResult, Game
|
||||||
from legendary.utils.cli import get_boolean_choice, get_int_choice, sdl_prompt, strtobool
|
from legendary.utils.cli import get_boolean_choice, get_int_choice, sdl_prompt, strtobool
|
||||||
from legendary.utils.crossover import *
|
from legendary.lfs.crossover import *
|
||||||
from legendary.utils.custom_parser import HiddenAliasSubparsersAction
|
from legendary.utils.custom_parser import HiddenAliasSubparsersAction
|
||||||
from legendary.utils.env import is_windows_mac_or_pyi
|
from legendary.utils.env import is_windows_mac_or_pyi
|
||||||
from legendary.utils.eos import add_registry_entries, query_registry_entries, remove_registry_entries
|
from legendary.lfs.eos import add_registry_entries, query_registry_entries, remove_registry_entries
|
||||||
from legendary.utils.lfs import validate_files, clean_filename
|
from legendary.lfs.utils import validate_files, clean_filename
|
||||||
from legendary.utils.selective_dl import get_sdl_appname
|
from legendary.utils.selective_dl import get_sdl_appname
|
||||||
from legendary.utils.wine_helpers import read_registry, get_shell_folders
|
from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_file_search
|
||||||
|
|
||||||
# todo custom formatter for cli logger (clean info, highlighted error/warning)
|
# todo custom formatter for cli logger (clean info, highlighted error/warning)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
@ -104,11 +104,11 @@ class LegendaryCLI:
|
||||||
|
|
||||||
if not egl_wine_pfx:
|
if not egl_wine_pfx:
|
||||||
logger.info('Please enter the path to the Wine prefix that has EGL installed')
|
logger.info('Please enter the path to the Wine prefix that has EGL installed')
|
||||||
wine_pfx = input('Path [empty input to quit]: ').strip()
|
egl_wine_pfx = input('Path [empty input to quit]: ').strip()
|
||||||
if not wine_pfx:
|
if not egl_wine_pfx:
|
||||||
print('Empty input, quitting...')
|
print('Empty input, quitting...')
|
||||||
exit(0)
|
exit(0)
|
||||||
if not os.path.exists(wine_pfx) and os.path.isdir(wine_pfx):
|
if not os.path.exists(egl_wine_pfx) and os.path.isdir(egl_wine_pfx):
|
||||||
print('Path is invalid (does not exist)!')
|
print('Path is invalid (does not exist)!')
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
@ -143,29 +143,27 @@ class LegendaryCLI:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
exchange_token = ''
|
exchange_token = ''
|
||||||
if not args.auth_code and not args.session_id:
|
auth_code = ''
|
||||||
|
if not args.auth_code and not args.session_id and not args.ex_token:
|
||||||
# only import here since pywebview import is slow
|
# only import here since pywebview import is slow
|
||||||
from legendary.utils.webview_login import webview_available, do_webview_login
|
from legendary.utils.webview_login import webview_available, do_webview_login
|
||||||
|
|
||||||
if not webview_available or args.no_webview or self.core.webview_killswitch:
|
if not webview_available or args.no_webview or self.core.webview_killswitch:
|
||||||
# unfortunately the captcha stuff makes a complete CLI login flow kinda impossible right now...
|
# unfortunately the captcha stuff makes a complete CLI login flow kinda impossible right now...
|
||||||
print('Please login via the epic web login!')
|
print('Please login via the epic web login!')
|
||||||
webbrowser.open(
|
url = 'https://legendary.gl/epiclogin'
|
||||||
'https://www.epicgames.com/id/login?redirectUrl='
|
webbrowser.open(url)
|
||||||
'https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect'
|
print(f'If the web page did not open automatically, please manually open the following URL: {url}')
|
||||||
)
|
auth_code = input('Please enter the "authorizationCode" value from the JSON response: ')
|
||||||
print('If the web page did not open automatically, please manually open the following URL: '
|
auth_code = auth_code.strip()
|
||||||
'https://www.epicgames.com/id/login?redirectUrl=https://www.epicgames.com/id/api/redirect')
|
if auth_code[0] == '{':
|
||||||
sid = input('Please enter the "sid" value from the JSON response: ')
|
tmp = json.loads(auth_code)
|
||||||
sid = sid.strip()
|
auth_code = tmp['authorizationCode']
|
||||||
if sid[0] == '{':
|
|
||||||
tmp = json.loads(sid)
|
|
||||||
sid = tmp['sid']
|
|
||||||
else:
|
else:
|
||||||
sid = sid.strip('"')
|
auth_code = auth_code.strip('"')
|
||||||
exchange_token = self.core.auth_sid(sid)
|
|
||||||
else:
|
else:
|
||||||
if do_webview_login(callback_sid=self.core.auth_sid, callback_code=self.core.auth_code):
|
if do_webview_login(callback_code=self.core.auth_ex_token,
|
||||||
|
user_agent=f'EpicGamesLauncher/{self.core.get_egl_version()}'):
|
||||||
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}" via WebView')
|
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}" via WebView')
|
||||||
else:
|
else:
|
||||||
logger.error('WebView login attempt failed, please see log for details.')
|
logger.error('WebView login attempt failed, please see log for details.')
|
||||||
|
@ -173,13 +171,17 @@ class LegendaryCLI:
|
||||||
elif args.session_id:
|
elif args.session_id:
|
||||||
exchange_token = self.core.auth_sid(args.session_id)
|
exchange_token = self.core.auth_sid(args.session_id)
|
||||||
elif args.auth_code:
|
elif args.auth_code:
|
||||||
exchange_token = args.auth_code
|
auth_code = args.auth_code
|
||||||
|
elif args.ex_token:
|
||||||
|
exchange_token = args.ex_token
|
||||||
|
|
||||||
if not exchange_token:
|
if not exchange_token and not auth_code:
|
||||||
logger.fatal('No exchange token, cannot login.')
|
logger.fatal('No exchange token/authorization code, cannot login.')
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.core.auth_code(exchange_token):
|
if exchange_token and self.core.auth_ex_token(exchange_token):
|
||||||
|
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}"')
|
||||||
|
elif auth_code and self.core.auth_code(auth_code):
|
||||||
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}"')
|
logger.info(f'Successfully logged in as "{self.core.lgd.userdata["displayName"]}"')
|
||||||
else:
|
else:
|
||||||
logger.error('Login attempt failed, please see log for details.')
|
logger.error('Login attempt failed, please see log for details.')
|
||||||
|
@ -240,20 +242,21 @@ class LegendaryCLI:
|
||||||
# a third-party application (such as Origin).
|
# a third-party application (such as Origin).
|
||||||
if not version:
|
if not version:
|
||||||
_store = game.third_party_store
|
_store = game.third_party_store
|
||||||
if _store == 'Origin':
|
if game.is_origin_game:
|
||||||
print(f' - This game has to be activated, installed, and launched via Origin, use '
|
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.')
|
f'"legendary launch --origin {game.app_name}" to activate and/or run the game.')
|
||||||
elif _store:
|
elif _store:
|
||||||
print(f' ! This game has to be installed through a third-party store ({_store}, not supported)')
|
print(f' ! This game has to be installed through a third-party store ({_store}, not supported)')
|
||||||
else:
|
else:
|
||||||
print(f' ! No version information (unknown cause)')
|
print(' ! No version information (unknown cause)')
|
||||||
# Games that have assets, but only require a one-time activation before they can be independently installed
|
# Games that have assets, but only require a one-time activation before they can be independently installed
|
||||||
# via a third-party platform (e.g. Uplay)
|
# via a third-party platform (e.g. Uplay)
|
||||||
if game.partner_link_type:
|
if game.partner_link_type:
|
||||||
_type = game.partner_link_type
|
_type = game.partner_link_type
|
||||||
if _type == 'ubisoft':
|
if _type == 'ubisoft':
|
||||||
print(' - This game can be activated directly on your Ubisoft account and does not require '
|
print(' - This game can be activated directly on your Ubisoft account and does not require '
|
||||||
'legendary to install/run. Use "legendary activate --uplay" and follow the instructions.')
|
'legendary to install/run. This game requires Ubisoft Connect to be installed. '
|
||||||
|
'Use "legendary activate --uplay" and follow the instructions.')
|
||||||
else:
|
else:
|
||||||
print(f' ! This app requires linking to a third-party account (name: "{_type}", not supported)')
|
print(f' ! This app requires linking to a third-party account (name: "{_type}", not supported)')
|
||||||
|
|
||||||
|
@ -314,7 +317,7 @@ class LegendaryCLI:
|
||||||
|
|
||||||
print('\nInstalled games:')
|
print('\nInstalled games:')
|
||||||
for game in 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}')
|
logger.debug(f'Updating missing size for {game.app_name}')
|
||||||
m = self.core.load_manifest(self.core.get_installed_manifest(game.app_name)[0])
|
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)
|
game.install_size = sum(fm.file_size for fm in m.file_manifest_list.elements)
|
||||||
|
@ -370,6 +373,8 @@ class LegendaryCLI:
|
||||||
|
|
||||||
if args.install_tag:
|
if args.install_tag:
|
||||||
files = [fm for fm in files if args.install_tag in fm.install_tags]
|
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:
|
if args.hashlist:
|
||||||
for fm in files:
|
for fm in files:
|
||||||
|
@ -379,15 +384,16 @@ class LegendaryCLI:
|
||||||
writer.writerow(['path', 'hash', 'size', 'install_tags'])
|
writer.writerow(['path', 'hash', 'size', 'install_tags'])
|
||||||
writer.writerows((fm.filename, fm.hash.hex(), fm.file_size, '|'.join(fm.install_tags)) for fm in files)
|
writer.writerows((fm.filename, fm.hash.hex(), fm.file_size, '|'.join(fm.install_tags)) for fm in files)
|
||||||
elif args.json:
|
elif args.json:
|
||||||
_files = []
|
_files = [
|
||||||
for fm in files:
|
dict(
|
||||||
_files.append(dict(
|
|
||||||
filename=fm.filename,
|
filename=fm.filename,
|
||||||
sha_hash=fm.hash.hex(),
|
sha_hash=fm.hash.hex(),
|
||||||
install_tags=fm.install_tags,
|
install_tags=fm.install_tags,
|
||||||
file_size=fm.file_size,
|
file_size=fm.file_size,
|
||||||
flags=fm.flags,
|
flags=fm.flags
|
||||||
))
|
)
|
||||||
|
for fm in files
|
||||||
|
]
|
||||||
return self._print_json(_files, args.pretty_json)
|
return self._print_json(_files, args.pretty_json)
|
||||||
else:
|
else:
|
||||||
install_tags = set()
|
install_tags = set()
|
||||||
|
@ -413,7 +419,11 @@ class LegendaryCLI:
|
||||||
print('Save games:')
|
print('Save games:')
|
||||||
for save in sorted(saves, key=lambda a: a.app_name + a.manifest_name):
|
for save in sorted(saves, key=lambda a: a.app_name + a.manifest_name):
|
||||||
if save.app_name != last_app:
|
if save.app_name != last_app:
|
||||||
game_title = self.core.get_game(save.app_name).app_title
|
if game := self.core.get_game(save.app_name):
|
||||||
|
game_title = game.app_title
|
||||||
|
else:
|
||||||
|
game_title = 'Unknown'
|
||||||
|
|
||||||
last_app = save.app_name
|
last_app = save.app_name
|
||||||
print(f'- {game_title} ("{save.app_name}")')
|
print(f'- {game_title} ("{save.app_name}")')
|
||||||
print(' +', save.manifest_name)
|
print(' +', save.manifest_name)
|
||||||
|
@ -429,7 +439,7 @@ class LegendaryCLI:
|
||||||
if not self.core.login():
|
if not self.core.login():
|
||||||
logger.error('Login failed! Cannot continue with download process.')
|
logger.error('Login failed! Cannot continue with download process.')
|
||||||
exit(1)
|
exit(1)
|
||||||
logger.info(f'Cleaning saves...')
|
logger.info('Cleaning saves...')
|
||||||
self.core.clean_saves(self._resolve_aliases(args.app_name), args.delete_incomplete)
|
self.core.clean_saves(self._resolve_aliases(args.app_name), args.delete_incomplete)
|
||||||
|
|
||||||
def sync_saves(self, args):
|
def sync_saves(self, args):
|
||||||
|
@ -447,11 +457,10 @@ class LegendaryCLI:
|
||||||
igames = [igame]
|
igames = [igame]
|
||||||
|
|
||||||
# check available saves
|
# check available saves
|
||||||
saves = self.core.get_save_games()
|
saves = self.core.get_save_games(args.app_name if args.app_name else '')
|
||||||
latest_save = dict()
|
latest_save = {
|
||||||
|
save.app_name: save for save in sorted(saves, key=lambda a: a.datetime)
|
||||||
for save in sorted(saves, key=lambda a: a.datetime):
|
}
|
||||||
latest_save[save.app_name] = save
|
|
||||||
|
|
||||||
logger.info(f'Got {len(latest_save)} remote save game(s)')
|
logger.info(f'Got {len(latest_save)} remote save game(s)')
|
||||||
|
|
||||||
|
@ -467,14 +476,17 @@ class LegendaryCLI:
|
||||||
logger.info(f'Checking "{igame.title}" ({igame.app_name})')
|
logger.info(f'Checking "{igame.title}" ({igame.app_name})')
|
||||||
# override save path only if app name is specified
|
# override save path only if app name is specified
|
||||||
if args.app_name and args.save_path:
|
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}"...')
|
logger.info(f'Overriding save path with "{args.save_path}"...')
|
||||||
igame.save_path = args.save_path
|
igame.save_path = args.save_path
|
||||||
self.core.lgd.set_installed_game(igame.app_name, igame)
|
self.core.lgd.set_installed_game(igame.app_name, igame)
|
||||||
|
|
||||||
# if there is no saved save path, try to get one
|
# 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:
|
if not igame.save_path and self.core.lgd.lock_installed():
|
||||||
if args.yes:
|
if args.yes and not args.accept_path:
|
||||||
logger.info(f'Save path for this title has not been set, skipping due to --yes')
|
logger.info('Save path for this title has not been set, skipping due to --yes')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
save_path = self.core.get_save_path(igame.app_name, platform=igame.platform)
|
save_path = self.core.get_save_path(igame.app_name, platform=igame.platform)
|
||||||
|
@ -485,6 +497,11 @@ class LegendaryCLI:
|
||||||
if '%' in save_path or '{' in save_path:
|
if '%' in save_path or '{' in save_path:
|
||||||
logger.warning('Path contains unprocessed variables, please enter the correct path manually.')
|
logger.warning('Path contains unprocessed variables, please enter the correct path manually.')
|
||||||
yn = False
|
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:
|
else:
|
||||||
yn = get_boolean_choice('Is this correct?')
|
yn = get_boolean_choice('Is this correct?')
|
||||||
|
|
||||||
|
@ -552,6 +569,7 @@ class LegendaryCLI:
|
||||||
|
|
||||||
def launch_game(self, args, extra):
|
def launch_game(self, args, extra):
|
||||||
app_name = self._resolve_aliases(args.app_name)
|
app_name = self._resolve_aliases(args.app_name)
|
||||||
|
addon_app_name = None
|
||||||
|
|
||||||
# Interactive CrossOver setup
|
# Interactive CrossOver setup
|
||||||
if args.crossover and sys_platform == 'darwin':
|
if args.crossover and sys_platform == 'darwin':
|
||||||
|
@ -562,12 +580,19 @@ class LegendaryCLI:
|
||||||
return self._launch_origin(args)
|
return self._launch_origin(args)
|
||||||
|
|
||||||
igame = self.core.get_installed_game(app_name)
|
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:
|
if not igame:
|
||||||
logger.error(f'Game {app_name} is not currently installed!')
|
logger.error(f'Game {app_name} is not currently installed!')
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if igame.is_dlc:
|
if igame.is_dlc and not igame.executable:
|
||||||
logger.error(f'{app_name} is DLC; please launch the base game instead!')
|
logger.error(f'{app_name} is DLC without an executable; please launch the base game instead!')
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if not os.path.exists(igame.install_path):
|
if not os.path.exists(igame.install_path):
|
||||||
|
@ -606,7 +631,8 @@ class LegendaryCLI:
|
||||||
disable_wine=args.no_wine,
|
disable_wine=args.no_wine,
|
||||||
executable_override=args.executable_override,
|
executable_override=args.executable_override,
|
||||||
crossover_app=args.crossover_app,
|
crossover_app=args.crossover_app,
|
||||||
crossover_bottle=args.crossover_bottle)
|
crossover_bottle=args.crossover_bottle,
|
||||||
|
addon_app_name=addon_app_name)
|
||||||
|
|
||||||
if args.set_defaults:
|
if args.set_defaults:
|
||||||
self.core.lgd.config[app_name] = dict()
|
self.core.lgd.config[app_name] = dict()
|
||||||
|
@ -697,7 +723,7 @@ class LegendaryCLI:
|
||||||
f'to fetch data for Origin titles before using this command.')
|
f'to fetch data for Origin titles before using this command.')
|
||||||
return
|
return
|
||||||
|
|
||||||
if not game.third_party_store or game.third_party_store != 'Origin':
|
if not game.is_origin_game:
|
||||||
logger.error(f'The specified game is not an Origin title.')
|
logger.error(f'The specified game is not an Origin title.')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -766,6 +792,8 @@ class LegendaryCLI:
|
||||||
f'wrapper in the configuration file or command line. See the README for details.')
|
f'wrapper in the configuration file or command line. See the README for details.')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# You cannot launch a URI without start.exe
|
||||||
|
command.append('start')
|
||||||
command.append(origin_uri)
|
command.append(origin_uri)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
if cmd:
|
if cmd:
|
||||||
|
@ -786,6 +814,11 @@ class LegendaryCLI:
|
||||||
subprocess.Popen(command, env=full_env)
|
subprocess.Popen(command, env=full_env)
|
||||||
|
|
||||||
def install_game(self, args):
|
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)
|
args.app_name = self._resolve_aliases(args.app_name)
|
||||||
if self.core.is_installed(args.app_name):
|
if self.core.is_installed(args.app_name):
|
||||||
igame = self.core.get_installed_game(args.app_name)
|
igame = self.core.get_installed_game(args.app_name)
|
||||||
|
@ -824,7 +857,7 @@ class LegendaryCLI:
|
||||||
|
|
||||||
if store := game.third_party_store:
|
if store := game.third_party_store:
|
||||||
logger.error(f'The selected title has to be installed via a third-party store: {store}')
|
logger.error(f'The selected title has to be installed via a third-party store: {store}')
|
||||||
if store == 'Origin':
|
if game.is_origin_game:
|
||||||
logger.info(f'For Origin games use "legendary launch --origin {args.app_name}" to '
|
logger.info(f'For Origin games use "legendary launch --origin {args.app_name}" to '
|
||||||
f'activate and/or run the game.')
|
f'activate and/or run the game.')
|
||||||
exit(0)
|
exit(0)
|
||||||
|
@ -891,7 +924,7 @@ class LegendaryCLI:
|
||||||
if config_tags:
|
if config_tags:
|
||||||
self.core.lgd.config.remove_option(game.app_name, 'install_tags')
|
self.core.lgd.config.remove_option(game.app_name, 'install_tags')
|
||||||
config_tags = None
|
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
|
sdl_enabled = False
|
||||||
# just disable SDL, but keep config tags that have been manually specified
|
# just disable SDL, but keep config tags that have been manually specified
|
||||||
elif config_disable_sdl or args.disable_sdl:
|
elif config_disable_sdl or args.disable_sdl:
|
||||||
|
@ -945,7 +978,8 @@ class LegendaryCLI:
|
||||||
disable_delta=args.disable_delta,
|
disable_delta=args.disable_delta,
|
||||||
override_delta_manifest=args.override_delta_manifest,
|
override_delta_manifest=args.override_delta_manifest,
|
||||||
preferred_cdn=args.preferred_cdn,
|
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
|
# game is either up-to-date or hasn't changed, so we have nothing to do
|
||||||
if not analysis.dl_size:
|
if not analysis.dl_size:
|
||||||
|
@ -966,6 +1000,15 @@ class LegendaryCLI:
|
||||||
self.core.uninstall_tag(old_igame)
|
self.core.uninstall_tag(old_igame)
|
||||||
self.core.install_game(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)
|
exit(0)
|
||||||
|
|
||||||
logger.info(f'Install size: {analysis.install_size / 1024 / 1024:.02f} MiB')
|
logger.info(f'Install size: {analysis.install_size / 1024 / 1024:.02f} MiB')
|
||||||
|
@ -1029,7 +1072,7 @@ class LegendaryCLI:
|
||||||
|
|
||||||
postinstall = self.core.install_game(igame)
|
postinstall = self.core.install_game(igame)
|
||||||
if postinstall:
|
if postinstall:
|
||||||
self._handle_postinstall(postinstall, igame, yes=args.yes)
|
self._handle_postinstall(postinstall, igame, skip_prereqs=args.yes)
|
||||||
|
|
||||||
dlcs = self.core.get_dlc_for_game(game.app_name)
|
dlcs = self.core.get_dlc_for_game(game.app_name)
|
||||||
if dlcs and not args.skip_dlcs:
|
if dlcs and not args.skip_dlcs:
|
||||||
|
@ -1083,13 +1126,13 @@ class LegendaryCLI:
|
||||||
|
|
||||||
logger.info(f'Finished installation process in {end_t - start_t:.02f} seconds.')
|
logger.info(f'Finished installation process in {end_t - start_t:.02f} seconds.')
|
||||||
|
|
||||||
def _handle_postinstall(self, postinstall, igame, yes=False):
|
def _handle_postinstall(self, postinstall, igame, skip_prereqs=False):
|
||||||
print('\nThis game lists the following prequisites to be installed:')
|
print('\nThis game lists the following prerequisites to be installed:')
|
||||||
print(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}')
|
print(f'- {postinstall["name"]}: {" ".join((postinstall["path"], postinstall["args"]))}')
|
||||||
print('')
|
print('')
|
||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
if yes:
|
if skip_prereqs:
|
||||||
c = 'n' # we don't want to launch anything, just silent install.
|
c = 'n' # we don't want to launch anything, just silent install.
|
||||||
else:
|
else:
|
||||||
choice = input('Do you wish to install the prerequisites? ([y]es, [n]o, [i]gnore): ')
|
choice = input('Do you wish to install the prerequisites? ([y]es, [n]o, [i]gnore): ')
|
||||||
|
@ -1114,6 +1157,11 @@ class LegendaryCLI:
|
||||||
logger.info('Automatic installation not available on Linux.')
|
logger.info('Automatic installation not available on Linux.')
|
||||||
|
|
||||||
def uninstall_game(self, args):
|
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)
|
args.app_name = self._resolve_aliases(args.app_name)
|
||||||
igame = self.core.get_installed_game(args.app_name)
|
igame = self.core.get_installed_game(args.app_name)
|
||||||
if not igame:
|
if not igame:
|
||||||
|
@ -1125,6 +1173,9 @@ class LegendaryCLI:
|
||||||
print('Aborting...')
|
print('Aborting...')
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
|
if os.name == 'nt' and igame.uninstaller and not args.skip_uninstaller:
|
||||||
|
self._handle_uninstaller(igame, args.yes)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not igame.is_dlc:
|
if not igame.is_dlc:
|
||||||
# Remove DLC first so directory is empty when game uninstall runs
|
# Remove DLC first so directory is empty when game uninstall runs
|
||||||
|
@ -1141,6 +1192,23 @@ class LegendaryCLI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f'Removing game failed: {e!r}, please remove {igame.install_path} manually.')
|
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):
|
def verify_game(self, args, print_command=True, repair_mode=False, repair_online=False):
|
||||||
args.app_name = self._resolve_aliases(args.app_name)
|
args.app_name = self._resolve_aliases(args.app_name)
|
||||||
if not self.core.is_installed(args.app_name):
|
if not self.core.is_installed(args.app_name):
|
||||||
|
@ -1177,7 +1245,7 @@ class LegendaryCLI:
|
||||||
key=lambda a: a.filename.lower())
|
key=lambda a: a.filename.lower())
|
||||||
|
|
||||||
# build list of hashes
|
# build list of hashes
|
||||||
if config_tags := self.core.lgd.config.get(args.app_name, 'install_tags', fallback=None):
|
if (config_tags := self.core.lgd.config.get(args.app_name, 'install_tags', fallback=None)) is not None:
|
||||||
install_tags = set(i.strip() for i in config_tags.split(','))
|
install_tags = set(i.strip() for i in config_tags.split(','))
|
||||||
file_list = [
|
file_list = [
|
||||||
(f.filename, f.sha_hash.hex())
|
(f.filename, f.sha_hash.hex())
|
||||||
|
@ -1205,7 +1273,7 @@ class LegendaryCLI:
|
||||||
percentage = (processed / total_size) * 100.0
|
percentage = (processed / total_size) * 100.0
|
||||||
num += 1
|
num += 1
|
||||||
|
|
||||||
if (delta := ((current_time := time.time()) - last_update)) > 1 or (not last_processed and delta > 1):
|
if (delta := ((current_time := time.time()) - last_update)) > 1:
|
||||||
last_update = current_time
|
last_update = current_time
|
||||||
speed = (processed - last_processed) / 1024 / 1024 / delta
|
speed = (processed - last_processed) / 1024 / 1024 / delta
|
||||||
last_processed = processed
|
last_processed = processed
|
||||||
|
@ -1244,6 +1312,11 @@ class LegendaryCLI:
|
||||||
logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.')
|
logger.info(f'Run "legendary repair {args.app_name}" to repair your game installation.')
|
||||||
|
|
||||||
def import_game(self, args):
|
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
|
# make sure path is absolute
|
||||||
args.app_path = os.path.abspath(args.app_path)
|
args.app_path = os.path.abspath(args.app_path)
|
||||||
args.app_name = self._resolve_aliases(args.app_name)
|
args.app_name = self._resolve_aliases(args.app_name)
|
||||||
|
@ -1282,6 +1355,8 @@ class LegendaryCLI:
|
||||||
# get everything needed for import from core, then run additional checks.
|
# get everything needed for import from core, then run additional checks.
|
||||||
manifest, igame = self.core.import_game(game, args.app_path, platform=args.platform)
|
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('/'))
|
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
|
# check if most files at least exist or if user might have specified the wrong directory
|
||||||
total = len(manifest.file_manifest_list.elements)
|
total = len(manifest.file_manifest_list.elements)
|
||||||
found = sum(os.path.exists(os.path.join(args.app_path, f.filename))
|
found = sum(os.path.exists(os.path.join(args.app_path, f.filename))
|
||||||
|
@ -1337,6 +1412,11 @@ class LegendaryCLI:
|
||||||
logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.')
|
logger.info(f'{"DLC" if game.is_dlc else "Game"} "{game.app_title}" has been imported.')
|
||||||
|
|
||||||
def egs_sync(self, args):
|
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:
|
if args.unlink:
|
||||||
logger.info('Unlinking and resetting EGS and LGD sync...')
|
logger.info('Unlinking and resetting EGS and LGD sync...')
|
||||||
self.core.lgd.config.remove_option('Legendary', 'egl_programdata')
|
self.core.lgd.config.remove_option('Legendary', 'egl_programdata')
|
||||||
|
@ -1578,7 +1658,7 @@ class LegendaryCLI:
|
||||||
else:
|
else:
|
||||||
logger.info('Game not installed and offline mode enabled, cannot load manifest.')
|
logger.info('Game not installed and offline mode enabled, cannot load manifest.')
|
||||||
elif game:
|
elif game:
|
||||||
entitlements = self.core.egs.get_user_entitlements()
|
entitlements = self.core.egs.get_user_entitlements_full()
|
||||||
egl_meta = self.core.egs.get_game_info(game.namespace, game.catalog_item_id)
|
egl_meta = self.core.egs.get_game_info(game.namespace, game.catalog_item_id)
|
||||||
game.metadata = egl_meta
|
game.metadata = egl_meta
|
||||||
# Get manifest if asset exists for current platform
|
# Get manifest if asset exists for current platform
|
||||||
|
@ -1616,7 +1696,7 @@ class LegendaryCLI:
|
||||||
# Find custom launch options, if available
|
# Find custom launch options, if available
|
||||||
launch_options = []
|
launch_options = []
|
||||||
i = 1
|
i = 1
|
||||||
while f'extraLaunchOption_{i:03d}_Name' in game.metadata['customAttributes']:
|
while f'extraLaunchOption_{i:03d}_Name' in game.metadata.get('customAttributes', {}):
|
||||||
launch_options.append((
|
launch_options.append((
|
||||||
game.metadata['customAttributes'][f'extraLaunchOption_{i:03d}_Name']['value'],
|
game.metadata['customAttributes'][f'extraLaunchOption_{i:03d}_Name']['value'],
|
||||||
game.metadata['customAttributes'][f'extraLaunchOption_{i:03d}_Args']['value']
|
game.metadata['customAttributes'][f'extraLaunchOption_{i:03d}_Args']['value']
|
||||||
|
@ -1634,6 +1714,9 @@ class LegendaryCLI:
|
||||||
else:
|
else:
|
||||||
game_infos.append(InfoItem('Extra launch options', 'launch_options', None, []))
|
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
|
# list all owned DLC based on entitlements
|
||||||
if entitlements and not game.is_dlc:
|
if entitlements and not game.is_dlc:
|
||||||
owned_entitlements = {i['entitlementName'] for i in entitlements}
|
owned_entitlements = {i['entitlementName'] for i in entitlements}
|
||||||
|
@ -1644,18 +1727,18 @@ class LegendaryCLI:
|
||||||
if dlc['entitlementName'] in owned_entitlements:
|
if dlc['entitlementName'] in owned_entitlements:
|
||||||
owned_dlc.append((installable, None, dlc['title'], dlc['id']))
|
owned_dlc.append((installable, None, dlc['title'], dlc['id']))
|
||||||
elif installable:
|
elif installable:
|
||||||
app_name = dlc['releaseInfo'][0]['appId']
|
dlc_app_name = dlc['releaseInfo'][0]['appId']
|
||||||
if app_name in owned_app_names:
|
if dlc_app_name in owned_app_names:
|
||||||
owned_dlc.append((installable, app_name, dlc['title'], dlc['id']))
|
owned_dlc.append((installable, dlc_app_name, dlc['title'], dlc['id']))
|
||||||
|
|
||||||
if owned_dlc:
|
if owned_dlc:
|
||||||
human_list = []
|
human_list = []
|
||||||
json_list = []
|
json_list = []
|
||||||
for installable, app_name, title, dlc_id in owned_dlc:
|
for installable, dlc_app_name, title, dlc_id in owned_dlc:
|
||||||
json_list.append(dict(app_name=app_name, title=title,
|
json_list.append(dict(app_name=dlc_app_name, title=title,
|
||||||
installable=installable, id=dlc_id))
|
installable=installable, id=dlc_id))
|
||||||
if installable:
|
if installable:
|
||||||
human_list.append(f'App name: {app_name}, Title: "{title}"')
|
human_list.append(f'App name: {dlc_app_name}, Title: "{title}"')
|
||||||
else:
|
else:
|
||||||
human_list.append(f'Title: "{title}" (no installation required)')
|
human_list.append(f'Title: "{title}" (no installation required)')
|
||||||
game_infos.append(InfoItem('Owned DLC', 'owned_dlc', human_list, json_list))
|
game_infos.append(InfoItem('Owned DLC', 'owned_dlc', human_list, json_list))
|
||||||
|
@ -1740,6 +1823,17 @@ class LegendaryCLI:
|
||||||
else:
|
else:
|
||||||
manifest_info.append(InfoItem('Prerequisites', 'prerequisites', None, None))
|
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 = {''}
|
install_tags = {''}
|
||||||
for fm in manifest.file_manifest_list.elements:
|
for fm in manifest.file_manifest_list.elements:
|
||||||
for tag in fm.install_tags:
|
for tag in fm.install_tags:
|
||||||
|
@ -1953,7 +2047,7 @@ class LegendaryCLI:
|
||||||
redeemed = {k['gameId'] for k in key_list if k['redeemedOnUplay']}
|
redeemed = {k['gameId'] for k in key_list if k['redeemedOnUplay']}
|
||||||
|
|
||||||
games = self.core.get_game_list()
|
games = self.core.get_game_list()
|
||||||
entitlements = self.core.egs.get_user_entitlements()
|
entitlements = self.core.egs.get_user_entitlements_full()
|
||||||
owned_entitlements = {i['entitlementName'] for i in entitlements}
|
owned_entitlements = {i['entitlementName'] for i in entitlements}
|
||||||
|
|
||||||
uplay_games = []
|
uplay_games = []
|
||||||
|
@ -2032,7 +2126,7 @@ class LegendaryCLI:
|
||||||
logger.info('Redeemed all outstanding Uplay codes.')
|
logger.info('Redeemed all outstanding Uplay codes.')
|
||||||
elif args.origin:
|
elif args.origin:
|
||||||
na_games, _ = self.core.get_non_asset_library_items(skip_ue=True)
|
na_games, _ = self.core.get_non_asset_library_items(skip_ue=True)
|
||||||
origin_games = [game for game in na_games if game.third_party_store == 'Origin']
|
origin_games = [game for game in na_games if game.is_origin_game]
|
||||||
|
|
||||||
if not origin_games:
|
if not origin_games:
|
||||||
logger.info('No redeemable games found.')
|
logger.info('No redeemable games found.')
|
||||||
|
@ -2219,20 +2313,19 @@ class LegendaryCLI:
|
||||||
print('Aborting...')
|
print('Aborting...')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info('Deleting overlay installation...')
|
||||||
|
self.core.remove_overlay_install()
|
||||||
|
|
||||||
if os.name != 'nt' and not prefix:
|
if os.name != 'nt' and not prefix:
|
||||||
logger.info('Registry entries in prefixes (if any) have not been removed. '
|
logger.info('Registry entries in prefixes (if any) have not been removed. '
|
||||||
f'This shouldn\'t cause any issues as the overlay will simply fail to load.')
|
f'This shouldn\'t cause any issues as the overlay will simply fail to load.')
|
||||||
return
|
else:
|
||||||
|
logger.info('Removing registry entries...')
|
||||||
|
remove_registry_entries(prefix)
|
||||||
|
|
||||||
logger.info('Removing registry entries...')
|
if os.name != 'nt':
|
||||||
remove_registry_entries(prefix)
|
logger.info(f'Registry entries in prefixes other than "{prefix}" were not removed. '
|
||||||
|
f'This shouldn\'t cause any issues as the overlay will simply fail to load.')
|
||||||
if os.name != 'nt':
|
|
||||||
logger.info(f'Registry entries in prefixes other than "{prefix}" were not removed. '
|
|
||||||
f'This shouldn\'t cause any issues as the overlay will simply fail to load.')
|
|
||||||
|
|
||||||
logger.info('Deleting overlay installation...')
|
|
||||||
self.core.remove_overlay_install()
|
|
||||||
logger.info('Done.')
|
logger.info('Done.')
|
||||||
|
|
||||||
elif args.action in {'install', 'update'}:
|
elif args.action in {'install', 'update'}:
|
||||||
|
@ -2493,6 +2586,11 @@ class LegendaryCLI:
|
||||||
logger.info('Saved choices to configuration.')
|
logger.info('Saved choices to configuration.')
|
||||||
|
|
||||||
def move(self, args):
|
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)
|
app_name = self._resolve_aliases(args.app_name)
|
||||||
igame = self.core.get_installed_game(app_name, skip_sync=True)
|
igame = self.core.get_installed_game(app_name, skip_sync=True)
|
||||||
if not igame:
|
if not igame:
|
||||||
|
@ -2530,6 +2628,10 @@ class LegendaryCLI:
|
||||||
|
|
||||||
|
|
||||||
def main():
|
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 = argparse.ArgumentParser(description=f'Legendary v{__version__} - "{__codename__}"')
|
||||||
parser.register('action', 'parsers', HiddenAliasSubparsersAction)
|
parser.register('action', 'parsers', HiddenAliasSubparsersAction)
|
||||||
|
|
||||||
|
@ -2621,8 +2723,10 @@ def main():
|
||||||
# Flags
|
# Flags
|
||||||
auth_parser.add_argument('--import', dest='import_egs_auth', action='store_true',
|
auth_parser.add_argument('--import', dest='import_egs_auth', action='store_true',
|
||||||
help='Import Epic Games Launcher authentication data (logs out of EGL)')
|
help='Import Epic Games Launcher authentication data (logs out of EGL)')
|
||||||
auth_parser.add_argument('--code', dest='auth_code', action='store', metavar='<exchange code>',
|
auth_parser.add_argument('--code', dest='auth_code', action='store', metavar='<authorization code>',
|
||||||
help='Use specified exchange code instead of interactive authentication')
|
help='Use specified authorization code instead of interactive authentication')
|
||||||
|
auth_parser.add_argument('--token', dest='ex_token', action='store', metavar='<exchange token>',
|
||||||
|
help='Use specified exchange token instead of interactive authentication')
|
||||||
auth_parser.add_argument('--sid', dest='session_id', action='store', metavar='<session id>',
|
auth_parser.add_argument('--sid', dest='session_id', action='store', metavar='<session id>',
|
||||||
help='Use specified session id instead of interactive authentication')
|
help='Use specified session id instead of interactive authentication')
|
||||||
auth_parser.add_argument('--delete', dest='auth_delete', action='store_true',
|
auth_parser.add_argument('--delete', dest='auth_delete', action='store_true',
|
||||||
|
@ -2693,9 +2797,13 @@ def main():
|
||||||
help='Automatically install all DLCs with the base game')
|
help='Automatically install all DLCs with the base game')
|
||||||
install_parser.add_argument('--skip-dlcs', dest='skip_dlcs', action='store_true',
|
install_parser.add_argument('--skip-dlcs', dest='skip_dlcs', action='store_true',
|
||||||
help='Do not ask about installing DLCs.')
|
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',
|
uninstall_parser.add_argument('--keep-files', dest='keep_files', action='store_true',
|
||||||
help='Keep files but remove game from Legendary database')
|
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',
|
launch_parser.add_argument('--offline', dest='offline', action='store_true',
|
||||||
default=False, help='Skip login and launch game without online authentication')
|
default=False, help='Skip login and launch game without online authentication')
|
||||||
|
@ -2803,6 +2911,8 @@ def main():
|
||||||
help='Override savegame path (requires single app name to be specified)')
|
help='Override savegame path (requires single app name to be specified)')
|
||||||
sync_saves_parser.add_argument('--disable-filters', dest='disable_filters', action='store_true',
|
sync_saves_parser.add_argument('--disable-filters', dest='disable_filters', action='store_true',
|
||||||
help='Disable save game file filtering')
|
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',
|
clean_saves_parser.add_argument('--delete-incomplete', dest='delete_incomplete', action='store_true',
|
||||||
help='Delete incomplete save files')
|
help='Delete incomplete save files')
|
||||||
|
@ -2925,7 +3035,7 @@ def main():
|
||||||
print(f'\nCommand: {choice}')
|
print(f'\nCommand: {choice}')
|
||||||
print(subparser.format_help())
|
print(subparser.format_help())
|
||||||
elif os.name == 'nt':
|
elif os.name == 'nt':
|
||||||
from legendary.utils.windows_helpers import double_clicked
|
from legendary.lfs.windows_helpers import double_clicked
|
||||||
if double_clicked():
|
if double_clicked():
|
||||||
print('Please note that this is not the intended way to run Legendary.')
|
print('Please note that this is not the intended way to run Legendary.')
|
||||||
print('Follow https://github.com/derrod/legendary/wiki/Setup-Instructions to set it up properly')
|
print('Follow https://github.com/derrod/legendary/wiki/Setup-Instructions to set it up properly')
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
@ -15,10 +13,10 @@ from locale import getdefaultlocale
|
||||||
from multiprocessing import Queue
|
from multiprocessing import Queue
|
||||||
from platform import system
|
from platform import system
|
||||||
from requests import session
|
from requests import session
|
||||||
from requests.exceptions import HTTPError
|
from requests.exceptions import HTTPError, ConnectionError
|
||||||
from sys import platform as sys_platform
|
from sys import platform as sys_platform
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from urllib.parse import urlencode, parse_qsl
|
from urllib.parse import urlencode, parse_qsl, urlparse
|
||||||
|
|
||||||
from legendary import __version__
|
from legendary import __version__
|
||||||
from legendary.api.egs import EPCAPI
|
from legendary.api.egs import EPCAPI
|
||||||
|
@ -26,7 +24,7 @@ from legendary.api.lgd import LGDAPI
|
||||||
from legendary.downloader.mp.manager import DLManager
|
from legendary.downloader.mp.manager import DLManager
|
||||||
from legendary.lfs.egl import EPCLFS
|
from legendary.lfs.egl import EPCLFS
|
||||||
from legendary.lfs.lgndry import LGDLFS
|
from legendary.lfs.lgndry import LGDLFS
|
||||||
from legendary.utils.lfs import clean_filename, delete_folder, delete_filelist, get_dir_size
|
from legendary.lfs.utils import clean_filename, delete_folder, delete_filelist, get_dir_size
|
||||||
from legendary.models.downloading import AnalysisResult, ConditionCheckResult
|
from legendary.models.downloading import AnalysisResult, ConditionCheckResult
|
||||||
from legendary.models.egl import EGLManifest
|
from legendary.models.egl import EGLManifest
|
||||||
from legendary.models.exceptions import *
|
from legendary.models.exceptions import *
|
||||||
|
@ -34,15 +32,14 @@ from legendary.models.game import *
|
||||||
from legendary.models.json_manifest import JSONManifest
|
from legendary.models.json_manifest import JSONManifest
|
||||||
from legendary.models.manifest import Manifest, ManifestMeta
|
from legendary.models.manifest import Manifest, ManifestMeta
|
||||||
from legendary.models.chunk import Chunk
|
from legendary.models.chunk import Chunk
|
||||||
from legendary.utils.crossover import *
|
from legendary.lfs.crossover import *
|
||||||
from legendary.utils.egl_crypt import decrypt_epic_data
|
from legendary.utils.egl_crypt import decrypt_epic_data
|
||||||
from legendary.utils.env import is_windows_mac_or_pyi
|
from legendary.utils.env import is_windows_mac_or_pyi
|
||||||
from legendary.utils.eos import EOSOverlayApp, query_registry_entries
|
from legendary.lfs.eos import EOSOverlayApp, query_registry_entries
|
||||||
from legendary.utils.game_workarounds import is_opt_enabled, update_workarounds, get_exe_override
|
from legendary.utils.game_workarounds import is_opt_enabled, update_workarounds, get_exe_override
|
||||||
from legendary.utils.savegame_helper import SaveGameHelper
|
from legendary.utils.savegame_helper import SaveGameHelper
|
||||||
from legendary.utils.selective_dl import games as sdl_games
|
from legendary.utils.selective_dl import games as sdl_games
|
||||||
from legendary.utils.manifests import combine_manifests
|
from legendary.lfs.wine_helpers import read_registry, get_shell_folders, case_insensitive_path_search
|
||||||
from legendary.utils.wine_helpers import read_registry, get_shell_folders, case_insensitive_path_search
|
|
||||||
|
|
||||||
|
|
||||||
# ToDo: instead of true/false return values for success/failure actually raise an exception that the CLI/GUI
|
# ToDo: instead of true/false return values for success/failure actually raise an exception that the CLI/GUI
|
||||||
|
@ -77,7 +74,8 @@ class LegendaryCore:
|
||||||
self.local_timezone = datetime.now().astimezone().tzinfo
|
self.local_timezone = datetime.now().astimezone().tzinfo
|
||||||
self.language_code, self.country_code = ('en', 'US')
|
self.language_code, self.country_code = ('en', 'US')
|
||||||
|
|
||||||
if locale := self.lgd.config.get('Legendary', 'locale', fallback=getdefaultlocale()[0]):
|
if locale := self.lgd.config.get('Legendary', 'locale',
|
||||||
|
fallback=getdefaultlocale(('LANG', 'LANGUAGE', 'LC_ALL', 'LC_CTYPE'))[0]):
|
||||||
try:
|
try:
|
||||||
self.language_code, self.country_code = locale.split('-' if '-' in locale else '_')
|
self.language_code, self.country_code = locale.split('-' if '-' in locale else '_')
|
||||||
self.log.debug(f'Set locale to {self.language_code}-{self.country_code}')
|
self.log.debug(f'Set locale to {self.language_code}-{self.country_code}')
|
||||||
|
@ -86,7 +84,7 @@ class LegendaryCore:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f'Getting locale failed: {e!r}, falling back to using en-US.')
|
self.log.warning(f'Getting locale failed: {e!r}, falling back to using en-US.')
|
||||||
elif system() != 'Darwin': # macOS doesn't have a default locale we can query
|
elif system() != 'Darwin': # macOS doesn't have a default locale we can query
|
||||||
self.log.warning(f'Could not determine locale, falling back to en-US')
|
self.log.warning('Could not determine locale, falling back to en-US')
|
||||||
|
|
||||||
self.update_available = False
|
self.update_available = False
|
||||||
self.force_show_update = False
|
self.force_show_update = False
|
||||||
|
@ -124,16 +122,29 @@ class LegendaryCore:
|
||||||
|
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
return r.json()['code']
|
return r.json()['code']
|
||||||
else:
|
|
||||||
self.log.error(f'Getting exchange code failed: {r.json()}')
|
self.log.error(f'Getting exchange code failed: {r.json()}')
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def auth_code(self, code) -> bool:
|
def auth_code(self, code) -> bool:
|
||||||
"""
|
"""
|
||||||
Handles authentication via exchange code (either retrieved manually or automatically)
|
Handles authentication via authorization code (either retrieved manually or automatically)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.lgd.userdata = self.egs.start_session(exchange_token=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.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def auth_ex_token(self, code) -> bool:
|
||||||
|
"""
|
||||||
|
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)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f'Logging in failed with {e!r}, please try again.')
|
self.log.error(f'Logging in failed with {e!r}, please try again.')
|
||||||
|
@ -162,22 +173,23 @@ class LegendaryCore:
|
||||||
raise ValueError('No login session in config')
|
raise ValueError('No login session in config')
|
||||||
refresh_token = re_data['Token']
|
refresh_token = re_data['Token']
|
||||||
try:
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f'Logging in failed with {e!r}, please try again.')
|
self.log.error(f'Logging in failed with {e!r}, please try again.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def login(self, force_refresh=False) -> bool:
|
def _login(self, lock, force_refresh=False) -> bool:
|
||||||
"""
|
"""
|
||||||
Attempts logging in with existing credentials.
|
Attempts logging in with existing credentials.
|
||||||
|
|
||||||
raises ValueError if no existing credentials or InvalidCredentialsError if the API return an error
|
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')
|
raise ValueError('No saved credentials')
|
||||||
elif self.logged_in and self.lgd.userdata['expires_at']:
|
elif self.logged_in and lock.data['expires_at']:
|
||||||
dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1])
|
dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
|
||||||
dt_now = datetime.utcnow()
|
dt_now = datetime.utcnow()
|
||||||
td = dt_now - dt_exp
|
td = dt_now - dt_exp
|
||||||
|
|
||||||
|
@ -203,8 +215,8 @@ class LegendaryCore:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f'Checking for EOS Overlay updates failed: {e!r}')
|
self.log.warning(f'Checking for EOS Overlay updates failed: {e!r}')
|
||||||
|
|
||||||
if self.lgd.userdata['expires_at'] and not force_refresh:
|
if lock.data['expires_at'] and not force_refresh:
|
||||||
dt_exp = datetime.fromisoformat(self.lgd.userdata['expires_at'][:-1])
|
dt_exp = datetime.fromisoformat(lock.data['expires_at'][:-1])
|
||||||
dt_now = datetime.utcnow()
|
dt_now = datetime.utcnow()
|
||||||
td = dt_now - dt_exp
|
td = dt_now - dt_exp
|
||||||
|
|
||||||
|
@ -212,7 +224,7 @@ class LegendaryCore:
|
||||||
if dt_exp > dt_now and abs(td.total_seconds()) > 600:
|
if dt_exp > dt_now and abs(td.total_seconds()) > 600:
|
||||||
self.log.info('Trying to re-use existing login session...')
|
self.log.info('Trying to re-use existing login session...')
|
||||||
try:
|
try:
|
||||||
self.egs.resume_session(self.lgd.userdata)
|
self.egs.resume_session(lock.data)
|
||||||
self.logged_in = True
|
self.logged_in = True
|
||||||
return True
|
return True
|
||||||
except InvalidCredentialsError as e:
|
except InvalidCredentialsError as e:
|
||||||
|
@ -224,19 +236,23 @@ class LegendaryCore:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.log.info('Logging in...')
|
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:
|
except InvalidCredentialsError:
|
||||||
self.log.error('Stored credentials are no longer valid! Please login again.')
|
self.log.error('Stored credentials are no longer valid! Please login again.')
|
||||||
self.lgd.invalidate_userdata()
|
lock.clear()
|
||||||
return False
|
return False
|
||||||
except HTTPError as e:
|
except (HTTPError, ConnectionError) as e:
|
||||||
self.log.error(f'HTTP request for login failed: {e!r}, please try again later.')
|
self.log.error(f'HTTP request for login failed: {e!r}, please try again later.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.lgd.userdata = userdata
|
lock.data = userdata
|
||||||
self.logged_in = True
|
self.logged_in = True
|
||||||
return 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):
|
def update_check_enabled(self):
|
||||||
return not self.lgd.config.getboolean('Legendary', 'disable_update_check', fallback=False)
|
return not self.lgd.config.getboolean('Legendary', 'disable_update_check', fallback=False)
|
||||||
|
|
||||||
|
@ -264,10 +280,10 @@ class LegendaryCore:
|
||||||
"""Applies configuration options returned by update API"""
|
"""Applies configuration options returned by update API"""
|
||||||
if not version_info:
|
if not version_info:
|
||||||
version_info = self.lgd.get_cached_version()['data']
|
version_info = self.lgd.get_cached_version()['data']
|
||||||
# if cached data is invalid
|
# if cached data is invalid
|
||||||
if not version_info:
|
if not version_info:
|
||||||
self.log.debug('No cached legendary config to apply.')
|
self.log.debug('No cached legendary config to apply.')
|
||||||
return
|
return
|
||||||
|
|
||||||
if 'egl_config' in version_info:
|
if 'egl_config' in version_info:
|
||||||
self.egs.update_egs_params(version_info['egl_config'])
|
self.egs.update_egs_params(version_info['egl_config'])
|
||||||
|
@ -285,6 +301,9 @@ class LegendaryCore:
|
||||||
if lgd_config := version_info.get('legendary_config'):
|
if lgd_config := version_info.get('legendary_config'):
|
||||||
self.webview_killswitch = lgd_config.get('webview_killswitch', False)
|
self.webview_killswitch = lgd_config.get('webview_killswitch', False)
|
||||||
|
|
||||||
|
def get_egl_version(self):
|
||||||
|
return self._egl_version
|
||||||
|
|
||||||
def get_update_info(self):
|
def get_update_info(self):
|
||||||
return self.lgd.get_cached_version()['data'].get('release_info')
|
return self.lgd.get_cached_version()['data'].get('release_info')
|
||||||
|
|
||||||
|
@ -332,10 +351,7 @@ class LegendaryCore:
|
||||||
if not self.egs.user:
|
if not self.egs.user:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if self.lgd.assets:
|
assets = self.lgd.assets.copy() if self.lgd.assets else dict()
|
||||||
assets = self.lgd.assets.copy()
|
|
||||||
else:
|
|
||||||
assets = dict()
|
|
||||||
|
|
||||||
assets.update({
|
assets.update({
|
||||||
platform: [
|
platform: [
|
||||||
|
@ -415,25 +431,46 @@ class LegendaryCore:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
game = self.lgd.get_game_meta(app_name)
|
game = self.lgd.get_game_meta(app_name)
|
||||||
asset_updated = False
|
asset_updated = sidecar_updated = False
|
||||||
if game:
|
if game:
|
||||||
asset_updated = any(game.app_version(_p) != app_assets[_p].build_version for _p in app_assets.keys())
|
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
|
games[app_name] = game
|
||||||
|
|
||||||
if update_assets and (not game or force_refresh or (game and asset_updated)):
|
if update_assets and (not game or force_refresh or (game and (asset_updated or sidecar_updated))):
|
||||||
self.log.debug(f'Scheduling metadata update for {app_name}')
|
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
|
# namespace/catalog item are the same for all platforms, so we can just use the first one
|
||||||
_ga = next(iter(app_assets.values()))
|
_ga = next(iter(app_assets.values()))
|
||||||
fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id))
|
fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id, sidecar_updated))
|
||||||
meta_updated = True
|
meta_updated = True
|
||||||
|
|
||||||
def fetch_game_meta(args):
|
def fetch_game_meta(args):
|
||||||
app_name, namespace, catalog_item_id = args
|
app_name, namespace, catalog_item_id, update_sidecar = args
|
||||||
eg_meta = self.egs.get_game_info(namespace, catalog_item_id, timeout=10.0)
|
eg_meta = self.egs.get_game_info(namespace, catalog_item_id, timeout=10.0)
|
||||||
game = Game(app_name=app_name, app_title=eg_meta['title'], metadata=eg_meta, asset_infos=assets[app_name])
|
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)
|
||||||
self.lgd.set_game_meta(game.app_name, game)
|
self.lgd.set_game_meta(game.app_name, game)
|
||||||
games[app_name] = game
|
games[app_name] = game
|
||||||
still_needs_update.remove(app_name)
|
try:
|
||||||
|
still_needs_update.remove(app_name)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
# setup and teardown of thread pool takes some time, so only do it when it makes sense.
|
# 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}
|
still_needs_update = {e[0] for e in fetch_list}
|
||||||
|
@ -454,10 +491,10 @@ class LegendaryCore:
|
||||||
if use_threads:
|
if use_threads:
|
||||||
self.log.warning(f'Fetching metadata for {app_name} failed, retrying')
|
self.log.warning(f'Fetching metadata for {app_name} failed, retrying')
|
||||||
_ga = next(iter(app_assets.values()))
|
_ga = next(iter(app_assets.values()))
|
||||||
fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id))
|
fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id, True))
|
||||||
game = games[app_name]
|
game = games[app_name]
|
||||||
|
|
||||||
if game.is_dlc:
|
if game.is_dlc and platform in app_assets:
|
||||||
_dlc[game.metadata['mainGameItem']['id']].append(game)
|
_dlc[game.metadata['mainGameItem']['id']].append(game)
|
||||||
elif not any(i['path'] == 'mods' for i in game.metadata.get('categories', [])) and platform in app_assets:
|
elif not any(i['path'] == 'mods' for i in game.metadata.get('categories', [])) and platform in app_assets:
|
||||||
_ret.append(game)
|
_ret.append(game)
|
||||||
|
@ -501,12 +538,18 @@ class LegendaryCore:
|
||||||
_dlc = defaultdict(list)
|
_dlc = defaultdict(list)
|
||||||
# get all the appnames we have to ignore
|
# get all the appnames we have to ignore
|
||||||
ignore = set(i.app_name for i in self.get_assets())
|
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():
|
for libitem in self.egs.get_library_items():
|
||||||
if libitem['namespace'] == 'ue' and skip_ue:
|
if libitem['namespace'] == 'ue' and skip_ue:
|
||||||
continue
|
continue
|
||||||
|
if 'appName' not in libitem:
|
||||||
|
continue
|
||||||
if libitem['appName'] in ignore:
|
if libitem['appName'] in ignore:
|
||||||
continue
|
continue
|
||||||
|
if libitem['sandboxType'] == 'PRIVATE':
|
||||||
|
continue
|
||||||
|
|
||||||
game = self.lgd.get_game_meta(libitem['appName'])
|
game = self.lgd.get_game_meta(libitem['appName'])
|
||||||
if not game or force_refresh:
|
if not game or force_refresh:
|
||||||
|
@ -569,9 +612,17 @@ class LegendaryCore:
|
||||||
# get environment overrides from config
|
# get environment overrides from config
|
||||||
env = dict()
|
env = dict()
|
||||||
if 'default.env' in self.lgd.config:
|
if 'default.env' in self.lgd.config:
|
||||||
env.update({k: v for k, v in self.lgd.config[f'default.env'].items() if v and not k.startswith(';')})
|
env |= {
|
||||||
|
k: v
|
||||||
|
for k, v in self.lgd.config['default.env'].items()
|
||||||
|
if v and not k.startswith(';')
|
||||||
|
}
|
||||||
if f'{app_name}.env' in self.lgd.config:
|
if f'{app_name}.env' in self.lgd.config:
|
||||||
env.update({k: v for k, v in self.lgd.config[f'{app_name}.env'].items() if v and not k.startswith(';')})
|
env |= {
|
||||||
|
k: v
|
||||||
|
for k, v in self.lgd.config[f'{app_name}.env'].items()
|
||||||
|
if v and not k.startswith(';')
|
||||||
|
}
|
||||||
|
|
||||||
if disable_wine:
|
if disable_wine:
|
||||||
return env
|
return env
|
||||||
|
@ -658,9 +709,10 @@ class LegendaryCore:
|
||||||
disable_wine: bool = False,
|
disable_wine: bool = False,
|
||||||
executable_override: str = None,
|
executable_override: str = None,
|
||||||
crossover_app: str = None,
|
crossover_app: str = None,
|
||||||
crossover_bottle: str = None) -> LaunchParameters:
|
crossover_bottle: str = None,
|
||||||
|
addon_app_name: str = None) -> LaunchParameters:
|
||||||
install = self.lgd.get_installed_game(app_name)
|
install = self.lgd.get_installed_game(app_name)
|
||||||
game = self.lgd.get_game_meta(app_name)
|
game = self.lgd.get_game_meta(addon_app_name if addon_app_name else app_name)
|
||||||
|
|
||||||
# Disable wine for non-Windows executables (e.g. native macOS)
|
# Disable wine for non-Windows executables (e.g. native macOS)
|
||||||
if not install.platform.startswith('Win'):
|
if not install.platform.startswith('Win'):
|
||||||
|
@ -702,6 +754,13 @@ class LegendaryCore:
|
||||||
self.log.warning(f'Parsing predefined launch parameters failed with: {e!r}, '
|
self.log.warning(f'Parsing predefined launch parameters failed with: {e!r}, '
|
||||||
f'input: {install.launch_parameters}')
|
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 = ''
|
game_token = ''
|
||||||
if not offline:
|
if not offline:
|
||||||
self.log.info('Getting authentication token...')
|
self.log.info('Getting authentication token...')
|
||||||
|
@ -709,10 +768,8 @@ class LegendaryCore:
|
||||||
elif not install.can_run_offline:
|
elif not install.can_run_offline:
|
||||||
self.log.warning('Game is not approved for offline use and may not work correctly.')
|
self.log.warning('Game is not approved for offline use and may not work correctly.')
|
||||||
|
|
||||||
user_name = self.lgd.userdata['displayName']
|
|
||||||
account_id = self.lgd.userdata['account_id']
|
account_id = self.lgd.userdata['account_id']
|
||||||
if user:
|
user_name = user or self.lgd.userdata['displayName']
|
||||||
user_name = user
|
|
||||||
|
|
||||||
params.egl_parameters.extend([
|
params.egl_parameters.extend([
|
||||||
'-AUTH_LOGIN=unused',
|
'-AUTH_LOGIN=unused',
|
||||||
|
@ -741,6 +798,10 @@ class LegendaryCore:
|
||||||
f'-epicsandboxid={game.namespace}'
|
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:
|
if extra_args:
|
||||||
params.user_parameters.extend(extra_args)
|
params.user_parameters.extend(extra_args)
|
||||||
|
|
||||||
|
@ -750,10 +811,7 @@ class LegendaryCore:
|
||||||
return params
|
return params
|
||||||
|
|
||||||
def get_origin_uri(self, app_name: str, offline: bool = False) -> str:
|
def get_origin_uri(self, app_name: str, offline: bool = False) -> str:
|
||||||
if offline:
|
token = '0' if offline else self.egs.get_game_token()['code']
|
||||||
token = '0'
|
|
||||||
else:
|
|
||||||
token = self.egs.get_game_token()['code']
|
|
||||||
|
|
||||||
user_name = self.lgd.userdata['displayName']
|
user_name = self.lgd.userdata['displayName']
|
||||||
account_id = self.lgd.userdata['account_id']
|
account_id = self.lgd.userdata['account_id']
|
||||||
|
@ -809,18 +867,20 @@ class LegendaryCore:
|
||||||
}
|
}
|
||||||
|
|
||||||
if sys_platform == 'win32':
|
if sys_platform == 'win32':
|
||||||
path_vars.update({
|
path_vars |= {
|
||||||
'{appdata}': os.path.expandvars('%LOCALAPPDATA%'),
|
'{appdata}': os.path.expandvars('%LOCALAPPDATA%'),
|
||||||
'{userdir}': os.path.expandvars('%userprofile%/documents'),
|
'{userdir}': os.path.expandvars('%userprofile%/documents'),
|
||||||
'{userprofile}': os.path.expandvars('%userprofile%'),
|
'{userprofile}': os.path.expandvars('%userprofile%'),
|
||||||
'{usersavedgames}': os.path.expandvars('%userprofile%/Saved Games')
|
'{usersavedgames}': os.path.expandvars('%userprofile%/Saved Games'),
|
||||||
})
|
}
|
||||||
elif sys_platform == 'darwin' and platform == 'Mac':
|
elif sys_platform == 'darwin' and platform == 'Mac':
|
||||||
path_vars.update({
|
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'),
|
'{appdata}': os.path.expanduser('~/Library/Application Support'),
|
||||||
'{userdir}': os.path.expanduser('~/Documents'),
|
'{userdir}': os.path.expanduser('~/Documents'),
|
||||||
'{userlibrary}': os.path.expanduser('~/Library')
|
'{userlibrary}': os.path.expanduser('~/Library'),
|
||||||
})
|
}
|
||||||
else:
|
else:
|
||||||
wine_pfx = None
|
wine_pfx = None
|
||||||
# on mac CrossOver takes precedence so check for a bottle first
|
# on mac CrossOver takes precedence so check for a bottle first
|
||||||
|
@ -851,6 +911,18 @@ class LegendaryCore:
|
||||||
wine_pfx = self.lgd.config.get('default.env', 'WINEPREFIX', fallback=None)
|
wine_pfx = self.lgd.config.get('default.env', 'WINEPREFIX', fallback=None)
|
||||||
wine_pfx = self.lgd.config.get('default', 'wine_prefix', fallback=wine_pfx)
|
wine_pfx = self.lgd.config.get('default', 'wine_prefix', fallback=wine_pfx)
|
||||||
|
|
||||||
|
# If we still didn't find anything, try to read the prefix from the environment variables of this process
|
||||||
|
if not wine_pfx and sys_platform == 'darwin':
|
||||||
|
cx_bottle = os.getenv('CX_BOTTLE')
|
||||||
|
if cx_bottle and mac_is_valid_bottle(cx_bottle):
|
||||||
|
wine_pfx = mac_get_bottle_path(cx_bottle)
|
||||||
|
|
||||||
|
if not wine_pfx:
|
||||||
|
if proton_pfx := os.getenv('STEAM_COMPAT_DATA_PATH'):
|
||||||
|
wine_pfx = f'{proton_pfx}/pfx'
|
||||||
|
else:
|
||||||
|
wine_pfx = os.getenv('WINEPREFIX', wine_pfx)
|
||||||
|
|
||||||
# if all else fails, use the WINE default
|
# if all else fails, use the WINE default
|
||||||
if not wine_pfx:
|
if not wine_pfx:
|
||||||
wine_pfx = os.path.expanduser('~/.wine')
|
wine_pfx = os.path.expanduser('~/.wine')
|
||||||
|
@ -976,9 +1048,22 @@ class LegendaryCore:
|
||||||
if not os.path.exists(_save_dir):
|
if not os.path.exists(_save_dir):
|
||||||
os.makedirs(_save_dir)
|
os.makedirs(_save_dir)
|
||||||
|
|
||||||
if clean_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)
|
||||||
self.log.info('Deleting old save files...')
|
self.log.info('Deleting old save files...')
|
||||||
delete_folder(_save_dir)
|
delete_filelist(_save_dir, save_files, silent=True)
|
||||||
|
|
||||||
self.log.info(f'Downloading "{fname.split("/", 2)[2]}"...')
|
self.log.info(f'Downloading "{fname.split("/", 2)[2]}"...')
|
||||||
# download manifest
|
# download manifest
|
||||||
|
@ -1089,7 +1174,7 @@ class LegendaryCore:
|
||||||
missing_chunks += 1
|
missing_chunks += 1
|
||||||
|
|
||||||
if (0 < missing_chunks < total_chunks and delete_incomplete) or missing_chunks == total_chunks:
|
if (0 < missing_chunks < total_chunks and delete_incomplete) or missing_chunks == total_chunks:
|
||||||
self.log.error(f'Chunk(s) missing, marking manifest for deletion.')
|
self.log.error('Chunk(s) missing, marking manifest for deletion.')
|
||||||
deletion_list.append(fname)
|
deletion_list.append(fname)
|
||||||
continue
|
continue
|
||||||
elif 0 < missing_chunks < total_chunks:
|
elif 0 < missing_chunks < total_chunks:
|
||||||
|
@ -1131,10 +1216,7 @@ class LegendaryCore:
|
||||||
|
|
||||||
for ass in self.get_assets(True):
|
for ass in self.get_assets(True):
|
||||||
if ass.app_name == app_name:
|
if ass.app_name == app_name:
|
||||||
if ass.build_version != installed.version:
|
return ass.build_version == installed.version
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
# if we get here something is very wrong
|
# if we get here something is very wrong
|
||||||
raise ValueError(f'Could not find {app_name} in asset list!')
|
raise ValueError(f'Could not find {app_name} in asset list!')
|
||||||
|
|
||||||
|
@ -1145,10 +1227,10 @@ class LegendaryCore:
|
||||||
return self._get_installed_game(app_name) is not None
|
return self._get_installed_game(app_name) is not None
|
||||||
|
|
||||||
def is_dlc(self, app_name: str) -> bool:
|
def is_dlc(self, app_name: str) -> bool:
|
||||||
meta = self.lgd.get_game_meta(app_name)
|
if meta := self.lgd.get_game_meta(app_name):
|
||||||
if not meta:
|
return meta.is_dlc
|
||||||
|
else:
|
||||||
raise ValueError('Game unknown!')
|
raise ValueError('Game unknown!')
|
||||||
return meta.is_dlc
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_manifest(data: bytes) -> Manifest:
|
def load_manifest(data: bytes) -> Manifest:
|
||||||
|
@ -1188,19 +1270,34 @@ class LegendaryCore:
|
||||||
|
|
||||||
def get_cdn_manifest(self, game, platform='Windows', disable_https=False):
|
def get_cdn_manifest(self, game, platform='Windows', disable_https=False):
|
||||||
manifest_urls, base_urls, manifest_hash = self.get_cdn_urls(game, platform)
|
manifest_urls, base_urls, manifest_hash = self.get_cdn_urls(game, platform)
|
||||||
|
if not manifest_urls:
|
||||||
|
raise ValueError('No manifest URLs returned by API')
|
||||||
|
|
||||||
if disable_https:
|
if disable_https:
|
||||||
manifest_urls = [url.replace('https://', 'http://') for url in manifest_urls]
|
manifest_urls = [url.replace('https://', 'http://') for url in manifest_urls]
|
||||||
|
|
||||||
self.log.debug(f'Downloading manifest from {manifest_urls[0]} ...')
|
for url in manifest_urls:
|
||||||
r = self.egs.unauth_session.get(manifest_urls[0])
|
self.log.debug(f'Trying to download manifest from "{url}"...')
|
||||||
r.raise_for_status()
|
try:
|
||||||
manifest_bytes = r.content
|
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
|
||||||
|
|
||||||
|
if r.status_code == 200:
|
||||||
|
manifest_bytes = r.content
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.log.warning(f'Unable to download manifest from "{urlparse(url).netloc}" '
|
||||||
|
f'(status: {r.status_code}), trying next URL...')
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unable to get manifest from any CDN URL, last result: {r.status_code} ({r.reason})')
|
||||||
|
|
||||||
if sha1(manifest_bytes).hexdigest() != manifest_hash:
|
if sha1(manifest_bytes).hexdigest() != manifest_hash:
|
||||||
raise ValueError('Manifest sha hash mismatch!')
|
raise ValueError('Manifest sha hash mismatch!')
|
||||||
|
|
||||||
return r.content, base_urls
|
return manifest_bytes, base_urls
|
||||||
|
|
||||||
def get_uri_manifest(self, uri):
|
def get_uri_manifest(self, uri):
|
||||||
if uri.startswith('http'):
|
if uri.startswith('http'):
|
||||||
|
@ -1221,10 +1318,7 @@ class LegendaryCore:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
r = self.egs.unauth_session.get(f'{base_url}/Deltas/{new_build_id}/{old_build_id}.delta')
|
r = self.egs.unauth_session.get(f'{base_url}/Deltas/{new_build_id}/{old_build_id}.delta')
|
||||||
if r.status_code == 200:
|
return r.content if r.status_code == 200 else None
|
||||||
return r.content
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def prepare_download(self, game: Game, base_game: Game = None, base_path: str = '',
|
def prepare_download(self, game: Game, base_game: Game = None, base_path: str = '',
|
||||||
status_q: Queue = None, max_shm: int = 0, max_workers: int = 0,
|
status_q: Queue = None, max_shm: int = 0, max_workers: int = 0,
|
||||||
|
@ -1237,7 +1331,7 @@ class LegendaryCore:
|
||||||
repair: bool = False, repair_use_latest: bool = False,
|
repair: bool = False, repair_use_latest: bool = False,
|
||||||
disable_delta: bool = False, override_delta_manifest: str = '',
|
disable_delta: bool = False, override_delta_manifest: str = '',
|
||||||
egl_guid: str = '', preferred_cdn: str = None,
|
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):
|
||||||
# load old manifest
|
# load old manifest
|
||||||
old_manifest = None
|
old_manifest = None
|
||||||
|
|
||||||
|
@ -1300,7 +1394,7 @@ class LegendaryCore:
|
||||||
self.log.info(f'Using optimized delta manifest to upgrade from build '
|
self.log.info(f'Using optimized delta manifest to upgrade from build '
|
||||||
f'"{old_manifest.meta.build_id}" to '
|
f'"{old_manifest.meta.build_id}" to '
|
||||||
f'"{new_manifest.meta.build_id}"...')
|
f'"{new_manifest.meta.build_id}"...')
|
||||||
combine_manifests(new_manifest, delta_manifest)
|
new_manifest.apply_delta_manifest(delta_manifest)
|
||||||
else:
|
else:
|
||||||
self.log.debug(f'No Delta manifest received from CDN.')
|
self.log.debug(f'No Delta manifest received from CDN.')
|
||||||
|
|
||||||
|
@ -1338,7 +1432,7 @@ class LegendaryCore:
|
||||||
self.log.info(f'"{base_path}" does not exist, creating...')
|
self.log.info(f'"{base_path}" does not exist, creating...')
|
||||||
os.makedirs(base_path)
|
os.makedirs(base_path)
|
||||||
|
|
||||||
install_path = os.path.normpath(os.path.join(base_path, game_folder))
|
install_path = os.path.normpath(os.path.join(base_path, game_folder.strip()))
|
||||||
|
|
||||||
# check for write access on the install path or its parent directory if it doesn't exist yet
|
# 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)
|
base_path = os.path.dirname(install_path)
|
||||||
|
@ -1404,7 +1498,7 @@ class LegendaryCore:
|
||||||
|
|
||||||
dlm = DLManager(install_path, base_url, resume_file=resume_file, status_q=status_q,
|
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,
|
max_shared_memory=max_shm * 1024 * 1024, max_workers=max_workers,
|
||||||
dl_timeout=dl_timeout)
|
dl_timeout=dl_timeout, bind_ip=bind_ip)
|
||||||
anlres = dlm.run_analysis(manifest=new_manifest, old_manifest=old_manifest,
|
anlres = dlm.run_analysis(manifest=new_manifest, old_manifest=old_manifest,
|
||||||
patch=not disable_patching, resume=not force,
|
patch=not disable_patching, resume=not force,
|
||||||
file_prefix_filter=file_prefix_filter,
|
file_prefix_filter=file_prefix_filter,
|
||||||
|
@ -1417,8 +1511,13 @@ class LegendaryCore:
|
||||||
prereq = dict(ids=new_manifest.meta.prereq_ids, name=new_manifest.meta.prereq_name,
|
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)
|
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')
|
offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
|
||||||
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
|
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false').lower()
|
||||||
|
|
||||||
if file_install_tag is None:
|
if file_install_tag is None:
|
||||||
file_install_tag = []
|
file_install_tag = []
|
||||||
|
@ -1440,7 +1539,7 @@ class LegendaryCore:
|
||||||
can_run_offline=offline == 'true', requires_ot=ot == 'true',
|
can_run_offline=offline == 'true', requires_ot=ot == 'true',
|
||||||
is_dlc=base_game is not None, install_size=anlres.install_size,
|
is_dlc=base_game is not None, install_size=anlres.install_size,
|
||||||
egl_guid=egl_guid, install_tags=file_install_tag,
|
egl_guid=egl_guid, install_tags=file_install_tag,
|
||||||
platform=platform)
|
platform=platform, uninstaller=uninstaller)
|
||||||
|
|
||||||
return dlm, anlres, igame
|
return dlm, anlres, igame
|
||||||
|
|
||||||
|
@ -1541,6 +1640,21 @@ class LegendaryCore:
|
||||||
results.warnings.add('You may want to consider trying one of the following executables '
|
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}')
|
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
|
return results
|
||||||
|
|
||||||
def get_default_install_dir(self, platform='Windows'):
|
def get_default_install_dir(self, platform='Windows'):
|
||||||
|
@ -1669,7 +1783,7 @@ class LegendaryCore:
|
||||||
path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args)
|
path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args)
|
||||||
|
|
||||||
offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
|
offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
|
||||||
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
|
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false').lower()
|
||||||
igame = InstalledGame(app_name=game.app_name, title=game.app_title, prereq_info=prereq, base_urls=base_urls,
|
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,
|
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',
|
executable=new_manifest.meta.launch_exe, can_run_offline=offline == 'true',
|
||||||
|
@ -1694,6 +1808,9 @@ class LegendaryCore:
|
||||||
def egl_import(self, app_name):
|
def egl_import(self, app_name):
|
||||||
if not self.asset_valid(app_name):
|
if not self.asset_valid(app_name):
|
||||||
raise ValueError(f'To-be-imported game {app_name} not in game asset database!')
|
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')
|
self.log.debug(f'Importing "{app_name}" from EGL')
|
||||||
# load egl json file
|
# load egl json file
|
||||||
|
@ -1741,9 +1858,12 @@ class LegendaryCore:
|
||||||
|
|
||||||
# mark game as installed
|
# mark game as installed
|
||||||
_ = self._install_game(lgd_igame)
|
_ = self._install_game(lgd_igame)
|
||||||
return
|
|
||||||
|
|
||||||
def egl_export(self, app_name):
|
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')
|
self.log.debug(f'Exporting "{app_name}" to EGL')
|
||||||
# load igame/game
|
# load igame/game
|
||||||
lgd_game = self.get_game(app_name)
|
lgd_game = self.get_game(app_name)
|
||||||
|
@ -1805,6 +1925,10 @@ class LegendaryCore:
|
||||||
"""
|
"""
|
||||||
Sync game installs between Legendary and the Epic Games Launcher
|
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
|
# read egl json files
|
||||||
if app_name:
|
if app_name:
|
||||||
lgd_igame = self._get_installed_game(app_name)
|
lgd_igame = self._get_installed_game(app_name)
|
||||||
|
|
|
@ -22,14 +22,14 @@ from legendary.models.manifest import ManifestComparison, Manifest
|
||||||
class DLManager(Process):
|
class DLManager(Process):
|
||||||
def __init__(self, download_dir, base_url, cache_dir=None, status_q=None,
|
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_workers=0, update_interval=1.0, dl_timeout=10, resume_file=None,
|
||||||
max_shared_memory=1024 * 1024 * 1024):
|
max_shared_memory=1024 * 1024 * 1024, bind_ip=None):
|
||||||
super().__init__(name='DLManager')
|
super().__init__(name='DLManager')
|
||||||
self.log = logging.getLogger('DLM')
|
self.log = logging.getLogger('DLM')
|
||||||
self.proc_debug = False
|
self.proc_debug = False
|
||||||
|
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.dl_dir = download_dir
|
self.dl_dir = download_dir
|
||||||
self.cache_dir = cache_dir if cache_dir else os.path.join(download_dir, '.cache')
|
self.cache_dir = cache_dir or os.path.join(download_dir, '.cache')
|
||||||
|
|
||||||
# All the queues!
|
# All the queues!
|
||||||
self.logging_queue = None
|
self.logging_queue = None
|
||||||
|
@ -37,8 +37,11 @@ class DLManager(Process):
|
||||||
self.writer_queue = None
|
self.writer_queue = None
|
||||||
self.dl_result_q = None
|
self.dl_result_q = None
|
||||||
self.writer_result_q = None
|
self.writer_result_q = None
|
||||||
self.max_workers = max_workers if max_workers else min(cpu_count() * 2, 16)
|
|
||||||
|
# Worker stuff
|
||||||
|
self.max_workers = max_workers or min(cpu_count() * 2, 16)
|
||||||
self.dl_timeout = dl_timeout
|
self.dl_timeout = dl_timeout
|
||||||
|
self.bind_ips = [] if not bind_ip else bind_ip.split(',')
|
||||||
|
|
||||||
# Analysis stuff
|
# Analysis stuff
|
||||||
self.analysis = None
|
self.analysis = None
|
||||||
|
@ -137,6 +140,24 @@ class DLManager(Process):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f'Reading resume file failed: {e!r}, continuing as normal...')
|
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
|
# Install tags are used for selective downloading, e.g. for language packs
|
||||||
additional_deletion_tasks = []
|
additional_deletion_tasks = []
|
||||||
if file_install_tag is not None:
|
if file_install_tag is not None:
|
||||||
|
@ -637,10 +658,15 @@ class DLManager(Process):
|
||||||
self.writer_result_q = MPQueue(-1)
|
self.writer_result_q = MPQueue(-1)
|
||||||
|
|
||||||
self.log.info(f'Starting download workers...')
|
self.log.info(f'Starting download workers...')
|
||||||
|
|
||||||
|
bind_ip = None
|
||||||
for i in range(self.max_workers):
|
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,
|
w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, self.dl_result_q,
|
||||||
self.shared_memory.name, logging_queue=self.logging_queue,
|
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)
|
self.children.append(w)
|
||||||
w.start()
|
w.start()
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import requests
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -10,6 +9,9 @@ from multiprocessing import Process
|
||||||
from multiprocessing.shared_memory import SharedMemory
|
from multiprocessing.shared_memory import SharedMemory
|
||||||
from queue import Empty
|
from queue import Empty
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK
|
||||||
|
|
||||||
from legendary.models.chunk import Chunk
|
from legendary.models.chunk import Chunk
|
||||||
from legendary.models.downloading import (
|
from legendary.models.downloading import (
|
||||||
DownloaderTask, DownloaderTaskResult,
|
DownloaderTask, DownloaderTaskResult,
|
||||||
|
@ -18,9 +20,22 @@ 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):
|
class DLWorker(Process):
|
||||||
def __init__(self, name, queue, out_queue, shm, max_retries=7,
|
def __init__(self, name, queue, out_queue, shm, max_retries=7,
|
||||||
logging_queue=None, dl_timeout=10):
|
logging_queue=None, dl_timeout=10, bind_addr=None):
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.q = queue
|
self.q = queue
|
||||||
self.o_q = out_queue
|
self.o_q = out_queue
|
||||||
|
@ -34,6 +49,12 @@ class DLWorker(Process):
|
||||||
self.logging_queue = logging_queue
|
self.logging_queue = logging_queue
|
||||||
self.dl_timeout = float(dl_timeout) if dl_timeout else 10.0
|
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):
|
def run(self):
|
||||||
# we have to fix up the logger before we can start
|
# we have to fix up the logger before we can start
|
||||||
_root = logging.getLogger()
|
_root = logging.getLogger()
|
||||||
|
@ -51,12 +72,12 @@ class DLWorker(Process):
|
||||||
empty = False
|
empty = False
|
||||||
except Empty:
|
except Empty:
|
||||||
if not empty:
|
if not empty:
|
||||||
logger.debug(f'Queue Empty, waiting for more...')
|
logger.debug('Queue Empty, waiting for more...')
|
||||||
empty = True
|
empty = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(job, TerminateWorkerTask): # let worker die
|
if isinstance(job, TerminateWorkerTask): # let worker die
|
||||||
logger.debug(f'Worker received termination signal, shutting down...')
|
logger.debug('Worker received termination signal, shutting down...')
|
||||||
break
|
break
|
||||||
|
|
||||||
tries = 0
|
tries = 0
|
||||||
|
@ -99,17 +120,18 @@ class DLWorker(Process):
|
||||||
break
|
break
|
||||||
|
|
||||||
if not chunk:
|
if not chunk:
|
||||||
logger.warning(f'Chunk somehow None?')
|
logger.warning('Chunk somehow None?')
|
||||||
self.o_q.put(DownloaderTaskResult(success=False, **job.__dict__))
|
self.o_q.put(DownloaderTaskResult(success=False, **job.__dict__))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# decompress stuff
|
# decompress stuff
|
||||||
try:
|
try:
|
||||||
size = len(chunk.data)
|
data = chunk.data
|
||||||
|
size = len(data)
|
||||||
if size > job.shm.size:
|
if size > job.shm.size:
|
||||||
logger.fatal(f'Downloaded chunk is longer than SharedMemorySegment!')
|
logger.fatal('Downloaded chunk is longer than SharedMemorySegment!')
|
||||||
|
|
||||||
self.shm.buf[job.shm.offset:job.shm.offset + size] = bytes(chunk.data)
|
self.shm.buf[job.shm.offset:job.shm.offset + size] = data
|
||||||
del chunk
|
del chunk
|
||||||
self.o_q.put(DownloaderTaskResult(success=True, size_decompressed=size,
|
self.o_q.put(DownloaderTaskResult(success=True, size_decompressed=size,
|
||||||
size_downloaded=compressed, **job.__dict__))
|
size_downloaded=compressed, **job.__dict__))
|
||||||
|
@ -130,7 +152,7 @@ class FileWorker(Process):
|
||||||
self.q = queue
|
self.q = queue
|
||||||
self.o_q = out_queue
|
self.o_q = out_queue
|
||||||
self.base_path = base_path
|
self.base_path = base_path
|
||||||
self.cache_path = cache_path if cache_path else os.path.join(base_path, '.cache')
|
self.cache_path = cache_path or os.path.join(base_path, '.cache')
|
||||||
self.shm = SharedMemory(name=shm)
|
self.shm = SharedMemory(name=shm)
|
||||||
self.log_level = logging.getLogger().level
|
self.log_level = logging.getLogger().level
|
||||||
self.logging_queue = logging_queue
|
self.logging_queue = logging_queue
|
||||||
|
@ -143,7 +165,7 @@ class FileWorker(Process):
|
||||||
|
|
||||||
logger = logging.getLogger(self.name)
|
logger = logging.getLogger(self.name)
|
||||||
logger.setLevel(self.log_level)
|
logger.setLevel(self.log_level)
|
||||||
logger.debug(f'Download worker reporting for duty!')
|
logger.debug('Download worker reporting for duty!')
|
||||||
|
|
||||||
last_filename = ''
|
last_filename = ''
|
||||||
current_file = None
|
current_file = None
|
||||||
|
@ -159,7 +181,7 @@ class FileWorker(Process):
|
||||||
if isinstance(j, TerminateWorkerTask):
|
if isinstance(j, TerminateWorkerTask):
|
||||||
if current_file:
|
if current_file:
|
||||||
current_file.close()
|
current_file.close()
|
||||||
logger.debug(f'Worker received termination signal, shutting down...')
|
logger.debug('Worker received termination signal, shutting down...')
|
||||||
# send termination task to results halnder as well
|
# send termination task to results halnder as well
|
||||||
self.o_q.put(TerminateWorkerTask())
|
self.o_q.put(TerminateWorkerTask())
|
||||||
break
|
break
|
||||||
|
@ -250,7 +272,7 @@ class FileWorker(Process):
|
||||||
if j.shared_memory:
|
if j.shared_memory:
|
||||||
shm_offset = j.shared_memory.offset + j.chunk_offset
|
shm_offset = j.shared_memory.offset + j.chunk_offset
|
||||||
shm_end = shm_offset + j.chunk_size
|
shm_end = shm_offset + j.chunk_size
|
||||||
current_file.write(self.shm.buf[shm_offset:shm_end].tobytes())
|
current_file.write(self.shm.buf[shm_offset:shm_end])
|
||||||
elif j.cache_file:
|
elif j.cache_file:
|
||||||
with open(os.path.join(self.cache_path, j.cache_file), 'rb') as f:
|
with open(os.path.join(self.cache_path, j.cache_file), 'rb') as f:
|
||||||
if j.chunk_offset:
|
if j.chunk_offset:
|
||||||
|
|
|
@ -4,7 +4,7 @@ import logging
|
||||||
from legendary.models.game import Game
|
from legendary.models.game import Game
|
||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
from legendary.utils.windows_helpers import *
|
from legendary.lfs.windows_helpers import *
|
||||||
|
|
||||||
logger = logging.getLogger('EOSUtils')
|
logger = logging.getLogger('EOSUtils')
|
||||||
# Dummy Game objects to use with Core methods that expect them
|
# Dummy Game objects to use with Core methods that expect them
|
|
@ -4,22 +4,31 @@ import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
|
||||||
|
from .utils import clean_filename, LockedJSONData
|
||||||
|
|
||||||
from legendary.models.game import *
|
from legendary.models.game import *
|
||||||
from legendary.utils.aliasing import generate_aliases
|
from legendary.utils.aliasing import generate_aliases
|
||||||
from legendary.utils.config import LGDConf
|
from legendary.models.config import LGDConf
|
||||||
from legendary.utils.env import is_windows_mac_or_pyi
|
from legendary.utils.env import is_windows_mac_or_pyi
|
||||||
from legendary.utils.lfs import clean_filename
|
|
||||||
|
|
||||||
|
FILELOCK_DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
class LGDLFS:
|
class LGDLFS:
|
||||||
def __init__(self, config_file=None):
|
def __init__(self, config_file=None):
|
||||||
self.log = logging.getLogger('LGDLFS')
|
self.log = logging.getLogger('LGDLFS')
|
||||||
|
|
||||||
if config_path := os.environ.get('XDG_CONFIG_HOME'):
|
if config_path := os.environ.get('LEGENDARY_CONFIG_PATH'):
|
||||||
|
self.path = config_path
|
||||||
|
elif config_path := os.environ.get('XDG_CONFIG_HOME'):
|
||||||
self.path = os.path.join(config_path, 'legendary')
|
self.path = os.path.join(config_path, 'legendary')
|
||||||
else:
|
else:
|
||||||
self.path = os.path.expanduser('~/.config/legendary')
|
self.path = os.path.expanduser('~/.config/legendary')
|
||||||
|
@ -83,13 +92,18 @@ class LGDLFS:
|
||||||
self.log.warning(f'Removing "{os.path.join(self.path, "manifests", "old")}" folder failed: '
|
self.log.warning(f'Removing "{os.path.join(self.path, "manifests", "old")}" folder failed: '
|
||||||
f'{e!r}, please remove manually')
|
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 loading config
|
||||||
try:
|
try:
|
||||||
self.config.read(self.config_path)
|
self.config.read(self.config_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f'Unable to read configuration file, please ensure that file is valid! '
|
self.log.error(f'Unable to read configuration file, please ensure that file is valid! '
|
||||||
f'(Error: {repr(e)})')
|
f'(Error: {repr(e)})')
|
||||||
self.log.warning(f'Continuing with blank config in safe-mode...')
|
self.log.warning('Continuing with blank config in safe-mode...')
|
||||||
self.config.read_only = True
|
self.config.read_only = True
|
||||||
|
|
||||||
# make sure "Legendary" section exists
|
# make sure "Legendary" section exists
|
||||||
|
@ -104,6 +118,8 @@ class LGDLFS:
|
||||||
self.config.set('Legendary', '; Disables the notice about an available update on exit')
|
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.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:
|
try:
|
||||||
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
|
self._installed = json.load(open(os.path.join(self.path, 'installed.json')))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -129,31 +145,35 @@ class LGDLFS:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f'Loading aliases failed with {e!r}')
|
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
|
@property
|
||||||
def userdata(self):
|
def userdata(self):
|
||||||
if self._user_data is not None:
|
if self._user_data is not None:
|
||||||
return self._user_data
|
return self._user_data
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._user_data = json.load(open(os.path.join(self.path, 'user.json')))
|
with self.userdata_lock as locked:
|
||||||
return self._user_data
|
return locked.data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f'Failed to load user data: {e!r}')
|
self.log.debug(f'Failed to load user data: {e!r}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@userdata.setter
|
@userdata.setter
|
||||||
def userdata(self, userdata):
|
def userdata(self, userdata):
|
||||||
if userdata is None:
|
raise NotImplementedError('The setter has been removed, use the locked userdata instead.')
|
||||||
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):
|
def invalidate_userdata(self):
|
||||||
self._user_data = None
|
with self.userdata_lock as lock:
|
||||||
if os.path.exists(os.path.join(self.path, 'user.json')):
|
lock.clear()
|
||||||
os.remove(os.path.join(self.path, 'user.json'))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entitlements(self):
|
def entitlements(self):
|
||||||
|
@ -220,8 +240,7 @@ class LGDLFS:
|
||||||
f.write(manifest_data)
|
f.write(manifest_data)
|
||||||
|
|
||||||
def get_game_meta(self, app_name):
|
def get_game_meta(self, app_name):
|
||||||
_meta = self._game_metadata.get(app_name, None)
|
if _meta := self._game_metadata.get(app_name, None):
|
||||||
if _meta:
|
|
||||||
return Game.from_json(_meta)
|
return Game.from_json(_meta)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -232,14 +251,14 @@ class LGDLFS:
|
||||||
json.dump(json_meta, open(meta_file, 'w'), indent=2, sort_keys=True)
|
json.dump(json_meta, open(meta_file, 'w'), indent=2, sort_keys=True)
|
||||||
|
|
||||||
def delete_game_meta(self, app_name):
|
def delete_game_meta(self, app_name):
|
||||||
if app_name in self._game_metadata:
|
if app_name not in self._game_metadata:
|
||||||
del self._game_metadata[app_name]
|
|
||||||
meta_file = os.path.join(self.path, 'metadata', f'{app_name}.json')
|
|
||||||
if os.path.exists(meta_file):
|
|
||||||
os.remove(meta_file)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Game {app_name} does not exist in metadata DB!')
|
raise ValueError(f'Game {app_name} does not exist in metadata DB!')
|
||||||
|
|
||||||
|
del self._game_metadata[app_name]
|
||||||
|
meta_file = os.path.join(self.path, 'metadata', f'{app_name}.json')
|
||||||
|
if os.path.exists(meta_file):
|
||||||
|
os.remove(meta_file)
|
||||||
|
|
||||||
def get_game_app_names(self):
|
def get_game_app_names(self):
|
||||||
return sorted(self._game_metadata.keys())
|
return sorted(self._game_metadata.keys())
|
||||||
|
|
||||||
|
@ -263,9 +282,16 @@ class LGDLFS:
|
||||||
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
||||||
|
|
||||||
def clean_manifests(self, in_use):
|
def clean_manifests(self, in_use):
|
||||||
in_use_files = set(f'{clean_filename(f"{app_name}_{version}")}.manifest' for app_name, version, _ in in_use)
|
in_use_files = {
|
||||||
in_use_files |= set(f'{clean_filename(f"{app_name}_{platform}_{version}")}.manifest'
|
f'{clean_filename(f"{app_name}_{version}")}.manifest'
|
||||||
for app_name, version, platform in in_use)
|
for app_name, version, _ in in_use
|
||||||
|
}
|
||||||
|
|
||||||
|
in_use_files |= {
|
||||||
|
f'{clean_filename(f"{app_name}_{platform}_{version}")}.manifest'
|
||||||
|
for app_name, version, platform in in_use
|
||||||
|
}
|
||||||
|
|
||||||
for f in os.listdir(os.path.join(self.path, 'manifests')):
|
for f in os.listdir(os.path.join(self.path, 'manifests')):
|
||||||
if f not in in_use_files:
|
if f not in in_use_files:
|
||||||
try:
|
try:
|
||||||
|
@ -273,6 +299,27 @@ class LGDLFS:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f'Failed to delete file "{f}": {e!r}')
|
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):
|
def get_installed_game(self, app_name):
|
||||||
if self._installed is None:
|
if self._installed is None:
|
||||||
try:
|
try:
|
||||||
|
@ -281,8 +328,7 @@ class LGDLFS:
|
||||||
self.log.debug(f'Failed to load installed game data: {e!r}')
|
self.log.debug(f'Failed to load installed game data: {e!r}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
game_json = self._installed.get(app_name, None)
|
if game_json := self._installed.get(app_name, None):
|
||||||
if game_json:
|
|
||||||
return InstalledGame.from_json(game_json)
|
return InstalledGame.from_json(game_json)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -391,7 +437,7 @@ class LGDLFS:
|
||||||
def get_overlay_install_info(self):
|
def get_overlay_install_info(self):
|
||||||
if not self._overlay_install_info:
|
if not self._overlay_install_info:
|
||||||
try:
|
try:
|
||||||
data = json.load(open(os.path.join(self.path, f'overlay_install.json')))
|
data = json.load(open(os.path.join(self.path, 'overlay_install.json')))
|
||||||
self._overlay_install_info = InstalledGame.from_json(data)
|
self._overlay_install_info = InstalledGame.from_json(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.debug(f'Failed to load overlay install data: {e!r}')
|
self.log.debug(f'Failed to load overlay install data: {e!r}')
|
||||||
|
@ -439,9 +485,7 @@ class LGDLFS:
|
||||||
|
|
||||||
def serialise_sets(obj):
|
def serialise_sets(obj):
|
||||||
"""Turn sets into sorted lists for storage"""
|
"""Turn sets into sorted lists for storage"""
|
||||||
if isinstance(obj, set):
|
return sorted(obj) if isinstance(obj, set) else obj
|
||||||
return sorted(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
json.dump(alias_map, open(os.path.join(self.path, 'aliases.json'), 'w', newline='\n'),
|
json.dump(alias_map, open(os.path.join(self.path, 'aliases.json'), 'w', newline='\n'),
|
||||||
indent=2, sort_keys=True, default=serialise_sets)
|
indent=2, sort_keys=True, default=serialise_sets)
|
||||||
|
|
|
@ -3,13 +3,16 @@
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stdout
|
from sys import stdout
|
||||||
from time import time
|
from time import perf_counter
|
||||||
from typing import List, Iterator
|
from typing import List, Iterator
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
|
||||||
from legendary.models.game import VerifyResult
|
from legendary.models.game import VerifyResult
|
||||||
|
|
||||||
logger = logging.getLogger('LFS Utils')
|
logger = logging.getLogger('LFS Utils')
|
||||||
|
@ -115,7 +118,7 @@ def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1',
|
||||||
stdout.write('\n')
|
stdout.write('\n')
|
||||||
show_progress = True
|
show_progress = True
|
||||||
interval = (_size / (1024 * 1024)) // 100
|
interval = (_size / (1024 * 1024)) // 100
|
||||||
start_time = time()
|
start_time = perf_counter()
|
||||||
|
|
||||||
with open(full_path, 'rb') as f:
|
with open(full_path, 'rb') as f:
|
||||||
real_file_hash = hashlib.new(hash_type)
|
real_file_hash = hashlib.new(hash_type)
|
||||||
|
@ -125,7 +128,7 @@ def validate_files(base_path: str, filelist: List[tuple], hash_type='sha1',
|
||||||
if show_progress and i % interval == 0:
|
if show_progress and i % interval == 0:
|
||||||
pos = f.tell()
|
pos = f.tell()
|
||||||
perc = (pos / _size) * 100
|
perc = (pos / _size) * 100
|
||||||
speed = pos / 1024 / 1024 / (time() - start_time)
|
speed = pos / 1024 / 1024 / (perf_counter() - start_time)
|
||||||
stdout.write(f'\r=> Verifying large file "{file_path}": {perc:.0f}% '
|
stdout.write(f'\r=> Verifying large file "{file_path}": {perc:.0f}% '
|
||||||
f'({pos / 1024 / 1024:.1f}/{_size / 1024 / 1024:.1f} MiB) '
|
f'({pos / 1024 / 1024:.1f}/{_size / 1024 / 1024:.1f} MiB) '
|
||||||
f'[{speed:.1f} MiB/s]\t')
|
f'[{speed:.1f} MiB/s]\t')
|
||||||
|
@ -153,3 +156,45 @@ def clean_filename(filename):
|
||||||
|
|
||||||
def get_dir_size(path):
|
def get_dir_size(path):
|
||||||
return sum(f.stat().st_size for f in Path(path).glob('**/*') if f.is_file())
|
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,6 +20,37 @@ def get_shell_folders(registry, wine_pfx):
|
||||||
return folders
|
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):
|
def case_insensitive_path_search(path):
|
||||||
"""
|
"""
|
||||||
Attempts to find a path case-insensitively
|
Attempts to find a path case-insensitively
|
|
@ -113,10 +113,7 @@ class Chunk:
|
||||||
return _chunk
|
return _chunk
|
||||||
|
|
||||||
def write(self, fp=None, compress=True):
|
def write(self, fp=None, compress=True):
|
||||||
if not fp:
|
bio = fp or BytesIO()
|
||||||
bio = BytesIO()
|
|
||||||
else:
|
|
||||||
bio = fp
|
|
||||||
|
|
||||||
self.uncompressed_size = self.compressed_size = len(self.data)
|
self.uncompressed_size = self.compressed_size = len(self.data)
|
||||||
if compress or self.compressed:
|
if compress or self.compressed:
|
||||||
|
@ -143,7 +140,4 @@ class Chunk:
|
||||||
# finally, add the data
|
# finally, add the data
|
||||||
bio.write(self._data)
|
bio.write(self._data)
|
||||||
|
|
||||||
if not fp:
|
return bio.tell() if fp else bio.getvalue()
|
||||||
return bio.getvalue()
|
|
||||||
else:
|
|
||||||
return bio.tell()
|
|
||||||
|
|
|
@ -145,9 +145,9 @@ class EGLManifest:
|
||||||
tmp.executable = igame.executable
|
tmp.executable = igame.executable
|
||||||
tmp.main_game_appname = game.app_name # todo for DLC support this needs to be the base game
|
tmp.main_game_appname = game.app_name # todo for DLC support this needs to be the base game
|
||||||
tmp.app_folder_name = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', '')
|
tmp.app_folder_name = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', '')
|
||||||
tmp.manifest_location = igame.install_path + '/.egstore'
|
tmp.manifest_location = f'{igame.install_path}/.egstore'
|
||||||
tmp.ownership_token = igame.requires_ot
|
tmp.ownership_token = igame.requires_ot
|
||||||
tmp.staging_location = igame.install_path + '/.egstore/bps'
|
tmp.staging_location = f'{igame.install_path}/.egstore/bps'
|
||||||
tmp.can_run_offline = igame.can_run_offline
|
tmp.can_run_offline = igame.can_run_offline
|
||||||
tmp.is_incomplete_install = False
|
tmp.is_incomplete_install = False
|
||||||
tmp.needs_validation = igame.needs_verification
|
tmp.needs_validation = igame.needs_verification
|
||||||
|
|
|
@ -18,6 +18,7 @@ class GameAsset:
|
||||||
label_name: str = ''
|
label_name: str = ''
|
||||||
namespace: str = ''
|
namespace: str = ''
|
||||||
metadata: Dict = field(default_factory=dict)
|
metadata: Dict = field(default_factory=dict)
|
||||||
|
sidecar_rev: int = 0
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_egs_json(cls, json):
|
def from_egs_json(cls, json):
|
||||||
|
@ -29,6 +30,7 @@ class GameAsset:
|
||||||
tmp.label_name = json.get('labelName', '')
|
tmp.label_name = json.get('labelName', '')
|
||||||
tmp.namespace = json.get('namespace', '')
|
tmp.namespace = json.get('namespace', '')
|
||||||
tmp.metadata = json.get('metadata', {})
|
tmp.metadata = json.get('metadata', {})
|
||||||
|
tmp.sidecar_rev = json.get('sidecarRvn', 0)
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -41,9 +43,26 @@ class GameAsset:
|
||||||
tmp.label_name = json.get('label_name', '')
|
tmp.label_name = json.get('label_name', '')
|
||||||
tmp.namespace = json.get('namespace', '')
|
tmp.namespace = json.get('namespace', '')
|
||||||
tmp.metadata = json.get('metadata', {})
|
tmp.metadata = json.get('metadata', {})
|
||||||
|
tmp.sidecar_rev = json.get('sidecar_rev', 0)
|
||||||
return tmp
|
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
|
@dataclass
|
||||||
class Game:
|
class Game:
|
||||||
"""
|
"""
|
||||||
|
@ -55,6 +74,7 @@ class Game:
|
||||||
asset_infos: Dict[str, GameAsset] = field(default_factory=dict)
|
asset_infos: Dict[str, GameAsset] = field(default_factory=dict)
|
||||||
base_urls: List[str] = field(default_factory=list)
|
base_urls: List[str] = field(default_factory=list)
|
||||||
metadata: Dict = field(default_factory=dict)
|
metadata: Dict = field(default_factory=dict)
|
||||||
|
sidecar: Optional[Sidecar] = None
|
||||||
|
|
||||||
def app_version(self, platform='Windows'):
|
def app_version(self, platform='Windows'):
|
||||||
if platform not in self.asset_infos:
|
if platform not in self.asset_infos:
|
||||||
|
@ -66,7 +86,11 @@ class Game:
|
||||||
return self.metadata and 'mainGameItem' in self.metadata
|
return self.metadata and 'mainGameItem' in self.metadata
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def third_party_store(self):
|
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]:
|
||||||
if not self.metadata:
|
if not self.metadata:
|
||||||
return None
|
return None
|
||||||
return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None)
|
return self.metadata.get('customAttributes', {}).get('ThirdPartyManagedApp', {}).get('value', None)
|
||||||
|
@ -91,6 +115,18 @@ class Game:
|
||||||
def supports_mac_cloud_saves(self):
|
def supports_mac_cloud_saves(self):
|
||||||
return self.metadata and (self.metadata.get('customAttributes', {}).get('CloudSaveFolder_MAC') is not None)
|
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
|
@property
|
||||||
def catalog_item_id(self):
|
def catalog_item_id(self):
|
||||||
if not self.metadata:
|
if not self.metadata:
|
||||||
|
@ -116,6 +152,9 @@ class Game:
|
||||||
# Migrate old asset_info to new asset_infos
|
# Migrate old asset_info to new asset_infos
|
||||||
tmp.asset_infos['Windows'] = GameAsset.from_json(json.get('asset_info', dict()))
|
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())
|
tmp.base_urls = json.get('base_urls', list())
|
||||||
return tmp
|
return tmp
|
||||||
|
|
||||||
|
@ -123,8 +162,9 @@ class Game:
|
||||||
def __dict__(self):
|
def __dict__(self):
|
||||||
"""This is just here so asset_infos gets turned into a dict as well"""
|
"""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()}
|
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,
|
return dict(metadata=self.metadata, asset_infos=assets_dictified, app_name=self.app_name,
|
||||||
app_title=self.app_title, base_urls=self.base_urls)
|
app_title=self.app_title, base_urls=self.base_urls, sidecar=sidecar_dictified)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -149,6 +189,7 @@ class InstalledGame:
|
||||||
needs_verification: bool = False
|
needs_verification: bool = False
|
||||||
platform: str = 'Windows'
|
platform: str = 'Windows'
|
||||||
prereq_info: Optional[Dict] = None
|
prereq_info: Optional[Dict] = None
|
||||||
|
uninstaller: Optional[Dict] = None
|
||||||
requires_ot: bool = False
|
requires_ot: bool = False
|
||||||
save_path: Optional[str] = None
|
save_path: Optional[str] = None
|
||||||
|
|
||||||
|
@ -165,6 +206,7 @@ class InstalledGame:
|
||||||
tmp.executable = json.get('executable', '')
|
tmp.executable = json.get('executable', '')
|
||||||
tmp.launch_parameters = json.get('launch_parameters', '')
|
tmp.launch_parameters = json.get('launch_parameters', '')
|
||||||
tmp.prereq_info = json.get('prereq_info', None)
|
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.can_run_offline = json.get('can_run_offline', False)
|
||||||
tmp.requires_ot = json.get('requires_ot', False)
|
tmp.requires_ot = json.get('requires_ot', False)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
@ -7,6 +9,7 @@ import zlib
|
||||||
|
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger('Manifest')
|
logger = logging.getLogger('Manifest')
|
||||||
|
|
||||||
|
@ -61,7 +64,7 @@ def get_chunk_dir(version):
|
||||||
|
|
||||||
class Manifest:
|
class Manifest:
|
||||||
header_magic = 0x44BEC00C
|
header_magic = 0x44BEC00C
|
||||||
serialisation_version = 18
|
default_serialisation_version = 17
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.header_size = 41
|
self.header_size = 41
|
||||||
|
@ -73,10 +76,10 @@ class Manifest:
|
||||||
self.data = b''
|
self.data = b''
|
||||||
|
|
||||||
# remainder
|
# remainder
|
||||||
self.meta = None
|
self.meta: Optional[ManifestMeta] = None
|
||||||
self.chunk_data_list = None
|
self.chunk_data_list: Optional[CDL] = None
|
||||||
self.file_manifest_list = None
|
self.file_manifest_list: Optional[FML] = None
|
||||||
self.custom_fields = None
|
self.custom_fields: Optional[CustomFields] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def compressed(self):
|
def compressed(self):
|
||||||
|
@ -92,8 +95,7 @@ class Manifest:
|
||||||
_m.file_manifest_list = FML.read(_tmp)
|
_m.file_manifest_list = FML.read(_tmp)
|
||||||
_m.custom_fields = CustomFields.read(_tmp)
|
_m.custom_fields = CustomFields.read(_tmp)
|
||||||
|
|
||||||
unhandled_data = _tmp.read()
|
if unhandled_data := _tmp.read():
|
||||||
if unhandled_data:
|
|
||||||
logger.warning(f'Did not read {len(unhandled_data)} remaining bytes in manifest! '
|
logger.warning(f'Did not read {len(unhandled_data)} remaining bytes in manifest! '
|
||||||
f'This may not be a problem.')
|
f'This may not be a problem.')
|
||||||
|
|
||||||
|
@ -138,6 +140,26 @@ class Manifest:
|
||||||
def write(self, fp=None, compress=True):
|
def write(self, fp=None, compress=True):
|
||||||
body_bio = BytesIO()
|
body_bio = BytesIO()
|
||||||
|
|
||||||
|
# set serialisation version based on enabled features or original version
|
||||||
|
target_version = max(self.default_serialisation_version, self.meta.feature_level)
|
||||||
|
if self.meta.data_version == 2:
|
||||||
|
target_version = max(21, target_version)
|
||||||
|
elif self.file_manifest_list.version == 2:
|
||||||
|
target_version = max(20, target_version)
|
||||||
|
elif self.file_manifest_list.version == 1:
|
||||||
|
target_version = max(19, target_version)
|
||||||
|
elif self.meta.data_version == 1:
|
||||||
|
target_version = max(18, target_version)
|
||||||
|
|
||||||
|
# Downgrade manifest if unknown newer version
|
||||||
|
if target_version > 21:
|
||||||
|
logger.warning(f'Trying to serialise an unknown target version: {target_version},'
|
||||||
|
f'clamping to 21.')
|
||||||
|
target_version = 21
|
||||||
|
|
||||||
|
# Ensure metadata will be correct
|
||||||
|
self.meta.feature_level = target_version
|
||||||
|
|
||||||
self.meta.write(body_bio)
|
self.meta.write(body_bio)
|
||||||
self.chunk_data_list.write(body_bio)
|
self.chunk_data_list.write(body_bio)
|
||||||
self.file_manifest_list.write(body_bio)
|
self.file_manifest_list.write(body_bio)
|
||||||
|
@ -152,10 +174,7 @@ class Manifest:
|
||||||
self.data = zlib.compress(self.data)
|
self.data = zlib.compress(self.data)
|
||||||
self.size_compressed = len(self.data)
|
self.size_compressed = len(self.data)
|
||||||
|
|
||||||
if not fp:
|
bio = fp or BytesIO()
|
||||||
bio = BytesIO()
|
|
||||||
else:
|
|
||||||
bio = fp
|
|
||||||
|
|
||||||
bio.write(struct.pack('<I', self.header_magic))
|
bio.write(struct.pack('<I', self.header_magic))
|
||||||
bio.write(struct.pack('<I', self.header_size))
|
bio.write(struct.pack('<I', self.header_size))
|
||||||
|
@ -163,18 +182,50 @@ class Manifest:
|
||||||
bio.write(struct.pack('<I', self.size_compressed))
|
bio.write(struct.pack('<I', self.size_compressed))
|
||||||
bio.write(self.sha_hash)
|
bio.write(self.sha_hash)
|
||||||
bio.write(struct.pack('B', self.stored_as))
|
bio.write(struct.pack('B', self.stored_as))
|
||||||
bio.write(struct.pack('<I', self.serialisation_version))
|
bio.write(struct.pack('<I', target_version))
|
||||||
bio.write(self.data)
|
bio.write(self.data)
|
||||||
|
|
||||||
if not fp:
|
return bio.tell() if fp else bio.getvalue()
|
||||||
return bio.getvalue()
|
|
||||||
else:
|
def apply_delta_manifest(self, delta_manifest: Manifest):
|
||||||
return bio.tell()
|
added = set()
|
||||||
|
# overwrite file elements with the ones from the delta manifest
|
||||||
|
for idx, file_elem in enumerate(self.file_manifest_list.elements):
|
||||||
|
try:
|
||||||
|
delta_file = delta_manifest.file_manifest_list.get_file_by_path(file_elem.filename)
|
||||||
|
self.file_manifest_list.elements[idx] = delta_file
|
||||||
|
added.add(delta_file.filename)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# add other files that may be missing
|
||||||
|
for delta_file in delta_manifest.file_manifest_list.elements:
|
||||||
|
if delta_file.filename not in added:
|
||||||
|
self.file_manifest_list.elements.append(delta_file)
|
||||||
|
# update count and clear map
|
||||||
|
self.file_manifest_list.count = len(self.file_manifest_list.elements)
|
||||||
|
self.file_manifest_list._path_map = None
|
||||||
|
|
||||||
|
# ensure guid map exists (0 will most likely yield no result, so ignore ValueError)
|
||||||
|
try:
|
||||||
|
self.chunk_data_list.get_chunk_by_guid(0)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# add new chunks from delta manifest to main manifest and again clear maps and update count
|
||||||
|
existing_chunk_guids = self.chunk_data_list._guid_int_map.keys()
|
||||||
|
|
||||||
|
for chunk in delta_manifest.chunk_data_list.elements:
|
||||||
|
if chunk.guid_num not in existing_chunk_guids:
|
||||||
|
self.chunk_data_list.elements.append(chunk)
|
||||||
|
|
||||||
|
self.chunk_data_list.count = len(self.chunk_data_list.elements)
|
||||||
|
self.chunk_data_list._guid_map = None
|
||||||
|
self.chunk_data_list._guid_int_map = None
|
||||||
|
self.chunk_data_list._path_map = None
|
||||||
|
|
||||||
|
|
||||||
class ManifestMeta:
|
class ManifestMeta:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.meta_size = 0
|
self.meta_size = 0
|
||||||
self.data_version = 0
|
self.data_version = 0
|
||||||
|
@ -189,6 +240,8 @@ class ManifestMeta:
|
||||||
self.prereq_name = ''
|
self.prereq_name = ''
|
||||||
self.prereq_path = ''
|
self.prereq_path = ''
|
||||||
self.prereq_args = ''
|
self.prereq_args = ''
|
||||||
|
self.uninstall_action_path = ''
|
||||||
|
self.uninstall_action_args = ''
|
||||||
# this build id is used for something called "delta file" which I guess I'll have to implement eventually
|
# this build id is used for something called "delta file" which I guess I'll have to implement eventually
|
||||||
self._build_id = ''
|
self._build_id = ''
|
||||||
|
|
||||||
|
@ -226,16 +279,20 @@ class ManifestMeta:
|
||||||
|
|
||||||
# This is a list though I've never seen more than one entry
|
# This is a list though I've never seen more than one entry
|
||||||
entries = struct.unpack('<I', bio.read(4))[0]
|
entries = struct.unpack('<I', bio.read(4))[0]
|
||||||
for i in range(entries):
|
for _ in range(entries):
|
||||||
_meta.prereq_ids.append(read_fstring(bio))
|
_meta.prereq_ids.append(read_fstring(bio))
|
||||||
|
|
||||||
_meta.prereq_name = read_fstring(bio)
|
_meta.prereq_name = read_fstring(bio)
|
||||||
_meta.prereq_path = read_fstring(bio)
|
_meta.prereq_path = read_fstring(bio)
|
||||||
_meta.prereq_args = read_fstring(bio)
|
_meta.prereq_args = read_fstring(bio)
|
||||||
|
|
||||||
# apparently there's a newer version that actually stores *a* build id.
|
# Manifest version 18 with data version >= 1 stores build ID
|
||||||
if _meta.data_version > 0:
|
if _meta.data_version >= 1:
|
||||||
_meta._build_id = read_fstring(bio)
|
_meta._build_id = read_fstring(bio)
|
||||||
|
# Manifest version 21 with data version >= 2 stores uninstall commands
|
||||||
|
if _meta.data_version >= 2:
|
||||||
|
_meta.uninstall_action_path = read_fstring(bio)
|
||||||
|
_meta.uninstall_action_args = read_fstring(bio)
|
||||||
|
|
||||||
if (size_read := bio.tell()) != _meta.meta_size:
|
if (size_read := bio.tell()) != _meta.meta_size:
|
||||||
logger.warning(f'Did not read entire manifest metadata! Version: {_meta.data_version}, '
|
logger.warning(f'Did not read entire manifest metadata! Version: {_meta.data_version}, '
|
||||||
|
@ -250,7 +307,7 @@ class ManifestMeta:
|
||||||
meta_start = bio.tell()
|
meta_start = bio.tell()
|
||||||
|
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.data_version))
|
||||||
bio.write(struct.pack('<I', self.feature_level))
|
bio.write(struct.pack('<I', self.feature_level))
|
||||||
bio.write(struct.pack('B', self.is_file_data))
|
bio.write(struct.pack('B', self.is_file_data))
|
||||||
bio.write(struct.pack('<I', self.app_id))
|
bio.write(struct.pack('<I', self.app_id))
|
||||||
|
@ -267,8 +324,11 @@ class ManifestMeta:
|
||||||
write_fstring(bio, self.prereq_path)
|
write_fstring(bio, self.prereq_path)
|
||||||
write_fstring(bio, self.prereq_args)
|
write_fstring(bio, self.prereq_args)
|
||||||
|
|
||||||
if self.data_version > 0:
|
if self.data_version >= 1:
|
||||||
write_fstring(bio, self.build_id)
|
write_fstring(bio, self.build_id)
|
||||||
|
if self.data_version >= 2:
|
||||||
|
write_fstring(bio, self.uninstall_action_path)
|
||||||
|
write_fstring(bio, self.uninstall_action_args)
|
||||||
|
|
||||||
meta_end = bio.tell()
|
meta_end = bio.tell()
|
||||||
bio.seek(meta_start)
|
bio.seek(meta_start)
|
||||||
|
@ -277,8 +337,6 @@ class ManifestMeta:
|
||||||
|
|
||||||
|
|
||||||
class CDL:
|
class CDL:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.version = 0
|
self.version = 0
|
||||||
self.size = 0
|
self.size = 0
|
||||||
|
@ -348,7 +406,7 @@ class CDL:
|
||||||
|
|
||||||
# the way this data is stored is rather odd, maybe there's a nicer way to write this...
|
# the way this data is stored is rather odd, maybe there's a nicer way to write this...
|
||||||
|
|
||||||
for i in range(_cdl.count):
|
for _ in range(_cdl.count):
|
||||||
_cdl.elements.append(ChunkInfo(manifest_version=manifest_version))
|
_cdl.elements.append(ChunkInfo(manifest_version=manifest_version))
|
||||||
|
|
||||||
# guid, doesn't seem to be a standard like UUID but is fairly straightfoward, 4 bytes, 128 bit.
|
# guid, doesn't seem to be a standard like UUID but is fairly straightfoward, 4 bytes, 128 bit.
|
||||||
|
@ -387,7 +445,7 @@ class CDL:
|
||||||
def write(self, bio):
|
def write(self, bio):
|
||||||
cdl_start = bio.tell()
|
cdl_start = bio.tell()
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.version))
|
||||||
bio.write(struct.pack('<I', len(self.elements)))
|
bio.write(struct.pack('<I', len(self.elements)))
|
||||||
|
|
||||||
for chunk in self.elements:
|
for chunk in self.elements:
|
||||||
|
@ -466,8 +524,6 @@ class ChunkInfo:
|
||||||
|
|
||||||
|
|
||||||
class FML:
|
class FML:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.version = 0
|
self.version = 0
|
||||||
self.size = 0
|
self.size = 0
|
||||||
|
@ -495,7 +551,7 @@ class FML:
|
||||||
_fml.version = struct.unpack('B', bio.read(1))[0]
|
_fml.version = struct.unpack('B', bio.read(1))[0]
|
||||||
_fml.count = struct.unpack('<I', bio.read(4))[0]
|
_fml.count = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
|
||||||
for i in range(_fml.count):
|
for _ in range(_fml.count):
|
||||||
_fml.elements.append(FileManifest())
|
_fml.elements.append(FileManifest())
|
||||||
|
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
|
@ -516,14 +572,14 @@ class FML:
|
||||||
# install tags, no idea what they do, I've only seen them in the Fortnite manifest
|
# install tags, no idea what they do, I've only seen them in the Fortnite manifest
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
_elem = struct.unpack('<I', bio.read(4))[0]
|
_elem = struct.unpack('<I', bio.read(4))[0]
|
||||||
for i in range(_elem):
|
for _ in range(_elem):
|
||||||
fm.install_tags.append(read_fstring(bio))
|
fm.install_tags.append(read_fstring(bio))
|
||||||
|
|
||||||
# Each file is made up of "Chunk Parts" that can be spread across the "chunk stream"
|
# Each file is made up of "Chunk Parts" that can be spread across the "chunk stream"
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
_elem = struct.unpack('<I', bio.read(4))[0]
|
_elem = struct.unpack('<I', bio.read(4))[0]
|
||||||
_offset = 0
|
_offset = 0
|
||||||
for i in range(_elem):
|
for _ in range(_elem):
|
||||||
chunkp = ChunkPart()
|
chunkp = ChunkPart()
|
||||||
_start = bio.tell()
|
_start = bio.tell()
|
||||||
_size = struct.unpack('<I', bio.read(4))[0]
|
_size = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
@ -537,7 +593,7 @@ class FML:
|
||||||
logger.warning(f'Did not read {diff} bytes from chunk part!')
|
logger.warning(f'Did not read {diff} bytes from chunk part!')
|
||||||
bio.seek(diff)
|
bio.seek(diff)
|
||||||
|
|
||||||
# MD5 hash + MIME type
|
# MD5 hash + MIME type (Manifest feature level 19)
|
||||||
if _fml.version >= 1:
|
if _fml.version >= 1:
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
_has_md5 = struct.unpack('<I', bio.read(4))[0]
|
_has_md5 = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
@ -547,7 +603,7 @@ class FML:
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
fm.mime_type = read_fstring(bio)
|
fm.mime_type = read_fstring(bio)
|
||||||
|
|
||||||
# SHA256 hash
|
# SHA256 hash (Manifest feature level 20)
|
||||||
if _fml.version >= 2:
|
if _fml.version >= 2:
|
||||||
for fm in _fml.elements:
|
for fm in _fml.elements:
|
||||||
fm.hash_sha256 = bio.read(32)
|
fm.hash_sha256 = bio.read(32)
|
||||||
|
@ -568,7 +624,7 @@ class FML:
|
||||||
def write(self, bio):
|
def write(self, bio):
|
||||||
fml_start = bio.tell()
|
fml_start = bio.tell()
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.version))
|
||||||
bio.write(struct.pack('<I', len(self.elements)))
|
bio.write(struct.pack('<I', len(self.elements)))
|
||||||
|
|
||||||
for fm in self.elements:
|
for fm in self.elements:
|
||||||
|
@ -594,6 +650,20 @@ class FML:
|
||||||
bio.write(struct.pack('<I', cp.offset))
|
bio.write(struct.pack('<I', cp.offset))
|
||||||
bio.write(struct.pack('<I', cp.size))
|
bio.write(struct.pack('<I', cp.size))
|
||||||
|
|
||||||
|
if self.version >= 1:
|
||||||
|
for fm in self.elements:
|
||||||
|
has_md5 = 1 if fm.hash_md5 else 0
|
||||||
|
bio.write(struct.pack('<I', has_md5))
|
||||||
|
if has_md5:
|
||||||
|
bio.write(fm.hash_md5)
|
||||||
|
|
||||||
|
for fm in self.elements:
|
||||||
|
write_fstring(bio, fm.mime_type)
|
||||||
|
|
||||||
|
if self.version >= 2:
|
||||||
|
for fm in self.elements:
|
||||||
|
bio.write(fm.hash_sha256)
|
||||||
|
|
||||||
fml_end = bio.tell()
|
fml_end = bio.tell()
|
||||||
bio.seek(fml_start)
|
bio.seek(fml_start)
|
||||||
bio.write(struct.pack('<I', fml_end - fml_start))
|
bio.write(struct.pack('<I', fml_end - fml_start))
|
||||||
|
@ -637,6 +707,7 @@ class FileManifest:
|
||||||
_cp.append('[...]')
|
_cp.append('[...]')
|
||||||
cp_repr = ', '.join(_cp)
|
cp_repr = ', '.join(_cp)
|
||||||
|
|
||||||
|
# ToDo add MD5, MIME, SHA256 if those ever become relevant
|
||||||
return '<FileManifest (filename="{}", symlink_target="{}", hash={}, flags={}, ' \
|
return '<FileManifest (filename="{}", symlink_target="{}", hash={}, flags={}, ' \
|
||||||
'install_tags=[{}], chunk_parts=[{}], file_size={})>'.format(
|
'install_tags=[{}], chunk_parts=[{}], file_size={})>'.format(
|
||||||
self.filename, self.symlink_target, self.hash.hex(), self.flags,
|
self.filename, self.symlink_target, self.hash.hex(), self.flags,
|
||||||
|
@ -673,8 +744,6 @@ class ChunkPart:
|
||||||
|
|
||||||
|
|
||||||
class CustomFields:
|
class CustomFields:
|
||||||
serialisation_version = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.size = 0
|
self.size = 0
|
||||||
self.version = 0
|
self.version = 0
|
||||||
|
@ -709,15 +778,8 @@ class CustomFields:
|
||||||
_cf.version = struct.unpack('B', bio.read(1))[0]
|
_cf.version = struct.unpack('B', bio.read(1))[0]
|
||||||
_cf.count = struct.unpack('<I', bio.read(4))[0]
|
_cf.count = struct.unpack('<I', bio.read(4))[0]
|
||||||
|
|
||||||
_keys = []
|
_keys = [read_fstring(bio) for _ in range(_cf.count)]
|
||||||
_values = []
|
_values = [read_fstring(bio) for _ in range(_cf.count)]
|
||||||
|
|
||||||
for i in range(_cf.count):
|
|
||||||
_keys.append(read_fstring(bio))
|
|
||||||
|
|
||||||
for i in range(_cf.count):
|
|
||||||
_values.append(read_fstring(bio))
|
|
||||||
|
|
||||||
_cf._dict = dict(zip(_keys, _values))
|
_cf._dict = dict(zip(_keys, _values))
|
||||||
|
|
||||||
if (size_read := bio.tell() - cf_start) != _cf.size:
|
if (size_read := bio.tell() - cf_start) != _cf.size:
|
||||||
|
@ -732,7 +794,7 @@ class CustomFields:
|
||||||
def write(self, bio):
|
def write(self, bio):
|
||||||
cf_start = bio.tell()
|
cf_start = bio.tell()
|
||||||
bio.write(struct.pack('<I', 0)) # placeholder size
|
bio.write(struct.pack('<I', 0)) # placeholder size
|
||||||
bio.write(struct.pack('B', self.serialisation_version))
|
bio.write(struct.pack('B', self.version))
|
||||||
bio.write(struct.pack('<I', len(self._dict)))
|
bio.write(struct.pack('<I', len(self._dict)))
|
||||||
|
|
||||||
for key in self.keys():
|
for key in self.keys():
|
||||||
|
@ -766,8 +828,7 @@ class ManifestComparison:
|
||||||
old_files = {fm.filename: fm.hash for fm in old_manifest.file_manifest_list.elements}
|
old_files = {fm.filename: fm.hash for fm in old_manifest.file_manifest_list.elements}
|
||||||
|
|
||||||
for fm in manifest.file_manifest_list.elements:
|
for fm in manifest.file_manifest_list.elements:
|
||||||
old_file_hash = old_files.pop(fm.filename, None)
|
if old_file_hash := old_files.pop(fm.filename, None):
|
||||||
if old_file_hash:
|
|
||||||
if fm.hash == old_file_hash:
|
if fm.hash == old_file_hash:
|
||||||
comp.unchanged.add(fm.filename)
|
comp.unchanged.add(fm.filename)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
def get_boolean_choice(prompt, default=True):
|
def get_boolean_choice(prompt, default=True):
|
||||||
if default:
|
yn = 'Y/n' if default else 'y/N'
|
||||||
yn = 'Y/n'
|
|
||||||
else:
|
|
||||||
yn = 'y/N'
|
|
||||||
|
|
||||||
choice = input(f'{prompt} [{yn}]: ')
|
choice = input(f'{prompt} [{yn}]: ')
|
||||||
if not choice:
|
if not choice:
|
||||||
|
@ -21,10 +18,10 @@ def get_int_choice(prompt, default=None, min_choice=None, max_choice=None, retur
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
inp = input(prompt)
|
if inp := input(prompt):
|
||||||
if not inp:
|
choice = int(inp)
|
||||||
|
else:
|
||||||
return default
|
return default
|
||||||
choice = int(inp)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
if return_on_invalid:
|
if return_on_invalid:
|
||||||
return None
|
return None
|
||||||
|
@ -61,7 +58,7 @@ def sdl_prompt(sdl_data, title):
|
||||||
examples = ', '.join([g for g in sdl_data.keys() if g != '__required'][:2])
|
examples = ', '.join([g for g in sdl_data.keys() if g != '__required'][:2])
|
||||||
print(f'Please enter tags of pack(s) to install (space/comma-separated, e.g. "{examples}")')
|
print(f'Please enter tags of pack(s) to install (space/comma-separated, e.g. "{examples}")')
|
||||||
print('Leave blank to use defaults (only required data will be downloaded).')
|
print('Leave blank to use defaults (only required data will be downloaded).')
|
||||||
choices = input(f'Additional packs [Enter to confirm]: ')
|
choices = input('Additional packs [Enter to confirm]: ')
|
||||||
if not choices:
|
if not choices:
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ class HiddenAliasSubparsersAction(argparse._SubParsersAction):
|
||||||
def add_parser(self, name, **kwargs):
|
def add_parser(self, name, **kwargs):
|
||||||
# set prog from the existing prefix
|
# set prog from the existing prefix
|
||||||
if kwargs.get('prog') is None:
|
if kwargs.get('prog') is None:
|
||||||
kwargs['prog'] = '%s %s' % (self._prog_prefix, name)
|
kwargs['prog'] = f'{self._prog_prefix} {name}'
|
||||||
|
|
||||||
aliases = kwargs.pop('aliases', ())
|
aliases = kwargs.pop('aliases', ())
|
||||||
hide_aliases = kwargs.pop('hide_aliases', False)
|
hide_aliases = kwargs.pop('hide_aliases', False)
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
from legendary.models.manifest import Manifest
|
|
||||||
|
|
||||||
|
|
||||||
def combine_manifests(base_manifest: Manifest, delta_manifest: Manifest):
|
|
||||||
added = set()
|
|
||||||
# overwrite file elements with the ones from the delta manifest
|
|
||||||
for idx, file_elem in enumerate(base_manifest.file_manifest_list.elements):
|
|
||||||
try:
|
|
||||||
delta_file = delta_manifest.file_manifest_list.get_file_by_path(file_elem.filename)
|
|
||||||
base_manifest.file_manifest_list.elements[idx] = delta_file
|
|
||||||
added.add(delta_file.filename)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# add other files that may be missing
|
|
||||||
for delta_file in delta_manifest.file_manifest_list.elements:
|
|
||||||
if delta_file.filename not in added:
|
|
||||||
base_manifest.file_manifest_list.elements.append(delta_file)
|
|
||||||
# update count and clear map
|
|
||||||
base_manifest.file_manifest_list.count = len(base_manifest.file_manifest_list.elements)
|
|
||||||
base_manifest.file_manifest_list._path_map = None
|
|
||||||
|
|
||||||
# ensure guid map exists
|
|
||||||
try:
|
|
||||||
base_manifest.chunk_data_list.get_chunk_by_guid(0)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# add new chunks from delta manifest to main manifest and again clear maps and update count
|
|
||||||
existing_chunk_guids = base_manifest.chunk_data_list._guid_int_map.keys()
|
|
||||||
|
|
||||||
for chunk in delta_manifest.chunk_data_list.elements:
|
|
||||||
if chunk.guid_num not in existing_chunk_guids:
|
|
||||||
base_manifest.chunk_data_list.elements.append(chunk)
|
|
||||||
|
|
||||||
base_manifest.chunk_data_list.count = len(base_manifest.chunk_data_list.elements)
|
|
||||||
base_manifest.chunk_data_list._guid_map = None
|
|
||||||
base_manifest.chunk_data_list._guid_int_map = None
|
|
||||||
base_manifest.chunk_data_list._path_map = None
|
|
|
@ -22,11 +22,14 @@ def _filename_matches(filename, patterns):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if pattern.endswith('/'):
|
# Pattern is a directory, just check if path starts with it
|
||||||
# pat is a directory, check if path starts with it
|
if pattern.endswith('/') and filename.startswith(pattern):
|
||||||
if filename.startswith(pattern):
|
return True
|
||||||
return True
|
# Check if pattern is a suffix of filename
|
||||||
elif fnmatch(filename, pattern):
|
if filename.endswith(pattern):
|
||||||
|
return True
|
||||||
|
# Check if pattern with wildcards ('*') matches
|
||||||
|
if fnmatch(filename, pattern):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
@ -167,3 +170,21 @@ class SaveGameHelper:
|
||||||
|
|
||||||
# return dict with created files for uploading/whatever
|
# return dict with created files for uploading/whatever
|
||||||
return self.files
|
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
|
||||||
|
|
|
@ -22,7 +22,7 @@ except Exception as e:
|
||||||
|
|
||||||
login_url = 'https://www.epicgames.com/id/login'
|
login_url = 'https://www.epicgames.com/id/login'
|
||||||
sid_url = 'https://www.epicgames.com/id/api/redirect?'
|
sid_url = 'https://www.epicgames.com/id/api/redirect?'
|
||||||
logout_url = 'https://www.epicgames.com/id/logout?productName=epic-games&redirectUrl=' + login_url
|
logout_url = f'https://www.epicgames.com/id/logout?productName=epic-games&redirectUrl={login_url}'
|
||||||
goodbye_url = 'https://legendary.gl/goodbye'
|
goodbye_url = 'https://legendary.gl/goodbye'
|
||||||
window_js = '''
|
window_js = '''
|
||||||
window.ue = {
|
window.ue = {
|
||||||
|
@ -70,9 +70,11 @@ class MockLauncher:
|
||||||
if self.inject_js:
|
if self.inject_js:
|
||||||
self.window.evaluate_js(window_js)
|
self.window.evaluate_js(window_js)
|
||||||
|
|
||||||
if 'logout' in url:
|
if 'logout' in url and self.callback_sid:
|
||||||
# prepare to close browser after logout redirect
|
# prepare to close browser after logout redirect
|
||||||
self.destroy_on_load = True
|
self.destroy_on_load = True
|
||||||
|
elif 'logout' in url:
|
||||||
|
self.inject_js = True
|
||||||
|
|
||||||
def nop(self, *args, **kwargs):
|
def nop(self, *args, **kwargs):
|
||||||
return
|
return
|
||||||
|
@ -87,21 +89,22 @@ class MockLauncher:
|
||||||
# skip logging out on those platforms and directly use the exchange code we're given.
|
# skip logging out on those platforms and directly use the exchange code we're given.
|
||||||
# On windows we have to do a little dance with the SID to create a session that
|
# On windows we have to do a little dance with the SID to create a session that
|
||||||
# remains valid after logging out in the embedded browser.
|
# remains valid after logging out in the embedded browser.
|
||||||
if self.window.gui.renderer in ('gtkwebkit2', 'qtwebengine', 'qtwebkit'):
|
# Update: Epic broke SID login, we'll also do this on Windows now
|
||||||
self.destroy_on_load = True
|
# if self.window.gui.renderer in ('gtkwebkit2', 'qtwebengine', 'qtwebkit'):
|
||||||
try:
|
self.destroy_on_load = True
|
||||||
self.callback_result = self.callback_code(exchange_code)
|
try:
|
||||||
except Exception as e:
|
self.callback_result = self.callback_code(exchange_code)
|
||||||
logger.error(f'Logging in via exchange-code failed with {e!r}')
|
except Exception as e:
|
||||||
finally:
|
logger.error(f'Logging in via exchange-code failed with {e!r}')
|
||||||
# We cannot destroy the browser from here,
|
finally:
|
||||||
# so we'll load a small goodbye site first.
|
# We cannot destroy the browser from here,
|
||||||
self.window.load_url(goodbye_url)
|
# so we'll load a small goodbye site first.
|
||||||
|
self.window.load_url(goodbye_url)
|
||||||
|
|
||||||
def trigger_sid_exchange(self, *args, **kwargs):
|
def trigger_sid_exchange(self, *args, **kwargs):
|
||||||
# check if code-based login hasn't already set the destroy flag
|
# check if code-based login hasn't already set the destroy flag
|
||||||
if not self.destroy_on_load:
|
if not self.destroy_on_load:
|
||||||
logger.debug(f'Injecting SID JS')
|
logger.debug('Injecting SID JS')
|
||||||
# inject JS to get SID API response and call our API
|
# inject JS to get SID API response and call our API
|
||||||
self.window.evaluate_js(get_sid_js)
|
self.window.evaluate_js(get_sid_js)
|
||||||
|
|
||||||
|
@ -121,22 +124,32 @@ class MockLauncher:
|
||||||
self.window.load_url(logout_url)
|
self.window.load_url(logout_url)
|
||||||
|
|
||||||
|
|
||||||
def do_webview_login(callback_sid=None, callback_code=None):
|
def do_webview_login(callback_sid=None, callback_code=None, user_agent=None):
|
||||||
api = MockLauncher(callback_sid=callback_sid, callback_code=callback_code)
|
api = MockLauncher(callback_sid=callback_sid, callback_code=callback_code)
|
||||||
|
url = login_url
|
||||||
|
|
||||||
|
if os.name == 'nt':
|
||||||
|
# On Windows we open the logout URL first to invalidate the current cookies (if any).
|
||||||
|
# Additionally, we have to disable JS injection for the first load, as otherwise the user
|
||||||
|
# will get an error for some reason.
|
||||||
|
url = logout_url
|
||||||
|
api.inject_js = False
|
||||||
|
|
||||||
logger.info('Opening Epic Games login window...')
|
logger.info('Opening Epic Games login window...')
|
||||||
|
# Open logout URL first to remove existing cookies, then redirect to login.
|
||||||
window = webview.create_window(f'Legendary {__version__} - Epic Games Account Login',
|
window = webview.create_window(f'Legendary {__version__} - Epic Games Account Login',
|
||||||
url=login_url, width=768, height=1024, js_api=api)
|
url=url, width=768, height=1024, js_api=api)
|
||||||
api.window = window
|
api.window = window
|
||||||
window.events.loaded += api.on_loaded
|
window.events.loaded += api.on_loaded
|
||||||
|
|
||||||
try:
|
try:
|
||||||
webview.start()
|
webview.start(user_agent=user_agent)
|
||||||
except Exception as we:
|
except Exception as we:
|
||||||
logger.error(f'Running webview failed with {we!r}. If this error persists try the manual '
|
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.')
|
f'login process by adding --disable-webview to your command line.')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if api.callback_result is None:
|
if api.callback_result is None:
|
||||||
logger.error(f'Login aborted by user.')
|
logger.error('Login aborted by user.')
|
||||||
|
|
||||||
return api.callback_result
|
return api.callback_result
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
requests<3.0
|
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
|
from legendary import __version__ as legendary_version
|
||||||
|
|
||||||
if sys.version_info < (3, 8):
|
if sys.version_info < (3, 9):
|
||||||
sys.exit('python 3.8 or higher is required for legendary')
|
sys.exit('python 3.9 or higher is required for legendary')
|
||||||
|
|
||||||
with open("README.md", "r") as fh:
|
with open("README.md", "r") as fh:
|
||||||
long_description_l = fh.readlines()
|
long_description_l = fh.readlines()
|
||||||
|
@ -37,7 +37,8 @@ setup(
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'requests<3.0',
|
'requests<3.0',
|
||||||
'setuptools',
|
'setuptools',
|
||||||
'wheel'
|
'wheel',
|
||||||
|
'filelock'
|
||||||
],
|
],
|
||||||
extras_require=dict(
|
extras_require=dict(
|
||||||
webview=['pywebview>=3.4'],
|
webview=['pywebview>=3.4'],
|
||||||
|
@ -47,11 +48,10 @@ setup(
|
||||||
description='Free and open-source replacement for the Epic Games Launcher application',
|
description='Free and open-source replacement for the Epic Games Launcher application',
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
python_requires='>=3.8',
|
python_requires='>=3.9',
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
|
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
|
||||||
'Programming Language :: Python',
|
'Programming Language :: Python',
|
||||||
'Programming Language :: Python :: 3.8',
|
|
||||||
'Programming Language :: Python :: 3.9',
|
'Programming Language :: Python :: 3.9',
|
||||||
'Operating System :: POSIX :: Linux',
|
'Operating System :: POSIX :: Linux',
|
||||||
'Operating System :: Microsoft',
|
'Operating System :: Microsoft',
|
||||||
|
|
Loading…
Reference in a new issue