Add auto detect java installation support.

This commit is contained in:
2026-07-14 01:13:36 +08:00
parent bd5a1e8974
commit 425f740d8f
9 changed files with 546 additions and 200 deletions
+76 -2
View File
@@ -1,11 +1,15 @@
import logging import logging
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import QApplication, QMainWindow from PySide6.QtGui import QIcon, Qt, QImage, QPixmap
from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget
from core_lib.qt.hack import move_window_to_center from core_lib.qt.hack import move_window_to_center
from dialog.test import TestDialog from dialog.test import TestDialog
LAUNCHER_VERSION = "alpha_0.0.1"
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
class Launcher(QApplication): class Launcher(QApplication):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -24,11 +28,69 @@ class Launcher(QApplication):
# test page # test page
self.test_dialog = TestDialog(self, self.main_window) self.test_dialog = TestDialog(self, self.main_window)
self.test_dialog.show()
# Set icon
if self.icon_path.exists():
self.setWindowIcon(QIcon(self.icon_path.as_posix()))
else:
self.logger.error("No icon found.")
self.central = QWidget()
# 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)
self.main_layout = QVBoxLayout(self.central)
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.")
self.main_layout.addStretch()
self.main_layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
self.main_layout.addWidget(self.dev_info_box, alignment=Qt.AlignmentFlag.AlignHCenter)
self.main_layout.addWidget(self.dev_info_2, alignment=Qt.AlignmentFlag.AlignHCenter)
self.main_layout.addWidget(self.version_label, alignment=Qt.AlignmentFlag.AlignHCenter)
self.main_layout.addWidget(self.repo_url_label, alignment=Qt.AlignmentFlag.AlignHCenter)
self.main_layout.addStretch()
self.main_window.setCentralWidget(self.central)
self.setStyleSheet("""
QLabel#dev_info_box {
font-size: 20pt;
font-weight: bold;
}
QLabel#dev_info_message {
font-size: 10pt;
}
QLabel#version_label, QLabel#version_label {
font-weight: bold;
}
QLabel#icon_label {
border-radius: 20px;
}
""")
def exec(self): def exec(self):
self.main_window.show() self.main_window.show()
self.test_dialog.show()
self.exec_() self.exec_()
@property @property
@@ -46,3 +108,15 @@ class Launcher(QApplication):
@property @property
def log_dir(self): def log_dir(self):
return self.work_dir / "log" return self.work_dir / "log"
@property
def resources_dir(self):
return self.program_dir / "resources"
@property
def icon_path(self):
return self.resources_dir / "icons" / "icon.png"
@property
def launcher_version(self):
return LAUNCHER_VERSION
+9 -2
View File
@@ -68,22 +68,26 @@ def replace_placeholders(argument, placeholders: dict) -> str:
class ArgumentMappings: class ArgumentMappings:
def __init__(self, player_name="Player", version_name="unknown", def __init__(self, player_name="Player", version_name="unknown",
game_directory=None, assets_root=None, assets_index_name=None, game_directory=None, assets_root=None, legacy_assets_root=None, assets_index_name=None,
auth_uuid="00000001-0002-0003-0004-000000000005", auth_uuid="00000001-0002-0003-0004-000000000005",
auth_access_token="", clientid=None, auth_xuid=None, auth_access_token="", clientid=None, auth_xuid=None,
user_type=None, version_type=None, natives_directory=None, user_type=None, user_properties=None, version_type=None, natives_directory=None,
launcher_name=None, launcher_version=None, launcher_name=None, launcher_version=None,
classpath=None, classpath_separator=None, library_directory=None): classpath=None, classpath_separator=None, library_directory=None):
self.player_name = player_name self.player_name = player_name
self.version_name = version_name self.version_name = version_name
self.game_directory = game_directory self.game_directory = game_directory
self.assets_root = assets_root self.assets_root = assets_root
self.game_assets = legacy_assets_root
self.assets_index_name = assets_index_name self.assets_index_name = assets_index_name
self.auth_uuid = auth_uuid self.auth_uuid = auth_uuid
self.auth_access_token = auth_access_token self.auth_access_token = auth_access_token
self.clientid = clientid self.clientid = clientid
self.auth_xuid = auth_xuid self.auth_xuid = auth_xuid
self.user_type = user_type self.user_type = user_type
self.user_properties = user_properties
if not isinstance(user_properties, dict):
self.user_properties = {}
self.version_type = version_type self.version_type = version_type
self.natives_directory = natives_directory self.natives_directory = natives_directory
self.launcher_name = launcher_name self.launcher_name = launcher_name
@@ -98,13 +102,16 @@ class ArgumentMappings:
"version_name": self.version_name, "version_name": self.version_name,
"game_directory": self.game_directory, "game_directory": self.game_directory,
"assets_root": self.assets_root, "assets_root": self.assets_root,
"game_assets": self.game_assets,
"assets_index_name": self.assets_index_name, "assets_index_name": self.assets_index_name,
"auth_uuid": self.auth_uuid, "auth_uuid": self.auth_uuid,
"auth_session": self.auth_access_token,
"auth_access_token": self.auth_access_token, "auth_access_token": self.auth_access_token,
"auth_player_name": self.player_name, "auth_player_name": self.player_name,
"clientid": self.clientid, "clientid": self.clientid,
"auth_xuid": self.auth_xuid, "auth_xuid": self.auth_xuid,
"user_type": self.user_type, "user_type": self.user_type,
"user_properties": self.user_properties,
"version_type": self.version_type, "version_type": self.version_type,
"natives_directory": self.natives_directory, "natives_directory": self.natives_directory,
"launcher_name": self.launcher_name, "launcher_name": self.launcher_name,
+82 -7
View File
@@ -9,7 +9,8 @@ import requests
from core_lib.common.common import FileObject, download_multiple_files from core_lib.common.common import FileObject, download_multiple_files
from core_lib.common.exception import VersionDataKeyNotFoundException, AssetManifestFetchException, \ from core_lib.common.exception import VersionDataKeyNotFoundException, AssetManifestFetchException, \
HashMismatchException, AssetManifestSaveException, AssetDataKeyNotFoundException, DownloadException HashMismatchException, AssetManifestSaveException, AssetDataKeyNotFoundException, DownloadException, \
AssetManifestException
from core_lib.game.version_info import find_useful_part_in_specific_version_manifest from core_lib.game.version_info import find_useful_part_in_specific_version_manifest
MOJANG_RESOURCE_ENDPOINT = "https://resources.download.minecraft.net/{beginning}/{hash}" MOJANG_RESOURCE_ENDPOINT = "https://resources.download.minecraft.net/{beginning}/{hash}"
@@ -103,14 +104,17 @@ def fetch_and_save_asset_manifest(target_version_manifest: dict, destination: Pa
"Error saving version manifest:\n{}".format(e) "Error saving version manifest:\n{}".format(e)
) )
def _check_asset(asset_id: str, asset_data: dict, relative_path: str, assets_dir: Path, virtual: bool, no_hash_check: bool=False)\ def _check_asset(asset_id: str, asset_data: dict, relative_path: str, assets_dir: Path, launcher_root: Path, virtual: bool,
map_to_resources: bool, no_hash_check: bool=False)\
-> tuple[FileObject | None, dict | None]: -> tuple[FileObject | None, dict | None]:
""" """
Check if asset exists. Check if asset exists.
:param asset_data: :param asset_data:
:param relative_path: :param relative_path:
:param assets_dir: :param assets_dir:
:param launcher_root:
:param virtual: :param virtual:
:param map_to_resources:
:return: :return:
asset_file: FileObject asset_file: FileObject
legacy_context: dict legacy_context: dict
@@ -151,15 +155,23 @@ def _check_asset(asset_id: str, asset_data: dict, relative_path: str, assets_dir
if virtual: if virtual:
legacy_context = { legacy_context = {
"file": file, "file": file,
"target": full_path if virtual else None, "target": full_path,
"sha1": hash_raw, "sha1": hash_raw,
"destination": Path(assets_dir, "virtual", asset_id, relative_path) if virtual else None, "destination": Path(assets_dir, "virtual", asset_id, relative_path),
"size": expected_size,
}
elif map_to_resources:
legacy_context = {
"file": file,
"target": full_path,
"sha1": hash_raw,
"destination": Path(launcher_root, "resources", relative_path),
"size": expected_size, "size": expected_size,
} }
return file if needs_download else None, legacy_context return file if needs_download else None, legacy_context
def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path, def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path, launcher_root: Path,
max_workers=ASSET_CHECK_MAX_WORKERS, max_workers=ASSET_CHECK_MAX_WORKERS,
no_hash_check=False) -> tuple[list[FileObject], list[dict]]: no_hash_check=False) -> tuple[list[FileObject], list[dict]]:
""" """
@@ -168,6 +180,7 @@ def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path
:param manifest: :param manifest:
:param asset_id: :param asset_id:
:param assets_dir: :param assets_dir:
:param launcher_root:
:param max_workers: :param max_workers:
:return: :return:
assets: list[FileObject] assets: list[FileObject]
@@ -175,6 +188,7 @@ def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path
""" """
objects = manifest.get("objects", []) objects = manifest.get("objects", [])
virtual = manifest.get("virtual", False) virtual = manifest.get("virtual", False)
map_to_resources = manifest.get("map_to_resources", False)
files = [] files = []
legacy_assets = [] legacy_assets = []
@@ -188,7 +202,7 @@ def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path
futures = [ futures = [
executor.submit( executor.submit(
_check_asset, _check_asset,
asset_id, objects[relative_path], relative_path, assets_dir, virtual, asset_id, objects[relative_path], relative_path, assets_dir, launcher_root, virtual, map_to_resources,
no_hash_check=no_hash_check no_hash_check=no_hash_check
) )
for relative_path in objects for relative_path in objects
@@ -341,7 +355,7 @@ def correct_assets_name_and_copy(manifest: dict, assets_dir: Path, output_dir: P
return missing return missing
def download_assets(manifest: dict, assets_dir: Path, no_hash_check: bool=False, def download_assets(manifest: dict, assets_dir: Path, launcher_root: Path, no_hash_check: bool=False,
progress_callback: Callable[[str, float], None]=None, progress_callback: Callable[[str, float], None]=None,
redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]]=None) -> list[FileObject]: redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]]=None) -> list[FileObject]:
_, asset_index, _, _, _, _, _ = find_useful_part_in_specific_version_manifest(manifest) _, asset_index, _, _, _, _, _ = find_useful_part_in_specific_version_manifest(manifest)
@@ -366,6 +380,7 @@ def download_assets(manifest: dict, assets_dir: Path, no_hash_check: bool=False,
asset_manifest, asset_manifest,
asset_id, asset_id,
assets_dir, assets_dir,
launcher_root,
no_hash_check=no_hash_check, no_hash_check=no_hash_check,
) )
@@ -378,3 +393,63 @@ def download_assets(manifest: dict, assets_dir: Path, no_hash_check: bool=False,
) )
return fails return fails
def check_assets_exist(manifest: dict, assets_dir: Path, launcher_root: Path, no_hash_check: bool=False) -> bool:
_, asset_index, _, _, _, _, _ = find_useful_part_in_specific_version_manifest(manifest)
asset_id = asset_index.get("id")
if not asset_id:
raise VersionDataKeyNotFoundException(
"Asset ID is missing."
)
index_path = Path(assets_dir, "indexes", f"{asset_id}.json")
asset_manifest = None
if not (index_path.exists() and index_path.is_file()):
fetch_and_save_asset_manifest(
manifest,
index_path,
)
with index_path.open() as f:
asset_manifest = json.load(f)
# Download assets files
assets, legacy_assets = collect_assets_from_manifest(
asset_manifest,
asset_id,
assets_dir,
launcher_root,
no_hash_check=no_hash_check,
)
if not assets and not legacy_assets:
return True
return False
def read_exist_assets_indexes(asset_id: str, assets_dir: Path):
return json.loads(Path(assets_dir, "indexes", f"{asset_id}.json").open().read())
def find_correct_assets_dir(asset_id: str, assets_dir: Path, launcher_root: Path, asset_manifest: dict | None=None) -> Path:
try:
if not asset_manifest:
asset_manifest = read_exist_assets_indexes(asset_id, assets_dir)
except FileNotFoundError:
raise AssetManifestException(
"Asset manifest not found in: {} (Index: {})".format(assets_dir, asset_id),
)
virtual = asset_manifest.get("virtual")
map_to_resources = asset_manifest.get("map_to_resources")
if virtual:
return assets_dir / "virtual" / asset_id
if map_to_resources:
return launcher_root / "resources"
return assets_dir
+156 -12
View File
@@ -1,6 +1,7 @@
import json import json
import logging import logging
import re import re
import zipfile
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -81,7 +82,8 @@ def get_natives_platform_info():
return platform_key_name, platform_rule_name, platform_arch_type return platform_key_name, platform_rule_name, platform_arch_type
def is_native_allowed(lib_name, platform_key_name, platform_arch_type, unspecified_arch_policy: str="x86_64") -> bool:
def is_native_allowed(lib_name, platform_key_name, platform_arch_type, unspecified_arch_policy: str = "x86_64") -> bool:
# Parse native key # Parse native key
match = re.search(r":(natives-[^:]+)$", lib_name) match = re.search(r":(natives-[^:]+)$", lib_name)
@@ -96,10 +98,12 @@ def is_native_allowed(lib_name, platform_key_name, platform_arch_type, unspecifi
native_os_name = parts[1] native_os_name = parts[1]
if unspecified_arch_policy == "universal": if unspecified_arch_policy == "universal":
arch_type = platform_arch_type arch_type = platform_arch_type
logger.debug(f"This native may for platform \"{native_os_name}\" (\"May\" for all architecture type)") logger.debug(
f"This native may for platform \"{native_os_name}\" (\"May\" for all architecture type)")
else: else:
arch_type = unspecified_arch_policy arch_type = unspecified_arch_policy
logger.debug(f"This native may for platform \"{native_os_name}\" and specific architecture type {unspecified_arch_policy}") logger.debug(
f"This native may for platform \"{native_os_name}\" and specific architecture type {unspecified_arch_policy}")
elif len(parts) == 3: # With architecture type elif len(parts) == 3: # With architecture type
native_os_name, arch_type = parts[1], parts[2] native_os_name, arch_type = parts[1], parts[2]
@@ -120,7 +124,9 @@ def is_native_allowed(lib_name, platform_key_name, platform_arch_type, unspecifi
return False return False
def is_legacy_native_allowed(natives, classifiers, platform_rule_name, platform_arch_type, unspecified_arch_policy: str="x86_64") -> tuple[bool, dict | None, str | None]:
def is_legacy_native_allowed(natives, classifiers, platform_rule_name, platform_arch_type,
unspecified_arch_policy: str = "x86_64") -> tuple[bool, dict | None, str | None]:
valid_non_arch_values = { valid_non_arch_values = {
"universal", "universal",
"x86", "x86",
@@ -174,10 +180,12 @@ def is_legacy_native_allowed(natives, classifiers, platform_rule_name, platform_
if unspecified_arch_policy == "universal": if unspecified_arch_policy == "universal":
universal = True universal = True
arch_type = platform_arch_type arch_type = platform_arch_type
logger.debug(f"This native may for platform \"{native_os_name}\" (\"May\" for all architecture type)") logger.debug(
f"This native may for platform \"{native_os_name}\" (\"May\" for all architecture type)")
else: else:
arch_type = unspecified_arch_policy arch_type = unspecified_arch_policy
logger.debug(f"This native may for platform \"{native_os_name}\" and specific architecture type {unspecified_arch_policy}") logger.debug(
f"This native may for platform \"{native_os_name}\" and specific architecture type {unspecified_arch_policy}")
elif len(parts) == 3: elif len(parts) == 3:
native_os_name, arch_type = parts[1].lower(), parts[2].lower() native_os_name, arch_type = parts[1].lower(), parts[2].lower()
logger.debug(f"This native may for platform \"{native_os_name}\" and architecture type \"{arch_type}\"") logger.debug(f"This native may for platform \"{native_os_name}\" and architecture type \"{arch_type}\"")
@@ -195,6 +203,7 @@ def is_legacy_native_allowed(natives, classifiers, platform_rule_name, platform_
return False, None, None return False, None, None
def generate_relative_path_by_maven_coordinate(maven_coordinate: str, extra=None) -> str | None: def generate_relative_path_by_maven_coordinate(maven_coordinate: str, extra=None) -> str | None:
parts = maven_coordinate.split(":") parts = maven_coordinate.split(":")
@@ -213,6 +222,7 @@ def generate_relative_path_by_maven_coordinate(maven_coordinate: str, extra=None
return f"{base_path}/{artifact}/{version}/{filename}.jar" return f"{base_path}/{artifact}/{version}/{filename}.jar"
def remove_duplicate_artifacts(artifacts: list[dict], ignore_null=False, compare_by_name=False) -> list[dict]: def remove_duplicate_artifacts(artifacts: list[dict], ignore_null=False, compare_by_name=False) -> list[dict]:
seen = set() seen = set()
result = [] result = []
@@ -238,8 +248,9 @@ def remove_duplicate_artifacts(artifacts: list[dict], ignore_null=False, compare
return result return result
def generate_formatted_artifact_with_name(lib_name, lib_artifact, libraries_endpoint=MOJANG_LIBRARIES_ENDPOINT, def generate_formatted_artifact_with_name(lib_name, lib_artifact, libraries_endpoint=MOJANG_LIBRARIES_ENDPOINT,
extra: dict | None=None, relative_args: dict|None=None) -> dict | None: extra: dict | None = None, relative_args: dict | None = None) -> dict | None:
""" """
Generates a formatted artifact Generates a formatted artifact
:param lib_name: Library name :param lib_name: Library name
@@ -277,6 +288,7 @@ def generate_formatted_artifact_with_name(lib_name, lib_artifact, libraries_endp
return artifact_new return artifact_new
def collect_library_artifacts(specific_version_manifest): def collect_library_artifacts(specific_version_manifest):
_, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest) _, _, _, _, libraries, _, _ = find_useful_part_in_specific_version_manifest(specific_version_manifest)
@@ -439,7 +451,8 @@ def collect_native_artifacts(specific_version_manifest):
return items return items
def download_libraries(artifacts, output_dir, progress_callback: Callable[[str, float], None]=None) \
def download_libraries(artifacts, output_dir, progress_callback: Callable[[str, float], None] = None) \
-> tuple[list[FileObject], list[FileObject]]: -> tuple[list[FileObject], list[FileObject]]:
""" """
Download libraries from a list that contains artifacts Download libraries from a list that contains artifacts
@@ -487,6 +500,7 @@ def download_libraries(artifacts, output_dir, progress_callback: Callable[[str,
return fails, successes return fails, successes
def unzip_natives(filtered, libraries_dir, destination: Path): def unzip_natives(filtered, libraries_dir, destination: Path):
""" """
Unzip native libraries from a list that contains artifacts Unzip native libraries from a list that contains artifacts
@@ -579,6 +593,136 @@ def check_libraries_exists(manifest: dict, libraries_dir: Path):
return not_found 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): def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path):
""" """
Generate classpath by using specific version's data Generate classpath by using specific version's data
@@ -645,9 +789,11 @@ def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path
return f"{separator}".join(classes) return f"{separator}".join(classes)
def download_full_libraries(manifest: dict, libraries_dir: Path, natives_dir: Path, def download_full_libraries(manifest: dict, libraries_dir: Path, natives_dir: Path,
progress_callback: Callable[[str, float], None]=None, progress_callback: Callable[[str, float], None] = None,
redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]]=None) -> None: redownload_callback: Callable[
[list[FileObject]], tuple[bool, list[FileObject]]] = None) -> None:
# libraries # libraries
libraries = collect_library_artifacts(manifest) libraries = collect_library_artifacts(manifest)
@@ -683,5 +829,3 @@ def download_full_libraries(manifest: dict, libraries_dir: Path, natives_dir: Pa
raise DependencyException( raise DependencyException(
"Certain libraries download failed. Unable to continue unzip process.\n" "Certain libraries download failed. Unable to continue unzip process.\n"
) )
+16 -4
View File
@@ -13,6 +13,11 @@ from core_lib.common.exception import JavaRuntimeException, JavaRuntimeParseExce
logger = logging.getLogger("Launcher.CoreLib") logger = logging.getLogger("Launcher.CoreLib")
def get_java_version(java_executable_path: str | Path) -> tuple[int, str]: def get_java_version(java_executable_path: str | Path) -> tuple[int, str]:
"""
Get java version by executing `java -version`
:param java_executable_path:
:return:
"""
if isinstance(java_executable_path, Path): if isinstance(java_executable_path, Path):
java_executable_path = java_executable_path.as_posix() java_executable_path = java_executable_path.as_posix()
@@ -56,7 +61,14 @@ def get_java_version(java_executable_path: str | Path) -> tuple[int, str]:
return major, raw_version return major, raw_version
def find_java_installations(extra_install_patterns: list | None=None) -> list[dict]: def find_java_installations(extra_install_patterns: list | None=None) -> tuple[list[dict], list[Path]]:
"""
Find Java installations
:param extra_install_patterns:
:return:
installations: List[dict]
errors: List[Path]
"""
candidates_paths = [] candidates_paths = []
installations = [] installations = []
@@ -141,11 +153,11 @@ def find_java_installations(extra_install_patterns: list | None=None) -> list[di
{ {
"major": ver, "major": ver,
"raw": raw, "raw": raw,
"homeDir": converted_path.parent.parent, "homeDir": converted_path.parent.parent.as_posix(),
"executable": converted_path, "executable": converted_path.as_posix(),
} }
) )
installations.sort(key=lambda item: item["major"], reverse=True) installations.sort(key=lambda item: item["major"], reverse=True)
return installations return installations, errors
+2 -2
View File
@@ -308,8 +308,8 @@ def handle_runtime_dir_and_executable_process(dirs: list[Path], executables: lis
def download_java_runtimes(component: str, runtime_dir: Path, no_hash_check: bool = False, def download_java_runtimes(component: str, runtime_dir: Path, no_hash_check: bool = False,
progress_callback: Callable[[str, float], None] = None, progress_callback: Callable[[str, float], None] = None,
redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]] = None, redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]] = None,
max_download_workers=RUNTIME_CHECK_MAX_WORKERS, max_download_workers=RUNTIME_DOWNLOAD_MAX_WORKERS,
max_check_workers=RUNTIME_DOWNLOAD_MAX_WORKERS) -> None: max_check_workers=RUNTIME_CHECK_MAX_WORKERS) -> None:
""" """
Download runtime files from given component. Download runtime files from given component.
:param component: Target component of the java runtime (e.g. java-runtime-alpha, java-runtime-beta) :param component: Target component of the java runtime (e.g. java-runtime-alpha, java-runtime-beta)
+198 -104
View File
@@ -9,17 +9,18 @@ from pathlib import Path
from PySide6.QtCore import Qt, QObject, Signal, Slot from PySide6.QtCore import Qt, QObject, Signal, Slot
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QDialog, QPlainTextEdit, \ from PySide6.QtWidgets import QPushButton, QVBoxLayout, QDialog, QPlainTextEdit, \
QHBoxLayout, QComboBox, QMessageBox, QInputDialog, QFileDialog QHBoxLayout, QComboBox, QMessageBox, QFileDialog
from core_lib.common.common import download_file, FileObject, download_multiple_files from core_lib.common.common import FileObject, download_multiple_files
from core_lib.common.exception import VersionNotSelectedException, UnsupportedPlatformException, \ from core_lib.common.exception import VersionNotSelectedException, UnsupportedPlatformException, \
NativeResolveException, DownloadException, DependencyException, VersionManifestFetchException, \ NativeResolveException, DownloadException, DependencyException, VersionManifestFetchException, \
VersionManifestSaveException, NoSpecifiedVersionKeyException, AssetManifestFetchException, \ VersionManifestSaveException, NoSpecifiedVersionKeyException, AssetManifestFetchException, \
AssetManifestSaveException, AssetDataKeyNotFoundException, VersionDataKeyNotFoundException AssetManifestSaveException, AssetDataKeyNotFoundException, VersionDataKeyNotFoundException
from core_lib.game.argument import ArgumentMappings, parse_and_generate_arguments, find_game_and_jvm_args, \ from core_lib.game.argument import ArgumentMappings, parse_and_generate_arguments, find_game_and_jvm_args, \
generate_launch_command generate_launch_command
from core_lib.game.asset import download_assets 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 from core_lib.game.library import generate_classpath, download_full_libraries, check_libraries_exists, \
check_full_libraries_exists
from core_lib.game.version_info import (find_useful_part_in_specific_version_manifest, from core_lib.game.version_info import (find_useful_part_in_specific_version_manifest,
get_core_file_download_url_and_hash, \ get_core_file_download_url_and_hash, \
@@ -29,11 +30,12 @@ from core_lib.game.version_info import (find_useful_part_in_specific_version_man
from core_lib.qt import messagebox from core_lib.qt import messagebox
from core_lib.qt.hack import is_main_thread 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.messagebox import ask_yes_no_with_plain_text
from core_lib.runtime.java import find_java_installations
MAX_DOWNLOAD_ATTEMPTS = 3 MAX_DOWNLOAD_ATTEMPTS = 3
class YesNoRequest: class AskForRequest:
def __init__(self, title, message): def __init__(self, title, message):
self.title = title self.title = title
self.message = message self.message = message
@@ -54,6 +56,11 @@ class YesNoRequest:
except queue.Empty: except queue.Empty:
return None return None
class SelectPathRequest(AskForRequest):
def __init__(self, title, message, filter_raw=None):
super().__init__(title, message)
self.filter = filter_raw
class TestDialog(QDialog): class TestDialog(QDialog):
def __init__(self, app, parent): def __init__(self, app, parent):
@@ -245,13 +252,13 @@ class TestDialog(QDialog):
enable_widget = Signal(object) enable_widget = Signal(object)
disable_widget = Signal(object) disable_widget = Signal(object)
askyesno = Signal(object) askyesno = Signal(object)
ask_for_path = Signal(dict) ask_for_path = Signal(object)
get_selected_ver = Signal(dict) get_selected_ver = Signal(dict)
get_selected_type = Signal(dict) get_selected_type = Signal(dict)
clean_output = Signal() clean_output = Signal()
@Slot(object) @Slot(object)
def ask_request(self, context: YesNoRequest): def ask_request(self, context: AskForRequest):
if not context.use_plain_text: if not context.use_plain_text:
msg = QMessageBox.question( msg = QMessageBox.question(
self, self,
@@ -276,6 +283,45 @@ class TestDialog(QDialog):
context.wait_event.set() 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) @Slot(dict)
def get_selected_ver(self, context: dict): def get_selected_ver(self, context: dict):
context["version"] = self.ver_dropdown.currentText() context["version"] = self.ver_dropdown.currentText()
@@ -309,27 +355,6 @@ class TestDialog(QDialog):
message=message message=message
) )
@Slot(dict)
def ask_for_path(self, context: dict):
event = context.get("event")
title = context.get("title", "Select a path")
filter_raw = context.get("filter", "All files (*)")
result = context.get("result")
path, _ = QFileDialog.getOpenFileName(
self,
title,
"",
filter_raw,
)
if isinstance(result, dict):
result["ok"] = bool(path)
result["path"] = path
if isinstance(event, threading.Event):
event.set()
@staticmethod @staticmethod
def get_progress(value): def get_progress(value):
width = 30 width = 30
@@ -522,38 +547,10 @@ class TestDialog(QDialog):
self.widget_signal.enable_widget.emit(self.button_7) self.widget_signal.enable_widget.emit(self.button_7)
return return
event = threading.Event()
result = {}
context = {
"title": "Require a java executable path to launch game.",
"event": event,
"result": result,
"filter": "Java executable (java.exe javaw.exe);;All files (*)"
}
# Ask for a support java version. If not, use system environment java
self.ask_for_path(
context,
)
if not event.wait(120):
self.widget_signal.show_error_message.emit(
"Timed out while waiting for Java executable selection."
)
return
java_path = result.get("path")
if java_path:
self.download_signal.log.emit(f"Use specified java executable path: {java_path}")
else:
self.download_signal.log.emit("Use system environment java.")
java_path = "java"
thread = threading.Thread( thread = threading.Thread(
target=self._launch_offline_game, target=self._launch_offline_game,
daemon=True, daemon=True,
args=(ver, java_path,) args=(ver,)
) )
thread.start() thread.start()
@@ -810,7 +807,10 @@ class TestDialog(QDialog):
assets_dir = Path(self.app.work_dir, "assets") assets_dir = Path(self.app.work_dir, "assets")
fails = download_assets(ver_data, assets_dir, no_hash_check=True) fails = download_assets(ver_data, assets_dir,
no_hash_check=True,
progress_callback=self.download_signal.progress.emit,
launcher_root=self.app.work_dir)
if fails: if fails:
self.widget_signal.show_warning_message.emit( self.widget_signal.show_warning_message.emit(
@@ -884,16 +884,15 @@ class TestDialog(QDialog):
finally: finally:
self.widget_signal.enable_widget.emit(self.button_6) self.widget_signal.enable_widget.emit(self.button_6)
def _launch_offline_game(self, ver, java_path): def _launch_offline_game(self, ver):
""" """
Launch offline game Launch offline game
:param ver: :param ver:
:param java_path:
:return: :return:
""" """
try: try:
current_status = 0 # 0 = fetch version manifest, 1 = check client, 2 = check libraries current_status = 0 # 0 = fetch version manifest, 1 = check client, 2 = check libraries
# 3 = check libraries, 4 = check assets # , 3 = check assets
# Version manifest # Version manifest
ver_data = self.get_cached_version_manifest(ver) ver_data = self.get_cached_version_manifest(ver)
@@ -907,62 +906,37 @@ class TestDialog(QDialog):
# Vars # Vars
asset_id = asset_index.get("id") asset_id = asset_index.get("id")
java_major_version = java_version.get("majorVersion")
# Some paths # Some paths
core_file_path = Path(self.app.work_dir, "versions", ver, f"{ver}.jar") core_file_path = Path(self.app.work_dir, "versions", ver, f"{ver}.jar")
game_dir = Path(self.app.temp_dir, "minecraft") game_dir = Path(self.app.temp_dir, "minecraft")
game_dir.mkdir(parents=True, exist_ok=True) game_dir.mkdir(parents=True, exist_ok=True)
natives_dir = Path(self.app.work_dir, "versions", ver, "natives") natives_dir = Path(self.app.work_dir, "versions", ver, "natives")
assets_dir = Path(self.app.work_dir, "assets")
libraries_dir = Path(self.app.work_dir, "libraries") libraries_dir = Path(self.app.work_dir, "libraries")
# Check client # Check client
self._download_client(ver) current_status = 1
if not core_file_path.exists(): if not core_file_path.exists():
url, sha1 = get_core_file_download_url_and_hash(ver_data) download_core_file(ver_data, core_file_path, raise_for_null_hash=True)
try: # Libraries
ver_data = self.get_cached_version_manifest(ver) current_status = 2
if not check_full_libraries_exists(ver_data, libraries_dir, natives_dir):
download_full_libraries(ver_data, libraries_dir, natives_dir)
if not ver_data: # Asset
return current_status = 3
default_assets_dir = Path(self.app.work_dir, "assets")
if not check_assets_exist(ver_data, default_assets_dir, launcher_root=self.app.work_dir):
download_assets(ver_data, default_assets_dir,
no_hash_check=True,
progress_callback=self.download_signal.progress.emit,
launcher_root=self.app.work_dir)
url, sha1 = get_core_file_download_url_and_hash(ver_data) assets_dir = find_correct_assets_dir(asset_id, default_assets_dir, self.app.work_dir)
dest = Path(self.app.work_dir, "versions", ver, f"{ver}.jar")
file = FileObject(dest, url=url)
if sha1 is None:
self.app.logger.warning("Client has no sha1.")
if file.exists and file.verify("sha1", sha1):
self.download_signal.log.emit(f"Client existed and sha1 matches.")
elif url is not None:
download_file(file, progress_callback=self.download_signal.progress.emit)
elif not url:
self.download_signal.failed.emit("Download URL not found.")
self.download_signal.log.emit(f"Downloaded URL: {url}")
except DownloadException as e:
self.widget_signal.show_error_message.emit(
"Unable to download client while downloading it: {}".format(e.user_message),
)
except Exception as e:
self.widget_signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Download Error",
"message": "Unable to download client, an unexpected error occurred."
}
)
finally:
self.widget_signal.enable_widget.emit(self.button_2)
self._download_libraries(ver)
self._download_assets(ver)
current_status = 4
classpath = generate_classpath( classpath = generate_classpath(
ver_data, ver_data,
libraries_dir, libraries_dir,
@@ -975,6 +949,7 @@ class TestDialog(QDialog):
version_name=ver, version_name=ver,
game_directory=game_dir.as_posix(), game_directory=game_dir.as_posix(),
assets_root=assets_dir.as_posix(), assets_root=assets_dir.as_posix(),
legacy_assets_root=assets_dir,
assets_index_name=asset_id, assets_index_name=asset_id,
auth_uuid="00000000000000000000000000000000", auth_uuid="00000000000000000000000000000000",
auth_access_token="0", auth_access_token="0",
@@ -996,6 +971,36 @@ class TestDialog(QDialog):
) )
return return
# Use found java runtime (if available)
java_path = self.get_support_runtime_jvm_executable(
java_major_version,
no_error=True
)
if not java_path:
# Ask for a support java version. If not, use system environment java
request = SelectPathRequest(
title="Require a java executable path to launch game. (Major Version: {})".format(java_major_version),
message=None,
filter_raw="Java executable (java.exe javaw.exe);;All files (*)"
)
self.widget_signal.ask_for_path.emit(request)
if not request.wait(120):
self.widget_signal.show_error_message.emit(
"Timed out while waiting for Java executable selection."
)
return
java_path = request.result()
if java_path:
self.download_signal.log.emit(f"Use specified java executable path: {java_path}")
else:
self.download_signal.log.emit("Use system environment java.")
java_path = "java"
# Finally, generate launch command # Finally, generate launch command
cmd = generate_launch_command( cmd = generate_launch_command(
arg_maps=arg_maps, arg_maps=arg_maps,
@@ -1039,7 +1044,7 @@ class TestDialog(QDialog):
"Would you like to retry downloading them?\n\n" "Would you like to retry downloading them?\n\n"
) )
request = YesNoRequest("Redownload", message) request = AskForRequest("Redownload", message)
request.details = "\n".join(file.posix_path for file in files) request.details = "\n".join(file.posix_path for file in files)
self.widget_signal.askyesno.emit(request) self.widget_signal.askyesno.emit(request)
@@ -1084,4 +1089,93 @@ class TestDialog(QDialog):
return False, pending return False, pending
#
# JVM Management
#
@property
def jvm_settings_path(self) -> Path:
return Path(self.app.work_dir) / "jvms.json"
def _find_and_save_jvm_search_result(self):
installations, errors = find_java_installations()
if not installations:
self.widget_signal.show_warning_message.emit(
"There are no Java installations available in you system.\n"
)
return []
if errors:
request = AskForRequest("Java Virtual Machine Finder", message = (
"Unable to detect below java installation version:\n"
))
request.details = "\n".join(path.as_posix() for path in errors)
request.use_plain_text = True
self.widget_signal.askyesno.emit(request)
self.jvm_settings_path.parent.mkdir(parents=True, exist_ok=True)
try:
with self.jvm_settings_path.open("w") as file:
json.dump(installations, file, indent=4)
return installations
except Exception as e:
self.widget_signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Settings Error",
"message": "An error occurred while attempting to save jvm settings. Please try again.",
}
)
def _read_exist_jvm_settings(self):
try:
return json.loads(self.jvm_settings_path.read_text())
except Exception as e:
self.widget_signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Settings Error",
"message": "An error occurred while reading jvm settings.",
}
)
return None
def get_or_cache_jvm_settings(self) -> list:
if self.jvm_settings_path.exists():
exists = self._read_exist_jvm_settings()
if exists:
return exists
return self._find_and_save_jvm_search_result()
def get_support_runtime_jvm_executable(self, major_version: int | str, no_error: bool=False) -> str | None:
result = self.get_or_cache_jvm_settings()
if not result:
return None
for item in result:
version = item.get("major", None)
executable = item.get("executable", None)
try:
version = str(version)
major_version = str(major_version)
except Exception as e:
self.app.logger.warning(
f"Unable to convert version {version} to string: {e}"
)
if major_version == version and executable:
return executable
if not no_error:
self.widget_signal.show_error_message.emit(
"No Java executable found for major version {}.".format(major_version)
)
return None
-60
View File
@@ -4,52 +4,11 @@ import argparse
import logging import logging
from app import Launcher from app import Launcher
import tkinter as tk
from tkinter import messagebox from tkinter import messagebox
from core_lib.log.default import DEFAULT_FORMAT, DEFAULT_DATE_FORMAT, get_colorful_handler from core_lib.log.default import DEFAULT_FORMAT, DEFAULT_DATE_FORMAT, get_colorful_handler
def select_mode() -> None | str:
value = None
def get_value(root: tk.Tk, listbox_obj: tk.Listbox):
nonlocal value
value = listbox.get(listbox_obj.curselection()[0]) if listbox_obj.curselection() else None
root.destroy()
support_list = ["full", "lightweight"]
r = tk.Tk()
r.title("TestLauncher")
r.geometry("300x300")
label = tk.Label(r, text="Select a mode to continue")
label.pack(anchor="n")
listbox = tk.Listbox(r, selectmode=tk.SINGLE)
listbox.pack(expand=True, fill="both")
listbox.bind("<Double-Button-1>", lambda e: get_value(r, listbox))
for support in support_list:
listbox.insert(tk.END, support)
listbox.selection_set(0)
# move to center
r.eval('tk::PlaceWindow . center')
r.mainloop()
return value
def tui_entry():
logger = logging.getLogger("TestLauncher.Tui")
logger.info("Wow tui is unfinished")
return 0
def main(): def main():
root_logger = logging.getLogger() root_logger = logging.getLogger()
logger = logging.getLogger("Launcher.Main") logger = logging.getLogger("Launcher.Main")
@@ -67,26 +26,13 @@ def main():
for arg in unknown: for arg in unknown:
logger.warning("Unknown argument '%s'" % arg) logger.warning("Unknown argument '%s'" % arg)
# args value set
if not hasattr(args, "mode") or args.mode == "choice":
mode = select_mode()
if mode is None:
logger.warning("No mode selected. Exiting...")
sys.exit(0)
else:
mode = args.mode
debug = args.debug debug = args.debug
# check workdir # check workdir
if not os.path.exists(args.work_dir) or not os.path.isdir(args.work_dir): if not os.path.exists(args.work_dir) or not os.path.isdir(args.work_dir):
if mode == "full":
messagebox.showerror("Error", f"Specified work directory \"{args.work_dir}\" does not exist or" messagebox.showerror("Error", f"Specified work directory \"{args.work_dir}\" does not exist or"
" is not a directory.") " is not a directory.")
else:
logger.error("Work directory '%s' does not exist. Exiting..." % args.work_dir) logger.error("Work directory '%s' does not exist. Exiting..." % args.work_dir)
sys.exit(-1) sys.exit(-1)
if os.getcwd() != args.work_dir: if os.getcwd() != args.work_dir:
@@ -105,14 +51,8 @@ def main():
logging.basicConfig(format=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, level=level) logging.basicConfig(format=DEFAULT_FORMAT, datefmt=DEFAULT_DATE_FORMAT, level=level)
# Start (the real) launcher # Start (the real) launcher
if mode == 'lightweight':
sys.exit(tui_entry())
elif mode == 'full':
app = Launcher() app = Launcher()
sys.exit(app.exec()) sys.exit(app.exec())
else:
logger.error("Unknown mode '%s'" % mode)
sys.exit(1)
if __name__ == '__main__': if __name__ == '__main__':
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB