Add module that about java runtime management to core_lib.

This commit is contained in:
2026-07-13 22:06:39 +08:00
parent 648cde6e33
commit bd5a1e8974
11 changed files with 1034 additions and 189 deletions
+9 -1
View File
@@ -19,12 +19,13 @@ THREADED_DOWNLOAD_MAX_WORKERS = 10
logger = logging.getLogger("Launcher.CoreLib") logger = logging.getLogger("Launcher.CoreLib")
class FileObject: class FileObject:
def __init__(self, path: Path, url=None, sha1=None, sha256=None, md5=None): def __init__(self, path: Path, url=None, sha1=None, sha256=None, md5=None, size=None):
self.__path = path self.__path = path
self.expected_sha1 = sha1 self.expected_sha1 = sha1
self.expected_sha256 = sha256 self.expected_sha256 = sha256
self.expected_md5 = md5 self.expected_md5 = md5
self.expected_size = size
self.__sha1 = None self.__sha1 = None
self.__sha256 = None self.__sha256 = None
@@ -98,8 +99,14 @@ class FileObject:
if algorithm == "md5": if algorithm == "md5":
return self.verify_md5(expected_hash if expected_hash else self.expected_md5) return self.verify_md5(expected_hash if expected_hash else self.expected_md5)
if algorithm == "size":
return self.verify_size(expected_hash if expected_hash else self.expected_size)
raise Exception("Unsupported algorithm: {}".format(algorithm)) raise Exception("Unsupported algorithm: {}".format(algorithm))
def verify_size(self, expected_size):
return self.size == expected_size if expected_size else self.expected_size == self.size
def _calculate_hash(self, algorithm): def _calculate_hash(self, algorithm):
h = hashlib.new(algorithm) h = hashlib.new(algorithm)
@@ -262,6 +269,7 @@ def unzip(filepath: Path, destination: Path, exclude: list[str] | None = None, f
def get_platform_info(): def get_platform_info():
""" """
Get information about the current platform. Get information about the current platform.
IMPORTANT: All return values are lower-case
:return: :return:
platform_name: str, architecture: str, os_version: str platform_name: str, architecture: str, os_version: str
""" """
+12
View File
@@ -63,3 +63,15 @@ class AssetManifestException(LauncherException):
class AssetDataKeyNotFoundException(LauncherException): class AssetDataKeyNotFoundException(LauncherException):
user_message = "Specified asset data section (key) not found." user_message = "Specified asset data section (key) not found."
class JavaRuntimeException(LauncherException):
user_message = "An error occurred while executing Java runtime."
class JavaRuntimeParseException(JavaRuntimeException):
user_message = "Unable to parse Java runtime details."
class RuntimeManifestFetchException(LauncherException):
user_message = "Unable to fetch runtime manifest."
class RuntimeManifestDataException(LauncherException):
user_message = "Runtime manifest is missing or corrupted."
+175 -46
View File
@@ -2,18 +2,20 @@ import json
import logging import logging
import shutil import shutil
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
import requests 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 HashMismatchException, AssetManifestSaveException, AssetDataKeyNotFoundException, DownloadException
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}"
ASSET_DOWNLOAD_MAX_WORKERS = 20 ASSET_DOWNLOAD_MAX_WORKERS = 20
ASSET_CHECK_MAX_WORKERS = 20
logger = logging.getLogger("Launcher.CoreLib") logger = logging.getLogger("Launcher.CoreLib")
@@ -101,12 +103,72 @@ 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 collect_assets_from_manifest(asset_id: str, manifest: dict, assets_dir: Path) -> tuple[list[FileObject], list[dict]]: def _check_asset(asset_id: str, asset_data: dict, relative_path: str, assets_dir: Path, virtual: bool, no_hash_check: bool=False)\
-> tuple[FileObject | None, dict | None]:
""" """
Collect assets from asset manifest. Check if asset exists.
:param asset_id: :param asset_data:
:param manifest: :param relative_path:
:param assets_dir: :param assets_dir:
:param virtual:
:return:
asset_file: FileObject
legacy_context: dict
"""
hash_raw = asset_data.get("hash", "")
expected_size = asset_data.get("size")
if not hash_raw:
raise AssetDataKeyNotFoundException(
"Asset that inside assets section (objects) missing hash."
)
beginning = hash_raw[:2]
full_path = Path(assets_dir, "objects", beginning, hash_raw) # For newer assets
full_url = MOJANG_RESOURCE_ENDPOINT.format(beginning=beginning, hash=hash_raw)
file = FileObject(path=full_path, sha1=hash_raw, url=full_url)
needs_download = True
if full_path.exists() and full_path.is_file():
if no_hash_check and expected_size is not None:
needs_download = full_path.stat().st_size != expected_size
else:
needs_download = not file.verify_sha1(hash_raw)
if not needs_download:
suffix = " (no_hash_check=True)" if no_hash_check else ""
logger.debug(
"Asset hash {} (Aka {}) already exists.{}".format(
hash_raw,
relative_path,
suffix,
)
)
legacy_context = None
if virtual:
legacy_context = {
"file": file,
"target": full_path if virtual else None,
"sha1": hash_raw,
"destination": Path(assets_dir, "virtual", asset_id, relative_path) if virtual else None,
"size": expected_size,
}
return file if needs_download else None, legacy_context
def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path,
max_workers=ASSET_CHECK_MAX_WORKERS,
no_hash_check=False) -> tuple[list[FileObject], list[dict]]:
"""
Collect assets from asset manifest. (Only return items that does not exist)
:param no_hash_check:
:param manifest:
:param asset_id:
:param assets_dir:
:param max_workers:
:return: :return:
assets: list[FileObject] assets: list[FileObject]
legacy_assets: list[dict] (Contains target and destination of the legacy asset) legacy_assets: list[dict] (Contains target and destination of the legacy asset)
@@ -122,64 +184,51 @@ def collect_assets_from_manifest(asset_id: str, manifest: dict, assets_dir: Path
"Assets section (objects) not found in asset manifest." "Assets section (objects) not found in asset manifest."
) )
for relative_path in objects: with ThreadPoolExecutor(max_workers=max_workers) as executor:
data = objects[relative_path] futures = [
hash_raw = data.get("hash", "") executor.submit(
_check_asset,
if not hash_raw: asset_id, objects[relative_path], relative_path, assets_dir, virtual,
raise AssetDataKeyNotFoundException( no_hash_check=no_hash_check
"Asset that inside assets section (objects) missing hash."
) )
for relative_path in objects
]
beginning = hash_raw[:2] for future in as_completed(futures):
asset_file, legacy_context = future.result()
full_path = Path(assets_dir, "objects", beginning, hash_raw) # For newer assets if asset_file:
full_path.parent.mkdir(parents=True, exist_ok=True) files.append(asset_file)
full_url = MOJANG_RESOURCE_ENDPOINT.format(beginning=beginning, hash=hash_raw)
file = FileObject(path=full_path, sha1=hash_raw, url=full_url)
if virtual: # Also copy the asset into legacy directory if needed.
# For legacy assets if legacy_context:
full_path_old = Path(assets_dir, "virtual", asset_id, relative_path) legacy_assets.append(legacy_context)
legacy_assets.append({
"target": full_path,
"destination": full_path_old,
"sha1": hash_raw,
})
if (full_path.exists() and full_path.is_file()) and file.verify_sha1(hash_raw):
logger.debug("Asset hash {} (Aka {}) already exists.".format(hash_raw, relative_path))
continue
files.append(file)
return files, legacy_assets return files, legacy_assets
def download_assets_and_copy(assets: list[FileObject], legacy_assets: list[dict], progress_callback: Callable[str]) -> list[FileObject]: def download_assets_and_copy(assets: list[FileObject], legacy_assets: list[dict],
no_hash_check: bool=False, allow_missing=False,
progress_callback: Callable[str]=None,
redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]]=None) -> list[FileObject]:
""" """
Download assets from target version's asset manifest. (Not sure why this function exist, may for compatibly) Download assets from target version's asset manifest. (Not sure why this function exist, may for compatibly)
Also, this function will copy Also, this function will copy
:param assets: :param assets:
:param legacy_assets: :param legacy_assets:
:param no_hash_check:
:param allow_missing:
:param progress_callback: :param progress_callback:
:param redownload_callback:
:return: :return:
files: list[FileObject] (files that download failed) files: list[FileObject] (files that download failed)
""" """
fails , _ = download_multiple_files(assets, progress_callback=progress_callback, def __worker(asset: dict):
max_workers=ASSET_DOWNLOAD_MAX_WORKERS)
if fails:
logger.warning(
"Download failed for assets: \n"
+"\n".join([asset.posix_path for asset in fails])
)
for asset in legacy_assets:
target = asset.get("target") target = asset.get("target")
destination = asset.get("destination") destination = asset.get("destination")
sha1 = asset.get("sha1") sha1 = asset.get("sha1")
size = asset.get("size")
if not target or not destination: if not target or not destination:
raise ValueError( raise ValueError(
@@ -188,18 +237,60 @@ def download_assets_and_copy(assets: list[FileObject], legacy_assets: list[dict]
# Check source file # Check source file
source_asset = FileObject(path=target) source_asset = FileObject(path=target)
if not source_asset.exists or (sha1 and not source_asset.verify("sha1", sha1)):
if (not source_asset.exists or
(not no_hash_check and sha1 and not source_asset.verify("sha1", sha1))):
logger.warning("Target asset {} not found.".format(target)) logger.warning("Target asset {} not found.".format(target))
continue return False, None
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
# Check destination file # Check destination file
old_asset = FileObject(path=destination) old_asset = FileObject(path=destination)
if old_asset.exists and (sha1 and old_asset.verify("sha1", sha1)): if old_asset.exists:
continue if no_hash_check and size is not None:
if old_asset.path.stat().st_size == size:
logger.debug("Asset {} already exists. (no_hash_check=True)".format(target))
return True, None
elif sha1 and old_asset.verify("sha1", sha1):
logger.debug("Asset {} already exists.".format(target))
return True, None
shutil.copyfile(target, destination) shutil.copyfile(target, destination)
return True, destination
fails , _ = download_multiple_files(assets, progress_callback=progress_callback,
max_workers=ASSET_DOWNLOAD_MAX_WORKERS)
download_ok = len(fails) == 0
if fails and callable(redownload_callback):
download_ok, fails = redownload_callback(fails)
if not download_ok:
logger.warning(
"Download failed for assets: \n"
+"\n".join([asset.posix_path for asset in fails])
)
if not allow_missing:
return fails
# Use thread pool to handle copy file process
with ThreadPoolExecutor(max_workers=ASSET_DOWNLOAD_MAX_WORKERS) as executor:
futures = [
executor.submit(
__worker,
asset
)
for asset in legacy_assets
]
for future in as_completed(futures):
result, dest = future.result()
if result and dest:
logger.debug("Created: {}".format(dest))
return fails return fails
@@ -249,3 +340,41 @@ def correct_assets_name_and_copy(manifest: dict, assets_dir: Path, output_dir: P
shutil.copy(file.posix_path, full_path_new.as_posix()) shutil.copy(file.posix_path, full_path_new.as_posix())
return missing return missing
def download_assets(manifest: dict, assets_dir: Path, no_hash_check: bool=False,
progress_callback: Callable[[str, float], None]=None,
redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]]=None) -> list[FileObject]:
_, 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")
# Download and save asset manifest
asset_manifest = fetch_and_save_asset_manifest(
manifest,
index_path,
)
# Download assets files
assets, legacy_assets = collect_assets_from_manifest(
asset_manifest,
asset_id,
assets_dir,
no_hash_check=no_hash_check,
)
fails = download_assets_and_copy(
assets,
legacy_assets,
progress_callback=progress_callback,
no_hash_check=no_hash_check,
redownload_callback=redownload_callback,
)
return fails
+47 -4
View File
@@ -14,7 +14,7 @@ from .version_info import (
NATIVE_OLD_TO_NEW_ARCH, find_useful_part_in_specific_version_manifest, NATIVE_OLD_TO_NEW_ARCH, find_useful_part_in_specific_version_manifest,
) )
from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException, \ from ..common.exception import UnsupportedPlatformException, NativeResolveException, DependencyException, \
VersionDataKeyNotFoundException VersionDataKeyNotFoundException, DownloadException
logger = logging.getLogger("Launcher.CoreLib") logger = logging.getLogger("Launcher.CoreLib")
@@ -439,7 +439,8 @@ def collect_native_artifacts(specific_version_manifest):
return items return items
def download_libraries(artifacts, output_dir, progress_callback: Callable[[str], None]): 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 Download libraries from a list that contains artifacts
:param artifacts: :param artifacts:
@@ -450,6 +451,7 @@ def download_libraries(artifacts, output_dir, progress_callback: Callable[[str],
successes: list (artifacts that were downloaded) successes: list (artifacts that were downloaded)
""" """
files = [] files = []
successes = []
for artifact in artifacts: for artifact in artifacts:
sha1 = artifact.get("sha1") sha1 = artifact.get("sha1")
@@ -475,15 +477,16 @@ def download_libraries(artifacts, output_dir, progress_callback: Callable[[str],
if file.exists and (sha1 is None or file.verify("sha1", sha1)): if file.exists and (sha1 is None or file.verify("sha1", sha1)):
logger.debug(f"File {full_path} exists.") logger.debug(f"File {full_path} exists.")
successes.append(file)
continue continue
files.append(file) files.append(file)
fails, successes = download_multiple_files(files, progress_callback=progress_callback) fails, finished = download_multiple_files(files, progress_callback=progress_callback)
successes.extend(finished)
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
@@ -642,3 +645,43 @@ 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,
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"
)
+89 -14
View File
@@ -1,11 +1,13 @@
import json import json
import logging import logging
import re
import warnings import warnings
from collections.abc import Callable
from pathlib import Path from pathlib import Path
import requests import requests
from ..common.common import FileObject from ..common.common import FileObject, download_file
from ..common.exception import VersionManifestFetchException, NoVersionFoundException, \ from ..common.exception import VersionManifestFetchException, NoVersionFoundException, \
NoSpecifiedVersionKeyException, VersionDataKeyNotFoundException, VersionManifestSaveException, HashMismatchException NoSpecifiedVersionKeyException, VersionDataKeyNotFoundException, VersionManifestSaveException, HashMismatchException
@@ -61,6 +63,43 @@ NATIVE_NEW_TO_OLD_ARCH = {
"x86_64": "64", "x86_64": "64",
} }
RUNTIME_PLATFORM_MAP = {
"linux": "linux",
"darwin": "mac-os",
"macos": "mac-os",
"osx": "mac-os",
"windows": "windows",
"win32": "windows",
"cygwin": "windows",
"msys": "windows",
}
RUNTIME_ARCH_EMPTY = "$EMPTY"
RUNTIME_ARCH_MAP = {
"linux": {
"i386": "i386",
"i686": "i386",
"x86": "i386",
"amd64": RUNTIME_ARCH_EMPTY,
"x86_64": RUNTIME_ARCH_EMPTY,
},
"windows": {
"i386": "x86",
"i686": "x86",
"x86": "x86",
"amd64": "x64",
"x86_64": "x64",
"aarch64": "arm64",
"arm64": "arm64",
},
"mac-os": {
"amd64": RUNTIME_ARCH_EMPTY,
"x86_64": RUNTIME_ARCH_EMPTY,
"aarch64": "arm64",
"arm64": "arm64",
},
}
logger = logging.getLogger("Launcher.CoreLib") logger = logging.getLogger("Launcher.CoreLib")
@@ -227,7 +266,7 @@ def get_specific_version_manifest_url_and_hash(version_manifest: dict, version_i
for version in versions: for version in versions:
if version.get("id") == version_id: if version.get("id") == version_id:
specific_ver_manifest_url = version.get("url", None) specific_ver_manifest_url = version.get("url", None)
specific_ver_manifest_hash = version.get("hash", None) specific_ver_manifest_hash = version.get("sha1", None)
if specific_ver_manifest_url is None: if specific_ver_manifest_url is None:
raise VersionManifestFetchException( raise VersionManifestFetchException(
@@ -244,13 +283,13 @@ def get_specific_version_manifest_url_and_hash(version_manifest: dict, version_i
) )
def fetch_specific_version_manifest(version_manifest: dict, spec_version: str) -> tuple[dict, str | None]: def fetch_specific_version_manifest(version_manifest: dict, spec_version: str) -> tuple[bytes, str | None]:
""" """
Get specific version's manifest data and hash from version manifest. Get specific version's manifest data and hash from version manifest.
:param version_manifest: :param version_manifest:
:param spec_version: :param spec_version:
:return: :return:
specific_version_manifest_data: str specific_version_manifest_data: bytes
hash: str (sha1) hash: str (sha1)
""" """
ver_url, sha1 = get_specific_version_manifest_url_and_hash(version_manifest, spec_version) ver_url, sha1 = get_specific_version_manifest_url_and_hash(version_manifest, spec_version)
@@ -263,14 +302,7 @@ def fetch_specific_version_manifest(version_manifest: dict, spec_version: str) -
"Failed to fetch version manifest URL: {}\n{}".format(ver_url, e) "Failed to fetch version manifest URL: {}\n{}".format(ver_url, e)
) )
try: return r.content, sha1
data = r.json()
logger.debug("SpecificVersionManifest: {}".format(json.dumps(data, indent=4)))
return data, sha1
except ValueError as e:
raise VersionManifestFetchException(
"Unable to deserialize response data into dictionary: {} (TargetVer: {})".format(e, ver_url)
)
# mapping # mapping
get_specific_version_manifest = fetch_specific_version_manifest get_specific_version_manifest = fetch_specific_version_manifest
@@ -400,8 +432,12 @@ def fetch_and_save_version_manifest(dest: Path, target_version: str=None, root_m
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
try: try:
with dest.open("w") as f: method = "wb" if isinstance(data, bytes) else "w"
json.dump(data, f, indent=4) with dest.open(method) as f:
if isinstance(data, bytes):
f.write(data)
elif isinstance(data, dict):
json.dump(data, f)
if sha1 is not None and not file.verify_sha1(sha1): if sha1 is not None and not file.verify_sha1(sha1):
raise HashMismatchException( raise HashMismatchException(
@@ -411,9 +447,48 @@ def fetch_and_save_version_manifest(dest: Path, target_version: str=None, root_m
file.posix_path file.posix_path
) )
if isinstance(data, bytes):
# Convert the data back to dict
return json.loads(data.decode("utf-8"))
else:
return data return data
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
raise VersionManifestSaveException( raise VersionManifestSaveException(
"Error saving version manifest:\n{}".format(e) "Error saving version manifest:\n{}".format(e)
) )
def download_core_file(manifest: dict, destination: Path, is_server=False, raise_for_null_hash=False,
progress_callback: Callable[str]=None) -> FileObject:
"""
Download core file from version manifest.
:param manifest:
:param destination:
:param is_server:
:param raise_for_null_hash:
:param progress_callback:
:return:
client_file: FileObject
"""
url, sha1 = get_core_file_download_url_and_hash(manifest, is_server=is_server)
destination.parent.mkdir(parents=True, exist_ok=True)
file = FileObject(destination, url=url)
if sha1 is None and raise_for_null_hash:
raise VersionDataKeyNotFoundException(
"Core file hash is missing. Enable \"raise_for_null_hash\" flag to ignore this exception.\n"
)
elif sha1 is None:
logger.warning("Client has no sha1.")
if file.exists and file.verify("sha1", sha1):
logger.info(f"Client existed and sha1 matches.")
elif url is not None:
download_file(file, progress_callback=progress_callback)
elif not url:
raise VersionDataKeyNotFoundException(
"Core file url is None. Unable to download it."
)
return file
+33 -1
View File
@@ -1,4 +1,6 @@
from PySide6.QtWidgets import QMessageBox, QMainWindow, QWidget from PySide6.QtWidgets import QMessageBox, QMainWindow, QWidget, QDialog, QVBoxLayout, QLabel, QPlainTextEdit, \
QDialogButtonBox, QSizePolicy
def create_messagebox(parent: QWidget, title: str, message: str, def create_messagebox(parent: QWidget, title: str, message: str,
icon: QMessageBox.Icon = QMessageBox.Icon.Information, icon: QMessageBox.Icon = QMessageBox.Icon.Information,
@@ -42,3 +44,33 @@ def warning(parent: QWidget, title: str, message: str,
icon=QMessageBox.Icon.Warning, icon=QMessageBox.Icon.Warning,
button=button button=button
).exec() ).exec()
def ask_yes_no_with_plain_text(parent: QWidget, title: str, message: str, content: str):
dialog = QDialog(parent)
dialog.setWindowTitle(title)
dialog.resize(720, 420)
layout = QVBoxLayout(dialog)
label = QLabel(message)
label.setWordWrap(True)
label.setWordWrap(True)
details_box = QPlainTextEdit()
details_box.setReadOnly(True)
details_box.setPlainText(content)
details_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Yes |
QDialogButtonBox.StandardButton.No
)
buttons.button(QDialogButtonBox.StandardButton.Yes).clicked.connect(dialog.accept)
buttons.button(QDialogButtonBox.StandardButton.No).clicked.connect(dialog.reject)
layout.addWidget(label)
layout.addWidget(details_box, 1)
layout.addWidget(buttons)
return dialog.exec() == QDialog.DialogCode.Accepted
View File
+151
View File
@@ -0,0 +1,151 @@
import glob
import logging
import os
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
from core_lib.common.exception import JavaRuntimeException, JavaRuntimeParseException
logger = logging.getLogger("Launcher.CoreLib")
def get_java_version(java_executable_path: str | Path) -> tuple[int, str]:
if isinstance(java_executable_path, Path):
java_executable_path = java_executable_path.as_posix()
try:
process = subprocess.run(
[java_executable_path, "-version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
)
except subprocess.TimeoutExpired:
raise JavaRuntimeException(
"Java runtime timed out.",
)
except subprocess.CalledProcessError as e:
raise JavaRuntimeException(
"Unable to run Java runtime: {}".format(e.stderr),
)
except Exception as e:
raise JavaRuntimeException(
"An error occurred while executing Java runtime: {}".format(e),
)
output = (process.stderr or "") + "\n" + (process.stdout or "")
match = re.search(r'version "([^"]+)"', output)
if not match:
raise JavaRuntimeParseException(
f"Unable to parse Java version:\n{output}"
)
raw_version = match.group(1)
if raw_version.startswith("1."):
major = int(raw_version.split(".")[1])
else:
major = int(raw_version.split(".")[0])
return major, raw_version
def find_java_installations(extra_install_patterns: list | None=None) -> list[dict]:
candidates_paths = []
installations = []
java_executable_name = "java.exe" if sys.platform == "win32" else "java"
# Check (system) environment
path_java = shutil.which("java")
if path_java:
candidates_paths.append(Path(path_java))
java_home = os.environ.get("JAVA_HOME")
if java_home:
candidates_paths.append(Path(java_home) / "bin" / java_executable_name)
system = platform.system().lower()
if system == "windows":
patterns = [
r"C:\Program Files\Java\*\bin\java.exe",
r"C:\Program Files\Eclipse Adoptium\*\bin\java.exe",
r"C:\Program Files\Microsoft\jdk-*\bin\java.exe",
r"C:\Program Files (x86)\Java\*\bin\java.exe",
]
elif system == "darwin":
patterns = [
"/Library/Java/JavaVirtualMachines/*/Contents/Home/bin/java",
"/System/Library/Java/JavaVirtualMachines/*/Contents/Home/bin/java",
"/opt/homebrew/opt/openjdk*/bin/java",
"/usr/local/opt/openjdk*/bin/java",
"/Applications/*/Contents/jbr/Contents/Home/bin/java",
]
else:
patterns = [
"/usr/bin/java",
"/bin/java",
"/usr/local/bin/java",
"/usr/lib/jvm/*/bin/java",
"/opt/java/*/bin/java",
"/opt/jdk*/bin/java",
"/snap/*/current/bin/java",
]
if isinstance(extra_install_patterns, list):
patterns.extend(extra_install_patterns)
for pattern in patterns:
candidates_paths.extend(Path(p) for p in glob.glob(pattern))
seen = set()
errors = []
for candidate_path in candidates_paths:
try:
converted_path = candidate_path.resolve().absolute()
except OSError:
continue
if converted_path in seen:
continue
ver = None
raw = None
try:
ver, raw = get_java_version(converted_path)
except JavaRuntimeParseException:
logger.debug(f"Unable to parse java version: {converted_path}")
except JavaRuntimeException:
logger.debug(f"Unable to run java executable: {converted_path}")
except Exception as e:
logger.exception(f"Unhandled exception: {e} (For path: {converted_path})")
finally:
if not ver:
errors.append(converted_path)
continue
logger.debug(f"Detected java version {ver} for path: {converted_path}")
installations.append(
{
"major": ver,
"raw": raw,
"homeDir": converted_path.parent.parent,
"executable": converted_path,
}
)
installations.sort(key=lambda item: item["major"], reverse=True)
return installations
+353
View File
@@ -0,0 +1,353 @@
import hashlib
import json
import logging
import platform
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Callable
import requests
from ..common.common import get_platform_info, FileObject, download_multiple_files
from ..common.exception import RuntimeManifestFetchException, UnsupportedPlatformException, \
RuntimeManifestDataException, HashMismatchException, DownloadException
from ..game.version_info import RUNTIME_PLATFORM_MAP, RUNTIME_ARCH_EMPTY, RUNTIME_ARCH_MAP
MOJANG_RUNTIME_MANIFEST = "https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"
RUNTIME_CHECK_MAX_WORKERS = 10
RUNTIME_DOWNLOAD_MAX_WORKERS = 12
logger = logging.getLogger("Launcher.CoreLib")
def get_current_platform_runtime_key() -> str | None:
"""
Get the current platform runtime key.
:return:
runtime_key: str
"""
platform_name, arch, os_version = get_platform_info()
runtime_os_name = RUNTIME_PLATFORM_MAP.get(platform_name, None)
if runtime_os_name is None:
return None
runtime_arch_name = RUNTIME_ARCH_MAP.get(runtime_os_name, {}).get(arch, None)
if runtime_arch_name is None:
return None
# If architecture type is x86_64, some platform's runtime key is same as runtime_os_name. (Such as linux and macOS)
if runtime_arch_name == RUNTIME_ARCH_EMPTY:
return runtime_os_name
full_key = f"{runtime_os_name}-{runtime_arch_name}"
return full_key
def fetch_runtime_manifest(manifest_url=MOJANG_RUNTIME_MANIFEST, timeout=10) -> dict:
"""
Fetch the runtime manifest data.
:param manifest_url:
:param timeout:
:return:
root_manifest: dict
"""
logger.debug("Getting version manifest from {}".format(manifest_url))
try:
r = requests.get(manifest_url, timeout=timeout)
r.raise_for_status()
except requests.exceptions.RequestException as e:
raise RuntimeManifestFetchException(
"Unable to fetch runtime manifest from {}: {}".format(manifest_url, e),
)
try:
data = r.json()
logger.debug("RuntimeManifest: {}".format(json.dumps(data, indent=4)))
return data
except ValueError as e:
raise RuntimeManifestFetchException(
"Unable to deserialize response data into dictionary: {}".format(e)
)
def find_support_runtime_manifests_info(root_runtime_manifest: dict, require_component: str) -> list:
"""
Find support runtime manifest (info) by platform.
:param root_runtime_manifest:
:param require_component:
:return:
manifests_info: list
"""
runtime_key = get_current_platform_runtime_key()
if not runtime_key:
raise UnsupportedPlatformException(
"Theres no runtime support the current platform or architecture."
)
runtime_section = root_runtime_manifest.get(runtime_key, {})
if not runtime_section:
raise RuntimeManifestDataException(
"Root runtime manifest missing runtime key {} data.".format(runtime_key)
)
runtimes = runtime_section.get(require_component, [])
if not runtimes:
raise RuntimeManifestDataException(
"Found require component section. But is empty: {}".format(require_component))
usable = [
item for item in runtimes
if item.get("manifest", {}).get("url")
]
if not usable:
raise RuntimeManifestDataException(
"No usable runtime manifest data found for component {}".format(require_component)
)
return usable
def get_support_runtime_manifest(root_runtime_manifest: dict, require_component: str, no_hash_check=False) -> dict:
runtime_info = find_support_runtime_manifests_info(root_runtime_manifest, require_component)[0]
manifest_info = runtime_info.get("manifest", {})
url = manifest_info.get("url")
expected_sha1 = manifest_info.get("sha1")
if not url:
raise RuntimeManifestDataException(
"Runtime info missing manifest url"
)
if not expected_sha1 and not no_hash_check:
raise RuntimeManifestDataException(
"Runtime manifest missing sha1 data from {}".format(url)
)
# Fetch data
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
except requests.exceptions.RequestException as e:
raise RuntimeManifestFetchException(
"Unable to fetch runtime manifest from {}: {}".format(url, e),
)
# Verify it and convert it to dict
content = r.content
actual_sha1 = hashlib.sha1(content).hexdigest()
try:
if not no_hash_check and actual_sha1 != expected_sha1:
raise RuntimeManifestFetchException(
"Hash mismatch for runtime manifest from url {} got {} "
"and root manifest expected {}".format(url, actual_sha1, expected_sha1)
)
data = json.loads(content.decode("utf-8"))
logger.debug("RuntimeManifest: {}".format(json.dumps(data, indent=4)))
return data
except Exception as e:
raise RuntimeManifestFetchException(
"Unable to fetch runtime manifest from {}: {}".format(url, e),
)
def _check_runtime_file(relative_path, file_type: str, sha1: str | None, size: str | None, url: str | None,
executable: bool, runtime_dir: Path, no_hash_check: bool = False) -> tuple[
bool, FileObject | Path | None]:
full_path = runtime_dir / Path(relative_path)
if (not sha1 and not no_hash_check) and file_type == "file":
raise RuntimeManifestDataException(
"Runtime manifest missing sha1 data from {}".format(url)
)
if not url and file_type == "file":
raise RuntimeManifestDataException(
"Runtime manifest missing file url. {}".format(relative_path)
)
if not file_type:
raise RuntimeManifestDataException(
"Runtime manifest missing file type. {}".format(relative_path)
)
needs_download = True
if file_type == "file":
target_file = FileObject(
path=full_path,
sha1=sha1,
size=size,
url=url,
)
if not target_file.expected_sha1 and not no_hash_check:
raise RuntimeManifestDataException(
"Runtime file hash is missing."
)
# Check file exist
if target_file.exists and target_file.path.is_file():
if no_hash_check and target_file.expected_size is not None:
needs_download = not target_file.verify("size")
else:
needs_download = not target_file.verify("sha1")
if not needs_download:
suffix = " (no_hash_check=True)" if no_hash_check else ""
logger.debug(
"Runtime file {} already exists.{}".format(
target_file,
suffix
)
)
elif file_type == "directory":
target_file = Path(full_path)
if target_file.exists() and target_file.is_dir():
needs_download = False
else:
raise RuntimeManifestDataException(
"Unsupported file type: {}".format(file_type)
)
return executable == True, target_file if needs_download else None
def collect_runtime_files(manifest: dict, runtime_dir: Path, max_workers=RUNTIME_CHECK_MAX_WORKERS,
no_hash_check=False, use_raw=True) -> tuple[list[FileObject], list[FileObject], list[Path]]:
"""
Download runtime files from given manifest.
:param manifest:
:param runtime_dir:
:param max_workers:
:param no_hash_check:
:param use_raw: Download raw files instead of download compressed files.
:return:
executables: Executable files that need to be downloaded and fix permission on some platform
files: Runtime files that need to be downloaded
dirs: Directories that need to be created
"""
result_executables = []
result_files = []
result_dirs = []
files = manifest.get("files", {})
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for relative_path in files:
data = files[relative_path]
downloads = data.get("downloads", {})
artifact = downloads.get("raw" if use_raw else "lzma", {})
file_type = data.get("type")
sha1 = artifact.get("sha1")
size = artifact.get("size")
url = artifact.get("url")
executable = data.get("executable", False)
futures.append(executor.submit(_check_runtime_file, relative_path, file_type, sha1, size, url,
executable, runtime_dir, no_hash_check))
for future in as_completed(futures):
is_executable, file = future.result()
if file and isinstance(file, FileObject) and not is_executable:
result_files.append(file)
elif file and isinstance(file, FileObject) and is_executable:
result_executables.append(file)
elif file and isinstance(file, Path):
result_dirs.append(file)
if not use_raw:
logger.warning(
"Flag \"raw\" is not enabled. But the launcher haven't implemented support for compressed files."
"You will likely to decompress all files before use them."
)
return result_executables, result_files, result_dirs
def handle_runtime_dir_and_executable_process(dirs: list[Path], executables: list[Path | FileObject], max_workers=6):
def _worker(path: Path | FileObject, is_executable: bool):
if isinstance(path, FileObject):
path = path.path
if is_executable:
if platform.system() != "Windows" and path.exists():
path.chmod(path.stat().st_mode | 0o755)
return
path.mkdir(parents=True, exist_ok=True)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
_worker,
path,
False)
for path in dirs
]
futures.extend(
executor.submit(_worker, path, True)
for path in executables
)
for future in as_completed(futures):
future.result()
def download_java_runtimes(component: str, runtime_dir: Path, no_hash_check: bool = False,
progress_callback: Callable[[str, float], None] = None,
redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]] = None,
max_download_workers=RUNTIME_CHECK_MAX_WORKERS,
max_check_workers=RUNTIME_DOWNLOAD_MAX_WORKERS) -> None:
"""
Download runtime files from given component.
:param component: Target component of the java runtime (e.g. java-runtime-alpha, java-runtime-beta)
:param runtime_dir:
:param no_hash_check:
:param progress_callback:
:param redownload_callback:
:param max_download_workers:
:param max_check_workers:
:return:
None
"""
# Fetch manifest
root_manifest = fetch_runtime_manifest()
manifest = get_support_runtime_manifest(root_manifest, component, no_hash_check=no_hash_check)
executables, files, dirs = collect_runtime_files(manifest, runtime_dir, max_workers=max_check_workers,
no_hash_check=no_hash_check)
# Download runtime files
full_files = list(files)
full_files.extend(list(executables))
fails, successes = download_multiple_files(full_files, max_workers=max_download_workers,
progress_callback=progress_callback)
download_ok = len(fails) == 0
if fails and callable(redownload_callback):
download_ok, fails = redownload_callback(fails)
if not download_ok:
logger.warning(
"Download failed for runtime files: \n"
+ "\n".join([asset.posix_path for asset in fails])
)
raise DownloadException(
"Unable to download some runtime files.\n"
)
handle_runtime_dir_and_executable_process(dirs, executables, max_workers=max_check_workers)
return
+3 -3
View File
@@ -1,3 +1,3 @@
"""
This file is for a function that you don't know where to put it.
"""
+155 -113
View File
@@ -1,4 +1,5 @@
import json import json
import queue
import subprocess import subprocess
import textwrap import textwrap
import threading import threading
@@ -14,23 +15,46 @@ from core_lib.common.common import download_file, FileObject, download_multiple_
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 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 fetch_and_save_asset_manifest, collect_assets_from_manifest, download_assets_and_copy from core_lib.game.asset import download_assets
from core_lib.game.library import collect_native_artifacts, download_libraries, unzip_natives, \ from core_lib.game.library import generate_classpath, download_full_libraries
collect_library_artifacts, generate_classpath
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, \
get_all_versions, get_available_version_types, \ get_all_versions, get_available_version_types, \
fetch_and_save_version_manifest, get_specific_version_manifest_url_and_hash) 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 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
MAX_DOWNLOAD_ATTEMPTS = 3 MAX_DOWNLOAD_ATTEMPTS = 3
class YesNoRequest:
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 TestDialog(QDialog): class TestDialog(QDialog):
def __init__(self, app, parent): def __init__(self, app, parent):
super().__init__(parent) super().__init__(parent)
@@ -220,30 +244,37 @@ class TestDialog(QDialog):
show_info_message = Signal(str) show_info_message = Signal(str)
enable_widget = Signal(object) enable_widget = Signal(object)
disable_widget = Signal(object) disable_widget = Signal(object)
askyesno = Signal(dict) # {"title", "targetEvent", "message", "result"} askyesno = Signal(object)
ask_for_path = Signal(dict) ask_for_path = Signal(dict)
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(dict) @Slot(object)
def ask_request(self, context: dict): def ask_request(self, context: YesNoRequest):
title = context.get("title", "Info") if not context.use_plain_text:
message = context.get("message", "No content")
event = context.get("event", None)
msg = QMessageBox.question( msg = QMessageBox.question(
self, self,
title, context.title,
message, context.message,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.Yes,
) )
result = msg == QMessageBox.StandardButton.Yes
else:
result = ask_yes_no_with_plain_text(
self,
context.title,
context.message,
context.details,
)
if msg == QMessageBox.StandardButton.Yes and type(event) == threading.Event: if result:
context["result"] = True context.result_queue.put(True)
else:
context.result_queue.put(False)
if isinstance(event, threading.Event): context.wait_event.set()
event.set()
@Slot(dict) @Slot(dict)
def get_selected_ver(self, context: dict): def get_selected_ver(self, context: dict):
@@ -595,11 +626,11 @@ class TestDialog(QDialog):
data = fetch_and_save_version_manifest(path, target_version=specified_ver, root_manifest=root_manifest) data = fetch_and_save_version_manifest(path, target_version=specified_ver, root_manifest=root_manifest)
except VersionManifestFetchException as e: except VersionManifestFetchException as e:
self.widget_signal.show_error_message.emit( self.widget_signal.show_error_message.emit(
"Unable to fetch version manifest: {}".format(e.user_message), e.user_message,
) )
except VersionManifestSaveException as e: except VersionManifestSaveException as e:
self.widget_signal.show_error_message.emit( self.widget_signal.show_error_message.emit(
"Unable to save version manifest: {}".format(e.user_message), e.user_message,
) )
except Exception as e: except Exception as e:
self.widget_signal.show_and_print_error_message.emit( self.widget_signal.show_and_print_error_message.emit(
@@ -665,7 +696,7 @@ class TestDialog(QDialog):
self.app.logger.warning("Specified version '{}' hash doesn't exist.".format(specified_ver)) self.app.logger.warning("Specified version '{}' hash doesn't exist.".format(specified_ver))
# Also check sha1 # Also check sha1
if specified_ver and age < max_age and (sha1 is None or file.verify("sha1", sha1)): if specified_ver and age < max_age and file.verify("sha1", sha1):
return self._read_cached_version_manifest(specified_ver) return self._read_cached_version_manifest(specified_ver)
result, data = self._cache_version_manifest(specified_ver, root_manifest=root_manifest) result, data = self._cache_version_manifest(specified_ver, root_manifest=root_manifest)
@@ -686,27 +717,22 @@ class TestDialog(QDialog):
if not ver_data: if not ver_data:
return return
url, sha1 = get_core_file_download_url_and_hash(ver_data) 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,
dest = Path(self.app.work_dir, "versions", ver, "client.jar") raise_for_null_hash=True)
self.download_signal.log.emit(f"Downloaded {ver} client to {client_dest}")
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: except DownloadException as e:
self.widget_signal.show_error_message.emit( self.widget_signal.show_error_message.emit(
"Unable to download client while downloading it: {}".format(e.user_message), "Unable to download client while downloading it: {}".format(e.user_message),
) )
except VersionDataKeyNotFoundException as e:
self.widget_signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Fetch Error",
"message": "Unable to download client due to some necessary key is missing.",
}
)
except Exception as e: except Exception as e:
self.widget_signal.show_and_print_error_message.emit( self.widget_signal.show_and_print_error_message.emit(
{ {
@@ -719,9 +745,6 @@ class TestDialog(QDialog):
self.widget_signal.enable_widget.emit(self.button_2) self.widget_signal.enable_widget.emit(self.button_2)
def _download_libraries(self, ver: str): def _download_libraries(self, ver: str):
output_dir = Path(self.app.work_dir, "libraries")
output_dir.mkdir(parents=True, exist_ok=True)
try: try:
self.widget_signal.clean_output.emit() self.widget_signal.clean_output.emit()
@@ -730,35 +753,21 @@ class TestDialog(QDialog):
if not ver_data: if not ver_data:
return return
# libraries libraries_dir = Path(self.app.work_dir, "libraries")
libraries = collect_library_artifacts(ver_data)
# natives
natives = collect_native_artifacts(ver_data)
# all
full = list(libraries)
full.extend(natives)
self.download_signal.log.emit(
"Supported native count: {}".format(len(natives))
)
# Start download
fails, successes = download_libraries(full, output_dir,
progress_callback=self.download_signal.progress.emit)
download_ok, fails = self.redownload_files(fails)
if not download_ok:
self.widget_signal.show_error_message.emit(
"Unable to continue process due to certain libraries download failed:\n"
+ "\n".join(file.posix_path for file in fails)
)
else:
# Start unzip natives
natives_dest = Path(self.app.work_dir, "versions", ver, "natives") natives_dest = Path(self.app.work_dir, "versions", ver, "natives")
unzip_natives(natives, output_dir, natives_dest) libraries_dir.mkdir(parents=True, exist_ok=True)
natives_dest.mkdir(parents=True, exist_ok=True)
download_full_libraries(
ver_data,
libraries_dir,
natives_dest,
progress_callback=self.download_signal.progress.emit,
redownload_callback=self.redownload_files
)
self.download_signal.log.emit(
"All libraries downloaded successfully."
)
except DependencyException as e: except DependencyException as e:
self.widget_signal.show_error_message.emit( self.widget_signal.show_error_message.emit(
"Unable to collect library artifacts.\n" "Unable to collect library artifacts.\n"
@@ -799,38 +808,19 @@ class TestDialog(QDialog):
if not ver_data: if not ver_data:
return return
_, asset_index, _, _, _, _, _ = find_useful_part_in_specific_version_manifest(ver_data)
asset_id = asset_index.get("id")
assets_dir = Path(self.app.work_dir, "assets") assets_dir = Path(self.app.work_dir, "assets")
index_path = Path(assets_dir, "indexes", f"{asset_id}.json")
# Download and save asset manifest fails = download_assets(ver_data, assets_dir, no_hash_check=True)
asset_manifest = fetch_and_save_asset_manifest(
ver_data,
index_path,
)
# Download assets files
assets, legacy_assets = collect_assets_from_manifest(
asset_id,
asset_manifest,
assets_dir,
)
fails = download_assets_and_copy(
assets,
legacy_assets,
progress_callback=self.download_signal.progress.emit,
)
if fails: if fails:
self.widget_signal.show_error_message.emit( self.widget_signal.show_warning_message.emit(
"Some assets failed to download:\n" "Some assets were not downloaded. Although is not necessary for launching game."
+ "\n".join(file.posix_path for file in fails) " But it may cause some texture issue when you playing. (Such as missing textures)"
) )
else: else:
self.download_signal.log.emit("All necessary assets were downloaded.") self.download_signal.log.emit(
"All assets downloaded successfully."
)
except AssetManifestFetchException as e: except AssetManifestFetchException as e:
self.widget_signal.show_error_message.emit( self.widget_signal.show_error_message.emit(
f"Unable to fetch asset manifest.\n{e.user_message}" f"Unable to fetch asset manifest.\n{e.user_message}"
@@ -895,9 +885,22 @@ class TestDialog(QDialog):
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, java_path):
"""
Launch offline game
:param ver:
:param java_path:
:return:
"""
try: try:
current_status = 0 # 0 = fetch version manifest, 1 = check client, 2 = check libraries
# 3 = check libraries, 4 = check assets
# Version manifest
ver_data = self.get_cached_version_manifest(ver) ver_data = self.get_cached_version_manifest(ver)
if not ver_data:
return
arguments, asset_index, downloads, java_version, libraries, main_class, old_args = ( arguments, asset_index, downloads, java_version, libraries, main_class, old_args = (
find_useful_part_in_specific_version_manifest(ver_data) find_useful_part_in_specific_version_manifest(ver_data)
) )
@@ -905,24 +908,65 @@ class TestDialog(QDialog):
# Vars # Vars
asset_id = asset_index.get("id") asset_id = asset_index.get("id")
# Download game files (ignore if exists)
# Client
self._download_client(ver)
self._download_libraries(ver)
self._download_assets(ver)
# Some paths # Some paths
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") 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
self._download_client(ver)
if not core_file_path.exists():
url, sha1 = get_core_file_download_url_and_hash(ver_data)
try:
ver_data = self.get_cached_version_manifest(ver)
if not ver_data:
return
url, sha1 = get_core_file_download_url_and_hash(ver_data)
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)
classpath = generate_classpath( classpath = generate_classpath(
ver_data, ver_data,
Path(self.app.work_dir, "libraries"), libraries_dir,
Path(self.app.work_dir, "versions", ver, "client.jar"), core_file_path,
) )
# Mappings # Mappings
@@ -993,24 +1037,22 @@ class TestDialog(QDialog):
message = ( message = (
"Some files failed to download.\n" "Some files failed to download.\n"
"Would you like to retry downloading them?\n\n" "Would you like to retry downloading them?\n\n"
+ "\n".join(file.posix_path for file in files)
) )
ask_event = threading.Event()
context = {
"event": ask_event,
"title": "Retry downloading",
"message": message,
}
self.widget_signal.askyesno.emit(context) request = YesNoRequest("Redownload", message)
request.details = "\n".join(file.posix_path for file in files)
if not ask_event.wait(60): self.widget_signal.askyesno.emit(request)
if not request.wait(60):
self.widget_signal.show_error_message.emit( self.widget_signal.show_error_message.emit(
"Timed out while waiting for confirmation. Please try again." "Timed out while waiting for confirmation. Please try again."
) )
return False, files return False, files
if not context.get("result", False): result = request.result()
if not result:
return False, files return False, files
# copy # copy