Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package legendary for openSUSE:Factory 
checked in at 2026-04-25 21:37:35
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/legendary (Old)
 and      /work/SRC/openSUSE:Factory/.legendary.new.11940 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "legendary"

Sat Apr 25 21:37:35 2026 rev:8 rq:1349119 version:0.20.43

Changes:
--------
--- /work/SRC/openSUSE:Factory/legendary/legendary.changes      2026-04-16 
17:26:08.977406146 +0200
+++ /work/SRC/openSUSE:Factory/.legendary.new.11940/legendary.changes   
2026-04-25 21:38:21.104171837 +0200
@@ -1,0 +2,9 @@
+Thu Apr 23 19:36:22 UTC 2026 - Jonatas Gonçalves <[email protected]>
+
+- Update to 0.20.43 Riding Shotgun (Hotfix)
+  * Revert "Also consider "ThirdPartyManagedProvider" attribute when check..." 
+    by @arielj in #39
+  * feat: support encrypted manifests and chunks by @imLinguin in #38
+  * fix: save-sync chunk read crash
+
+-------------------------------------------------------------------

Old:
----
  legendary-0.20.42.tar.gz

New:
----
  legendary-0.20.43.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ legendary.spec ++++++
--- /var/tmp/diff_new_pack.sSH5fG/_old  2026-04-25 21:38:21.736197618 +0200
+++ /var/tmp/diff_new_pack.sSH5fG/_new  2026-04-25 21:38:21.740197782 +0200
@@ -16,7 +16,7 @@
 #
 
 Name:             legendary
-Version:          0.20.42
+Version:        0.20.43
 Release:          0
 Summary:          An Epic Games Launcher alternative
 License:          GPL-3.0-only

++++++ _scmsync.obsinfo ++++++
--- /var/tmp/diff_new_pack.sSH5fG/_old  2026-04-25 21:38:21.788199740 +0200
+++ /var/tmp/diff_new_pack.sSH5fG/_new  2026-04-25 21:38:21.792199903 +0200
@@ -1,5 +1,5 @@
-mtime: 1776303601
-commit: 08b4909351213c0ae34a7cc821bcd1ad5c0358e3de86bf47b365fe361556ad3c
+mtime: 1776973287
+commit: 245f94b2319a376e36ee19b87dba05ae05beb90589961d6443716297708c857d
 url: https://src.opensuse.org/MaxxedSUSE/legendary
 revision: master
 

++++++ legendary-0.20.42.tar.gz -> legendary-0.20.43.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/.github/workflows/python.yml 
new/legendary-0.20.43/.github/workflows/python.yml
--- old/legendary-0.20.42/.github/workflows/python.yml  2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/.github/workflows/python.yml  2026-04-22 
16:35:32.000000000 +0200
@@ -79,12 +79,12 @@
       run: pip3 install --requirement requirements.txt --target build
 
     - run: cp -r legendary build
+    - run: cp zipapp_main.py build/__main__.py
 
     - name: Build
       run: python -m zipapp
         --output dist/legendary
         --python "/usr/bin/env python3"
