From 07d5b8a3789f6295008ed4379139dfcc38f7f8e5 Mon Sep 17 00:00:00 2001 From: Wei Hong Date: Tue, 28 Jul 2026 13:35:04 +0800 Subject: [PATCH] Add download progress dialog and some mod loader parse function. --- app.py | 151 ++------- constant.py | 2 + core_lib/common/common.py | 8 +- core_lib/game/argument.py | 131 +++++--- core_lib/game/library.py | 555 +------------------------------ core_lib/game/library_core.py | 589 +++++++++++++++++++++++++++++++++ core_lib/game/libraryx.py | 130 ++++++++ core_lib/game/mod/fabric.py | 4 +- core_lib/game/mod/mod_core.py | 110 ++++-- core_lib/qt/progress_dialog.py | 168 ++++++++++ main.py | 2 +- manager/launcher.py | 5 + manager/profile.py | 129 ++++++++ manager/widgets.py | 60 ++++ pages/create_profile.py | 5 +- pages/test.py | 483 ++++++++++++--------------- window.py | 366 ++++++++++++++++++++ 17 files changed, 1873 insertions(+), 1025 deletions(-) create mode 100644 constant.py create mode 100644 core_lib/game/library_core.py create mode 100644 core_lib/game/libraryx.py create mode 100644 core_lib/qt/progress_dialog.py create mode 100644 manager/launcher.py create mode 100644 manager/profile.py create mode 100644 manager/widgets.py create mode 100644 window.py diff --git a/app.py b/app.py index d048db0..a8b86e7 100644 --- a/app.py +++ b/app.py @@ -5,20 +5,15 @@ from typing import Callable from PySide6 import QtWidgets from PySide6.QtCore import QSize from PySide6.QtGui import QIcon, Qt, QPixmap -from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QStyle, QToolButton, \ - QStackedWidget +from PySide6.QtWidgets import QApplication, QWidget, QStyle, QToolButton +from constant import LAUNCHER_VERSION from core_lib.qt.hack import move_window_to_center, is_light_theme, \ recolor_icon from core_lib.qt import messagebox from core_lib.launcher.object import Launcher as LauncherObject - -from pages.account import AccountPage -from pages.create_profile import CreateProfile -from pages.test import TestPage - -LAUNCHER_VERSION = "alpha_0.0.2" -MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" +from manager.widgets import WidgetManager +from window import LauncherMainWindow class ToolBar(QtWidgets.QToolBar): def __init__(self, app: QApplication, parent=None): @@ -100,141 +95,38 @@ class ToolBar(QtWidgets.QToolBar): self.update() class Launcher(QApplication, LauncherObject): - def __init__(self): + def __init__(self, logger): super().__init__() - # Window config - self.setApplicationName("TestLauncher") - self.main_window = QMainWindow() - self.main_window.setGeometry(0, 0, 800, 600) - move_window_to_center(self.main_window) - # logger - self.logger = logging.getLogger("Launcher.MainWindow") + self.logger = logger # dirs self.program_dir = Path(__file__).parent self.work_dir = Path.cwd() + # Widget (Signal, Event) + self.widget_manager = WidgetManager() + + # Window config + self.setApplicationName("TestLauncher") + self.main_window = LauncherMainWindow( + app=self, + widget_manager=self.widget_manager, + ) + self.main_window.setGeometry(0, 0, 800, 600) + move_window_to_center(self.main_window) + # Set icon if self.icon_path.exists(): self.setWindowIcon(QIcon(self.icon_path.as_posix())) else: self.logger.error("No icon found.") - # Main layout - self.central = QStackedWidget() - self.toolbar = ToolBar(self, self.main_window) - - # center message (will be deleted if launcher finished development) - content = """Still digging!""" - message = "If you found that something might be a issue. You can report it to the main repo! Any suggestion are welcome." - self.icon_label = QLabel() - self.icon_label.setObjectName("icon_label") - self.dev_info_box = QLabel(content) - self.dev_info_box.setObjectName("dev_info_box") - self.dev_info_2 = QLabel(message) - self.dev_info_2.setObjectName("dev_info_message") - self.version_label = QLabel("Current version: {}".format(self.launcher_version)) - self.version_label.setObjectName("version_label") - self.repo_url_label = QLabel(f"Main repo") - self.repo_url_label.setObjectName("repo_url_label") - self.repo_url_label.setOpenExternalLinks(True) - - if Path(self.resources_dir, "pictures", "in_progress.png").exists(): - image = QPixmap(Path(self.resources_dir, "pictures", "in_progress.png")) - self.icon_label.setPixmap(image.scaled(300, 300)) - else: - self.icon_label.setText("Ouch. The icon is missing.") - - # Homepage - self.home_page = QWidget() - self.home_layout = QVBoxLayout(self.home_page) - self.home_layout.addStretch() - self.home_layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter) - self.home_layout.addWidget(self.dev_info_box, alignment=Qt.AlignmentFlag.AlignHCenter) - self.home_layout.addWidget(self.dev_info_2, alignment=Qt.AlignmentFlag.AlignHCenter) - self.home_layout.addWidget(self.version_label, alignment=Qt.AlignmentFlag.AlignHCenter) - self.home_layout.addWidget(self.repo_url_label, alignment=Qt.AlignmentFlag.AlignHCenter) - self.home_layout.addStretch() - - # test page - self.test_page = TestPage(self, self.main_window) - - # Account page - self.account_page = AccountPage(self, self.main_window) - - # Profile page - self.create_profile_page = CreateProfile(self, self.main_window) - - # Add page - self.central.addWidget(self.home_page) - self.central.addWidget(self.test_page) - self.central.addWidget(self.account_page) - self.central.addWidget(self.create_profile_page) - - # Bind toolbar and center widget - self.main_window.addToolBar(Qt.ToolBarArea.LeftToolBarArea, self.toolbar) - self.main_window.setCentralWidget(self.central) - - # Apply qss - self.apply_qss(Path("main.qss"), self.setStyleSheet) - self.apply_qss(Path("toolbar.qss"), self.toolbar.setStyleSheet) - - # Toolbar - - # Test Window btn - self.open_test_page = self.toolbar.add_button(" Tests", icon=self.get_icon(Path(self.icon_dir, "debug.png"))) - self.open_test_page.setObjectName("open_test_page") - - # Home btn - self.home_button = self.toolbar.add_button(icon=self.get_icon(Path(self.icon_dir, "home.png"))) - - # Account btn (will be moved to the bottom (or last one item) of the toolbar - self.open_account_page = self.toolbar.add_button(icon=self.get_icon(Path(self.icon_dir, "head.png"))) - - # Profile btn - self.open_create_profile_page = self.toolbar.add_button(icon=self.get_icon(Path(self.icon_dir, "add_temp.png"))) - - # Bind tool buttons - self.toolbar.addWidget(self.home_button) - self.toolbar.addWidget(self.open_test_page) - self.toolbar.addWidget(self.open_create_profile_page) - self.toolbar.setMovable(True) - self.toolbar.setFloatable(False) - self.toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - - self.toolbar.addWidget(self.toolbar.spacer) - self.toolbar.addSeparator() - self.toolbar.addWidget(self.open_account_page) - - # Bind page-related button click event - self.home_button.clicked.connect( - lambda: self.set_current_page(self.home_page, self.home_button) - ) - - self.open_test_page.clicked.connect( - lambda: self.set_current_page(self.test_page, self.open_test_page) - ) - - self.open_account_page.clicked.connect( - lambda: self.set_current_page(self.account_page, self.open_account_page) - ) - - self.open_create_profile_page.clicked.connect( - lambda: self.set_current_page(self.create_profile_page, self.open_create_profile_page) - ) - - self.set_current_page(self.home_page, self.home_button) - def exec(self): self.main_window.show() - self.toolbar.update_all() + self.main_window.toolbar.update_all() self.exec_() - def set_current_page(self, page: QWidget, related_button: QToolButton): - self.central.setCurrentWidget(page) - related_button.setFocus() - def get_icon(self, path: Path): icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) if not path.exists() or not path.is_file(): @@ -342,6 +234,5 @@ class Launcher(QApplication, LauncherObject): return "" - - - + def get_main_window(self): + return self.main_window diff --git a/constant.py b/constant.py new file mode 100644 index 0000000..f8f5d42 --- /dev/null +++ b/constant.py @@ -0,0 +1,2 @@ +LAUNCHER_VERSION = "alpha_0.0.2" +MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" \ No newline at end of file diff --git a/core_lib/common/common.py b/core_lib/common/common.py index 9f28578..eceaa53 100644 --- a/core_lib/common/common.py +++ b/core_lib/common/common.py @@ -180,6 +180,7 @@ def download_file(file: FileObject, overwrite=True, progress_callback: Callable[ try: with requests.get(file.url, stream=True) as r: + r.raise_for_status() current_chunk = 0 total_bytes = r.headers.get("content-length", None) logger.debug("URL: {}".format(file.url)) @@ -211,7 +212,7 @@ def download_file(file: FileObject, overwrite=True, progress_callback: Callable[ if any([file.expected_sha1, file.expected_md5, file.expected_sha256]): logger.debug("File hash is match as the expected hash.") else: - logger.warning("File hash is not checked.") + logger.warning("Hash is not checked for file: {}".format(file.posix_path)) return file @@ -299,3 +300,8 @@ def get_platform_info(): return platform_name, arch, os_version +def is_valid_zipfile(path: Path) -> bool: + try: + return zipfile.is_zipfile(path) + except OSError: + return False \ No newline at end of file diff --git a/core_lib/game/argument.py b/core_lib/game/argument.py index f46e48e..f4b9b7e 100644 --- a/core_lib/game/argument.py +++ b/core_lib/game/argument.py @@ -1,12 +1,15 @@ +import json import logging import re import shlex +from core_lib.common.common import get_platform_info 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 | None=None) -> bool: + +def is_argument_allowed(argument: dict | str, allow_features: dict | None = None) -> bool: """ Check if argument is allowed or not. :param argument: @@ -22,7 +25,8 @@ def is_argument_allowed(argument: dict | str, allow_features: dict | None=None) rules = argument.get('rules', {}) - _, platform_rule_name, _ = get_natives_platform_info() + _, platform_rule_name, platform_arch_type = get_natives_platform_info() + _, _, platform_version = get_platform_info() allowed = False @@ -30,33 +34,52 @@ def is_argument_allowed(argument: dict | str, allow_features: dict | None=None) 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 not isinstance(rule, dict): + logger.warning("Ignoring invalid argument rule: {}".format(rule)) + continue + rule_action = rule.get("action", None) + os_rule = rule.get("os") or {} + rule_os = os_rule.get("name", None) + feature_rule = rule.get("features") or {} + rule_arch = os_rule.get("arch") + rule_version = os_rule.get("version") + + # Check platform 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: + # Check architecture + if rule_arch is not None and rule_arch != platform_arch_type: continue + # Check platform version + if rule_version is not None: + try: + if re.search( + rule_version, + str(platform_version), + ) is None: + continue + except re.error as exc: + logger.warning( + "Unsupported argument OS version regex %r: %s", + rule_version, + exc, + ) + continue + + + # Check feature rule + if any(allow_features.get(key, False) != expected for key, expected in feature_rule.items()): + continue if rule_action == "allow": allowed = True elif rule_action == "disallow": allowed = False else: - logger.warning(f"Unknown action {rule_action}\n") + logger.warning(f"Unknown argument rule action {rule_action}\n") return allowed @@ -67,6 +90,7 @@ def replace_placeholders(argument, placeholders: dict) -> str: return argument + class ArgumentMappings: def __init__(self, player_name="Player", version_name="unknown", game_directory=None, assets_root=None, legacy_assets_root=None, assets_index_name=None, @@ -87,8 +111,15 @@ class ArgumentMappings: self.auth_xuid = auth_xuid self.user_type = user_type self.user_properties = user_properties - if not isinstance(user_properties, dict): - self.user_properties = {} + if isinstance(user_properties, dict): + self.user_properties = json.dumps( + user_properties, + separators=(",", ":"), + ) + else: + if user_properties is not None: + logger.warning(f"User properties should be a dictionary.") + self.user_properties = "{}" self.version_type = version_type self.natives_directory = natives_directory self.launcher_name = launcher_name @@ -122,6 +153,7 @@ class ArgumentMappings: "library_directory": self.library_directory } + def convert_argument_to_multiple_string(argument: str | dict) -> list[str]: if isinstance(argument, str): return [argument] @@ -130,12 +162,13 @@ def convert_argument_to_multiple_string(argument: str | dict) -> list[str]: value = argument.get("value", None) if isinstance(value, list): - return [str(item) for item in value] # ensure all item are string + 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 find_game_and_jvm_args(arguments: dict | str) -> tuple[list, list]: game_args = [] jvm_args = [] @@ -160,11 +193,13 @@ def find_game_and_jvm_args(arguments: dict | str) -> tuple[list, list]: return game_args, jvm_args + REMAPPING_ARGUMENTS = { # For some (I don't know) reason, in 26.2, Mojang include some "-Djava.library.path=${natives_directory}/java": "-Djava.library.path=${natives_directory}" } + def hard_remapping_special_arguments(argument: str) -> str: for arg in REMAPPING_ARGUMENTS: new_value = REMAPPING_ARGUMENTS[arg] @@ -174,7 +209,9 @@ def hard_remapping_special_arguments(argument: str) -> str: return argument -def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappings, features: dict | None=None) -> list[str]: + +def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappings, features: dict | None = None) -> \ +list[str]: if features is None: features = {} @@ -204,38 +241,59 @@ def parse_and_generate_arguments(arguments: list | str, mappings: ArgumentMappin return parsed_arguments +def append_missing_necessary_jvm_arguments(arguments: list, arg_maps: ArgumentMappings) -> list[str]: + result = [] + has_classpath = any( + argument in ("-cp", "-classpath", "--class-path") + for argument in arguments + ) + + has_native_path = any( + argument.startswith("-Djava.library.path=") + for argument in arguments + ) + + if not has_native_path: + result.append(f"-Djava.library.path={arg_maps.natives_directory}") + + if not has_classpath: + result.extend(["-cp", arg_maps.classpath]) + + result.extend(arguments) + + return result + + def generate_launch_command(arg_maps: ArgumentMappings, main_class: str, java_path: str, full_args: dict | str - ) -> list[str]: + , features: dict | None = None) -> list[str]: cmd = [java_path] game_args, jvm_args = find_game_and_jvm_args(full_args) parsed_jvm_args = parse_and_generate_arguments( jvm_args, - arg_maps + arg_maps, + features=features ) parsed_game_args = parse_and_generate_arguments( game_args, - arg_maps + arg_maps, + features=features ) # Legacy Minecraft don't have jvm arguments exist in the version library. We need to generate by ourselves - if len(parsed_jvm_args) == 0: - cmd.extend([ - f"-Djava.library.path={arg_maps.natives_directory}", - "-cp", - arg_maps.classpath, - ]) - else: - cmd.extend(parsed_jvm_args) + parsed_jvm_args = append_missing_necessary_jvm_arguments(parsed_jvm_args, arg_maps) + cmd.extend(parsed_jvm_args) cmd.append(main_class) cmd.extend(parsed_game_args) return cmd + def is_a_argument(arg: str, startswith="--"): pattern = re.compile(rf"{startswith}(.+)") return pattern.match(arg) is not None + def generate_argument_details(arguments: list[str | dict], startswith="--") -> list[dict]: items = [] @@ -271,13 +329,6 @@ def generate_argument_details(arguments: list[str | dict], startswith="--") -> l return items + def is_same_argument(item: dict, another: dict) -> bool: pass - - - - - - - - diff --git a/core_lib/game/library.py b/core_lib/game/library.py index ee72a7e..7064c37 100644 --- a/core_lib/game/library.py +++ b/core_lib/game/library.py @@ -1,22 +1,17 @@ import json import logging import re -import zipfile -from pathlib import Path -from typing import Callable from urllib.parse import urlsplit, unquote -from ..common.common import get_platform_info, FileObject, download_multiple_files, unzip +from ..common.common import get_platform_info from .version_info import ( MOJANG_LIBRARIES_ENDPOINT, NATIVE_OS_KEY_MAP, NATIVE_OS_RULE_MAP, NATIVE_ARCH_KEY_MAP, NATIVE_NEW_TO_OLD_ARCH, - NATIVE_OLD_TO_NEW_ARCH, find_useful_part_in_specific_version_manifest, + NATIVE_OLD_TO_NEW_ARCH ) -from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException, \ - VersionDataKeyNotFoundException, DownloadException logger = logging.getLogger("Launcher.CoreLib") @@ -32,9 +27,6 @@ def parse_maven_coordinate(value: str, default_extension="jar"): group, artifact, version = parts[:3] classifier = parts[3] if len(parts) == 4 else None - if version.count("-") > 0: - version, extra = version.split("-", 1) - if not group or not artifact or not version: raise ValueError(f"Coordinate key can not be empty: {value!r}") @@ -45,7 +37,6 @@ def parse_maven_coordinate(value: str, default_extension="jar"): def convert_maven_version(version: str, failback=None) -> tuple[int, int, int]: try: - print(version) items = version.split(".") if len(items) >= 3: major, minor, patch = items[0], items[1], items[2] @@ -369,545 +360,3 @@ def generate_formatted_artifact_with_name(lib_name, lib_artifact, libraries_endp artifact_new.update(extra) return artifact_new - - -def collect_library_artifacts(specific_version_manifest): - _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest) - - items = [] - - # Same as natives - _, _, os_version = get_platform_info() - platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() - - for library in libraries: - name = library.get("name", "") - artifact_raw = library.get("downloads", {}).get("artifact", {}) - rules = library.get("rules", []) - classifiers = library.get("downloads", {}).get("classifiers", {}) - natives = library.get("natives", {}) - - # Ignore natives - if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or - any(native.startswith("natives-") for native in classifiers)): - logger.info(f"Skipping {name} because its a native library.") - continue - - if not is_library_allowed( - rules, - platform_rule_name, - platform_arch_type, - os_version, - ): - logger.debug(f"Library {name} can't pass library rules.") - continue - - artifact = generate_formatted_artifact_with_name( - name, - artifact_raw - ) - - if artifact: - items.append(artifact) - - if not items: - raise DependencyException( - "Could not find any artifact in any of the libraries." - ) - - return items - - -def collect_native_artifacts(specific_version_manifest): - """ - Collects native artifacts that support current platform - :param specific_version_manifest: - :return: - filtered_natives_artifacts: list[dict] - """ - filtered = [] - - # Convert current platform and architecture type to same formula - _, _, os_version = get_platform_info() - platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() - - if platform_key_name is None or platform_arch_type is None or platform_rule_name is None: - raise UnsupportedPlatformException( - f"Unsupported platform: key={platform_key_name}, " - f"rule={platform_rule_name}, arch={platform_arch_type}" - ) - - _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest) - - for library in libraries: - name = library.get("name", "") - artifact = library.get("downloads", {}).get("artifact", {}) - # For newer Minecraft (VER>1.7.10) - rules = library.get("rules", []) - # For legacy version (VER<1.8) - classifiers = library.get("downloads", {}).get("classifiers", {}) - natives = library.get("natives", {}) - exclude = library.get("extract", {}).get("exclude", ["META-INF/"]) # For extract use - - is_natives = False - - # Check if library is a native - if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or - any(native.startswith("natives-") for native in classifiers)): - is_natives = True - - if not is_natives: - continue - - if rules and classifiers: - logger.debug(f"Found newer and legacy (classifier) native rules for {name}") - elif rules: - logger.debug(f"Found newer native rules for {name}") - elif classifiers: - logger.debug(f"Found legacy native rules (classifier) for {name}") - - # =============== New rules =============== - - # Parse action section - if not is_library_allowed( - rules, - platform_rule_name, - platform_arch_type, - os_version, - ): - logger.debug(f"Library {name} can't pass library rules.") - continue - - if is_native_allowed( - name, - platform_key_name, - platform_arch_type, - ): - result_artifact = generate_formatted_artifact_with_name(name, artifact, extra={"exclude": exclude, - "flatten": True}) - if result_artifact: - filtered.append(result_artifact) - # For some reason, we still need to continue process because some version use multiple type of - # classifier. (Such as 1.14.2) - else: - logger.debug(f"Library {name} can't pass native rules.") - - # =============== Old rules =============== - logger.debug( - "Library {} doesn't have native key. This library may use old rule.".format(name) - ) - - result, result_artifact, classifier = is_legacy_native_allowed( - natives, - classifiers, - platform_rule_name, - platform_arch_type, - ) - - if not result: - logger.debug(f"Library {name} can't pass legacy native rules.") - continue - else: - result_artifact = generate_formatted_artifact_with_name(name, - result_artifact, - extra={"exclude": exclude, "flatten": True}, - relative_args={ - "extra": classifier - }) - if result_artifact: - filtered.append(result_artifact) - - # Remove duplicate items - items = remove_duplicate_artifacts(filtered) - - if not filtered: - raise NativeResolveException( - "No compatible native artifact found.", - user_message="No compatible native library was found for this platform.", - ) - elif not items: - raise NativeResolveException( - "No compatible native artifact found. Report developer because this may be a issue" - " at function \"remove_duplicate_artifacts\"", - ) - - return items - - -def download_libraries(artifacts, output_dir, progress_callback: Callable[[str, float], None] = None) \ - -> tuple[list[FileObject], list[FileObject]]: - """ - Download libraries from a list that contains artifacts - :param artifacts: - :param output_dir: - :param progress_callback: - :return: - fails: list (List of artifacts that download failed) - successes: list (artifacts that were downloaded) - """ - files = [] - successes = [] - - for artifact in artifacts: - sha1 = artifact.get("sha1") - relative_path = artifact.get("path") - url = artifact.get("url") - - if not relative_path or not url: - logger.warning( - "Artifact {} has no path or url. Skipping.".format(artifact["name"]) - ) - continue - else: - full_path = output_dir / Path(relative_path) - - file = FileObject( - full_path, - url=url, - sha1=sha1, - ) - - if sha1 is None: - logger.warning(f"File {full_path} has no SHA1 hash.") - - if file.exists and (sha1 is None or file.verify("sha1", sha1)): - logger.debug(f"File {full_path} exists.") - successes.append(file) - continue - - files.append(file) - - fails, finished = download_multiple_files(files, progress_callback=progress_callback) - successes.extend(finished) - - return fails, successes - - -def unzip_natives(filtered, libraries_dir, destination: Path): - """ - Unzip native libraries from a list that contains artifacts - :param filtered: - :param libraries_dir: - :param destination: - :return: - - IMPORTANT: - Please put "exclude" key inside the artifact (from the version data). If not, some unnecessary file will be also - included when unzipping the native library. - """ - destination.mkdir(parents=True, exist_ok=True) - - for result_artifact in filtered: - exclude = result_artifact.get("exclude", []) - flatten = result_artifact.get("flatten", False) - relative_path = result_artifact.get("path") - - if not relative_path: - logger.warning( - "Artifact {} has no relative path. Skipping.".format(result_artifact["name"]) - ) - continue - else: - full_path = libraries_dir / Path(relative_path) - - unzip(full_path, destination, exclude=exclude, flatten=flatten) - - logger.debug(f"Unzipped {full_path}.") - - -def check_libraries_exists(manifest: dict, libraries_dir: Path): - """ - Check if libraries are present in the libraries directory - :param manifest: - :param libraries_dir: - :return: - not_found: list - """ - libraries = manifest.get("libraries", []) - - if not libraries: - raise VersionDataKeyNotFoundException( - "Libraries not found in manifest.", - ) - - not_found = [] - - # Same as natives - _, _, os_version = get_platform_info() - platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() - - for library in libraries: - name = library.get("name", "") - artifact_raw = library.get("downloads", {}).get("artifact", {}) - rules = library.get("rules", []) - classifiers = library.get("downloads", {}).get("classifiers", {}) - natives = library.get("natives", {}) - - # Ignore natives - if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or - any(native.startswith("natives-") for native in classifiers)): - logger.info(f"Skipping {name} because its a native library.") - continue - - if not is_library_allowed( - rules, - platform_rule_name, - platform_arch_type, - os_version, - ): - continue - - artifact = generate_formatted_artifact_with_name( - name, - artifact_raw - ) - - if not artifact: - logger.warning(f"Artifact {name} are not valid. Skipping...") - continue - - relative_path = artifact.get("path") - full_path = libraries_dir / Path(relative_path) - - if not full_path.is_file() or not full_path.exists(): - logger.debug(f"Artifact {name} missing.") - not_found.append(full_path) - - return not_found - - -def check_natives_exists(manifest: dict, libraries_dir: Path, natives_dir: Path): - filtered = [] - - # Convert current platform and architecture type to same formula - _, _, os_version = get_platform_info() - platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() - - if platform_key_name is None or platform_arch_type is None or platform_rule_name is None: - raise UnsupportedPlatformException( - f"Unsupported platform: key={platform_key_name}, " - f"rule={platform_rule_name}, arch={platform_arch_type}" - ) - - _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(manifest) - - for library in libraries: - name = library.get("name", "") - artifact = library.get("downloads", {}).get("artifact", {}) - # For newer Minecraft (VER>1.7.10) - rules = library.get("rules", []) - # For legacy version (VER<1.8) - classifiers = library.get("downloads", {}).get("classifiers", {}) - natives = library.get("natives", {}) - exclude = library.get("extract", {}).get("exclude", ["META-INF/"]) # For extract use - - is_natives = False - - # Check if library is a native - if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or - any(native.startswith("natives-") for native in classifiers)): - is_natives = True - - if not is_natives: - continue - - # =============== New rules =============== - - # Parse action section - if not is_library_allowed( - rules, - platform_rule_name, - platform_arch_type, - os_version, - ): - continue - - if is_native_allowed( - name, - platform_key_name, - platform_arch_type, - ): - result_artifact = generate_formatted_artifact_with_name(name, artifact, extra={"exclude": exclude, - "flatten": True}) - if result_artifact: - filtered.append(result_artifact) - - # =============== Old rules =============== - result, result_artifact, classifier = is_legacy_native_allowed( - natives, - classifiers, - platform_rule_name, - platform_arch_type, - ) - - if not result: - continue - - result_artifact = generate_formatted_artifact_with_name(name, - result_artifact, - extra={"exclude": exclude, "flatten": True}, - relative_args={ - "extra": classifier - }) - - if result_artifact: - filtered.append(result_artifact) - - # Remove duplicate items - items = remove_duplicate_artifacts(filtered) - - if not filtered: - raise NativeResolveException( - "No compatible native artifact found.", - user_message="No compatible native library was found for this platform.", - ) - elif not items: - raise NativeResolveException( - "No compatible native artifact found. Report developer because this may be a issue" - " at function \"remove_duplicate_artifacts\"", - ) - - expected = set() - - for result_artifact in items: - full_path = libraries_dir / Path(result_artifact["path"]) - exclude = result_artifact.get("exclude", []) or [] - flatten = result_artifact.get("flatten", False) - - if not full_path.is_file() or not full_path.exists(): - return False - - with zipfile.ZipFile(full_path) as zf: - for member in zf.namelist(): - if member.endswith("/"): - continue - if any(member.startswith(item) for item in exclude): - continue - - target = Path(member).name if flatten else member - if target: - expected.add(target) - - if not expected: - return False - - for target in expected: - if not (natives_dir / target).exists(): - return False - - return True - - -def check_full_libraries_exists(manifest: dict, libraries_dir: Path, natives_dir: Path) -> bool: - if not check_natives_exists(manifest, libraries_dir, natives_dir) or not check_libraries_exists(manifest, libraries_dir): - return False - - return True - - -def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path): - """ - Generate classpath by using specific version's data - :param manifest: - :param libraries_dir: - :param core_file_path: - :return: - not_found: list - """ - libraries = manifest.get("libraries", []) - - if not libraries: - raise VersionDataKeyNotFoundException( - "Libraries not found in manifest.", - ) - - # Same as natives - _, _, os_version = get_platform_info() - platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() - - separator = ";" if platform_key_name == "windows" else ":" - classes = [] - - for library in libraries: - name = library.get("name", "") - artifact_raw = library.get("downloads", {}).get("artifact", {}) - rules = library.get("rules", []) - classifiers = library.get("downloads", {}).get("classifiers", {}) - natives = library.get("natives", {}) - - # Ignore natives - if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or - any(native.startswith("natives-") for native in classifiers)): - logger.info(f"Skipping {name} because its a native library.") - continue - - if not is_library_allowed( - rules, - platform_rule_name, - platform_arch_type, - os_version, - ): - continue - - artifact = generate_formatted_artifact_with_name( - name, - artifact_raw - ) - - if not artifact: - logger.warning(f"Artifact {name} are not valid. Skipping...") - continue - - relative_path = artifact.get("path") - full_path = libraries_dir / Path(relative_path) - - if not full_path.is_file() or not full_path.exists(): - raise FileNotFoundError(f"Artifact {name} missing.") - - classes.append(full_path.as_posix()) - - # Append core file - classes.append(core_file_path.as_posix()) - - return f"{separator}".join(classes) - - -def download_full_libraries(manifest: dict, libraries_dir: Path, natives_dir: Path, - progress_callback: Callable[[str, float], None] = None, - redownload_callback: Callable[ - [list[FileObject]], tuple[bool, list[FileObject]]] = None) -> None: - # libraries - libraries = collect_library_artifacts(manifest) - - # natives - natives = collect_native_artifacts(manifest) - - # all - full = list(libraries) - full.extend(natives) - - logger.info( - "Supported native count: {}".format(len(natives)) - ) - - # Start download - fails, successes = download_libraries(full, libraries_dir, progress_callback=progress_callback) - - download_ok = len(fails) == 0 - - if redownload_callback and not download_ok: - download_ok, fails = redownload_callback(fails) - - if not download_ok: - raise DownloadException( - "Unable to continue process due to certain libraries download failed:\n" - + "\n".join(file.posix_path for file in fails) - ) - - if download_ok: - # Start unzip natives - unzip_natives(natives, libraries_dir, natives_dir) - else: - raise DependencyException( - "Certain libraries download failed. Unable to continue unzip process.\n" - ) diff --git a/core_lib/game/library_core.py b/core_lib/game/library_core.py new file mode 100644 index 0000000..ca1d506 --- /dev/null +++ b/core_lib/game/library_core.py @@ -0,0 +1,589 @@ +import logging +import re +import zipfile +from pathlib import Path +from typing import Callable + +from .library import get_natives_platform_info, is_library_allowed, generate_formatted_artifact_with_name, \ + is_native_allowed, is_legacy_native_allowed, remove_duplicate_artifacts +from .libraryx import normalize_library_to_common_struct +from ..common.common import get_platform_info, FileObject, download_multiple_files, unzip, is_valid_zipfile +from .version_info import find_useful_part_in_specific_version_manifest +from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException, \ + VersionDataKeyNotFoundException, DownloadException + +logger = logging.getLogger("Launcher.CoreLib") + +def collect_library_artifacts(specific_version_manifest): + _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest) + + items = [] + + # Same as natives + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + for library_raw in libraries: + library = normalize_library_to_common_struct( + library_raw + ) + + name = library.get("name", "") + artifact_raw = library.get("downloads", {}).get("artifact", {}) + rules = library.get("rules", []) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + + # Ignore natives + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + logger.info(f"Skipping {name} because its a native library.") + continue + + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + logger.debug(f"Library {name} can't pass library rules.") + continue + + artifact = generate_formatted_artifact_with_name( + name, + artifact_raw + ) + + if artifact: + items.append(artifact) + + if not items: + raise DependencyException( + "Could not find any artifact in any of the libraries." + ) + + return items + + +def collect_native_artifacts(specific_version_manifest): + """ + Collects native artifacts that support current platform + :param specific_version_manifest: + :return: + filtered_natives_artifacts: list[dict] + """ + filtered = [] + + # Convert current platform and architecture type to same formula + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + if platform_key_name is None or platform_arch_type is None or platform_rule_name is None: + raise UnsupportedPlatformException( + f"Unsupported platform: key={platform_key_name}, " + f"rule={platform_rule_name}, arch={platform_arch_type}" + ) + + _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest) + + for library_raw in libraries: + library = normalize_library_to_common_struct( + library_raw + ) + + name = library.get("name", "") + artifact = library.get("downloads", {}).get("artifact", {}) + # For newer Minecraft (VER>1.7.10) + rules = library.get("rules", []) + # For legacy version (VER<1.8) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + exclude = library.get("extract", {}).get("exclude", ["META-INF/"]) # For extract use + + is_natives = False + + # Check if library is a native + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + is_natives = True + + if not is_natives: + continue + + if rules and classifiers: + logger.debug(f"Found newer and legacy (classifier) native rules for {name}") + elif rules: + logger.debug(f"Found newer native rules for {name}") + elif classifiers: + logger.debug(f"Found legacy native rules (classifier) for {name}") + + # =============== New rules =============== + + # Parse action section + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + logger.debug(f"Library {name} can't pass library rules.") + continue + + if is_native_allowed( + name, + platform_key_name, + platform_arch_type, + ): + result_artifact = generate_formatted_artifact_with_name(name, artifact, extra={"exclude": exclude, + "flatten": True}) + if result_artifact: + filtered.append(result_artifact) + # For some reason, we still need to continue process because some version use multiple type of + # classifier. (Such as 1.14.2) + else: + logger.debug(f"Library {name} can't pass native rules.") + + # =============== Old rules =============== + logger.debug( + "Library {} doesn't have native key. This library may use old rule.".format(name) + ) + + result, result_artifact, classifier = is_legacy_native_allowed( + natives, + classifiers, + platform_rule_name, + platform_arch_type, + ) + + if not result: + logger.debug(f"Library {name} can't pass legacy native rules.") + continue + else: + result_artifact = generate_formatted_artifact_with_name(name, + result_artifact, + extra={"exclude": exclude, "flatten": True}, + relative_args={ + "extra": classifier + }) + if result_artifact: + filtered.append(result_artifact) + + # Remove duplicate items + items = remove_duplicate_artifacts(filtered) + + if not filtered: + raise NativeResolveException( + "No compatible native artifact found.", + user_message="No compatible native library was found for this platform.", + ) + elif not items: + raise NativeResolveException( + "No compatible native artifact found. Report developer because this may be a issue" + " at function \"remove_duplicate_artifacts\"", + ) + + return items + + +def download_libraries(artifacts, output_dir, progress_callback: Callable[[str, float], None] = None) \ + -> tuple[list[FileObject], list[FileObject]]: + """ + Download libraries from a list that contains artifacts + :param artifacts: + :param output_dir: + :param progress_callback: + :return: + fails: list (List of artifacts that download failed) + successes: list (artifacts that were downloaded) + """ + files = [] + successes = [] + + for artifact in artifacts: + sha1 = artifact.get("sha1") + sha256 = artifact.get("sha256") + sha512 = artifact.get("sha512") + relative_path = artifact.get("path") + url = artifact.get("url") + + if not relative_path or not url: + logger.warning( + "Artifact {} has no path or url. Skipping.".format(artifact["name"]) + ) + continue + else: + full_path = output_dir / Path(relative_path) + + file = FileObject( + full_path, + url=url, + sha1=sha1, + ) + + if sha1 is None: + logger.warning(f"File {full_path} has no SHA1 hash.") + + if file.exists: + if sha1 is not None: + valid = file.verify("sha1", sha1) + elif sha256 is not None: + valid = file.verify("sha256", sha256) + elif sha512 is not None: + valid = file.verify("sha512", sha512) + elif full_path.suffix.lower() == ".jar": + valid = is_valid_zipfile(file.path) + else: + valid = full_path.stat().st_size > 0 + + if valid: + successes.append(file) + continue + + files.append(file) + + fails, finished = download_multiple_files(files, progress_callback=progress_callback) + successes.extend(finished) + + return fails, successes + + +def unzip_natives(filtered, libraries_dir, destination: Path): + """ + Unzip native libraries from a list that contains artifacts + :param filtered: + :param libraries_dir: + :param destination: + :return: + + IMPORTANT: + Please put "exclude" key inside the artifact (from the version data). If not, some unnecessary file will be also + included when unzipping the native library. + """ + destination.mkdir(parents=True, exist_ok=True) + + for result_artifact in filtered: + exclude = result_artifact.get("exclude", []) + flatten = result_artifact.get("flatten", False) + relative_path = result_artifact.get("path") + + if not relative_path: + logger.warning( + "Artifact {} has no relative path. Skipping.".format(result_artifact["name"]) + ) + continue + else: + full_path = libraries_dir / Path(relative_path) + + unzip(full_path, destination, exclude=exclude, flatten=flatten) + + logger.debug(f"Unzipped {full_path}.") + + +def check_libraries_exists(manifest: dict, libraries_dir: Path): + """ + Check if libraries are present in the libraries directory + :param manifest: + :param libraries_dir: + :return: + not_found: list + """ + libraries = manifest.get("libraries", []) + + if not libraries: + raise VersionDataKeyNotFoundException( + "Libraries not found in manifest.", + ) + + not_found = [] + + # Same as natives + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + for library_raw in libraries: + library = normalize_library_to_common_struct( + library_raw + ) + + name = library.get("name", "") + artifact_raw = library.get("downloads", {}).get("artifact", {}) + rules = library.get("rules", []) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + + # Ignore natives + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + logger.info(f"Skipping {name} because its a native library.") + continue + + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + continue + + artifact = generate_formatted_artifact_with_name( + name, + artifact_raw + ) + + if not artifact: + logger.warning(f"Artifact {name} are not valid. Skipping...") + continue + + relative_path = artifact.get("path") + full_path = libraries_dir / Path(relative_path) + + if not full_path.is_file() or not full_path.exists(): + logger.debug(f"Artifact {name} missing.") + not_found.append(full_path) + + return not_found + + +def check_natives_exists(manifest: dict, libraries_dir: Path, natives_dir: Path): + filtered = [] + + # Convert current platform and architecture type to same formula + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + if platform_key_name is None or platform_arch_type is None or platform_rule_name is None: + raise UnsupportedPlatformException( + f"Unsupported platform: key={platform_key_name}, " + f"rule={platform_rule_name}, arch={platform_arch_type}" + ) + + _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(manifest) + + for library_raw in libraries: + library = normalize_library_to_common_struct( + library_raw + ) + + name = library.get("name", "") + artifact = library.get("downloads", {}).get("artifact", {}) + # For newer Minecraft (VER>1.7.10) + rules = library.get("rules", []) + # For legacy version (VER<1.8) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + exclude = library.get("extract", {}).get("exclude", ["META-INF/"]) # For extract use + + is_natives = False + + # Check if library is a native + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + is_natives = True + + if not is_natives: + continue + + # =============== New rules =============== + + # Parse action section + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + continue + + if is_native_allowed( + name, + platform_key_name, + platform_arch_type, + ): + result_artifact = generate_formatted_artifact_with_name(name, artifact, extra={"exclude": exclude, + "flatten": True}) + if result_artifact: + filtered.append(result_artifact) + + # =============== Old rules =============== + result, result_artifact, classifier = is_legacy_native_allowed( + natives, + classifiers, + platform_rule_name, + platform_arch_type, + ) + + if not result: + continue + + result_artifact = generate_formatted_artifact_with_name(name, + result_artifact, + extra={"exclude": exclude, "flatten": True}, + relative_args={ + "extra": classifier + }) + + if result_artifact: + filtered.append(result_artifact) + + # Remove duplicate items + items = remove_duplicate_artifacts(filtered) + + if not filtered: + raise NativeResolveException( + "No compatible native artifact found.", + user_message="No compatible native library was found for this platform.", + ) + elif not items: + raise NativeResolveException( + "No compatible native artifact found. Report developer because this may be a issue" + " at function \"remove_duplicate_artifacts\"", + ) + + expected = set() + + for result_artifact in items: + full_path = libraries_dir / Path(result_artifact["path"]) + exclude = result_artifact.get("exclude", []) or [] + flatten = result_artifact.get("flatten", False) + + if not full_path.is_file() or not full_path.exists(): + return False + + with zipfile.ZipFile(full_path) as zf: + for member in zf.namelist(): + if member.endswith("/"): + continue + if any(member.startswith(item) for item in exclude): + continue + + target = Path(member).name if flatten else member + if target: + expected.add(target) + + if not expected: + return False + + for target in expected: + if not (natives_dir / target).exists(): + return False + + return True + + +def check_full_libraries_exists(manifest: dict, libraries_dir: Path, natives_dir: Path) -> bool: + if not check_natives_exists(manifest, libraries_dir, natives_dir) or not check_libraries_exists(manifest, libraries_dir): + return False + + return True + + +def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path): + """ + Generate classpath by using specific version's data + :param manifest: + :param libraries_dir: + :param core_file_path: + :return: + not_found: list + """ + libraries = manifest.get("libraries", []) + + if not libraries: + raise VersionDataKeyNotFoundException( + "Libraries not found in manifest.", + ) + + # Same as natives + _, _, os_version = get_platform_info() + platform_key_name, platform_rule_name, platform_arch_type = get_natives_platform_info() + + separator = ";" if platform_key_name == "windows" else ":" + classes = [] + + for library_raw in libraries: + library = normalize_library_to_common_struct( + library_raw + ) + + name = library.get("name", "") + artifact_raw = library.get("downloads", {}).get("artifact", {}) + rules = library.get("rules", []) + classifiers = library.get("downloads", {}).get("classifiers", {}) + natives = library.get("natives", {}) + + # Ignore natives + if (len(natives) > 0 or bool(re.search(r":natives-[^:]+$", name)) or + any(native.startswith("natives-") for native in classifiers)): + logger.info(f"Skipping {name} because its a native library.") + continue + + if not is_library_allowed( + rules, + platform_rule_name, + platform_arch_type, + os_version, + ): + continue + + artifact = generate_formatted_artifact_with_name( + name, + artifact_raw + ) + + if not artifact: + logger.warning(f"Artifact {name} are not valid. Skipping...") + continue + + relative_path = artifact.get("path") + full_path = libraries_dir / Path(relative_path) + + if not full_path.is_file() or not full_path.exists(): + raise FileNotFoundError(f"Artifact {name} missing.") + + classes.append(full_path.as_posix()) + + # Append core file + classes.append(core_file_path.as_posix()) + + return f"{separator}".join(classes) + + +def download_full_libraries(manifest: dict, libraries_dir: Path, natives_dir: Path, + progress_callback: Callable[[str, float], None] = None, + redownload_callback: Callable[ + [list[FileObject]], tuple[bool, list[FileObject]]] = None) -> None: + # libraries + libraries = collect_library_artifacts(manifest) + + # natives + natives = collect_native_artifacts(manifest) + + # all + full = list(libraries) + full.extend(natives) + + logger.info( + "Supported native count: {}".format(len(natives)) + ) + + # Start download + fails, successes = download_libraries(full, libraries_dir, progress_callback=progress_callback) + + download_ok = len(fails) == 0 + + if redownload_callback and not download_ok: + download_ok, fails = redownload_callback(fails) + + if not download_ok: + raise DownloadException( + "Unable to continue process due to certain libraries download failed:\n" + + "\n".join(file.posix_path for file in fails) + ) + + if download_ok: + # Start unzip natives + unzip_natives(natives, libraries_dir, natives_dir) + else: + raise DependencyException( + "Certain libraries download failed. Unable to continue unzip process.\n" + ) \ No newline at end of file diff --git a/core_lib/game/libraryx.py b/core_lib/game/libraryx.py new file mode 100644 index 0000000..559a55b --- /dev/null +++ b/core_lib/game/libraryx.py @@ -0,0 +1,130 @@ +""" +This module provide some advanced function for library parse/download process use. +""" +import logging +import re +from copy import deepcopy + +from .library import (convert_maven_version, parse_maven_coordinate, generate_relative_path_by_maven_coordinate, + is_complete_maven_url, generate_repo_url) +from .version_info import MOJANG_LIBRARIES_ENDPOINT +from ..common.exception import VersionDataKeyNotFoundException, DependencyException + +logger = logging.getLogger("Launcher.CoreLib") + + +def convert_maven_version_with_extra(version: str, failback=None) -> tuple[tuple[int, int, int] | None, str | None]: + match = re.fullmatch( + r"(\d+(?:\.\d+){1,2})(.*)", + version, + ) + + if match is None: + return failback, None + + base_ver = match.group(1) + extra_ver = match.group(2) or None + + base_ver_converted = convert_maven_version(base_ver, failback=None) + + if base_ver_converted is None: + return failback, extra_ver + + return base_ver_converted, extra_ver + +def generate_unique_library_key(group: str, artifact: str, classifier: str | None=None, extension: str | None=None, version: str | None=None) -> str: + key = f"{group}:{artifact}" + if version is not None: + key += f":{version}" + + if classifier is not None: + key += f":{classifier}" + + if extension is not None: + key += f":{extension}" + + return key + + +def normalize_library_to_common_struct(library: dict, default_maven_url: str = MOJANG_LIBRARIES_ENDPOINT) -> dict: + result = deepcopy(library) + name: str = library.get("name") + + if name is None: + raise VersionDataKeyNotFoundException( + "Provided mod loader version manifest library does not contain key \"name\"." + ) + + group, artifact, version, classifier, extension = parse_maven_coordinate(name) + + downloads_raw = result.setdefault("downloads", {}) + + if isinstance(downloads_raw, dict): + downloads = downloads_raw + else: + downloads = {} + + artifact_raw = dict(downloads.get("artifact") or {}) + + # Generate new relative path and put back + if not artifact_raw.get("path"): + path = generate_relative_path_by_maven_coordinate(name) + if not path: + raise DependencyException( + f"Unable to generate artifact path for {name}" + ) + artifact_raw["path"] = path + + if not artifact_raw.get("url"): + top_level_url = result.get("url") + + if top_level_url: + if is_complete_maven_url( + top_level_url, + group, + artifact, + version, + classifier, + extension, + ): + # Put existing url to downloads.artifact + artifact_raw["url"] = top_level_url + else: + # Replace broken url + artifact_raw["url"] = generate_repo_url( + top_level_url, + group, + artifact, + version, + classifier, + extension, + ) + else: + # Generate missing url + artifact_raw["url"] = generate_repo_url( + default_maven_url, + group, + artifact, + version, + classifier, + extension, + ) + + # Put hash information to artifact + for key in ( + "sha1", + "sha256", + "sha512", + "md5", + "size", + ): + if artifact_raw.get(key) is None: + value = result.get(key) + if value is not None: + artifact_raw[key] = value + + # Put updated data back to result + downloads["artifact"] = artifact_raw + result["downloads"] = downloads + + return result diff --git a/core_lib/game/mod/fabric.py b/core_lib/game/mod/fabric.py index eb54e23..3260503 100644 --- a/core_lib/game/mod/fabric.py +++ b/core_lib/game/mod/fabric.py @@ -9,8 +9,8 @@ from core_lib.common.exception import VersionManifestFetchException, VersionData logger = logging.getLogger("Launcher.CoreLib") FABRIC_META_VERSION_ENDPOINT_V2 = "https://meta.fabricmc.net/v2/versions" -FABRIC_META_LOADER_VERSION_ENDPOINT_V2 = "https://meta.fabricmc.net/v1/versions/loader/{}/" -FABRIC_META_LOADER_MANIFEST_ENDPOINT_V2 = "https://meta.fabricmc.net//v2/versions/loader/{}/{}/profile/json" +FABRIC_META_LOADER_VERSION_ENDPOINT_V2 = "https://meta.fabricmc.net/v2/versions/loader/{}/" +FABRIC_META_LOADER_MANIFEST_ENDPOINT_V2 = "https://meta.fabricmc.net/v2/versions/loader/{}/{}/profile/json" FABRIC_MAVEN_URL = "https://maven.fabricmc.net/" def fetch_fabric_version_list(version_manifest_url: str=FABRIC_META_VERSION_ENDPOINT_V2, timeout: int=5) -> list[dict]: diff --git a/core_lib/game/mod/mod_core.py b/core_lib/game/mod/mod_core.py index 7cfc0a5..f9a6897 100644 --- a/core_lib/game/mod/mod_core.py +++ b/core_lib/game/mod/mod_core.py @@ -3,8 +3,10 @@ from copy import deepcopy import shlex from core_lib.common.exception import VersionDataKeyNotFoundException, DependencyException -from core_lib.game.library import parse_maven_coordinate, convert_maven_version, generate_repo_url, \ +from core_lib.game.library import parse_maven_coordinate, generate_repo_url, \ is_complete_maven_url +from core_lib.game.libraryx import convert_maven_version_with_extra, generate_unique_library_key, \ + normalize_library_to_common_struct from core_lib.game.version_info import MOJANG_LIBRARIES_ENDPOINT logger = logging.getLogger("Launcher.CoreLib") @@ -58,10 +60,12 @@ def find_useful_part_in_mod_version_manifest(mod_version_manifest: dict): "mainClass": main_class, } + def merge_necessary_dependencies(inherits_libraries: list, mod_libraries: list, mod_maven_url=None, inherits_maven_url=MOJANG_LIBRARIES_ENDPOINT, default_extension="jar") -> list[dict]: inherits = {} + result_pre = [] result = [] # ensure below step won't modify original data @@ -73,7 +77,7 @@ def merge_necessary_dependencies(inherits_libraries: list, mod_libraries: list, source_from = lib["sourceFrom"] lib_group=lib["parsed"]["group"] lib_artifact=lib["parsed"]["artifact"] - lib_version=lib["parsed"]["version"] + lib_version=lib["parsed"]["versionRaw"] lib_classifier=lib["parsed"]["classifier"] lib_extension=lib["parsed"]["extension"] @@ -111,73 +115,119 @@ def merge_necessary_dependencies(inherits_libraries: list, mod_libraries: list, "Provided inherited library does not contain key \"name\"." ) - group, artifact, version, classifier, extension = parse_maven_coordinate(name, - default_extension=default_extension) + group, artifact, version_raw, classifier, extension = parse_maven_coordinate(name, default_extension=default_extension) + ver_converted, extra = convert_maven_version_with_extra(version_raw) + + if extra: + logger.debug("Found extra version \"{}\" for library {}".format(extra, name)) + + if not ver_converted: + raise DependencyException( + f"Unable to convert provided inherited library name {name}'s version {version_raw} to tuple." + ) + + lib_key = generate_unique_library_key( + group=group, + artifact=artifact, + classifier=classifier, + extension=extension, + ) # Save into dict for next step to check if library is existing - if not f"{group}:{artifact}" in inherits: - inherits[f"{group}:{artifact}"] = library - inherits[f"{group}:{artifact}"]["parsed"] = { + if not lib_key in inherits: + inherits[lib_key] = library + inherits[lib_key]["parsed"] = { "group": group, "artifact": artifact, - "version": version, + "version": ver_converted, + "versionRaw": version_raw, + "versionExtra": extra, "classifier": classifier, "extension": extension, + "endpoint": inherits_maven_url } - inherits[f"{group}:{artifact}"]["sourceFrom"] = "official" + inherits[lib_key]["sourceFrom"] = "official" else: logger.warning("Duplicate library: {}".format(name)) for mod_library in mod_libraries: name = mod_library.get("name", None) - group, artifact, version, classifier, extension = parse_maven_coordinate(name, default_extension=default_extension) - - ver_converted = convert_maven_version(version) if not name: raise DependencyException( "Provided mod library does not contain key \"name\"." ) + group, artifact, version_raw, classifier, extension = parse_maven_coordinate(name, default_extension=default_extension) + + ver_converted, extra = convert_maven_version_with_extra(version_raw) + + if extra: + logger.debug("Found extra version \"{}\" for library {}".format(extra, name)) + if ver_converted is None: raise DependencyException( - "Unable to convert inherits library version {} to tuple".format(version), + "Unable to convert mod library version {} to tuple".format(version_raw), ) - group, artifact, version, classifier, extension = parse_maven_coordinate(name, default_extension=default_extension) + group, artifact, version_raw, classifier, extension = parse_maven_coordinate(name, default_extension=default_extension) + lib_key = generate_unique_library_key( + group=group, + artifact=artifact, + classifier=classifier, + extension=extension, + ) mod_library["sourceFrom"] = "mod" mod_library["parsed"] = { "group": group, "artifact": artifact, - "version": version, + # "version": {"raw": version, "converted": ver_converted, "extra": extra}, + "version": ver_converted, + "versionRaw": version_raw, + "versionExtra": extra, "classifier": classifier, "extension": extension, + "endpoint": mod_maven_url, } - if f"{group}:{artifact}" in inherits: - found_lib = inherits.pop(f"{group}:{artifact}") - found_lib_ver = convert_maven_version(found_lib["parsed"]["version"], None) + if lib_key in inherits: + found_lib = inherits.pop(lib_key) + found_lib_ver = found_lib["parsed"]["version"] + found_lib_ver_extra = found_lib["parsed"]["versionExtra"] if found_lib_ver is None: raise DependencyException( - "Unable to convert mod library version {} to tuple".format(found_lib["version"]) + "Unable to convert mod library version {} to tuple".format(found_lib["versionRaw"]) ) - if found_lib_ver > ver_converted or found_lib_ver == ver_converted: - result.append(found_lib) + if found_lib_ver > ver_converted: + result_pre.append(found_lib) elif found_lib_ver < ver_converted: - result.append(mod_library) + result_pre.append(mod_library) + else: + # Use mod loader library if extra version is existing + if found_lib_ver_extra == mod_library["parsed"]["versionExtra"]: + result_pre.append(found_lib) + else: + result_pre.append(mod_library) else: - result.append(mod_library) + result_pre.append(mod_library) # Add the remaining dependencies back to result for key in inherits: value = inherits[key] - result.append(value) + result_pre.append(value) - for library in result: + for library in result_pre: + # Cleanup check_lib(library) + library_normalized = normalize_library_to_common_struct(library, default_maven_url=library["parsed"]["endpoint"]) + + library_normalized.pop("parsed", None) + library_normalized.pop("sourceFrom", None) + library_normalized.pop("endpoint", None) + result.append(library_normalized) return result @@ -240,4 +290,14 @@ def merge_manifest(inherits_version_manifest: dict, mod_version_manifest: dict, merged_manifest["arguments"] = merge_game_and_jvm_args(inherits_arguments, mod_arguments) + # Update version number + mod_id = mod_version_manifest.get("id") + inherits_id = inherits_version_manifest.get("id") + + if mod_id: + merged_manifest["id"] = mod_id + + if inherits_id: + merged_manifest["inheritsFrom"] = inherits_id + return merged_manifest \ No newline at end of file diff --git a/core_lib/qt/progress_dialog.py b/core_lib/qt/progress_dialog.py new file mode 100644 index 0000000..d4a4f74 --- /dev/null +++ b/core_lib/qt/progress_dialog.py @@ -0,0 +1,168 @@ +from PySide6.QtCore import Qt, Slot, QTimer +from PySide6.QtWidgets import ( + QDialog, + QLabel, + QProgressBar, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + + +class ProgressRow(QWidget): + def __init__(self, name: str, parent=None): + super().__init__(parent) + + self.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + + self.name_label = QLabel(name, self) + self.name_label.setWordWrap(True) + self.name_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + + self.status_label = QLabel("Preparing...", self) + self.status_label.setWordWrap(True) + self.status_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + + self.progress_bar = QProgressBar(self) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + + # Flags + self.finished = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(4) + layout.addWidget(self.name_label) + layout.addWidget(self.status_label) + layout.addWidget(self.progress_bar) + + +class ProgressDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Downloads") + self.resize(500, 300) + self.setMinimumSize(360, 220) + + self.rows: dict[str, ProgressRow] = {} + + self.scroll_area = QScrollArea(self) + self.download_widget = QWidget(self.scroll_area) + self.download_layout = QVBoxLayout(self.download_widget) + self.download_layout.setContentsMargins(4, 4, 4, 4) + self.download_layout.setSpacing(8) + self.download_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.scroll_area.setWidget(self.download_widget) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + + self.close_button = QPushButton("Close", self) + self.close_button.clicked.connect(self.hide) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + layout.addWidget(self.scroll_area, 1) + layout.addWidget( + self.close_button, + 0, + Qt.AlignmentFlag.AlignRight, + ) + + self.close_timer = QTimer(self) + self.close_timer.setSingleShot(True) + self.close_timer.timeout.connect(self.cleanup) + + @Slot(str, str) + def add_task(self, task_id: str, name: str): + if task_id in self.rows: + return + + row = ProgressRow(name, self.download_widget) + self.rows[task_id] = row + + self.download_layout.addWidget(row) + + if not self.isVisible(): + self.show() + + @Slot(str, str, float) + def update_task( + self, + task_id: str, + status: str, + percent: float, + ): + row = self.rows.get(task_id) + if row is None: + # Prevent started、progress missing due to timing issues + self.add_task(task_id, task_id) + row = self.rows[task_id] + + value = max(0, min(100, round(percent))) + row.status_label.setText(status) + row.progress_bar.setValue(value) + + @Slot(str) + def finish_task(self, task_id: str): + row = self.rows.get(task_id) + if row is None: + return + + row.status_label.setText("Completed") + row.progress_bar.setValue(100) + row.finished = True + + if self.all_tasks_finished(): + self.close_timer.start(2000) + + def all_tasks_finished(self): + return all(row.finished == True for task_id, row in self.rows.items()) + + def cleanup(self): + """ + If you want to use this function with a timer. use self.close_timer.start(TIMEOUT) + :return: + """ + QTimer.singleShot(2000, self.cleanup) + for task_id in list(self.rows): + self.remove_task(task_id) + + self.hide() + + @Slot(str, str) + def fail_task(self, task_id: str, error: str): + row = self.rows.get(task_id) + if row is None: + self.add_task(task_id, task_id) + row = self.rows[task_id] + + row.status_label.setText(f"Failed: {error}") + row.progress_bar.setStyleSheet( + "QProgressBar::chunk { background-color: #c62828; }" + ) + # Move to top + self.download_layout.removeWidget(row) + self.download_layout.insertWidget(0, row) + + @Slot(str) + def remove_task(self, task_id: str): + row = self.rows.pop(task_id, None) + if row is not None: + self.download_layout.removeWidget(row) + row.setParent(None) + row.deleteLater() diff --git a/main.py b/main.py index 6b684db..52475a6 100644 --- a/main.py +++ b/main.py @@ -50,7 +50,7 @@ def main(): logging.basicConfig(format=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, level=level) # Start (the real) launcher - app = Launcher() + app = Launcher(logger) sys.exit(app.exec()) diff --git a/manager/launcher.py b/manager/launcher.py new file mode 100644 index 0000000..b7732cd --- /dev/null +++ b/manager/launcher.py @@ -0,0 +1,5 @@ +from PySide6.QtCore import QObject + + +class RocketLauncher(QObject): + pass \ No newline at end of file diff --git a/manager/profile.py b/manager/profile.py new file mode 100644 index 0000000..723dc33 --- /dev/null +++ b/manager/profile.py @@ -0,0 +1,129 @@ +""" +IMPORTANT: Still under construction! +""" +from dataclasses import dataclass +from pathlib import Path + +from PySide6.QtCore import QObject, Signal +from datetime import datetime + +from PySide6.QtWidgets import QApplication + +from core_lib.common.exception import ProfileException +from core_lib.game.profile import read_profile_file + +DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json") + +@dataclass(frozen=True) +class ProfileSummary: + id: str + name: str + version_id: str + version_type: str + icon: str + last_used: datetime | None + + +@dataclass(frozen=True) +class ProfileDetail(ProfileSummary): + game_dir: Path | None + java_args: str + resolution: tuple[int, int] | None + created: datetime + + +@dataclass(frozen=True) +class CreateProfileRequest: + name: str + version_id: str + version_type: str = "custom" + game_dir: Path | None = None + java_args: str = "" + icon: str = "" + resolution: tuple[int, int] | None = None + + +@dataclass(frozen=True) +class UpdateProfileRequest: + name: str | None = None + version_id: str | None = None + version_type: str | None = None + game_dir: Path | None = None + java_args: str | None = None + icon: str | None = None + resolution: tuple[int, int] | None = None + +@dataclass(frozen=True) +class LaunchProfile: + pass + +@dataclass(frozen=True) +class ValidationResult: + pass + +class ProfileManager(QObject): + profiles_changed = Signal() + profile_created = Signal(str) + profile_updated = Signal(str) + profile_removed = Signal(str) + current_profile_changed = Signal(object) # str | None + error_occurred = Signal(str, str) # error_code, message + replace_relative_path = Signal(str) + + def __init__(self, app): + QObject.__init__(self, app) + self.app = app + self.profiles = [] + + @property + def profile_path(self): + return Path(self.app.work_dir, DEFAuLT_PROFILE_RELATIVE_PATH) + + # File lifetime + def load(self, path: Path=None, return_value=False) -> None | dict: + if path is None: + path = self.profile_path + + try: + result = read_profile_file(path) + except ProfileException: + pass + + + # def load(self, path: str | Path) -> None: ... + def save(self) -> None: ... + def save_as(self, path: str | Path) -> None: ... + def reload(self) -> None: ... + + # Profile search + def list_profiles(self) -> list[ProfileSummary]: ... + def get_profile(self, profile_id: str) -> ProfileDetail: ... + def contains(self, profile_id: str) -> bool: ... + + # CRUD + def create_profile(self, request: CreateProfileRequest) -> str: ... + def update_profile( + self, profile_id: str, changes: UpdateProfileRequest + ) -> None: ... + def remove_profile(self, profile_id: str) -> None: ... + def duplicate_profile( + self, profile_id: str, new_name: str | None = None + ) -> str: ... + + # Profile switch + def set_current_profile(self, profile_id: str | None) -> None: ... + def get_current_profile_id(self) -> str | None: ... + def get_launch_profile(self, profile_id: str) -> LaunchProfile: ... + + # Validation + def validate(self, request: CreateProfileRequest) -> ValidationResult: ... + + # Property + @property + def path(self) -> Path | None: ... + + @property + def is_loaded(self) -> bool: ... + + @property + def is_dirty(self) -> bool: ... \ No newline at end of file diff --git a/manager/widgets.py b/manager/widgets.py new file mode 100644 index 0000000..5be683e --- /dev/null +++ b/manager/widgets.py @@ -0,0 +1,60 @@ +import queue +import threading + +from PySide6.QtCore import QObject, Signal, Slot +from PySide6.QtWidgets import QProgressDialog + +from core_lib.qt import messagebox + + +class AskForRequest: + 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 SelectPathRequest(AskForRequest): + def __init__(self, title, message, filter_raw=None): + super().__init__(title, message) + self.filter = filter_raw + + +class WidgetManager(QObject): + def __init__(self): + super(WidgetManager, self).__init__() + + self.signal = self.WidgetSignal(self) + self.download_signal = self.DownloadSignal(self) + + class DownloadSignal(QObject): + started = Signal(str, str) # task_id, display_name + progress = Signal(str, str, float) # task_id, status, percent + finished = Signal(str) # task_id + failed = Signal(str, str) # task_id, error + removed = Signal(str) # task_id + log = Signal(str) + + class WidgetSignal(QObject): + 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(object) \ No newline at end of file diff --git a/pages/create_profile.py b/pages/create_profile.py index 3588e58..598535e 100644 --- a/pages/create_profile.py +++ b/pages/create_profile.py @@ -1,15 +1,14 @@ from pathlib import Path -from PySide6 import QtGui from PySide6.QtCore import QSize from PySide6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QListWidget, QPushButton, QLabel, QHBoxLayout from core_lib.launcher.object import Launcher as LauncherObject class CreateProfile(QWidget): - def __init__(self, launcher: "LauncherObject",parent=None): + def __init__(self, app, parent=None): super(CreateProfile, self).__init__(parent) - self.app = launcher + self.app = app self.layout = QHBoxLayout() diff --git a/pages/test.py b/pages/test.py index d878dc7..38dc5a9 100644 --- a/pages/test.py +++ b/pages/test.py @@ -1,15 +1,15 @@ import json -import queue import subprocess import textwrap import threading import time import traceback from pathlib import Path +from uuid import uuid4 from PySide6.QtCore import Qt, QObject, Signal, Slot from PySide6.QtWidgets import QPushButton, QVBoxLayout, QPlainTextEdit, \ - QHBoxLayout, QComboBox, QMessageBox, QFileDialog, QWidget + QHBoxLayout, QComboBox, QWidget from core_lib.common.common import FileObject, download_multiple_files from core_lib.common.exception import VersionNotSelectedException, UnsupportedPlatformException, \ @@ -19,52 +19,28 @@ from core_lib.common.exception import VersionNotSelectedException, UnsupportedPl 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, check_assets_exist, find_correct_assets_dir -from core_lib.game.library import generate_classpath, download_full_libraries, check_full_libraries_exists +from core_lib.game.library_core import generate_classpath, download_full_libraries, check_full_libraries_exists from core_lib.game.version_info import (find_useful_part_in_specific_version_manifest, \ 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 +from core_lib.qt.progress_dialog import ProgressDialog from core_lib.runtime.java import find_java_installations +from manager.widgets import SelectPathRequest, AskForRequest MAX_DOWNLOAD_ATTEMPTS = 3 -class AskForRequest: - 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 SelectPathRequest(AskForRequest): - def __init__(self, title, message, filter_raw=None): - super().__init__(title, message) - self.filter = filter_raw - class TestPage(QWidget): - def __init__(self, app, parent): + def __init__(self, /, app, parent, widget_manager): super().__init__(parent) self.app = app self.parent = parent + self.widget_mgr = widget_manager + self.resize(800, 600) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMaximizeButtonHint) @@ -138,188 +114,64 @@ class TestPage(QWidget): } """)) - # Bind signal functions - self.download_signal = self.DownloadSignal(self) - self.widget_signal = self.WidgetSignal(self) + self.signals = self.PageSignals(self) - self.download_signal.log.connect( + # Bind signal functions + self.widget_mgr.download_signal.log.connect( self.output.appendPlainText ) - self.download_signal.finished.connect( - lambda count: self.output.appendPlainText( - f"Download finished, failed: {count}" - ) - ) + # self.widget_mgr.download_signal.finished.connect( + # lambda count: self.output.appendPlainText( + # f"Download finished, failed: {count}" + # ) + # ) + # + # self.widget_mgr.download_signal.progress.connect( + # lambda name, progress: self.output.setPlainText( + # f"Downloading {name}: {self.get_progress(progress)}" + # ) + # ) - 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.signals.clean_ver_types_dropdown.connect( self.ver_type_dropdown.clear ) - self.widget_signal.update_types_dropdown.connect( + self.signals.update_types_dropdown.connect( lambda types: self.update_type_dropdown(types) ) - self.widget_signal.clean_vers_dropdown.connect( + self.signals.clean_vers_dropdown.connect( self.ver_dropdown.clear ) - self.widget_signal.update_vers_dropdown.connect( + self.signals.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.signals.get_selected_ver.connect( self.get_selected_ver ) - self.widget_signal.get_selected_type.connect( + self.signals.get_selected_type.connect( self.get_selected_type ) - self.widget_signal.clean_output.connect( + self.signals.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): + class PageSignals(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(object) get_selected_ver = Signal(dict) get_selected_type = Signal(dict) clean_output = Signal() - @Slot(object) - def ask_request(self, context: AskForRequest): - 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(object) - def ask_request(self, context: AskForRequest): - 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(object) - def ask_for_path(self, context: SelectPathRequest): - path, _ = QFileDialog.getOpenFileName( - self, - context.title, - "", - context.filter, - ) - - context.result_queue.put(path) - - context.wait_event.set() - @Slot(dict) def get_selected_ver(self, context: dict): context["version"] = self.ver_dropdown.currentText() @@ -334,25 +186,6 @@ class TestPage(QWidget): 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 @@ -393,7 +226,7 @@ class TestPage(QWidget): context = { "event": event, } - self.widget_signal.get_selected_ver.emit(context) + self.signals.get_selected_ver.emit(context) if not event.wait(10): raise VersionNotSelectedException( @@ -413,7 +246,7 @@ class TestPage(QWidget): ver = self._get_selected_version_unsafe() return ver except VersionNotSelectedException as e: - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( "Select a version before continuing.\n" "Details: {}".format(e.user_message) ) @@ -452,7 +285,7 @@ class TestPage(QWidget): """) self.output.setPlainText(content) except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Test Error", @@ -470,7 +303,7 @@ class TestPage(QWidget): ver = self.get_selected_version() if not ver: - self.widget_signal.enable_widget.emit(self.button_2) + self.widget_mgr.signal.enable_widget.emit(self.button_2) return thread = threading.Thread( @@ -488,7 +321,7 @@ class TestPage(QWidget): ver = self.get_selected_version() if not ver: - self.widget_signal.enable_widget.emit(self.button_3) + self.widget_mgr.signal.enable_widget.emit(self.button_3) return thread = threading.Thread( @@ -506,7 +339,7 @@ class TestPage(QWidget): ver = self.get_selected_version() if not ver: - self.widget_signal.enable_widget.emit(self.button_5) + self.widget_mgr.signal.enable_widget.emit(self.button_5) return thread = threading.Thread( @@ -524,7 +357,7 @@ class TestPage(QWidget): ver = self.get_selected_version() if not ver: - self.widget_signal.enable_widget.emit(self.button_6) + self.widget_mgr.signal.enable_widget.emit(self.button_6) return thread = threading.Thread( @@ -542,7 +375,7 @@ class TestPage(QWidget): ver = self.get_selected_version() if not ver: - self.widget_signal.enable_widget.emit(self.button_7) + self.widget_mgr.signal.enable_widget.emit(self.button_7) return thread = threading.Thread( @@ -561,7 +394,7 @@ class TestPage(QWidget): args=(ver_type,) ) thread.start() - self.download_signal.log.emit("Loading all versions...") + self.widget_mgr.download_signal.log.emit("Loading all versions...") def load_all_types(self): thread = threading.Thread( @@ -569,21 +402,21 @@ class TestPage(QWidget): daemon=True ) thread.start() - self.download_signal.log.emit("Loading all types...") + self.widget_mgr.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() + self.signals.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) + self.signals.update_types_dropdown.emit(ver_types) def _load_all_version(self, ver_type): - self.widget_signal.clean_vers_dropdown.emit() + self.signals.clean_vers_dropdown.emit() data = self.get_cached_version_manifest() if not data: @@ -593,7 +426,7 @@ class TestPage(QWidget): data, specified_type=ver_type ) - self.widget_signal.update_vers_dropdown.emit(vers) + self.signals.update_vers_dropdown.emit(vers) # # ============ Version Data Cache ============ @@ -620,15 +453,15 @@ class TestPage(QWidget): 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( + self.widget_mgr.signal.show_error_message.emit( e.user_message, ) except VersionManifestSaveException as e: - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( e.user_message, ) except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Cache Error", @@ -641,7 +474,7 @@ class TestPage(QWidget): 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( + self.widget_mgr.signal.show_error_message.emit( "Version manifest missing after check. Please try again later.", ) return None @@ -650,7 +483,7 @@ class TestPage(QWidget): version_manifest = json.loads(path.read_text()) return version_manifest except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Cache Error", @@ -674,7 +507,7 @@ class TestPage(QWidget): try: _, sha1 = get_specific_version_manifest_url_and_hash(root_manifest, specified_ver) except NoSpecifiedVersionKeyException as e: - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( f"Version {specified_ver} does not exist in version manifest.\n{e.user_message}" ) return None @@ -701,27 +534,59 @@ class TestPage(QWidget): return data + def create_download_progress_callback(self, progress_title: str): + task_id = uuid4().hex + signals = self.widget_mgr.download_signal + + signals.started.emit(task_id, progress_title) + + def callback(name, percent): + signals.progress.emit( + task_id, + f"Downloading {name}", + percent, + ) + + return callback, task_id + # # ============ Game Files ============ # # INFO: All are not in main thread def _download_client(self, ver: str): + callback, task_id = self.create_download_progress_callback(f"Downloading {ver} client") try: ver_data = self.get_cached_version_manifest(ver) if not ver_data: + self.widget_mgr.download_signal.failed.emit( + task_id, + "Version manifest is unavailable.", + ) 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}") + download_core_file( + ver_data, + client_dest, + progress_callback=callback, + raise_for_null_hash=True, + ) + self.widget_mgr.download_signal.log.emit(f"Downloaded {ver} client to {client_dest}") except DownloadException as e: - self.widget_signal.show_error_message.emit( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Fetch Error", @@ -729,23 +594,34 @@ class TestPage(QWidget): } ) except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.download_signal.failed.emit( + task_id, + str(e), + ) + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Download Error", "message": "Unable to download client, an unexpected error occurred." } ) + else: + self.widget_mgr.download_signal.finished.emit(task_id) finally: - self.widget_signal.enable_widget.emit(self.button_2) + self.widget_mgr.signal.enable_widget.emit(self.button_2) def _download_libraries(self, ver: str): + callback, task_id = self.create_download_progress_callback(f"Downloading libraries for version {ver}") try: - self.widget_signal.clean_output.emit() + self.signals.clean_output.emit() ver_data = self.get_cached_version_manifest(ver) if not ver_data: + self.widget_mgr.download_signal.failed.emit( + task_id, + "Version manifest is unavailable.", + ) return libraries_dir = Path(self.app.work_dir, "libraries") @@ -757,90 +633,136 @@ class TestPage(QWidget): ver_data, libraries_dir, natives_dest, - progress_callback=self.download_signal.progress.emit, + progress_callback=callback, redownload_callback=self.redownload_files ) - self.download_signal.log.emit( + self.widget_mgr.download_signal.log.emit( "All libraries downloaded successfully." ) except DependencyException as e: - self.widget_signal.show_error_message.emit( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + str(e), + ) + self.widget_mgr.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." } ) + else: + self.widget_mgr.download_signal.finished.emit(task_id) finally: - self.widget_signal.enable_widget.emit(self.button_3) + self.widget_mgr.signal.enable_widget.emit(self.button_3) def _download_assets(self, ver: str): + callback, task_id = self.create_download_progress_callback(f"Downloading assets for version {ver}") try: - self.widget_signal.clean_output.emit() + self.signals.clean_output.emit() ver_data = self.get_cached_version_manifest(ver) if not ver_data: + self.widget_mgr.download_signal.failed.emit( + task_id, + "Version manifest is unavailable.", + ) return assets_dir = Path(self.app.work_dir, "assets") fails = download_assets(ver_data, assets_dir, no_hash_check=True, - progress_callback=self.download_signal.progress.emit, + progress_callback=callback, launcher_root=self.app.work_dir) if fails: - self.widget_signal.show_warning_message.emit( + self.widget_mgr.download_signal.failed.emit( + task_id, + f"{len(fails)} asset files failed to download.", + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.log.emit( "All assets downloaded successfully." ) except AssetManifestFetchException as e: - self.widget_signal.show_error_message.emit( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.download_signal.failed.emit( + task_id, + e.user_message, + ) + self.widget_mgr.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( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Download Error", "message": "Unable to download assets. An unknown error occurred." } ) + else: + self.widget_mgr.download_signal.finished.emit(task_id) finally: - self.widget_signal.enable_widget.emit(self.button_5) + self.widget_mgr.signal.enable_widget.emit(self.button_5) def _parse_argument(self, ver): try: @@ -854,7 +776,7 @@ class TestPage(QWidget): target_args = new_args if new_args else old_args if not target_args: - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( "Unable to parse arguments because data are invalid.\n" ) return @@ -869,10 +791,10 @@ class TestPage(QWidget): 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)}") + self.widget_mgr.download_signal.log.emit(f"Parsed jvm arguments: \n{parsed_jvm_args}\n{" ".join(parsed_jvm_args)}") + self.widget_mgr.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( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Parse Error", @@ -880,7 +802,7 @@ class TestPage(QWidget): } ) finally: - self.widget_signal.enable_widget.emit(self.button_6) + self.widget_mgr.signal.enable_widget.emit(self.button_6) def _launch_offline_game(self, ver): """ @@ -927,10 +849,23 @@ class TestPage(QWidget): current_status = 3 default_assets_dir = Path(self.app.work_dir, "assets") if not check_assets_exist(ver_data, default_assets_dir, launcher_root=self.app.work_dir): - download_assets(ver_data, default_assets_dir, - no_hash_check=True, - progress_callback=self.download_signal.progress.emit, - launcher_root=self.app.work_dir) + callback, task_id = self.create_download_progress_callback( + "Downloading missing assets" + ) + + try: + download_assets( + ver_data, + default_assets_dir, + no_hash_check=True, + progress_callback=callback, + launcher_root=self.app.work_dir, + ) + except Exception as e: + self.widget_mgr.download_signal.failed.emit(task_id, str(e)) + raise + else: + self.widget_mgr.download_signal.finished.emit(task_id) assets_dir = find_correct_assets_dir(asset_id, default_assets_dir, self.app.work_dir) @@ -964,7 +899,7 @@ class TestPage(QWidget): target_args = arguments if arguments else old_args if not target_args: - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( "Unable to parse arguments because data are invalid.\n" ) return @@ -983,10 +918,10 @@ class TestPage(QWidget): filter_raw="Java executable (java.exe javaw.exe);;All files (*)" ) - self.widget_signal.ask_for_path.emit(request) + self.widget_mgr.signal.ask_for_path.emit(request) if not request.wait(120): - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( "Timed out while waiting for Java executable selection." ) return @@ -994,9 +929,9 @@ class TestPage(QWidget): java_path = request.result() if java_path: - self.download_signal.log.emit(f"Use specified java executable path: {java_path}") + self.widget_mgr.download_signal.log.emit(f"Use specified java executable path: {java_path}") else: - self.download_signal.log.emit("Use system environment java.") + self.widget_mgr.download_signal.log.emit("Use system environment java.") java_path = "java" # Finally, generate launch command @@ -1007,7 +942,7 @@ class TestPage(QWidget): full_args=target_args, ) - self.download_signal.log.emit(f"Launch command: {cmd}") + self.widget_mgr.download_signal.log.emit(f"Launch command: {cmd}") # run it process = subprocess.Popen( @@ -1021,9 +956,9 @@ class TestPage(QWidget): ) for line in process.stdout: - self.download_signal.log.emit(line.rstrip()) + self.widget_mgr.download_signal.log.emit(line.rstrip()) except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Launch Error", @@ -1031,7 +966,7 @@ class TestPage(QWidget): } ) finally: - self.widget_signal.enable_widget.emit(self.button_7) + self.widget_mgr.signal.enable_widget.emit(self.button_7) def redownload_files(self, files: list[FileObject]) -> tuple[bool, list[FileObject]]: if not files: @@ -1045,10 +980,10 @@ class TestPage(QWidget): request = AskForRequest("Redownload", message) request.details = "\n".join(file.posix_path for file in files) - self.widget_signal.askyesno.emit(request) + self.widget_mgr.signal.askyesno.emit(request) if not request.wait(60): - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( "Timed out while waiting for confirmation. Please try again." ) return False, files @@ -1061,26 +996,34 @@ class TestPage(QWidget): # copy pending = list(files) + callback, task_id = self.create_download_progress_callback(f"Redownloading {len(files)} 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)}") + self.widget_mgr.download_signal.log.emit(f"Attempt {retry_time}/{len(pending)}") - fails, successes = download_multiple_files(pending, progress_callback=self.download_signal.progress.emit) + fails, successes = download_multiple_files(pending, + progress_callback=callback) for file in successes: - self.download_signal.log.emit(f"Downloaded {file.posix_path}.") + self.widget_mgr.download_signal.log.emit(f"Downloaded {file.posix_path}.") if fails: pending = fails else: + self.widget_mgr.download_signal.finished.emit(task_id) pending = [] if not pending: return True, [] failed_list = "\n".join(file.posix_path for file in pending) + self.widget_mgr.download_signal.failed.emit( + task_id, + f"{len(pending)} files still failed after retries.", + ) self.app.logger.warning( "Unable to download these files:\n{}".format(failed_list) ) @@ -1097,7 +1040,7 @@ class TestPage(QWidget): def _find_and_save_jvm_search_result(self): installations, errors = find_java_installations() if not installations: - self.widget_signal.show_warning_message.emit( + self.widget_mgr.signal.show_warning_message.emit( "There are no Java installations available in you system.\n" ) return [] @@ -1109,7 +1052,7 @@ class TestPage(QWidget): request.details = "\n".join(path.as_posix() for path in errors) request.use_plain_text = True - self.widget_signal.askyesno.emit(request) + self.widget_mgr.signal.askyesno.emit(request) self.jvm_settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -1119,7 +1062,7 @@ class TestPage(QWidget): return installations except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Settings Error", @@ -1131,7 +1074,7 @@ class TestPage(QWidget): try: return json.loads(self.jvm_settings_path.read_text()) except Exception as e: - self.widget_signal.show_and_print_error_message.emit( + self.widget_mgr.signal.show_and_print_error_message.emit( { "error": traceback.format_exception(e), "title": "Settings Error", @@ -1171,7 +1114,7 @@ class TestPage(QWidget): return executable if not no_error: - self.widget_signal.show_error_message.emit( + self.widget_mgr.signal.show_error_message.emit( "No Java executable found for major version {}.".format(major_version) ) diff --git a/window.py b/window.py new file mode 100644 index 0000000..5548c8c --- /dev/null +++ b/window.py @@ -0,0 +1,366 @@ +from pathlib import Path + +from PySide6 import QtWidgets +from PySide6.QtCore import QSize +from PySide6.QtGui import QIcon, Qt, QPixmap +from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QToolButton, \ + QStackedWidget +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QMessageBox, QFileDialog + +from core_lib.qt import messagebox +from core_lib.qt.progress_dialog import ProgressDialog +from core_lib.qt.messagebox import ask_yes_no_with_plain_text +from constant import MAIN_REPO_URL +from manager.profile import ProfileManager +from manager.widgets import AskForRequest, SelectPathRequest +from pages.account import AccountPage +from pages.create_profile import CreateProfile +from pages.test import TestPage + +class ToolBar(QtWidgets.QToolBar): + def __init__(self, app: QApplication, parent=None): + super(ToolBar, self).__init__(parent) + self.app = app + self.spacer = QWidget() + self.orientationChanged.connect(self._update_widget_size) + + def _update_widget_size(self, orientation): + vertical = orientation == Qt.Orientation.Vertical + + if vertical: + self.spacer.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Expanding, + ) + else: + self.spacer.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + + for button in self.findChildren(QToolButton): + if vertical: + button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonIconOnly + ) + button.setFixedSize(60, 60) + continue + + button.setMinimumSize(0, 0) + button.setMaximumSize(16777215, 16777215) + + if not button.icon().isNull() and not button.text(): + button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonIconOnly + ) + button.setFixedSize(60, 60) + elif button.icon().isNull(): + button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextOnly + ) + button.setFixedWidth(150) + else: + button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextBesideIcon + ) + button.setMinimumWidth(60) + + def add_button(self, text=None, icon: QIcon=None) -> QToolButton: + button = QtWidgets.QToolButton(self) + + if text: + text = " "+text + button.setText(text) + # else: + # button.setText("\u00a0") + + if icon: + button.setIcon(icon) + button.setIconSize(QSize(32, 32)) + + if text: + button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonTextBesideIcon + ) + else: + button.setToolButtonStyle( + Qt.ToolButtonStyle.ToolButtonIconOnly + ) + + button.setAutoRaise(True) + button.setFixedHeight(60) + + return button + + def update_all(self): + self._update_widget_size(self.orientation()) + self.update() + +class LauncherMainWindow(QMainWindow): + def __init__(self, / ,app, widget_manager, parent: QWidget | None=None): + super(LauncherMainWindow, self).__init__(parent) + self.app = app + self.widget_manager = widget_manager + + # Main layout + self.central = QStackedWidget() + self.toolbar = ToolBar(self.app, self) + + # center message (will be deleted if launcher finished development) + content = """Still digging!""" + message = "If you found that something might be a issue. You can report it to the main repo! Any suggestion are welcome." + self.icon_label = QLabel() + self.icon_label.setObjectName("icon_label") + self.dev_info_box = QLabel(content) + self.dev_info_box.setObjectName("dev_info_box") + self.dev_info_2 = QLabel(message) + self.dev_info_2.setObjectName("dev_info_message") + self.version_label = QLabel("Current version: {}".format(self.app.launcher_version)) + self.version_label.setObjectName("version_label") + self.repo_url_label = QLabel(f"Main repo") + self.repo_url_label.setObjectName("repo_url_label") + self.repo_url_label.setOpenExternalLinks(True) + + if Path(self.app.resources_dir, "pictures", "in_progress.png").exists(): + image = QPixmap(Path(self.app.resources_dir, "pictures", "in_progress.png")) + self.icon_label.setPixmap(image.scaled(300, 300)) + else: + self.icon_label.setText("Ouch. The icon is missing.") + + # Manager + self.profile_manager = ProfileManager(self) + + # Dialog + self.download_dialog = ProgressDialog(self) + + # Homepage + self.home_page = QWidget() + self.home_layout = QVBoxLayout(self.home_page) + self.home_layout.addStretch() + self.home_layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + self.home_layout.addWidget(self.dev_info_box, alignment=Qt.AlignmentFlag.AlignHCenter) + self.home_layout.addWidget(self.dev_info_2, alignment=Qt.AlignmentFlag.AlignHCenter) + self.home_layout.addWidget(self.version_label, alignment=Qt.AlignmentFlag.AlignHCenter) + self.home_layout.addWidget(self.repo_url_label, alignment=Qt.AlignmentFlag.AlignHCenter) + self.home_layout.addStretch() + + # test page + self.test_page = TestPage( + app=self.app, + parent=self, + widget_manager=self.app.widget_manager + ) + + # Account page + self.account_page = AccountPage(self.app, self) + + # Profile page + self.create_profile_page = CreateProfile(self.app, self) + + # Add page + self.central.addWidget(self.home_page) + self.central.addWidget(self.test_page) + self.central.addWidget(self.account_page) + self.central.addWidget(self.create_profile_page) + + # Bind toolbar and center widget + self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, self.toolbar) + self.setCentralWidget(self.central) + + # Apply qss + self.app.apply_qss(Path("main.qss"), self.setStyleSheet) + self.app.apply_qss(Path("toolbar.qss"), self.toolbar.setStyleSheet) + + # Toolbar + + # Test Window btn + self.open_test_page = self.toolbar.add_button(" Tests", icon=self.app.get_icon(Path(self.app.icon_dir, "debug.png"))) + self.open_test_page.setObjectName("open_test_page") + + # Home btn + self.home_button = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "home.png"))) + + # Account btn (will be moved to the bottom (or last one item) of the toolbar + self.open_account_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "head.png"))) + + # Profile btn + self.open_create_profile_page = self.toolbar.add_button(icon=self.app.get_icon(Path(self.app.icon_dir, "add_temp.png"))) + + # Bind tool buttons + self.toolbar.addWidget(self.home_button) + self.toolbar.addWidget(self.open_test_page) + self.toolbar.addWidget(self.open_create_profile_page) + self.toolbar.setMovable(True) + self.toolbar.setFloatable(False) + self.toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + + self.toolbar.addWidget(self.toolbar.spacer) + self.toolbar.addSeparator() + self.toolbar.addWidget(self.open_account_page) + + # Bind page-related button click event + self.home_button.clicked.connect( + lambda: self.set_current_page(self.home_page, self.home_button) + ) + + self.open_test_page.clicked.connect( + lambda: self.set_current_page(self.test_page, self.open_test_page) + ) + + self.open_account_page.clicked.connect( + lambda: self.set_current_page(self.account_page, self.open_account_page) + ) + + self.open_create_profile_page.clicked.connect( + lambda: self.set_current_page(self.create_profile_page, self.open_create_profile_page) + ) + + # Bind signal event + self.bind_event() + + self.set_current_page(self.home_page, self.home_button) + + @Slot(object) + def ask_request(self, context: AskForRequest): + 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(object) + def ask_for_path(self, context: SelectPathRequest): + path, _ = QFileDialog.getOpenFileName( + self, + context.title, + "", + context.filter, + ) + + context.result_queue.put(path) + + context.wait_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, + title, + message=message + ) + + @Slot(object) + def enable_widget(self, widget): + widget.setEnabled(True) + + @Slot(object) + def disable_widget(self, widget): + widget.setDisabled(True) + + @Slot(str) + def show_error(self, message): + messagebox.error(self, "Error", message=message) + + @Slot(str) + def show_warning(self, widget): + messagebox.warning(self, "Warning", message=widget) + + @Slot(str) + def show_info(self, widget): + messagebox.info(self, "Info", message=widget) + + def set_current_page(self, page: QWidget, related_button: QToolButton): + self.central.setCurrentWidget(page) + related_button.setFocus() + + def bind_event(self): + download_signal = self.widget_manager.download_signal + widget_signal = self.widget_manager.signal + + download_signal.started.connect( + self.download_dialog.add_task, + Qt.ConnectionType.QueuedConnection, + ) + download_signal.progress.connect( + self.download_dialog.update_task, + Qt.ConnectionType.QueuedConnection, + ) + download_signal.finished.connect( + self.download_dialog.finish_task, + Qt.ConnectionType.QueuedConnection, + ) + download_signal.failed.connect( + self.download_dialog.fail_task, + Qt.ConnectionType.QueuedConnection, + ) + download_signal.removed.connect( + self.download_dialog.remove_task, + Qt.ConnectionType.QueuedConnection, + ) + + widget_signal.show_error_message.connect( + self.show_error, + Qt.ConnectionType.QueuedConnection, + ) + + widget_signal.show_warning_message.connect( + self.show_warning, + Qt.ConnectionType.QueuedConnection, + ) + + widget_signal.show_info_message.connect( + self.show_info, + Qt.ConnectionType.QueuedConnection, + ) + + widget_signal.show_and_print_error_message.connect( + self.show_and_print_error + ) + + widget_signal.enable_widget.connect( + self.enable_widget, + Qt.ConnectionType.QueuedConnection, + ) + + widget_signal.disable_widget.connect( + self.disable_widget, + Qt.ConnectionType.QueuedConnection, + ) + + widget_signal.askyesno.connect( + self.ask_request + ) + + widget_signal.ask_for_path.connect( + self.ask_for_path + )