Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: Heroic-Games-Launcher/legendary
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 0.20.35
Choose a base ref
...
head repository: Heroic-Games-Launcher/legendary
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 0.20.36
Choose a head ref
  • 17 commits
  • 7 files changed
  • 7 contributors

Commits on May 24, 2024

  1. Copy the full SHA
    2f76616 View commit details

Commits on Jul 15, 2024

  1. Copy the full SHA
    ccee067 View commit details

Commits on Jul 22, 2024

  1. [cli/core]: Add EULA utilities (#4)

    * [cli/core]: add eula utilities
    
    * improv: better readability
    imLinguin authored and CommandMC committed Jul 22, 2024
    Copy the full SHA
    addcf14 View commit details

Commits on Aug 23, 2024

  1. [cli/models] Support both origin and EA App names (derrod#632)

    Note that the actual title has different case for different games (e.g.
    it's "the EA app" for one game, but "The EA App" for another)
    CommandMC authored Aug 23, 2024
    Copy the full SHA
    90e5f75 View commit details
  2. Copy the full SHA
    9395eb9 View commit details
  3. Copy the full SHA
    09d280f View commit details
  4. [cli] Update Ubisoft game instructions (derrod#591)

    Added that Ubisoft Connect is required for the game to be launched.
    SpyGuy0215 authored Aug 23, 2024
    Copy the full SHA
    08c64eb View commit details
  5. [core] Normalise OwnershipToken value to lowercase

    Apparently this can be uppercase or lowercase, thanks Epic!
    imLinguin authored and derrod committed Aug 23, 2024
    Copy the full SHA
    f1f5cc0 View commit details
  6. [core] Warn if app does not have metadata

    I forgot why I did this, but I think it works around a crash?
    derrod committed Aug 23, 2024
    Copy the full SHA
    4d63dcc View commit details
  7. Copy the full SHA
    56a2314 View commit details
  8. Bump Version

    derrod committed Aug 23, 2024
    Copy the full SHA
    49dcdf1 View commit details

Commits on Aug 24, 2024

  1. Update CI runners

    Keeping macos-13 for now to stay on x86 runners.
    derrod committed Aug 24, 2024
    Copy the full SHA
    3963382 View commit details
  2. Make JSON decoding in LockedJSONData fallible (#2)

    CommandMC authored Aug 24, 2024
    Copy the full SHA
    14457ab View commit details
  3. Automatically disable EGL sync on Windows as well (#5)

    CommandMC authored Aug 24, 2024
    Copy the full SHA
    900d189 View commit details
  4. Don't build a deb (#8)

    While upstream might need this, we don't
    CommandMC authored Aug 24, 2024
    Copy the full SHA
    d13e5f5 View commit details

Commits on Sep 10, 2024

  1. Update actions/setup-python to v5 (#9)

    CommandMC authored Sep 10, 2024
    Copy the full SHA
    7b538a5 View commit details
  2. Bump Version

    CommandMC committed Sep 10, 2024
    Copy the full SHA
    2efea0f View commit details
Showing with 173 additions and 72 deletions.
  1. +5 −38 .github/workflows/python.yml
  2. +2 −2 legendary/__init__.py
  3. +21 −0 legendary/api/egs.py
  4. +68 −5 legendary/cli.py
  5. +39 −20 legendary/core.py
  6. +8 −5 legendary/lfs/utils.py
  7. +30 −2 legendary/models/game.py
43 changes: 5 additions & 38 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -11,14 +11,14 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: ['ubuntu-20.04', 'windows-2019', 'macos-11']
os: ['ubuntu-24.04', 'windows-latest', 'macos-13', 'macos-14']
fail-fast: false
max-parallel: 3

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: '3.9'

@@ -49,40 +49,7 @@ jobs:
env:
PYTHONOPTIMIZE: 1

- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: ${{ runner.os }}-package
name: ${{ matrix.os }}-package
path: legendary/dist/*

deb:
runs-on: ubuntu-22.04

steps:
- uses: actions/checkout@v3

- name: Dependencies
run: |
sudo apt install ruby
sudo gem install fpm
- name: Build
run: fpm
--input-type python
--output-type deb
--python-package-name-prefix python3
--deb-suggests python3-webview
--maintainer "Rodney <rodney@rodney.io>"
--category python
--depends "python3 >= 3.9"
setup.py

- name: Os version
id: os_version
run: |
source /etc/os-release
echo ::set-output name=version::$NAME-$VERSION_ID
- uses: actions/upload-artifact@v3
with:
name: ${{ steps.os_version.outputs.version }}-deb-package
path: ./*.deb
4 changes: 2 additions & 2 deletions legendary/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Legendary!"""

__version__ = '0.20.34'
__codename__ = 'Direct Intervention'
__version__ = '0.20.36'
__codename__ = 'Urban Flight (Heroic)'
21 changes: 21 additions & 0 deletions legendary/api/egs.py
Original file line number Diff line number Diff line change
@@ -24,6 +24,7 @@ class EPCAPI:
_oauth_host = 'account-public-service-prod03.ol.epicgames.com'
_launcher_host = 'launcher-public-service-prod06.ol.epicgames.com'
_entitlements_host = 'entitlement-public-service-prod08.ol.epicgames.com'
_eulatracking_host = 'eulatracking-public-service-prod06.ol.epicgames.com'
_catalog_host = 'catalog-public-service-prod06.ol.epicgames.com'
_ecommerce_host = 'ecommerceintegration-public-service-ecomprod02.ol.epicgames.com'
_datastorage_host = 'datastorage-public-service-liveegs.live.use1a.on.epicgames.com'
@@ -310,3 +311,23 @@ def store_redeem_uplay_codes(self, uplay_id):
timeout=self.request_timeout)
r.raise_for_status()
return r.json()

def eula_get_status(self, eula_id):
user_id = self.user.get('account_id')
r = self.session.get(f'https://{self._eulatracking_host}/eulatracking/api/public/agreements/{eula_id}/account/{user_id}',
params=dict(locale=self.language_code))

if r.status_code == 204:
return None
r.raise_for_status()
return r.json()

def eula_accept(self, eula_id, version, locale=None):
user_id = self.user.get('account_id')
locale = locale or self.language_code
r = self.session.post(f'https://{self._eulatracking_host}/eulatracking/api/public/agreements/{eula_id}/version/{version}/account/{user_id}/accept',
params=dict(locale=locale))

r.raise_for_status()


73 changes: 68 additions & 5 deletions legendary/cli.py
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@
import subprocess
import time
import webbrowser
import re

from collections import defaultdict, namedtuple
from logging.handlers import QueueListener
@@ -242,7 +243,7 @@ def list_games(self, args):
# a third-party application (such as Origin).
if not version:
_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 '
f'"legendary launch --origin {game.app_name}" to activate and/or run the game.')
elif _store:
@@ -255,7 +256,8 @@ def list_games(self, args):
_type = game.partner_link_type
if _type == 'ubisoft':
print(' - This game can be activated directly on your Ubisoft account and does not require '
'legendary to install/run. 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:
print(f' ! This app requires linking to a third-party account (name: "{_type}", not supported)')

@@ -722,7 +724,7 @@ def _launch_origin(self, args):
f'to fetch data for Origin titles before using this command.')
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.')
return

@@ -856,7 +858,7 @@ def install_game(self, args):

if store := game.third_party_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 '
f'activate and/or run the game.')
exit(0)
@@ -2125,7 +2127,7 @@ def read_service_response(response):
logger.info('Redeemed all outstanding Uplay codes.')
elif args.origin:
na_games, _ = self.core.get_non_asset_library_items(skip_ue=True)
origin_games = [game for game in na_games if game.third_party_store == 'Origin']
origin_games = [game for game in na_games if game.is_origin_game]

if not origin_games:
logger.info('No redeemable games found.')
@@ -2625,6 +2627,58 @@ def move(self, args):
self.core.install_game(igame)
logger.info('Finished.')

def eula(self, args):
if not self.core.login():
logger.error('Login failed! Unable to check for EULAs.')
exit(1)
app_name = self._resolve_aliases(args.app_name)
game = self.core.get_game(app_name, update_meta=True)
if not game:
self.logger.error(f'No game found for "{app_name}"')
return
eulas = game.metadata.get('eulaIds') or ['$']

pattern = r'\w+'
keys = []
for eula in eulas:
keys += re.findall(pattern, eula)

not_accepted_eulas = []
for key in keys:
if args.skip_epic and key == 'egstore':
continue
self.logger.debug(f'Fetching eula status for "{key}"')
eula = self.core.egs.eula_get_status(key)
if eula:
not_accepted_eulas.append(eula)

accepted = False

if not args.json:
for eula in not_accepted_eulas:
title = eula.get('title')
url = eula.get('url')
print(f' * {title} - {url}')
print(f'EULA(s) to accept: {len(not_accepted_eulas)}')
if not_accepted_eulas:
accepted = args.yes or get_boolean_choice('Mark them as accepted?')
else:
json_out = not_accepted_eulas
self._print_json(json_out, args.pretty_json)
accepted = args.yes

if accepted:
for eula in not_accepted_eulas:
key = eula.get('key')
version = eula.get('version')
locale = eula.get('locale')
self.logger.debug(f'Accepting "{key}" version {version}')
try:
self.core.egs.eula_accept(key, version, locale)
except Exception as e:
self.logger.error(f"Failed to accept EULA {key} {e!r}")
return


def main():
# Set output encoding to UTF-8 if not outputting to a terminal
@@ -2679,6 +2733,7 @@ def main():
uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall (delete) a game')
verify_parser = subparsers.add_parser('verify', help='Verify a game\'s local files',
aliases=('verify-game',), hide_aliases=True)
eula_parser = subparsers.add_parser('eula', help='Check for unaccepted EULA(s) of a given game')

# hidden commands have no help text
get_token_parser = subparsers.add_parser('get-token')
@@ -3012,6 +3067,12 @@ def main():
move_parser.add_argument('--skip-move', dest='skip_move', action='store_true',
help='Only change legendary database, do not move files (e.g. if already moved)')

eula_parser.add_argument('app_name', metavar='<App Name>', help='Name of the app')
eula_parser.add_argument('--skip-epic', dest='skip_epic', action='store_true',
help='Skip checking for egstore EULA')
eula_parser.add_argument('--json', dest='json', action='store_true',
help='Output information in JSON format')

args, extra = parser.parse_known_args()

if args.version:
@@ -3112,6 +3173,8 @@ def main():
cli.crossover_setup(args)
elif args.subparser_name == 'move':
cli.move(args)
elif args.subparser_name == 'eula':
cli.eula(args)
except KeyboardInterrupt:
logger.info('Command was aborted via KeyboardInterrupt, cleaning up...')

59 changes: 39 additions & 20 deletions legendary/core.py
Original file line number Diff line number Diff line change
@@ -64,12 +64,14 @@ def __init__(self, override_config=None, timeout=10.0):
# on non-Windows load the programdata path from config
if os.name != 'nt':
self.egl.programdata_path = self.lgd.config.get('Legendary', 'egl_programdata', fallback=None)
if self.egl.programdata_path and not os.path.exists(self.egl.programdata_path):
self.log.error(f'Config EGL path ("{self.egl.programdata_path}") is invalid! Disabling sync...')

if self.egl.programdata_path and not os.path.exists(self.egl.programdata_path):
self.log.error(f'EGL ProgramData path ("{self.egl.programdata_path}") is invalid! Disabling sync...')
if os.name != 'nt':
self.egl.programdata_path = None
self.lgd.config.remove_option('Legendary', 'egl_programdata')
self.lgd.config.remove_option('Legendary', 'egl_sync')
self.lgd.save_config()
self.lgd.config.remove_option('Legendary', 'egl_programdata')
self.lgd.config.remove_option('Legendary', 'egl_sync')
self.lgd.save_config()

self.local_timezone = datetime.now().astimezone().tzinfo
self.language_code, self.country_code = ('en', 'US')
@@ -431,22 +433,40 @@ def get_game_and_dlc_list(self, update_assets=True, platform='Windows',
continue

game = self.lgd.get_game_meta(app_name)
asset_updated = False
asset_updated = sidecar_updated = False
if game:
asset_updated = any(game.app_version(_p) != app_assets[_p].build_version for _p in app_assets.keys())
# assuming sidecar data is the same for all platforms, just check the baseline (Windows) for updates.
sidecar_updated = (app_assets['Windows'].sidecar_rev > 0 and
(not game.sidecar or game.sidecar.rev != app_assets['Windows'].sidecar_rev))
games[app_name] = game

if update_assets and (not game or force_refresh or (game and asset_updated)):
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}')
# namespace/catalog item are the same for all platforms, so we can just use the first one
_ga = next(iter(app_assets.values()))
fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id))
fetch_list.append((app_name, _ga.namespace, _ga.catalog_item_id, sidecar_updated))
meta_updated = True

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)
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)
games[app_name] = game
try:
@@ -473,7 +493,7 @@ def fetch_game_meta(args):
if use_threads:
self.log.warning(f'Fetching metadata for {app_name} failed, retrying')
_ga = next(iter(app_assets.values()))
fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id))
fetch_game_meta((app_name, _ga.namespace, _ga.catalog_item_id, True))
game = games[app_name]

if game.is_dlc and platform in app_assets:
@@ -526,6 +546,8 @@ def get_non_asset_library_items(self, force_refresh=False,
for libitem in self.egs.get_library_items():
if libitem['namespace'] == 'ue' and skip_ue:
continue
if 'appName' not in libitem:
continue
if libitem['appName'] in ignore:
continue
if libitem['sandboxType'] == 'PRIVATE':
@@ -778,6 +800,10 @@ def get_launch_parameters(self, app_name: str, offline: bool = False,
f'-epicsandboxid={game.namespace}'
])

if sidecar := game.sidecar:
if deployment_id := sidecar.config.get('deploymentId', None):
params.egl_parameters.append(f'-epicdeploymentid={deployment_id}')

if extra_args:
params.user_parameters.extend(extra_args)

@@ -1493,7 +1519,7 @@ def prepare_download(self, game: Game, base_game: Game = None, base_path: str =
args=new_manifest.meta.uninstall_action_args)

offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false').lower()

if file_install_tag is None:
file_install_tag = []
@@ -1564,14 +1590,7 @@ def check_installation_conditions(analysis: AnalysisResult,
f'available. If you previously deleted the game folder without uninstalling, run '
f'"legendary uninstall -y {game.app_name}" first.')

# check if the game actually ships the files or just a uplay installer + packed game files
uplay_required = False
executables = [f for f in analysis.manifest_comparison.added if
f.lower().endswith('.exe') and not f.startswith('Installer/')]
if not updating and not any('uplay' not in e.lower() for e in executables) and \
any('uplay' in e.lower() for e in executables):
uplay_required = True
results.failures.add('This game requires installation via Uplay and does not ship executable game files.')

if install.prereq_info:
prereq_path = install.prereq_info['path'].lower()
@@ -1759,7 +1778,7 @@ def import_game(self, game: Game, app_path: str, egl_guid='', platform='Windows'
path=new_manifest.meta.prereq_path, args=new_manifest.meta.prereq_args)

offline = game.metadata.get('customAttributes', {}).get('CanRunOffline', {}).get('value', 'true')
ot = game.metadata.get('customAttributes', {}).get('OwnershipToken', {}).get('value', 'false')
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,
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',
13 changes: 8 additions & 5 deletions legendary/lfs/utils.py
Original file line number Diff line number Diff line change
@@ -159,10 +159,10 @@ def get_dir_size(path):


class LockedJSONData(FileLock):
def __init__(self, file_path: str):
super().__init__(file_path + '.lock')
def __init__(self, lock_file: str):
super().__init__(lock_file + '.lock')

self._file_path = file_path
self._file_path = lock_file
self._data = None
self._initial_data = None

@@ -171,8 +171,11 @@ def __enter__(self):

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
try:
self._data = json.load(f)
self._initial_data = self._data
except json.JSONDecodeError:
pass
return self

def __exit__(self, exc_type, exc_val, exc_tb):
Loading