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
+ )