diff --git a/core_lib/game/argument.py b/core_lib/game/argument.py new file mode 100644 index 0000000..3b6e8f9 --- /dev/null +++ b/core_lib/game/argument.py @@ -0,0 +1,151 @@ +import logging +import shlex + +from core_lib.game.library import get_natives_platform_info + +logger = logging.getLogger("Launcher.CoreLib") + +def is_argument_allowed(argument: dict | str, allow_features: dict=dict) -> bool: + """ + Check if argument is allowed or not. + :param argument: + :param allow_features: Should be like {key: value} + :return: + allowed: bool + """ + if isinstance(argument, str): + return True + + rules = argument.get('rules', {}) + + _, platform_rule_name, _ = get_natives_platform_info() + + allowed = False + + if not rules: + return True + + for rule in rules: + rule_action = rule.get("action", None) + os_section = rule.get("os", {}) + rule_os = os_section.get("name", None) + features_rule = rule.get("features", None) + + if rule_os is not None and platform_rule_name != rule_os: + continue + + matched = True + + # Check feature dict + if features_rule: + for key, expected in features_rule.items(): + if allow_features.get(key, False) != expected: + matched = False + break + + if not matched: + continue + + + if rule_action == "allow": + allowed = True + elif rule_action == "disallow": + allowed = False + else: + logger.warning(f"Unknown action {rule_action}\n") + + return allowed + +def replace_placeholders(argument, placeholders: dict) -> str: + if argument: + for key, value in placeholders.items(): + argument = argument.replace("${" + key + "}", "" if value is None else str(value)) + + return argument + +class ArgumentMappings: + def __init__(self, player_name="Player", version_name="unknown", + game_directory=None, assets_root=None, assets_index_name=None, + auth_uuid="00000001-0002-0003-0004-000000000005", + auth_access_token="", clientid=None, auth_xuid=None, + user_type=None, version_type=None, natives_directory=None, + launcher_name=None, launcher_version=None, + classpath=None, classpath_separator=None, library_directory=None): + self.player_name = player_name + self.version_name = version_name + self.game_directory = game_directory + self.assets_root = assets_root + self.assets_index_name = assets_index_name + self.auth_uuid = auth_uuid + self.auth_access_token = auth_access_token + self.clientid = clientid + self.auth_xuid = auth_xuid + self.user_type = user_type + self.version_type = version_type + self.natives_directory = natives_directory + self.launcher_name = launcher_name + self.launcher_version = launcher_version + self.classpath = classpath + self.classpath_separator = classpath_separator + self.library_directory = library_directory + + def generate_placeholders(self): + return { + "player_name": self.player_name, + "version_name": self.version_name, + "game_directory": self.game_directory, + "assets_root": self.assets_root, + "assets_index_name": self.assets_index_name, + "auth_uuid": self.auth_uuid, + "auth_access_token": self.auth_access_token, + "auth_player_name": self.player_name, + "clientid": self.clientid, + "auth_xuid": self.auth_xuid, + "user_type": self.user_type, + "version_type": self.version_type, + "natives_directory": self.natives_directory, + "launcher_name": self.launcher_name, + "launcher_version": self.launcher_version, + "classpath": self.classpath, + "classpath_separator": self.classpath_separator, + "library_directory": self.library_directory + } + +def convert_argument_to_multiple_string(argument: str | dict) -> list[str]: + if isinstance(argument, str): + return [argument] + + if isinstance(argument, dict): + value = argument.get("value", None) + + if isinstance(value, list): + return [str(item) for item in value] # ensure all item are string + elif isinstance(value, str): + return [value] + + raise TypeError("Argument must be of type str or dict") + +def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappings, features: dict=dict) -> list[str]: + parsed_arguments = [] + + if isinstance(arguments, str): + # Convert legacy argument into a list type + arguments = shlex.split(arguments) + + placeholders = mappings.generate_placeholders() + + for argument in arguments: + # Check rules + if not is_argument_allowed(argument, allow_features=features): + continue + + # Each argument (section) may contain multiple items, convert it to list before replace placeholder + converted = convert_argument_to_multiple_string(argument) + + for item in converted: + parsed = replace_placeholders(item, placeholders) + parsed_arguments.append(parsed) + + return parsed_arguments + + diff --git a/core_lib/game/asset.py b/core_lib/game/asset.py index ec9bf17..acf7c11 100644 --- a/core_lib/game/asset.py +++ b/core_lib/game/asset.py @@ -13,6 +13,8 @@ from core_lib.game.version_info import find_useful_part_in_specific_version_mani MOJANG_RESOURCE_ENDPOINT = "https://resources.download.minecraft.net/{beginning}/{hash}" +ASSET_DOWNLOAD_MAX_WORKERS = 20 + logger = logging.getLogger("Launcher.CoreLib") def fetch_asset_manifest(target_version_manifest: dict, raw=False) -> tuple[dict | bytes, str]: @@ -84,7 +86,7 @@ def fetch_and_save_asset_manifest(target_version_manifest: dict, destination: Pa with file.path.open("wb") as f: f.write(data) - if sha1 is not None and not file.verify_sha1(sha1): + if sha1 and not file.verify_sha1(sha1): raise HashMismatchException( "sha1", file.sha1, @@ -94,6 +96,7 @@ def fetch_and_save_asset_manifest(target_version_manifest: dict, destination: Pa return json.loads(data.decode("utf-8")) except Exception as e: + logger.exception(e) raise AssetManifestSaveException( "Error saving version manifest:\n{}".format(e) ) @@ -164,7 +167,14 @@ def download_assets_and_copy(assets: list[FileObject], legacy_assets: list[dict] files: list[FileObject] (files that download failed) """ - fails , _ = download_multiple_files(assets, progress_callback=progress_callback) + fails , _ = download_multiple_files(assets, progress_callback=progress_callback, + max_workers=ASSET_DOWNLOAD_MAX_WORKERS) + + if fails: + logger.warning( + "Download failed for assets: \n" + +"\n".join([asset.posix_path for asset in fails]) + ) for asset in legacy_assets: target = asset.get("target") diff --git a/core_lib/game/version_info.py b/core_lib/game/version_info.py index 7f77417..03da6bb 100644 --- a/core_lib/game/version_info.py +++ b/core_lib/game/version_info.py @@ -413,6 +413,7 @@ def fetch_and_save_version_manifest(dest: Path, target_version: str=None, root_m return data except Exception as e: + logger.exception(e) raise VersionManifestSaveException( "Error saving version manifest:\n{}".format(e) ) \ No newline at end of file diff --git a/dialog/test.py b/dialog/test.py index be9affc..43b1ccb 100644 --- a/dialog/test.py +++ b/dialog/test.py @@ -2,6 +2,7 @@ import json import textwrap import threading import time +import traceback from pathlib import Path from PySide6.QtCore import Qt, QObject, Signal, Slot @@ -11,7 +12,9 @@ from PySide6.QtWidgets import QPushButton, QVBoxLayout, QDialog, QPlainTextEdit, 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 + VersionManifestSaveException, NoSpecifiedVersionKeyException, AssetManifestFetchException, \ + AssetManifestSaveException, AssetDataKeyNotFoundException +from core_lib.game.asset import fetch_and_save_asset_manifest, collect_assets_from_manifest, download_assets_and_copy from core_lib.game.library import collect_native_artifacts, download_libraries, unzip_natives, collect_library_artifacts from core_lib.game.version_info import (find_useful_part_in_specific_version_manifest, @@ -48,6 +51,10 @@ class TestDialog(QDialog): 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.ver_dropdown = QComboBox() self.ver_dropdown.setObjectName("Dropdown") @@ -70,6 +77,7 @@ class TestDialog(QDialog): 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.addStretch() self.right_layout.addWidget(self.ver_type_dropdown) self.right_layout.addWidget(self.ver_dropdown) @@ -131,6 +139,10 @@ class TestDialog(QDialog): ) ) + 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, @@ -185,6 +197,7 @@ class TestDialog(QDialog): 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) @@ -227,6 +240,25 @@ class TestDialog(QDialog): 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 + ) + @staticmethod def get_progress(value): width = 30 @@ -326,8 +358,12 @@ class TestDialog(QDialog): """) self.output.setPlainText(content) except Exception as e: - self.widget_signal.show_error_message.emit( - "Failed to run test. Details: {}".format(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) @@ -362,7 +398,25 @@ class TestDialog(QDialog): return thread = threading.Thread( - target=self._download_libraries_process, + 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,) ) @@ -444,8 +498,12 @@ class TestDialog(QDialog): "Unable to save version manifest: {}".format(e.user_message), ) except Exception as e: - self.widget_signal.show_error_message.emit( - "An unexpected error occurred while caching version manifest: {}".format(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 @@ -462,8 +520,12 @@ class TestDialog(QDialog): version_manifest = json.loads(path.read_text()) return version_manifest except Exception as e: - self.widget_signal.show_error_message.emit( - "Unable to read version manifest:\n{}".format(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 @@ -542,13 +604,17 @@ class TestDialog(QDialog): "Unable to download client while downloading it: {}".format(e.user_message), ) except Exception as e: - self.widget_signal.show_error_message.emit( - "Unable to download client: {}".format(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_process(self, ver: str): + def _download_libraries(self, ver: str): output_dir = Path(self.app.work_dir, "libraries") output_dir.mkdir(parents=True, exist_ok=True) @@ -610,13 +676,80 @@ class TestDialog(QDialog): f"{e.user_message}" ) except Exception as e: - self.widget_signal.show_error_message.emit( - "Unable to collect library artifacts. An unknown error occurred:\n" - f"{str(e) + f"\n{type(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 + + _, asset_index, _, _, _, _, _ = find_useful_part_in_specific_version_manifest(ver_data) + + asset_id = asset_index.get("id") + assets_dir = Path(self.app.work_dir, "assets") + index_path = Path(assets_dir, "indexes", f"{asset_id}.json") + + # Download and save asset manifest + asset_manifest = fetch_and_save_asset_manifest( + ver_data, + index_path, + ) + + # Download assets files + assets, legacy_assets = collect_assets_from_manifest( + asset_id, + asset_manifest, + assets_dir, + ) + + fails = download_assets_and_copy( + assets, + legacy_assets, + progress_callback=self.download_signal.progress.emit, + ) + + if fails: + self.widget_signal.show_error_message.emit( + "Some assets failed to download:\n" + + "\n".join(file.posix_path for file in fails) + ) + else: + self.download_signal.log.emit("All necessary assets were downloaded.") + 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 redownload_files(self, files: list[FileObject]) -> tuple[bool, list[FileObject]]: if not files: return True, []