import json import queue import subprocess import textwrap import threading import time import traceback from pathlib import Path from PySide6.QtCore import Qt, QObject, Signal, Slot from PySide6.QtWidgets import QPushButton, QVBoxLayout, QDialog, QPlainTextEdit, \ QHBoxLayout, QComboBox, QMessageBox, QInputDialog, QFileDialog from core_lib.common.common import download_file, FileObject, download_multiple_files from core_lib.common.exception import VersionNotSelectedException, UnsupportedPlatformException, \ NativeResolveException, DownloadException, DependencyException, VersionManifestFetchException, \ VersionManifestSaveException, NoSpecifiedVersionKeyException, AssetManifestFetchException, \ AssetManifestSaveException, AssetDataKeyNotFoundException, VersionDataKeyNotFoundException from core_lib.game.argument import ArgumentMappings, parse_and_generate_arguments, find_game_and_jvm_args, \ generate_launch_command from core_lib.game.asset import download_assets from core_lib.game.library import generate_classpath, download_full_libraries from core_lib.game.version_info import (find_useful_part_in_specific_version_manifest, get_core_file_download_url_and_hash, \ get_all_versions, get_available_version_types, \ fetch_and_save_version_manifest, get_specific_version_manifest_url_and_hash, download_core_file) from core_lib.qt import messagebox from core_lib.qt.hack import is_main_thread from core_lib.qt.messagebox import ask_yes_no_with_plain_text MAX_DOWNLOAD_ATTEMPTS = 3 class YesNoRequest: def __init__(self, title, message): self.title = title self.message = message self.result_queue = queue.Queue(maxsize=1) self.wait_event = threading.Event() self.details = None # Set this if flag use_plain_text is enabled # flags self.use_plain_text = True def wait(self, timeout: int | None = 10): return self.wait_event.wait(timeout) def result(self): try: return self.result_queue.get_nowait() except queue.Empty: return None class TestDialog(QDialog): def __init__(self, app, parent): super().__init__(parent) self.app = app self.parent = parent self.resize(800, 600) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint) self.button = QPushButton("Fetch Version Data") self.button.setObjectName("ActionButton") self.button.clicked.connect(self.run_test) self.button_2 = QPushButton("Download client") self.button_2.setObjectName("ActionButton") self.button_2.clicked.connect(self.run_download_client) self.button_3 = QPushButton("Download libraries") self.button_3.setObjectName("ActionButton") self.button_3.clicked.connect(self.run_download_libraries) self.button_4 = QPushButton("Refresh versions") self.button_4.setObjectName("ActionButton") self.button_4.clicked.connect(self.load_all_version) self.button_5 = QPushButton("Download assets") self.button_5.setObjectName("ActionButton") self.button_5.clicked.connect(self.download_assets) self.button_6 = QPushButton("Parse Arguments") self.button_6.setObjectName("ActionButton") self.button_6.clicked.connect(self.parse_argument) self.button_7 = QPushButton("Launch Game! (Offline)") self.button_7.setObjectName("ActionButton") self.button_7.clicked.connect(self.launch_offline_game) self.ver_dropdown = QComboBox() self.ver_dropdown.setObjectName("Dropdown") self.ver_type_dropdown = QComboBox() self.ver_type_dropdown.setObjectName("Dropdown") self.ver_type_dropdown.currentTextChanged.connect( self.load_all_version ) self.output = QPlainTextEdit() self.output.setReadOnly(True) self.layout = QHBoxLayout() self.layout.addWidget(self.output) self.right_layout = QVBoxLayout() self.right_layout.setObjectName("RightLayout") self.right_layout.setAlignment(Qt.AlignmentFlag.AlignRight) self.right_layout.addWidget(self.button) self.right_layout.addWidget(self.button_2) self.right_layout.addWidget(self.button_3) self.right_layout.addWidget(self.button_5) self.right_layout.addWidget(self.button_6) self.right_layout.addWidget(self.button_7) self.right_layout.addStretch() self.right_layout.addWidget(self.ver_type_dropdown) self.right_layout.addWidget(self.ver_dropdown) self.right_layout.addWidget(self.button_4) self.right_layout.setContentsMargins(0, 10, 0, 0) self.layout.addLayout(self.right_layout) self.setLayout(self.layout) self.setStyleSheet( textwrap.dedent(""" QPushButton#ActionButton { padding: 6px 12px; } """)) # Bind signal functions self.download_signal = self.DownloadSignal(self) self.widget_signal = self.WidgetSignal(self) self.download_signal.log.connect( self.output.appendPlainText ) self.download_signal.finished.connect( lambda count: self.output.appendPlainText( f"Download finished, failed: {count}" ) ) self.download_signal.progress.connect( lambda name, progress: self.output.setPlainText( f"Downloading {name}: {self.get_progress(progress)}" ) ) self.widget_signal.clean_ver_types_dropdown.connect( self.ver_type_dropdown.clear ) self.widget_signal.update_types_dropdown.connect( lambda types: self.update_type_dropdown(types) ) self.widget_signal.clean_vers_dropdown.connect( self.ver_dropdown.clear ) self.widget_signal.update_vers_dropdown.connect( lambda vers: self.update_version_dropdown(vers) ) self.widget_signal.show_error_message.connect( lambda msg: messagebox.error( self.parent, "Error", message=msg ) ) self.widget_signal.show_and_print_error_message.connect( self.show_and_print_error ) self.widget_signal.show_warning_message.connect( lambda msg: messagebox.warning( self.parent, "Warning", message=msg ) ) self.widget_signal.show_info_message.connect( lambda msg: messagebox.info( self.parent, "Info", message=msg ) ) self.widget_signal.enable_widget.connect( lambda widget: widget.setEnabled(True) ) self.widget_signal.disable_widget.connect( lambda widget: widget.setDisabled(True) ) self.widget_signal.askyesno.connect( self.ask_request ) self.widget_signal.ask_for_path.connect( self.ask_for_path ) self.widget_signal.get_selected_ver.connect( self.get_selected_ver ) self.widget_signal.get_selected_type.connect( self.get_selected_type ) self.widget_signal.clean_output.connect( lambda : self.output.clear() ) self.load_all_types() class DownloadSignal(QObject): log = Signal(str) finished = Signal(int) failed = Signal(str) progress = Signal(str, float) class WidgetSignal(QObject): clean_ver_types_dropdown = Signal() clean_vers_dropdown = Signal() update_vers_dropdown = Signal(list) update_types_dropdown = Signal(list) show_error_message = Signal(str) show_and_print_error_message = Signal(dict) show_warning_message = Signal(str) show_info_message = Signal(str) enable_widget = Signal(object) disable_widget = Signal(object) askyesno = Signal(object) ask_for_path = Signal(dict) get_selected_ver = Signal(dict) get_selected_type = Signal(dict) clean_output = Signal() @Slot(object) def ask_request(self, context: YesNoRequest): if not context.use_plain_text: msg = QMessageBox.question( self, context.title, context.message, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes, ) result = msg == QMessageBox.StandardButton.Yes else: result = ask_yes_no_with_plain_text( self, context.title, context.message, context.details, ) if result: context.result_queue.put(True) else: context.result_queue.put(False) context.wait_event.set() @Slot(dict) def get_selected_ver(self, context: dict): context["version"] = self.ver_dropdown.currentText() event = context.get("event", None) if isinstance(event, threading.Event): event.set() @Slot(dict) def get_selected_type(self, context: dict): context["type"] = self.ver_type_dropdown.currentText() event = context.get("event", None) if isinstance(event, threading.Event): event.set() @Slot(dict) def show_and_print_error(self, context: dict): error = context.get("error", None) title = context.get("title", "Error") message = context.get("message", "No content") if isinstance(error, list): self.app.logger.error("".join(error)) elif isinstance(error, str): self.app.logger.error(error) elif isinstance(error, Exception): self.app.logger.exception(error) messagebox.error( self.parent, title, message=message ) @Slot(dict) def ask_for_path(self, context: dict): event = context.get("event") title = context.get("title", "Select a path") filter_raw = context.get("filter", "All files (*)") result = context.get("result") path, _ = QFileDialog.getOpenFileName( self, title, "", filter_raw, ) if isinstance(result, dict): result["ok"] = bool(path) result["path"] = path if isinstance(event, threading.Event): event.set() @staticmethod def get_progress(value): width = 30 filled = int(width * value / 100) bar = "█" * filled + "░" * (width - filled) return f"[{bar}] {value:.1f}%" def update_type_dropdown(self, types: list): if "release" in types: types.pop(types.index("release")) types.insert(0, "release") self.ver_type_dropdown.addItems(types) if len(self.ver_type_dropdown.currentText()) == 0: self.ver_type_dropdown.setCurrentIndex(0) def update_version_dropdown(self, versions: list): ids = [] for version in versions: ver_id = version.get("id", None) if ver_id is not None: ids.append(ver_id) self.ver_dropdown.addItems(ids) if len(self.ver_dropdown.currentText()) == 0: self.ver_dropdown.setCurrentIndex(0) def _get_selected_version_unsafe(self): if is_main_thread(): ver = self.ver_dropdown.currentText() else: event = threading.Event() context = { "event": event, } self.widget_signal.get_selected_ver.emit(context) if not event.wait(10): raise VersionNotSelectedException( "Timed out while waiting for selected version from the UI thread.", user_message="Unable to read selected version from the UI. Please try again." ) ver = context.get("version", None) if not ver: raise VersionNotSelectedException() return ver def get_selected_version(self): try: ver = self._get_selected_version_unsafe() return ver except VersionNotSelectedException as e: self.widget_signal.show_error_message.emit( "Select a version before continuing.\n" "Details: {}".format(e.user_message) ) return None def run_test(self): self.button.setDisabled(True) try: self.output.clear() ver = self.get_selected_version() if not ver: return ver_data = self.get_cached_version_manifest(ver) arguments, asset_index, downloads, java_version, libraries, main_class, legacy_game_args = ( find_useful_part_in_specific_version_manifest(ver_data)) content = textwrap.dedent(f"""\ Arguments: {arguments}\n Asset: {asset_index}\n Downloads: {downloads}\n Java Version: {java_version}\n Libraries: {libraries}\n Main Class: {main_class}\n Extra: minecraftArguments: {legacy_game_args} """) self.output.setPlainText(content) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Test Error", "message": "Failed to run test." } ) finally: self.button.setDisabled(False) def run_download_client(self): self.button_2.setDisabled(True) self.output.clear() ver = self.get_selected_version() if not ver: self.widget_signal.enable_widget.emit(self.button_2) return thread = threading.Thread( target=self._download_client, daemon=True, args=(ver,) ) thread.start() def run_download_libraries(self): self.button_3.setDisabled(True) self.output.clear() ver = self.get_selected_version() if not ver: self.widget_signal.enable_widget.emit(self.button_3) return thread = threading.Thread( target=self._download_libraries, daemon=True, args=(ver,) ) thread.start() def download_assets(self): self.button_5.setDisabled(True) self.output.clear() ver = self.get_selected_version() if not ver: self.widget_signal.enable_widget.emit(self.button_5) return thread = threading.Thread( target=self._download_assets, daemon=True, args=(ver,) ) thread.start() def parse_argument(self): self.button_6.setDisabled(True) self.output.clear() ver = self.get_selected_version() if not ver: self.widget_signal.enable_widget.emit(self.button_6) return thread = threading.Thread( target=self._parse_argument, daemon=True, args=(ver,) ) thread.start() def launch_offline_game(self): self.button_7.setDisabled(True) self.output.clear() ver = self.get_selected_version() if not ver: self.widget_signal.enable_widget.emit(self.button_7) return event = threading.Event() result = {} context = { "title": "Require a java executable path to launch game.", "event": event, "result": result, "filter": "Java executable (java.exe javaw.exe);;All files (*)" } # Ask for a support java version. If not, use system environment java self.ask_for_path( context, ) if not event.wait(120): self.widget_signal.show_error_message.emit( "Timed out while waiting for Java executable selection." ) return java_path = result.get("path") if java_path: self.download_signal.log.emit(f"Use specified java executable path: {java_path}") else: self.download_signal.log.emit("Use system environment java.") java_path = "java" thread = threading.Thread( target=self._launch_offline_game, daemon=True, args=(ver, java_path,) ) thread.start() def load_all_version(self): ver_type = self.ver_type_dropdown.currentText() thread = threading.Thread( target=self._load_all_version, daemon=True, args=(ver_type,) ) thread.start() self.download_signal.log.emit("Loading all versions...") def load_all_types(self): thread = threading.Thread( target=self._load_all_types, daemon=True ) thread.start() self.download_signal.log.emit("Loading all types...") # All functions below that must be run in background thread def _load_all_types(self): self.widget_signal.clean_ver_types_dropdown.emit() data = self.get_cached_version_manifest() if not data: return ver_types = get_available_version_types(data) self.widget_signal.update_types_dropdown.emit(ver_types) def _load_all_version(self, ver_type): self.widget_signal.clean_vers_dropdown.emit() data = self.get_cached_version_manifest() if not data: return vers = get_all_versions( data, specified_type=ver_type ) self.widget_signal.update_vers_dropdown.emit(vers) # # ============ Version Data Cache ============ # @property def version_manifest_path(self) -> Path: return Path(self.app.work_dir, "versions", "version_manifest_v2.json") def get_specific_version_manifest_path(self, target_version: str) -> Path | None: return Path(self.app.work_dir, "versions", target_version, f"{target_version}.json") def switch_between_specific_path(self, specified_ver: str | None = None): if specified_ver is None: path = self.version_manifest_path else: path = self.get_specific_version_manifest_path(specified_ver) return path def _cache_version_manifest(self, specified_ver: str | None = None, root_manifest: dict | None = None) \ -> tuple[bool, dict | None]: data = None path = self.switch_between_specific_path(specified_ver) try: data = fetch_and_save_version_manifest(path, target_version=specified_ver, root_manifest=root_manifest) except VersionManifestFetchException as e: self.widget_signal.show_error_message.emit( e.user_message, ) except VersionManifestSaveException as e: self.widget_signal.show_error_message.emit( e.user_message, ) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Cache Error", "message": "An unexpected error occurred while caching version manifest." } ) return True if data else False, data def _read_cached_version_manifest(self, specified_ver: str = None) -> dict | None: path = self.switch_between_specific_path(specified_ver) if not path.exists(): self.widget_signal.show_error_message.emit( "Version manifest missing after check. Please try again later.", ) return None try: version_manifest = json.loads(path.read_text()) return version_manifest except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Cache Error", "message": "Unable to read version manifest." } ) return None def get_cached_version_manifest(self, specified_ver: str = None, max_age: int = 3600) -> dict | None: path = self.switch_between_specific_path(specified_ver) file = FileObject(path) sha1 = None root_manifest = None if specified_ver: root_manifest = self.get_cached_version_manifest() if root_manifest is None: return None try: _, sha1 = get_specific_version_manifest_url_and_hash(root_manifest, specified_ver) except NoSpecifiedVersionKeyException as e: self.widget_signal.show_error_message.emit( f"Version {specified_ver} does not exist in version manifest.\n{e.user_message}" ) return None if file.exists: age = time.time() - path.stat().st_mtime # For root manifest if not specified_ver and age < max_age: return self._read_cached_version_manifest() # Below are all for specific ver if specified_ver and sha1 is None: self.app.logger.warning("Specified version '{}' hash doesn't exist.".format(specified_ver)) # Also check sha1 if specified_ver and age < max_age and file.verify("sha1", sha1): return self._read_cached_version_manifest(specified_ver) result, data = self._cache_version_manifest(specified_ver, root_manifest=root_manifest) if not result: return None return data # # ============ Game Files ============ # # INFO: All are not in main thread def _download_client(self, ver: str): try: ver_data = self.get_cached_version_manifest(ver) if not ver_data: return client_dest = Path(self.app.work_dir, "versions", ver, f"{ver}.jar") download_core_file(ver_data, client_dest, progress_callback=self.download_signal.progress.emit, raise_for_null_hash=True) self.download_signal.log.emit(f"Downloaded {ver} client to {client_dest}") except DownloadException as e: self.widget_signal.show_error_message.emit( "Unable to download client while downloading it: {}".format(e.user_message), ) except VersionDataKeyNotFoundException as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Fetch Error", "message": "Unable to download client due to some necessary key is missing.", } ) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Download Error", "message": "Unable to download client, an unexpected error occurred." } ) finally: self.widget_signal.enable_widget.emit(self.button_2) def _download_libraries(self, ver: str): try: self.widget_signal.clean_output.emit() ver_data = self.get_cached_version_manifest(ver) if not ver_data: return libraries_dir = Path(self.app.work_dir, "libraries") natives_dest = Path(self.app.work_dir, "versions", ver, "natives") libraries_dir.mkdir(parents=True, exist_ok=True) natives_dest.mkdir(parents=True, exist_ok=True) download_full_libraries( ver_data, libraries_dir, natives_dest, progress_callback=self.download_signal.progress.emit, redownload_callback=self.redownload_files ) self.download_signal.log.emit( "All libraries downloaded successfully." ) except DependencyException as e: self.widget_signal.show_error_message.emit( "Unable to collect library artifacts.\n" f"{e.user_message}" ) except DownloadException as e: self.widget_signal.show_error_message.emit( "Unable to collect library artifacts. An download error occurred:\n" f"{e.user_message}" ) except UnsupportedPlatformException as e: self.widget_signal.show_error_message.emit( "Unable to collect native artifacts.\n" f"{e.user_message}" ) except NativeResolveException as e: self.widget_signal.show_error_message.emit( "Unable to collect native artifacts.\n" f"{e.user_message}" ) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Download Error", "message": "Unable to collect library artifacts. An unknown error occurred." } ) finally: self.widget_signal.enable_widget.emit(self.button_3) def _download_assets(self, ver: str): try: self.widget_signal.clean_output.emit() ver_data = self.get_cached_version_manifest(ver) if not ver_data: return assets_dir = Path(self.app.work_dir, "assets") fails = download_assets(ver_data, assets_dir, no_hash_check=True) if fails: self.widget_signal.show_warning_message.emit( "Some assets were not downloaded. Although is not necessary for launching game." " But it may cause some texture issue when you playing. (Such as missing textures)" ) else: self.download_signal.log.emit( "All assets downloaded successfully." ) except AssetManifestFetchException as e: self.widget_signal.show_error_message.emit( f"Unable to fetch asset manifest.\n{e.user_message}" ) except AssetManifestSaveException as e: self.widget_signal.show_error_message.emit( f"Unable to save asset manifest.\n{e.user_message}" ) except AssetDataKeyNotFoundException as e: self.widget_signal.show_error_message.emit( f"Unable to collect assets.\n{e.user_message}" ) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Download Error", "message": "Unable to download assets. An unknown error occurred." } ) finally: self.widget_signal.enable_widget.emit(self.button_5) def _parse_argument(self, ver): try: ver_data = self.get_cached_version_manifest(ver) if not ver_data: return new_args, asset_index, _, _, _, _, old_args = find_useful_part_in_specific_version_manifest(ver_data) target_args = new_args if new_args else old_args if not target_args: self.widget_signal.show_error_message.emit( "Unable to parse arguments because data are invalid.\n" ) return arg_maps = ArgumentMappings() game_args, jvm_args = find_game_and_jvm_args(target_args) parsed_jvm_args = parse_and_generate_arguments( jvm_args, arg_maps ) parsed_game_args = parse_and_generate_arguments( game_args, arg_maps ) self.download_signal.log.emit(f"Parsed jvm arguments: \n{parsed_jvm_args}\n{" ".join(parsed_jvm_args)}") self.download_signal.log.emit(f"Parsed game arguments: \n{parsed_game_args}\n{" ".join(parsed_game_args)}") except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Parse Error", "message": "Unable to parse arguments. An unknown error occurred." } ) finally: self.widget_signal.enable_widget.emit(self.button_6) def _launch_offline_game(self, ver, java_path): """ Launch offline game :param ver: :param java_path: :return: """ try: current_status = 0 # 0 = fetch version manifest, 1 = check client, 2 = check libraries # 3 = check libraries, 4 = check assets # Version manifest ver_data = self.get_cached_version_manifest(ver) if not ver_data: return arguments, asset_index, downloads, java_version, libraries, main_class, old_args = ( find_useful_part_in_specific_version_manifest(ver_data) ) # Vars asset_id = asset_index.get("id") # Some paths core_file_path = Path(self.app.work_dir, "versions", ver, f"{ver}.jar") game_dir = Path(self.app.temp_dir, "minecraft") game_dir.mkdir(parents=True, exist_ok=True) natives_dir = Path(self.app.work_dir, "versions", ver, "natives") assets_dir = Path(self.app.work_dir, "assets") libraries_dir = Path(self.app.work_dir, "libraries") # Check client self._download_client(ver) if not core_file_path.exists(): url, sha1 = get_core_file_download_url_and_hash(ver_data) try: ver_data = self.get_cached_version_manifest(ver) if not ver_data: return url, sha1 = get_core_file_download_url_and_hash(ver_data) dest = Path(self.app.work_dir, "versions", ver, f"{ver}.jar") file = FileObject(dest, url=url) if sha1 is None: self.app.logger.warning("Client has no sha1.") if file.exists and file.verify("sha1", sha1): self.download_signal.log.emit(f"Client existed and sha1 matches.") elif url is not None: download_file(file, progress_callback=self.download_signal.progress.emit) elif not url: self.download_signal.failed.emit("Download URL not found.") self.download_signal.log.emit(f"Downloaded URL: {url}") except DownloadException as e: self.widget_signal.show_error_message.emit( "Unable to download client while downloading it: {}".format(e.user_message), ) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Download Error", "message": "Unable to download client, an unexpected error occurred." } ) finally: self.widget_signal.enable_widget.emit(self.button_2) self._download_libraries(ver) self._download_assets(ver) classpath = generate_classpath( ver_data, libraries_dir, core_file_path, ) # Mappings arg_maps = ArgumentMappings( player_name="Player", version_name=ver, game_directory=game_dir.as_posix(), assets_root=assets_dir.as_posix(), assets_index_name=asset_id, auth_uuid="00000000000000000000000000000000", auth_access_token="0", user_type="legacy", version_type="release", natives_directory=natives_dir.as_posix(), launcher_name="TestLauncher", launcher_version="0.1", classpath=classpath, classpath_separator=";", library_directory=libraries_dir.as_posix(), ) target_args = arguments if arguments else old_args if not target_args: self.widget_signal.show_error_message.emit( "Unable to parse arguments because data are invalid.\n" ) return # Finally, generate launch command cmd = generate_launch_command( arg_maps=arg_maps, main_class=main_class, java_path=java_path, full_args=target_args, ) self.download_signal.log.emit(f"Launch command: {cmd}") # run it process = subprocess.Popen( cmd, cwd=game_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", ) for line in process.stdout: self.download_signal.log.emit(line.rstrip()) except Exception as e: self.widget_signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Launch Error", "message": "Unable to launch error. An unknown error occurred." } ) finally: self.widget_signal.enable_widget.emit(self.button_7) def redownload_files(self, files: list[FileObject]) -> tuple[bool, list[FileObject]]: if not files: return True, [] message = ( "Some files failed to download.\n" "Would you like to retry downloading them?\n\n" ) request = YesNoRequest("Redownload", message) request.details = "\n".join(file.posix_path for file in files) self.widget_signal.askyesno.emit(request) if not request.wait(60): self.widget_signal.show_error_message.emit( "Timed out while waiting for confirmation. Please try again." ) return False, files result = request.result() if not result: return False, files # copy pending = list(files) for retry_time in range(1, MAX_DOWNLOAD_ATTEMPTS + 1): if not pending: return True, [] self.download_signal.log.emit(f"Attempt {retry_time}/{len(pending)}") fails, successes = download_multiple_files(pending, progress_callback=self.download_signal.progress.emit) for file in successes: self.download_signal.log.emit(f"Downloaded {file.posix_path}.") if fails: pending = fails else: pending = [] if not pending: return True, [] failed_list = "\n".join(file.posix_path for file in pending) self.app.logger.warning( "Unable to download these files:\n{}".format(failed_list) ) return False, pending