Add download progress dialog and some mod loader parse function.

This commit is contained in:
wei
2026-07-28 13:35:04 +08:00
parent 8d850408cd
commit 07d5b8a378
17 changed files with 1873 additions and 1025 deletions
+21 -130
View File
@@ -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"<a href=\"{MAIN_REPO_URL}\">Main repo</a>")
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
+2
View File
@@ -0,0 +1,2 @@
LAUNCHER_VERSION = "alpha_0.0.2"
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
+7 -1
View File
@@ -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
+89 -38
View File
@@ -1,11 +1,14 @@
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:
"""
Check if argument is allowed or not.
@@ -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]
@@ -136,6 +168,7 @@ def convert_argument_to_multiple_string(argument: str | dict) -> list[str]:
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
+2 -553
View File
@@ -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"
)
+589
View File
@@ -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"
)
+130
View File
@@ -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
+2 -2
View File
@@ -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]:
+85 -25
View File
@@ -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:
result.append(mod_library)
# 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_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
+168
View File
@@ -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()
+1 -1
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
from PySide6.QtCore import QObject
class RocketLauncher(QObject):
pass
+129
View File
@@ -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: ...
+60
View File
@@ -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)
+2 -3
View File
@@ -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()
+212 -269
View File
@@ -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,
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=self.download_signal.progress.emit,
launcher_root=self.app.work_dir)
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)
)
+366
View File
@@ -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"<a href=\"{MAIN_REPO_URL}\">Main repo</a>")
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
)