Add download progress dialog and some mod loader parse function.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
LAUNCHER_VERSION = "alpha_0.0.2"
|
||||
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from PySide6.QtCore import QObject
|
||||
|
||||
|
||||
class RocketLauncher(QObject):
|
||||
pass
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user