-        --main legendary.cli:main
         --compress
         build
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/.github/workflows/release.yml 
new/legendary-0.20.43/.github/workflows/release.yml
--- old/legendary-0.20.42/.github/workflows/release.yml 1970-01-01 
01:00:00.000000000 +0100
+++ new/legendary-0.20.43/.github/workflows/release.yml 2026-04-22 
16:35:32.000000000 +0200
@@ -0,0 +1,172 @@
+name: Release
+
+on:
+  push:
+    tags:
+      - '[0-9]*'
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: 'Tag to release (e.g. 0.20.43). Must already exist.'
+        required: true
+        type: string
+
+permissions:
+  contents: write
+
+jobs:
+  pyinstaller:
+    name: Build (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        include:
+          - os: windows-2025
+            asset_name: legendary_windows_x86_64.exe
+          - os: windows-11-arm
+            asset_name: legendary_windows_arm64.exe
+          - os: macos-15-intel
+            asset_name: legendary_macOS_x86_64
+          - os: macos-15
+            asset_name: legendary_macOS_arm64
+      fail-fast: false
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ inputs.tag || github.ref }}
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.13'
+
+      - name: Dependencies
+        run: pip3 install --requirement requirements.txt
+
+      - name: Build tools
+        run: pip3 install --upgrade setuptools pyinstaller
+
+      - name: Optional dependencies (WebView)
+        run: pip3 install --upgrade pywebview
+        if: runner.os != 'macOS'
+
+      - name: Set strip option on non-Windows
+        id: strip
+        run: echo "option=--strip" >> $GITHUB_OUTPUT
+        if: runner.os != 'Windows'
+
+      - name: Build
+        working-directory: legendary
+        run: pyinstaller
+          --onefile
+          --name legendary
+          ${{ steps.strip.outputs.option }}
+          -i ../assets/windows_icon.ico
+          cli.py
+        env:
+          PYTHONOPTIMIZE: 1
+
+      - name: Rename artifact (Windows)
+        if: runner.os == 'Windows'
+        shell: bash
+        run: mv legendary/dist/legendary.exe legendary/dist/${{ 
matrix.asset_name }}
+
+      - name: Rename artifact (macOS)
+        if: runner.os == 'macOS'
+        run: mv legendary/dist/legendary legendary/dist/${{ matrix.asset_name 
}}
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: ${{ matrix.asset_name }}
+          path: legendary/dist/${{ matrix.asset_name }}
+          if-no-files-found: error
+
+  zipapp:
+    name: Build (${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        include:
+          - os: ubuntu-24.04
+            asset_name: legendary_linux_x86_64
+          - os: ubuntu-24.04-arm
+            asset_name: legendary_linux_arm64
+      fail-fast: false
+
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ inputs.tag || github.ref }}
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.13'
+
+      - run: mkdir -p build dist
+
+      - name: Dependencies
+        run: pip3 install --requirement requirements.txt --target build
+
+      - run: cp -r legendary build
+      - run: cp zipapp_main.py build/__main__.py
+
+      - name: Build
+        run: python -m zipapp
+          --output dist/${{ matrix.asset_name }}
+          --python "/usr/bin/env python3"
+          --compress
+          build
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: ${{ matrix.asset_name }}
+          path: dist/${{ matrix.asset_name }}
+          if-no-files-found: error
+
+  sdist:
+    name: Build sdist + wheel
+    runs-on: ubuntu-24.04
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: ${{ inputs.tag || github.ref }}
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: '3.13'
+
+      - run: pip3 install --upgrade build
+
+      - run: python -m build
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: python-dist
+          path: dist/*
+          if-no-files-found: error
+
+  release:
+    name: Publish GitHub Release
+    needs: [pyinstaller, zipapp, sdist]
+    runs-on: ubuntu-24.04
+    steps:
+      - name: Resolve tag
+        id: tag
+        run: echo "name=${{ inputs.tag || github.ref_name }}" >> $GITHUB_OUTPUT
+
+      - uses: actions/download-artifact@v4
+        with:
+          path: artifacts
+          merge-multiple: true
+
+      - name: List artifacts
+        run: ls -la artifacts
+
+      - uses: softprops/action-gh-release@v2
+        with:
+          tag_name: ${{ steps.tag.outputs.name }}
+          name: ${{ steps.tag.outputs.name }}
+          draft: true
+          generate_release_notes: true
+          files: artifacts/*
+          fail_on_unmatched_files: true
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/__init__.py 
new/legendary-0.20.43/legendary/__init__.py
--- old/legendary-0.20.42/legendary/__init__.py 2026-02-26 13:32:49.000000000 
+0100
+++ new/legendary-0.20.43/legendary/__init__.py 2026-04-22 16:35:32.000000000 
+0200
@@ -1,4 +1,4 @@
 """Legendary!"""
 
-__version__ = '0.20.42'
+__version__ = '0.20.43'
 __codename__ = 'Riding Shotgun (Heroic)'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/cli.py 
new/legendary-0.20.43/legendary/cli.py
--- old/legendary-0.20.42/legendary/cli.py      2026-02-26 13:32:49.000000000 
+0100
+++ new/legendary-0.20.43/legendary/cli.py      2026-04-22 16:35:32.000000000 
+0200
@@ -351,7 +351,7 @@
             return
         elif args.app_name:
             args.app_name = self._resolve_aliases(args.app_name)
-
+        manifest_secrets = dict()
         # check if we even need to log in
         if args.override_manifest:
             logger.info(f'Loading manifest from "{args.override_manifest}"')
@@ -368,9 +368,12 @@
             if not game:
                 logger.fatal(f'Could not fetch metadata for "{args.app_name}" 
(check spelling/account ownership)')
                 exit(1)
-            manifest_data, _, _ = self.core.get_cdn_manifest(game, 
platform=args.platform)
+            manifest_data, _, _, _, manifest_secrets = 
self.core.get_cdn_manifest(game, platform=args.platform)
 
         manifest = self.core.load_manifest(manifest_data)
+        if not manifest.decrypt(manifest_secrets):
+            logger.warning('Manifest key wasn\'t found. File names will be 
obfuscated')
+
         files = sorted(manifest.file_manifest_list.elements,
                        key=lambda a: a.filename.lower())
 
@@ -1296,6 +1299,8 @@
             return
 
         manifest_data, _ = self.core.get_installed_manifest(args.app_name)
+        manifest_secrets = dict()
+        
         if manifest_data is None:
             if repair_mode:
                 if not repair_online:
@@ -1304,7 +1309,7 @@
 
                 logger.warning('No manifest could be loaded, the file may be 
missing. Downloading the latest manifest.')
                 game = self.core.get_game(args.app_name, 
platform=igame.platform)
-                manifest_data, _, _ = self.core.get_cdn_manifest(game, 
igame.platform)
+                manifest_data, _, _, _, manifest_secrets = 
self.core.get_cdn_manifest(game, igame.platform)
             else:
                 logger.critical(f'Manifest appears to be missing! To repair, 
run "legendary repair '
                                 f'{args.app_name} --repair-and-update", this 
will however redownload all files '
@@ -1312,6 +1317,9 @@
                 return
 
         manifest = self.core.load_manifest(manifest_data)
+        if not manifest.decrypt(manifest_secrets):
+            logger.critical('Unable to decrypt the manifest. The key appears 
to be missing. Please report this on GitHub.')
+            return
 
         files = sorted(manifest.file_manifest_list.elements,
                        key=lambda a: a.filename.lower())
@@ -1717,6 +1725,8 @@
         manifest_data = None
         entitlements = None
         use_signed_url = None
+        is_preloaded = False
+        manifest_secrets = dict()
         # load installed manifest or URI
         if args.offline or manifest_uri:
             if app_name and self.core.is_installed(app_name):
@@ -1736,8 +1746,7 @@
             game.metadata = egl_meta
             # Get manifest if asset exists for current platform
             if args.platform in game.asset_infos:
-                manifest_data, _, use_signed_url = 
self.core.get_cdn_manifest(game, args.platform)
-
+                manifest_data, _, use_signed_url, is_preloaded, 
manifest_secrets = self.core.get_cdn_manifest(game, args.platform)
         if game:
             game_infos = info_items['game']
             game_infos.append(InfoItem('App name', 'app_name', game.app_name, 
game.app_name))
@@ -1861,6 +1870,8 @@
         if manifest_data:
             manifest_info = info_items['manifest']
             manifest = self.core.load_manifest(manifest_data)
+            manifest.decrypt(manifest_secrets)
+
             manifest_size = len(manifest_data)
             manifest_size_human = f'{manifest_size / 1024:.01f} KiB'
             manifest_info.append(InfoItem('Manifest size', 'size', 
manifest_size_human, manifest_size))
@@ -1869,6 +1880,8 @@
             manifest_info.append(InfoItem('Manifest version', 'version', 
manifest.version, manifest.version))
             manifest_info.append(InfoItem('Manifest feature level', 
'feature_level',
                                           manifest.meta.feature_level, 
manifest.meta.feature_level))
+            manifest_info.append(InfoItem('Manifest compressed', 'compressed', 
bool(manifest.compressed), bool(manifest.compressed)))
+            manifest_info.append(InfoItem('Manifest encrypted', 'encrypted', 
bool(manifest.encrypted), bool(manifest.encrypted)))
             manifest_info.append(InfoItem('Manifest app name', 'app_name', 
manifest.meta.app_name,
                                           manifest.meta.app_name))
             manifest_info.append(InfoItem('Launch EXE', 'launch_exe',
@@ -1965,11 +1978,14 @@
                                           tag_disk_size_human or 'N/A', 
tag_disk_size))
             manifest_info.append(InfoItem('Download size by install tag', 
'tag_download_size',
                                           tag_download_size_human or 'N/A', 
tag_download_size))
+            manifest_info.append(InfoItem('Is preload', 'is_preloaded', 
is_preloaded, is_preloaded))
+            
 
         if use_signed_url is not None:
             info_items["manifest"].append(
                 InfoItem('Uses signed chunk URLs', 'use_signed_urls', 
use_signed_url, use_signed_url)
             )
+        
 
         if not args.json:
             def print_info_item(item: InfoItem):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/core.py 
new/legendary-0.20.43/legendary/core.py
--- old/legendary-0.20.42/legendary/core.py     2026-02-26 13:32:49.000000000 
+0100
+++ new/legendary-0.20.43/legendary/core.py     2026-04-22 16:35:32.000000000 
+0200
@@ -1270,6 +1270,8 @@
 
         manifest_hash = m_api_r['elements'][0]['hash']
         manifest_use_signed_url: bool = m_api_r['elements'][0]['useSignedUrl']
+        manifest_is_preloaded: bool = 
m_api_r['elements'][0].get('isPreloaded') or False
+        manifest_secrets: dict = m_api_r['elements'][0].get('secrets') or 
dict()
         base_urls = []
         manifest_urls = []
         for manifest in m_api_r['elements'][0]['manifests']:
@@ -1283,10 +1285,10 @@
             else:
                 manifest_urls.append(manifest['uri'])
 
-        return manifest_urls, base_urls, manifest_hash, manifest_use_signed_url
+        return manifest_urls, base_urls, manifest_hash, 
manifest_use_signed_url, manifest_is_preloaded, manifest_secrets
 
     def get_cdn_manifest(self, game, platform='Windows', disable_https=False):
-        manifest_urls, base_urls, manifest_hash, use_signed_url = 
self.get_cdn_urls(game, platform)
+        manifest_urls, base_urls, manifest_hash, use_signed_url, 
manifest_is_preloaded, manifest_secrets = self.get_cdn_urls(game, platform)
         if not manifest_urls:
             raise ValueError('No manifest URLs returned by API')
 
@@ -1314,7 +1316,7 @@
         if sha1(manifest_bytes).hexdigest() != manifest_hash:
             raise ValueError('Manifest sha hash mismatch!')
 
-        return manifest_bytes, base_urls, use_signed_url
+        return manifest_bytes, base_urls, use_signed_url, 
manifest_is_preloaded, manifest_secrets
 
     def get_uri_manifest(self, uri):
         if uri.startswith('http'):
@@ -1380,11 +1382,14 @@
             new_manifest_data, _base_urls = 
self.get_uri_manifest(override_manifest)
             # FIXME: Populate `use_signed_urls`
             use_signed_urls = False
+            is_preloaded = False
+            # FIXME: Populate manifest secrets
+            manifest_secrets = dict()
             # if override manifest has a base URL use that instead
             if _base_urls:
                 base_urls = _base_urls
         else:
-            new_manifest_data, base_urls, use_signed_urls = 
self.get_cdn_manifest(game, platform, disable_https=disable_https)
+            new_manifest_data, base_urls, use_signed_urls, is_preloaded, 
manifest_secrets = self.get_cdn_manifest(game, platform, 
disable_https=disable_https)
             # overwrite base urls in metadata with current ones to avoid using 
old/dead CDNs
             game.base_urls = base_urls
             # save base urls to game metadata
@@ -1392,9 +1397,12 @@
 
         self.log.info('Parsing game manifest...')
         new_manifest = self.load_manifest(new_manifest_data)
+        if not new_manifest.decrypt(manifest_secrets):
+            raise ValueError('Decrypting manifest failed, key was missing, 
preloading isnt implemented yet')
+
         self.log.debug(f'Base urls: {base_urls}')
         # save manifest with version name as well for testing/downgrading/etc.
-        self.lgd.save_manifest(game.app_name, new_manifest_data,
+        self.lgd.save_manifest(game.app_name, new_manifest,
                                version=new_manifest.meta.build_version,
                                platform=platform)
 
@@ -1524,8 +1532,8 @@
             use_signed_urls = True
 
         asset = self.get_asset(game.app_name, platform)
-        dlm = DLManager(install_path, base_url, use_signed_urls, asset,
-                        resume_file=resume_file, status_q=status_q,
+        dlm = DLManager(install_path, base_url, use_signed_urls, 
manifest_secrets,
+                        asset, resume_file=resume_file, status_q=status_q,
                         max_shared_memory=max_shm * 1024 * 1024, 
max_workers=max_workers,
                         dl_timeout=dl_timeout, bind_ip=bind_ip)
 
@@ -1544,7 +1552,7 @@
             if read_files:
                 raise
             self.log.warning('Memory error encountered, retrying with file 
read enabled...')
-            dlm = DLManager(install_path, base_url, use_signed_urls, asset,
+            dlm = DLManager(install_path, base_url, use_signed_urls, 
manifest_secrets, asset,
                         resume_file=resume_file, status_q=status_q,
                         max_shared_memory=max_shm * 1024 * 1024, 
max_workers=max_workers,
                         dl_timeout=dl_timeout, bind_ip=bind_ip)
@@ -1800,9 +1808,10 @@
 
         # FIXME: Populate `use_signed_url`
         use_signed_url = False
+        manifest_secrets = dict()
         if not manifest_data:
             self.log.info(f'Downloading latest manifest for "{game.app_name}"')
-            manifest_data, base_urls, use_signed_url = 
self.get_cdn_manifest(game)
+            manifest_data, base_urls, use_signed_url, is_preloaded, 
manifest_secrets = self.get_cdn_manifest(game)
             if not game.base_urls:
                 game.base_urls = base_urls
                 self.lgd.set_game_meta(game.app_name, game)
@@ -1812,7 +1821,8 @@
 
         # parse and save manifest to disk for verification step of import
         new_manifest = self.load_manifest(manifest_data)
-        self.lgd.save_manifest(game.app_name, manifest_data,
+        new_manifest.decrypt(manifest_secrets)
+        self.lgd.save_manifest(game.app_name, new_manifest,
                                version=new_manifest.meta.build_version, 
platform=platform)
         install_size = sum(fm.file_size for fm in 
new_manifest.file_manifest_list.elements)
 
@@ -1887,7 +1897,7 @@
         with open(manifest_filename, 'rb') as f:
             manifest_data = f.read()
         new_manifest = self.load_manifest(manifest_data)
-        self.lgd.save_manifest(lgd_igame.app_name, manifest_data,
+        self.lgd.save_manifest(lgd_igame.app_name, new_manifest,
                                version=new_manifest.meta.build_version,
                                platform='Windows')
 
@@ -2079,7 +2089,7 @@
         if not self.logged_in:
             self.egs.start_session(client_credentials=True)
 
-        _manifest, base_urls, use_signed_urls = 
self.get_cdn_manifest(EOSOverlayApp)
+        _manifest, base_urls, use_signed_urls, _, manifest_secrets = 
self.get_cdn_manifest(EOSOverlayApp)
         manifest = self.load_manifest(_manifest)
 
         if igame := self.lgd.get_overlay_install_info():
@@ -2090,7 +2100,7 @@
         if use_signed_urls:
             raise ValueError('EOS Overlay requiring signed URLs, not sure what 
to do here')
 
-        dlm = DLManager(path, base_urls[0], use_signed_urls, GameAsset())
+        dlm = DLManager(path, base_urls[0], use_signed_urls, manifest_secrets, 
GameAsset())
         analysis_result = dlm.run_analysis(manifest=manifest)
 
         install_size = analysis_result.install_size
@@ -2139,7 +2149,7 @@
         if os.path.exists(path):
             raise FileExistsError(f'Bottle {bottle_name} already exists')
 
-        dlm = DLManager(path, base_url, False, GameAsset())
+        dlm = DLManager(path, base_url, False, dict(), GameAsset())
         analysis_result = dlm.run_analysis(manifest=manifest)
 
         install_size = analysis_result.install_size
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/downloader/mp/manager.py 
new/legendary-0.20.43/legendary/downloader/mp/manager.py
--- old/legendary-0.20.42/legendary/downloader/mp/manager.py    2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/legendary/downloader/mp/manager.py    2026-04-22 
16:35:32.000000000 +0200
@@ -23,8 +23,8 @@
 
 
 class DLManager(Process):
-    def __init__(self, download_dir, base_url, use_signed_chunk_urls: bool, 
asset: GameAsset,
-                 cache_dir=None, status_q=None,
+    def __init__(self, download_dir, base_url, use_signed_chunk_urls: bool, 
manifest_secrets: dict,
+                 asset: GameAsset, cache_dir=None, status_q=None,
                  max_workers=0, update_interval=1.0, dl_timeout=10, 
resume_file=None,
                  max_shared_memory=1024 * 1024 * 1024, bind_ip=None):
         super().__init__(name='DLManager')
@@ -35,6 +35,7 @@
         self.dl_dir = download_dir
         self.cache_dir = cache_dir or os.path.join(download_dir, '.cache')
         self.use_signed_chunk_urls = use_signed_chunk_urls
+        self.manifest_secrets = manifest_secrets
         self.asset = asset
         # Called with (<ticket>, <list of chunk paths>), expected to return 
signed chunk URLs
         self.sign_pipe: Optional[Connection] = None
@@ -784,7 +785,7 @@
 
             w = DLWorker(f'DLWorker {i + 1}', self.dl_worker_queue, 
self.dl_result_q,
                          self.shared_memory.name, 
logging_queue=self.logging_queue,
-                         dl_timeout=self.dl_timeout, bind_addr=bind_ip)
+                         dl_timeout=self.dl_timeout, bind_addr=bind_ip, 
secrets=self.manifest_secrets)
             self.children.append(w)
             w.start()
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/downloader/mp/workers.py 
new/legendary-0.20.43/legendary/downloader/mp/workers.py
--- old/legendary-0.20.42/legendary/downloader/mp/workers.py    2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/legendary/downloader/mp/workers.py    2026-04-22 
16:35:32.000000000 +0200
@@ -35,10 +35,11 @@
 
 class DLWorker(Process):
     def __init__(self, name, queue, out_queue, shm, max_retries=7,
-                 logging_queue=None, dl_timeout=10, bind_addr=None):
+                 logging_queue=None, dl_timeout=10, bind_addr=None, 
secrets=dict()):
         super().__init__(name=name)
         self.q = queue
         self.o_q = out_queue
+        self.secrets = secrets
         self.session = requests.session()
         self.session.headers.update({
             'User-Agent': 
'EpicGamesLauncher/11.0.1-14907503+++Portal+Release-Live 
Windows/10.0.19041.1.256.64bit'
@@ -107,7 +108,7 @@
                         continue
                     else:
                         compressed = len(r.content)
-                        chunk = Chunk.read_buffer(r.content)
+                        chunk = Chunk.read_buffer(r.content, self.secrets)
                         break
                 else:
                     raise TimeoutError('Max retries reached')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/lfs/lgndry.py 
new/legendary-0.20.43/legendary/lfs/lgndry.py
--- old/legendary-0.20.42/legendary/lfs/lgndry.py       2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/legendary/lfs/lgndry.py       2026-04-22 
16:35:32.000000000 +0200
@@ -235,9 +235,9 @@
             except FileNotFoundError:  # all other errors should propagate
                 return None
 
-    def save_manifest(self, app_name, manifest_data, version, 
platform='Windows'):
+    def save_manifest(self, app_name, manifest, version, platform='Windows'):
         with open(self._get_manifest_filename(app_name, version, platform), 
'wb') as f:
-            f.write(manifest_data)
+            manifest.write(f)
 
     def get_game_meta(self, app_name):
         if _meta := self._game_metadata.get(app_name, None):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/models/chunk.py 
new/legendary-0.20.43/legendary/models/chunk.py
--- old/legendary-0.20.42/legendary/models/chunk.py     2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/legendary/models/chunk.py     2026-04-22 
16:35:32.000000000 +0200
@@ -6,6 +6,7 @@
 from hashlib import sha1
 from io import BytesIO
 from uuid import uuid4
+from Cryptodome.Cipher import AES
 
 from legendary.utils.rolling_hash import get_hash
 
@@ -26,6 +27,10 @@
         self.sha_hash = None
         self.uncompressed_size = 1024 * 1024
 
+        self.secret_guid = None
+        self.secret_key = None
+        self.encryption_tag = None
+
         self._guid_str = ''
         self._guid_num = 0
         self._bio = None
@@ -35,12 +40,15 @@
     def data(self):
         if self._data:
             return self._data
-
+        
+        data = self._bio.read()
+        if self.encrypted:
+            cipher = AES.new(bytes.fromhex(self.secret_key), AES.MODE_GCM, 
nonce=self.sha_hash[:12])
+            data = cipher.decrypt_and_verify(data, self.encryption_tag)
         if self.compressed:
-            self._data = zlib.decompress(self._bio.read())
-        else:
-            self._data = self._bio.read()
+            data = zlib.decompress(data)
 
+        self._data = data
         # close BytesIO with raw data since we no longer need it
         self._bio.close()
         self._bio = None
@@ -78,14 +86,20 @@
     @property
     def compressed(self):
         return self.stored_as & 0x1
+    
+    @property
+    def encrypted(self):
+        return self.stored_as & 0x2
 
     @classmethod
-    def read_buffer(cls, data):
+    def read_buffer(cls, data, secrets=None):
         _sio = BytesIO(data)
-        return cls.read(_sio)
+        return cls.read(_sio, secrets)
 
     @classmethod
-    def read(cls, bio):
+    def read(cls, bio, secrets=None):
+        if secrets is None:
+            secrets = dict()
         head_start = bio.tell()
 
         if struct.unpack('<I', bio.read(4))[0] != cls.header_magic:
@@ -106,6 +120,11 @@
 
         if _chunk.header_version >= 3:
             _chunk.uncompressed_size = struct.unpack('<I', bio.read(4))[0]
+        
+        if _chunk.header_version >= 4:
+            _chunk.secret_guid = struct.unpack('<IIII', bio.read(16))
+            _chunk.secret_key = secrets.get(''.join('{:08X}'.format(g) for g 
in _chunk.secret_guid))
+            _chunk.encryption_tag = bio.read(16)
 
         if bio.tell() - head_start != _chunk.header_size:
             raise ValueError('Did not read entire chunk header!')
@@ -122,9 +141,10 @@
             self.compressed_size = len(self._data)
 
         bio.write(struct.pack('<I', self.header_magic))
-        # we only serialize the latest version so version/size are hardcoded 
to 3/66
-        bio.write(struct.pack('<I', 3))
-        bio.write(struct.pack('<I', 66))
+        # we only serialize the latest version so version/size are hardcoded 
to 4/98
+        header_size = 98 if self.header_version >= 4 else 66
+        bio.write(struct.pack('<I', self.header_version))
+        bio.write(struct.pack('<I', header_size))
         bio.write(struct.pack('<I', self.compressed_size))
         bio.write(struct.pack('<IIII', *self.guid))
         bio.write(struct.pack('<Q', self.hash))
@@ -135,7 +155,13 @@
         bio.write(struct.pack('B', self.hash_type))
 
         # header version 3 stuff
-        bio.write(struct.pack('<I', self.uncompressed_size))
+        if self.header_version >= 3:
+            bio.write(struct.pack('<I', self.uncompressed_size))
+
+        # header version 4
+        if self.header_version >= 4:
+            bio.write(struct.pack('<IIII', *self.secret_guid))
+            bio.write(self.encryption_tag)
 
         # finally, add the data
         bio.write(self._data)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/models/game.py 
new/legendary-0.20.43/legendary/models/game.py
--- old/legendary-0.20.42/legendary/models/game.py      2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/legendary/models/game.py      2026-04-22 
16:35:32.000000000 +0200
@@ -93,14 +93,7 @@
     def third_party_store(self) -> Optional[str]:
         if not self.metadata:
             return None
-
-        custom_attributes: dict[str, dict] = 
self.metadata.get('customAttributes', {})
-        for key in ['ThirdPartyManagedApp', 'ThirdPartyManagedProvider']:
-            if key not in custom_attributes:
-                continue
-            return custom_attributes.get(key).get('value')
-
-        return None
+        return self.metadata.get('customAttributes', 
{}).get('ThirdPartyManagedApp', {}).get('value', None)
 
     @property
     def partner_link_type(self):
@@ -201,6 +194,7 @@
     save_path: Optional[str] = None
     save_timestamp: Optional[float] = None
     use_signed_url: bool = False
+    is_preloaded: bool = False
 
     @classmethod
     def from_json(cls, json):
@@ -229,6 +223,7 @@
         tmp.egl_guid = json.get('egl_guid', '')
         tmp.install_tags = json.get('install_tags', [])
         tmp.use_signed_url = json.get('use_signed_url', False)
+        tmp.is_preloaded = json.get('is_preloaded', False)
         return tmp
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/legendary/models/manifest.py 
new/legendary-0.20.43/legendary/models/manifest.py
--- old/legendary-0.20.42/legendary/models/manifest.py  2026-02-26 
13:32:49.000000000 +0100
+++ new/legendary-0.20.43/legendary/models/manifest.py  2026-04-22 
16:35:32.000000000 +0200
@@ -5,7 +5,9 @@
 import hashlib
 import logging
 import struct
+import base64
 import zlib
+from Cryptodome.Cipher import AES
 
 from base64 import b64encode
 from io import BytesIO
@@ -52,7 +54,9 @@
 
 def get_chunk_dir(version):
     # The lowest version I've ever seen was 12 (Unreal Tournament), but for 
completeness sake leave all of them in
-    if version >= 15:
+    if version >= 22:
+        return 'ChunksV5'
+    elif version >= 15:
         return 'ChunksV4'
     elif version >= 6:
         return 'ChunksV3'
@@ -73,6 +77,8 @@
         self.sha_hash = ''
         self.stored_as = 0
         self.version = 18
+        self.secret_guid = (0,0,0,0)
+        self.encryption_tag = b''
         self.data = b''
 
         # remainder
@@ -80,11 +86,49 @@
         self.chunk_data_list: Optional[CDL] = None
         self.file_manifest_list: Optional[FML] = None
         self.custom_fields: Optional[CustomFields] = None
+        self.encrypted_data: Optional[EncryptedData] = None
 
     @property
     def compressed(self):
         return self.stored_as & 0x1
+    
+    @property
+    def encrypted(self):
+        return self.stored_as & 0x2
+    
+    def decrypt(self, secrets):
+        if not self.encrypted:
+            return True
+        secret_str = ''.join('{:08X}'.format(guid) for guid in 
self.secret_guid)
+        secret = secrets.get(secret_str)
+        if secret is None:
+            return False
+        cipher = AES.new(bytes.fromhex(secret), AES.MODE_GCM, 
nonce=self.encrypted_data.encrypted_header.iv)
+        decrypted = cipher.decrypt_and_verify(self.encrypted_data.ciphertext, 
self.encryption_tag)
+        if self.encrypted_data.encrypted_header.compressed:
+            decrypted = zlib.decompress(decrypted)
+
+        bio = BytesIO(decrypted)
+        self.meta.launch_exe = read_fstring(bio)
+        self.meta.launch_command = read_fstring(bio)
+        prereq_count = struct.unpack('<I', bio.read(4))[0]
+        self.meta.prereq_ids = [read_fstring(bio) for _ in range(prereq_count)]
+        self.meta.prereq_name = read_fstring(bio)
+        self.meta.prereq_path = read_fstring(bio)
+        self.meta.prereq_args = read_fstring(bio)
+        self.meta.uninstall_action_path = read_fstring(bio)
+        self.meta.uninstall_action_args = read_fstring(bio)
+
+        for file in self.file_manifest_list.elements:
+            file.filename = read_fstring(bio)
+            file.symlink_target = read_fstring(bio)
+
+        self.stored_as ^= 0x2
+        self.secret_guid = (0,0,0,0)
+        self.encrypted_data.reset()
 
+        return True
+        
     @classmethod
     def read_all(cls, data):
         _m = cls.read(data)
@@ -94,6 +138,7 @@
         _m.chunk_data_list = CDL.read(_tmp, _m.meta.feature_level)
         _m.file_manifest_list = FML.read(_tmp)
         _m.custom_fields = CustomFields.read(_tmp)
+        _m.encrypted_data = EncryptedData.read(_tmp, _m.meta.feature_level)
 
         if unhandled_data := _tmp.read():
             logger.warning(f'Did not read {len(unhandled_data)} remaining 
bytes in manifest! '
@@ -119,6 +164,9 @@
         _manifest.sha_hash = bio.read(20)
         _manifest.stored_as = struct.unpack('B', bio.read(1))[0]
         _manifest.version = struct.unpack('<I', bio.read(4))[0]
+        if _manifest.version >= 22:
+            _manifest.secret_guid = struct.unpack('<IIII', bio.read(16))
+            _manifest.encryption_tag = bio.read(16)
 
         if bio.tell() != _manifest.header_size:
             logger.warning(f'Did not read entire header {bio.tell()} != 
{_manifest.header_size}! '
@@ -152,10 +200,10 @@
             target_version = max(18, target_version)
 
         # Downgrade manifest if unknown newer version
-        if target_version > 21:
+        if target_version > 24:
             logger.warning(f'Trying to serialise an unknown target version: 
{target_version},'
-                           f'clamping to 21.')
-            target_version = 21
+                           f'clamping to 24.')
+            target_version = 24
 
         # Ensure metadata will be correct
         self.meta.feature_level = target_version
@@ -164,6 +212,8 @@
         self.chunk_data_list.write(body_bio)
         self.file_manifest_list.write(body_bio)
         self.custom_fields.write(body_bio)
+        if target_version >= 22:
+            self.encrypted_data.write(body_bio)
 
         self.data = body_bio.getvalue()
         self.size_uncompressed = self.size_compressed = len(self.data)
@@ -183,6 +233,10 @@
         bio.write(self.sha_hash)
         bio.write(struct.pack('B', self.stored_as))
         bio.write(struct.pack('<I', target_version))
+        if target_version >= 22:
+            bio.write(struct.pack('<IIII', *self.secret_guid))
+            bio.write(self.encryption_tag)
+
         bio.write(self.data)
 
         return bio.tell() if fp else bio.getvalue()
@@ -433,6 +487,16 @@
         for chunk in _cdl.elements:
             chunk.file_size = struct.unpack('<q', bio.read(8))[0]
 
+        if manifest_version >= 22:
+            for chunk in _cdl.elements:
+                chunk.secret_guid = struct.unpack('<IIII', bio.read(16))
+
+            for chunk in _cdl.elements:
+                chunk.window_size_compressed = struct.unpack('<I', 
bio.read(4))[0]
+                
+            for chunk in _cdl.elements:
+                chunk.encryption_tag = bio.read(16)
+
         if (size_read := bio.tell() - cdl_start) != _cdl.size:
             logger.warning(f'Did not read entire chunk data list! Version: 
{_cdl.version}, '
                            f'{_cdl.size - size_read} bytes missing, 
skipping...')
@@ -461,6 +525,16 @@
         for chunk in self.elements:
             bio.write(struct.pack('<q', chunk.file_size))
 
+        if self._manifest_version >= 22:
+            for chunk in self.elements:
+                bio.write(struct.pack('<IIII', *chunk.secret_guid))
+
+            for chunk in self.elements:
+                bio.write(struct.pack('<I', chunk.window_size_compressed))
+                
+            for chunk in self.elements:
+                bio.write(chunk.encryption_tag)
+
         cdl_end = bio.tell()
         bio.seek(cdl_start)
         bio.write(struct.pack('<I', cdl_end - cdl_start))
@@ -474,6 +548,9 @@
         self.sha_hash = b''
         self.window_size = 0
         self.file_size = 0
+        self.secret_guid = None
+        self.window_size_compressed = 0
+        self.encryption_tag = b''
 
         self._manifest_version = manifest_version
         # caches for things that are "expensive" to compute
@@ -518,6 +595,13 @@
 
     @property
     def path(self):
+        if self._manifest_version >= 22:
+            secret_b64 = base64.urlsafe_b64encode(struct.pack('<IIII', 
*self.secret_guid)).decode().strip('=')
+            hash_b64 = base64.urlsafe_b64encode(struct.pack('<Q', 
self.hash)).decode().strip('=')
+            guid_b64 = base64.urlsafe_b64encode(struct.pack('<IIII', 
*self.guid)).decode().strip('=')
+            return '{}/{}/{:02d}/{}_{}.chunk'.format(
+                get_chunk_dir(self._manifest_version), secret_b64,
+                self.group_num, hash_b64, guid_b64)
         return '{}/{:02d}/{:016X}_{}.chunk'.format(
             get_chunk_dir(self._manifest_version), self.group_num,
             self.hash, ''.join('{:08X}'.format(g) for g in self.guid))
@@ -810,6 +894,110 @@
         bio.seek(cf_end)
 
 
+class EncryptedData:
+    def __init__(self):
+        self.size = 0
+        self.version = 0
+        
+        self.encrypted_header = EncryptedDataHeader()
+        self.ciphertext = b''
+
+    def reset(self):
+        self.size = 0
+        self.version = 0
+        self.ciphertext = b''
+        self.encrypted_header = EncryptedDataHeader()
+
+    @classmethod
+    def read(cls, bio, feature_level):
+        _ed = cls()
+        if feature_level < 24:
+            return _ed
+
+        ed_start = bio.tell()
+        _ed.size = struct.unpack('<I', bio.read(4))[0]
+        _ed.version = struct.unpack('B', bio.read(1))[0]
+        cipher_size = struct.unpack('<I', bio.read(4))[0]
+        data = BytesIO(bio.read(cipher_size))
+        _ed.encrypted_header = EncryptedDataHeader.read(data)
+        ciphertext_size = struct.unpack('<I', data.read(4))[0]
+        _ed.ciphertext = data.read(ciphertext_size)
+
+        if (size_read := bio.tell() - ed_start) != _ed.size:
+            logger.warning(f'Did not read entire encrypted data part! Version: 
{_ed.version}, '
+                           f'{_ed.size - size_read} bytes missing, 
skipping...')
+            bio.seek(_ed.size - size_read, 1)
+            _ed.version = 0
+        return _ed
+    
+    def write(self, bio):
+        ed_start = bio.tell()
+        bio.write(struct.pack('<I', 0))  # placeholder size
+        bio.write(struct.pack('B', self.version))
+
+        # FIXME: Ensure all sizes are properly set in the header        
+        cipher_start = bio.tell()
+        bio.write(struct.pack('<I', 0)) # cipher size
+        self.encrypted_header.write(bio)
+        bio.write(struct.pack('<I', len(self.ciphertext)))
+        bio.write(self.ciphertext)
+        
+        ed_end = bio.tell()
+        bio.seek(cipher_start)
+        bio.write(struct.pack('<I', ed_end - cipher_start))
+        bio.seek(ed_start)
+        bio.write(struct.pack('<I', ed_end - ed_start))
+        bio.seek(ed_end)
+
+class EncryptedDataHeader:
+    def __init__(self):
+        self.size = 0
+        self.version = 0
+        self.stored_as = 0
+        self.data_uncompressed = 0
+        self.data_compressed = 0
+        self.iv = b''
+
+    @property
+    def compressed(self):
+        return self.stored_as & 0x1
+
+    @classmethod
+    def read(cls, bio):
+        _edh = cls()
+        
+        edh_start = bio.tell()
+        _edh.size = struct.unpack('<I', bio.read(4))[0]
+        _edh.version = struct.unpack('<I', bio.read(4))[0]
+        _edh.stored_as = struct.unpack('B', bio.read(1))[0]
+        _edh.data_uncompressed = struct.unpack('<I', bio.read(4))[0]
+        _edh.data_compressed = struct.unpack('<I', bio.read(4))[0]
+        iv_len = struct.unpack('<I', bio.read(4))[0]
+        _edh.iv = bio.read(iv_len)
+    
+        if (size_read := bio.tell() - edh_start) != _edh.size:
+            logger.warning(f'Did not read entire encrypted header data part! 
Version: {_edh.version}, '
+                            f'{_edh.size - size_read} bytes missing, 
skipping...')
+            bio.seek(_edh.size - size_read, 1)
+            _edh.version = 0
+
+        return _edh
+    
+    def write(self, bio):
+        edh_start = bio.tell()
+        bio.write(struct.pack('<I', 0))
+        bio.write(struct.pack('<I', self.version))
+        bio.write(struct.pack('B', self.stored_as))
+        bio.write(struct.pack('<I', self.data_uncompressed))
+        bio.write(struct.pack('<I', self.data_compressed))
+        bio.write(struct.pack('<I', len(self.iv)))
+        bio.write(self.iv)
+        
+        end = bio.tell()
+        bio.seek(edh_start)
+        bio.write(struct.pack('<I', end - edh_start))
+        bio.seek(end)
+
 class ManifestComparison:
     def __init__(self):
         self.added = set()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/requirements.txt 
new/legendary-0.20.43/requirements.txt
--- old/legendary-0.20.42/requirements.txt      2026-02-26 13:32:49.000000000 
+0100
+++ new/legendary-0.20.43/requirements.txt      2026-04-22 16:35:32.000000000 
+0200
@@ -1,3 +1,4 @@
 requests<3.0
 requests_futures
 filelock
+pycryptodomex
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/setup.py 
new/legendary-0.20.43/setup.py
--- old/legendary-0.20.42/setup.py      2026-02-26 13:32:49.000000000 +0100
+++ new/legendary-0.20.43/setup.py      2026-04-22 16:35:32.000000000 +0200
@@ -39,7 +39,8 @@
         'requests_futures',
         'setuptools',
         'wheel',
-        'filelock'
+        'filelock',
+        'pycryptodomex'
     ],
     extras_require=dict(
         webview=['pywebview>=3.4'],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/legendary-0.20.42/zipapp_main.py 
new/legendary-0.20.43/zipapp_main.py
--- old/legendary-0.20.42/zipapp_main.py        1970-01-01 01:00:00.000000000 
+0100
+++ new/legendary-0.20.43/zipapp_main.py        2026-04-22 16:35:32.000000000 
+0200
@@ -0,0 +1,40 @@
+import os
+import sys
+import zipfile
+import filelock
+import zlib
+
+cache_path = os.environ.get('XDG_CACHE_HOME')
+if cache_path:
+    cache_path = os.path.join(cache_path, 'legendary')
+else:
+    cache_path = os.path.expanduser('~/.cache/legendary')
+
+vendored_packages_path = os.path.join(cache_path, 'vendored')
+vendored_packages_lock = os.path.join(cache_path, 'vendored.lock')
+
+# At the moment only Cryptodome AES uses a native module
+# Thus we only handle the extraction of that
+
+if zipfile.is_zipfile(os.path.dirname(__file__)):
+    with filelock.FileLock(vendored_packages_lock) as lock:
+        with zipfile.ZipFile(os.path.dirname(__file__)) as zf:
+            # First see if we need to do the extraction
+            should_extract = True
+            init_path = os.path.join(vendored_packages_path, 
'Cryptodome/__init__.py')
+            if os.path.exists(init_path):
+                file = zf.getinfo('Cryptodome/__init__.py')
+                with open(init_path, 'rb') as init:
+                    should_extract = zlib.crc32(init.read()) != file.CRC
+
+            # We extract only dependencies that require native code
+            if should_extract:
+                for file in zf.infolist():
+                    if file.filename.startswith('Cryptodome'):
+                        extracted = zf.extract(file.filename, 
vendored_packages_path)
+                        os.chmod(extracted, file.external_attr >> 16)
+    sys.path.insert(0, vendored_packages_path)
+
+# Run CLI
+import legendary.cli
+legendary.cli.main()
\ No newline at end of file

Reply via email to