diff --git a/main.py b/main.py index fd2627a..0fa5e6b 100644 --- a/main.py +++ b/main.py @@ -25,9 +25,13 @@ import click import yaml from utils.common import get_latest_version_minecraft, get_specific_version_paper_builds, \ download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar, \ - read_server_properties, save_server_properties, activate_eula_file + read_server_properties, save_server_properties, activate_eula_file, \ + get_specific_version_minecraft_require_java_version, find_system_java_executables +from utils.explorer_library import get_java_version_by_execute, search_available_java_runtimes_in_directory from utils.file_settings import FileSettings from utils.file_settings import required_list, required_value +from utils.java.java_info import create_java_version_info +from utils.java.jvm_installer import install_azul_build_version_jvm from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes @@ -40,6 +44,7 @@ VERSION = "1.1" LOG_DIR_NAME = "logs" SERVERJAR_LOG_FILE = "serverjar.log" LOG_DOWNLOAD_CHUNK_SIZE = 4096 +JVM_RUNTIME_DIR = ROOT_DIR / "data" / "jvm" def exit(message): click.echo(click.style(message, fg='green')) @@ -173,6 +178,159 @@ def parse_server_property_overrides(values): return overrides +def normalize_machine_arch(machine=None): + machine = (machine or platform.machine()).lower() + if machine in ("amd64", "x86_64", "x64"): + return "amd64" + if machine in ("arm64", "aarch64"): + return "arm64" + if machine in ("x86", "i386", "i686"): + return "i686" + return machine + + +def java_executable_names(): + return ["java.exe", "javaw.exe"] if os.name == "nt" else ["java"] + + +def java_runtime_search_roots(): + roots = [JVM_RUNTIME_DIR] + java_home = os.environ.get("JAVA_HOME") + if java_home: + roots.append(Path(java_home)) + return roots + + +def collect_java_executable_candidates(): + candidates = [] + + for root in java_runtime_search_roots(): + if root.exists(): + candidates.extend(search_available_java_runtimes_in_directory(str(root))) + + candidates.extend(find_system_java_executables(java_executable_names())) + + for java_name in java_executable_names(): + candidates.append(java_name) + + unique_candidates = [] + seen = set() + for candidate in candidates: + candidate_key = str(candidate).lower() + if candidate_key in seen: + continue + seen.add(candidate_key) + unique_candidates.append(str(candidate)) + + return unique_candidates + + +def inspect_java_candidate(java_executable_path): + status, major_version, error = get_java_version_by_execute(java_executable_path) + if not status: + return None, error + + try: + major_version = int(major_version) + except (TypeError, ValueError): + return None, f"Invalid Java major version: {major_version}" + + return major_version, None + + +def find_compatible_java(required_major_version): + required_major_version = int(required_major_version) + compatible = [] + checked = [] + + for candidate in collect_java_executable_candidates(): + major_version, error = inspect_java_candidate(candidate) + if major_version is None: + checked.append((candidate, None, error)) + continue + + checked.append((candidate, major_version, None)) + if major_version >= required_major_version: + compatible.append((candidate, major_version)) + + compatible.sort(key=lambda item: (item[1], item[0])) + if compatible: + return compatible[0][0], compatible[0][1], checked + + return None, None, checked + + +def install_compatible_java(required_major_version): + arch = normalize_machine_arch() + platform_name = platform.system().lower() + install_dir = JVM_RUNTIME_DIR / f"java_{required_major_version}_{arch}" + + click.echo(f"Installing Azul Java {required_major_version} into {install_dir} ...") + status, error = install_azul_build_version_jvm( + str(required_major_version), + str(install_dir), + arch, + platform_name, + ) + if not status: + raise click.ClickException(f"Unable to install Azul Java {required_major_version}: {error}") + + create_java_version_info(str(required_major_version), arch, str(install_dir)) + java_executable = install_dir / "bin" / ("java.exe" if os.name == "nt" else "java") + if not java_executable.exists(): + raise click.ClickException(f"Installed JVM does not contain a Java executable: {java_executable}") + + return java_executable.absolute().as_posix() + + +def resolve_java_executable(java_exec_path, minecraft_version, release, auto_install_java): + required_major_version = get_specific_version_minecraft_require_java_version(minecraft_version, release=release) + if required_major_version is None: + click.echo(click.style("Unable to determine required Java version. Using configured Java executable.", fg="yellow")) + return java_exec_path or "java" + + click.echo(f"Minecraft {minecraft_version} requires Java {required_major_version}.") + + if java_exec_path: + major_version, error = inspect_java_candidate(java_exec_path) + if major_version is None: + raise click.ClickException(f"Unable to use Java executable '{java_exec_path}': {error}") + if major_version < int(required_major_version): + raise click.ClickException( + f"Java executable '{java_exec_path}' is Java {major_version}, " + f"but Minecraft {minecraft_version} requires Java {required_major_version}." + ) + click.echo(f"Using configured Java executable: {java_exec_path} (Java {major_version})") + return java_exec_path + + java_path, major_version, checked = find_compatible_java(required_major_version) + if java_path: + click.echo(f"Using compatible Java executable: {java_path} (Java {major_version})") + return java_path + + click.echo(click.style(f"No compatible Java runtime found for Java {required_major_version}.", fg="yellow")) + if checked: + click.echo("Checked Java candidates:") + for candidate, candidate_major, error in checked: + if candidate_major is None: + click.echo(f"- {candidate}: unavailable ({error})") + else: + click.echo(f"- {candidate}: Java {candidate_major}") + + should_install = auto_install_java + if not should_install: + result = input("Install a compatible Azul JVM now? [y/N] ") + should_install = result.strip().lower() == "y" + + if should_install: + return install_compatible_java(required_major_version) + + raise click.ClickException( + f"No compatible Java runtime is available. Install Java {required_major_version}+ " + "or rerun with --install-java." + ) + + @main.command() @click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name") @click.option("--mc-version", "-m", @@ -196,7 +354,10 @@ def parse_server_property_overrides(values): @click.option("--custom-args", "-ce", help="Custom arguments (command)", type=str, default="") @click.option("--java-exec-path", "-p", show_default=True, - help="The destination of the java executable", default="java") + help="The destination of the java executable. If omitted, ServerJar searches for a compatible Java.", + default=None) +@click.option("--install-java", is_flag=True, + help="Install a compatible Azul JVM automatically when no local compatible Java is found.") @click.option("--x-memory-initial", "-xms", show_default=True, help="Initial allocation size of the memory for server", type=str, default="1G") @@ -213,7 +374,8 @@ def parse_server_property_overrides(values): @click.option("--server-property", "-sp", "server_properties", multiple=True, help="server.properties override written after the first startup. Use key=value; can be repeated.") def create_server(name, mc_version, build, server_type, snapshot, latest, list_builds, filename, extra_args, java_exec_path, - x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host, server_properties): + install_java, x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host, + server_properties): server_dir = Path("servers", name) server_type = server_type.lower() property_overrides = parse_server_property_overrides(server_properties) @@ -297,8 +459,12 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b exit("User aborted.") return + selected_mc_version = latest_ver if latest_ver is not None else mc_version + if not custom_args: + java_exec_path = resolve_java_executable(java_exec_path, selected_mc_version, release, install_java) + args = [ - java_exec_path, + java_exec_path or "java", "-Xms{}".format(x_memory_initial), "-Xmx{}".format(x_memory_maximum), "-jar", diff --git a/utils/bk_utils/crypto.py b/utils/bk_utils/crypto.py new file mode 100644 index 0000000..1d4340d --- /dev/null +++ b/utils/bk_utils/crypto.py @@ -0,0 +1,39 @@ +""" +bk_core + +Copyright (c) 2024~2025 Techarerm/TedKai +Copyright (c) 2026 Kitee Contributors. All rights reserved. +""" +import hashlib + + +def verify_checksum(file_path, expected_sha1): + sha1 = hashlib.sha1() + with open(file_path, "rb") as f: + while True: + data = f.read(65536) # Read in 64KB chunks + if not data: + break + sha1.update(data) + file_sha1 = sha1.hexdigest() + return file_sha1 == expected_sha1 + + +def verify_checksum_v2(file_path, expected_hash, crypto_type): + hash_dict = { + 'sha1': hashlib.sha1, + 'sha256': hashlib.sha256, + 'md5': hashlib.md5 + } + + if crypto_type not in hash_dict: + raise ValueError(f"Unsupported hash type: {crypto_type}") + + hash_obj = hash_dict[crypto_type]() + + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + hash_obj.update(chunk) + + file_sha1 = hash_obj.hexdigest() + return file_sha1 == expected_hash diff --git a/utils/bk_utils/java/maven/maven_util.py b/utils/bk_utils/java/maven/maven_util.py new file mode 100644 index 0000000..750c839 --- /dev/null +++ b/utils/bk_utils/java/maven/maven_util.py @@ -0,0 +1,29 @@ +""" +bk_core + +Copyright (c) 2024~2025 Techarerm/TedKai +""" +import os +from pathlib import Path + + +def deduplicate_java_classpath(classpath, classpath_separator): + paths = classpath.split(classpath_separator) + + cleaned_classpath = [str(Path(p.strip())) for p in paths if p.strip()] + + return classpath_separator.join(cleaned_classpath) + + +def delete_missing_java_classpath(classpath, classpath_separator): + paths = classpath.split(classpath_separator) + existing_classpath = [p for p in paths if os.path.exists(p)] + return classpath_separator.join(existing_classpath) + + +def replace_specified_value_to_target_string_in_java_classpath(classpath, classpath_separator, value, string): + paths = classpath.split(classpath_separator) + + new_paths = [path.replace(value, string) for path in paths] + + return classpath_separator.join(new_paths) \ No newline at end of file diff --git a/utils/bk_utils/settings.py b/utils/bk_utils/settings.py new file mode 100644 index 0000000..453d0c6 --- /dev/null +++ b/utils/bk_utils/settings.py @@ -0,0 +1,310 @@ +""" +bk_core + +Copyright (c) 2024~2025 Techarerm/TedKai +Copyright (c) 2026 Kitee Contributors. All rights reserved. +""" +import contextlib +import copy +import json +from pathlib import Path + + +def validation_rule( + default=None, + *, + children=None, + write_back_if_not_exist=False, + recover_missing_items=False, +): + rule = { + "writeBackIfNotExist": write_back_if_not_exist, + "recoverMissingItems": recover_missing_items, + } + + if children is not None: + rule["children"] = children + else: + rule["default"] = default + + return rule + + +def required_value(default, *, recover_missing_items=False): + return validation_rule( + default, + write_back_if_not_exist=True, + recover_missing_items=recover_missing_items, + ) + + +def required_section(children): + return validation_rule(children=children, write_back_if_not_exist=True) + + +class FileSettings: + """ + Simple Settings Object + IMPORTANT: Settings always use self.data as the main operate data, NOT FROM disk settings file!!! + + Usage: + FileSettings.create() -> Create settings file (path is self.path) + FileSettings.load() - > Load settings file + FileSettings.save() -> Save settings file + FileSettings.edit() -> Edit settings file (Auto save when with block completes) + + Example: + with FileSettings.edit() as settings: + settings["hello"] = "world" + """ + def __init__(self, path, default, validation_rules=None, + dumps_func=json.dumps, load_func=json.load, + settings_change_callback=None): + self.data = copy.deepcopy(default) + self.default = copy.deepcopy(default) + self.path: Path = Path(path) + self.validation_rules = validation_rules + self.dumps_func = dumps_func + self.load_func = load_func + self.settings_change_callback = settings_change_callback + + def reset(self): + """Replace self.data with self.default and save""" + self.data = copy.deepcopy(self.default) + self.save() + + if callable(self.settings_change_callback): + self.settings_change_callback(self.data) + + def __repr__(self): + return f"" + + def create(self, exist_ok=False): + """ + Create settings file (path is self.path, data use default value) + :param exist_ok: If not True, raise exception if file already exists + :return: + """ + self.path.parent.mkdir(parents=True, exist_ok=True) + + if not exist_ok and self.path.exists(): + raise FileExistsError(f'{self.path} already exists') + + self.path.write_text(self._dumps(self.default)) + + def read_from_exist(self): + self.data = self.load() + + def mload(self): + """Get data from memory""" + return copy.deepcopy(self.data) + + def load(self): + """ + Load settings file data into self.data (path is self.path) + :return: + """ + if not self.path.exists(): + raise FileNotFoundError(f'{self.path} does not exist') + + with self.path.open("rb") as settings_file: + data = self.load_func(settings_file) + + if isinstance(self.validation_rules, dict): + data = self.validate_data(data, self.default, self.validation_rules) + + return data + + def save(self): + """ + Save self.data (data in memory) into settings file + :return: + """ + if not self.path.exists(): + raise FileNotFoundError(f'{self.path} does not exist. Create it before saving.') + + self.path.write_text(self._dumps(self.data)) + + def get(self, key, default=None): + return self.data.get(key, default) + + def dget(self, key, default=None): # dget -> get_default + return self.default.get(key, default) + + def exists(self): + return self.path.exists() + + @contextlib.contextmanager + def edit(self): + """ + Edit settings (With auto save) + :return: + """ + if not self.exists(): + self.create() + yield self + + self.save() + + if callable(self.settings_change_callback): + self.settings_change_callback(self) + + def validate_data(self, data, default, rules): + """ + Validates data by validating rules. + :param data: dict data + :param default: default sample + :param rules: rules of all keys + :return: + """ + if not isinstance(data, dict): + data = {} + + if not isinstance(default, dict): + default = {} + + if not isinstance(rules, dict): + return copy.deepcopy(data) + + validated = copy.deepcopy(data) + + for key, rule in rules.items(): + rule_default, options = self._parse_validation_rule(rule) + default_value = copy.deepcopy(default.get(key, rule_default)) + + if key not in validated: + if options.get("writeBackIfNotExist"): + validated[key] = self._validate_value( + default_value, + default_value, + rule_default, + options, + ) + continue + + validated[key] = self._validate_value( + validated[key], + default_value, + rule_default, + options, + ) + + return validated + + def update(self, settings): + """Update self.data with new settings values.""" + if not isinstance(settings, dict): + raise TypeError("settings must be a dict") + + def update_inner(new, old): + if not isinstance(old, dict): + return + + for n_k, n_v in new.items(): + if isinstance(n_v, dict) and (n_k in old and isinstance(old[n_k], dict)): + update_inner(n_v, old[n_k]) + else: + old[n_k] = copy.deepcopy(n_v) + + update_inner(settings, self.data) + + if callable(self.settings_change_callback): + self.settings_change_callback(self) + + def update_new_settings(self, new_default_settings): + """Add missing settings to self.default and self.data.""" + if not isinstance(new_default_settings, dict): + raise TypeError("new_default_settings must be a dict") + + def add_missing(new, old): + if not isinstance(old, dict): + return + + for n_k, n_v in new.items(): + if isinstance(n_v, dict) and (n_k in old and isinstance(old[n_k], dict)): + add_missing(n_v, old[n_k]) + if n_k not in old: + old[n_k] = copy.deepcopy(n_v) + + add_missing(new_default_settings, self.default) + add_missing(new_default_settings, self.data) + + if callable(self.settings_change_callback): + self.settings_change_callback(self) + + @staticmethod + def _parse_validation_rule(rule): + if isinstance(rule, dict) and ("default" in rule or "children" in rule): + options = { + "writeBackIfNotExist": rule.get("writeBackIfNotExist", False), + "recoverMissingItems": rule.get("recoverMissingItems", False), # Recover missing item (only for list) + } + + if "children" in rule: + return rule["children"], options + + return rule.get("default"), options + + if ( + isinstance(rule, (list, tuple)) + and len(rule) == 2 + and isinstance(rule[1], dict) + ): + return rule[0], rule[1] + + return rule, {} + + def _validate_value(self, value, default_value, rule_default, options): + if isinstance(rule_default, dict): + if not isinstance(value, dict): + value = {} + + if not isinstance(default_value, dict): + default_value = {} + + return self.validate_data(value, default_value, rule_default) + + if isinstance(default_value, list): + if not isinstance(value, list): + return copy.deepcopy(default_value) + + validated = copy.deepcopy(value) + + if options.get("recoverMissingItems"): + for item in default_value: + if item not in validated: + validated.append(copy.deepcopy(item)) + + return validated + + if default_value is None: + return value + + if type(value) is not type(default_value): + return copy.deepcopy(default_value) + + return value + + def _dumps(self, data): + if not callable(self.dumps_func): + raise TypeError(f"dumps_func must be callable") + + return self.dumps_func(data) + + def __getitem__(self, key): + return self.data[key] + + def __setitem__(self, key, value): + self.data[key] = value + + if callable(self.settings_change_callback): + self.settings_change_callback(self) + + def __eq__(self, other): + if isinstance(other, FileSettings): + return self.path == other.path and self.data == other.data + + if isinstance(other, dict): + return self.data == other + + return NotImplemented diff --git a/utils/bk_utils/utils.py b/utils/bk_utils/utils.py new file mode 100644 index 0000000..45ead09 --- /dev/null +++ b/utils/bk_utils/utils.py @@ -0,0 +1,155 @@ +""" +bk_core + +Copyright (c) 2024~2025 Techarerm/TedKai +Copyright (c) 2026 Kitee Contributors. All rights reserved. +""" +import zipfile +import requests +import os + + +def create_download_task( + url, + dest_path, + sha1=None, + with_verify=True, + crypto_type="sha1", + chunk_size=8192, +): + return { + "url": url, + "dest": dest_path, + "sha1": sha1, + "with_verify": with_verify, + "crypto_type": crypto_type, + "chunk_size": chunk_size, + } + + +def flatten_download_queue(nested_urls_and_paths): + return [ + create_download_task(url, dest_path) + for sublist in nested_urls_and_paths + for url, dest_path in sublist + ] + + +def download_file(url, dest_path, with_verify=True, sha1=None, no_output=False, custom_chunk_size=8192): + """ + Downloads a file from a URL and saves it to dest_path. + """ + return [ + create_download_task( + url, + dest_path, + sha1=sha1, + with_verify=with_verify, + chunk_size=custom_chunk_size, + ) + ] + +def n_download_file(url, dest_path, enable_hash_check=False, sha1=None, no_download_output=False, chunk_size=8192): + """ + Downloads a file from a URL and saves it to dest_path. + """ + return [ + create_download_task( + url, + dest_path, + sha1=sha1, + with_verify=enable_hash_check, + chunk_size=chunk_size, + ) + ] + + +def extract_zip(zip_path, extract_to): + try: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(extract_to) + print(f"Extracted {zip_path} to {extract_to}") + except zipfile.BadZipFile as e: + print(f"[ERR] Error extracting {zip_path}: {e}") + + +def multi_thread_download(nested_urls_and_paths, name, max_workers=5, retries=1, download_with_progress_bar=True): + """ + Downloads multiple files using multiple threads with retry attempts. + nested_urls_and_paths should be a nested list where each element is a list containing a tuple of (url, dest_path). + """ + return flatten_download_queue(nested_urls_and_paths) + + +def multithread_download( + download_url_list, + file_dest_list, + progress_name, + max_workers=8, + with_verify_checksum=False, + file_hash_list=None, + download_with_progress_bar=False, + no_output=None, + crypto_type="sha1", +): + # parameters + if file_hash_list is None: + file_hash_list = [] + if no_output is None: + no_output = download_with_progress_bar + + if not with_verify_checksum: + file_hash_list = [None for _ in download_url_list] + + return [ + create_download_task( + file_url, + file_dest, + sha1=file_hash, + with_verify=with_verify_checksum, + crypto_type=crypto_type, + ) + for file_url, file_dest, file_hash in zip(download_url_list, file_dest_list, file_hash_list) + ] + + +def find_jar_file_main_class(jar_file_path): + manifest_path = 'META-INF/MANIFEST.MF' + try: + with zipfile.ZipFile(jar_file_path, 'r') as jar: + if not manifest_path in jar.namelist(): + return None + + manifest = jar.read(manifest_path).decode('utf-8') + for line in manifest.splitlines(): + if line.startswith('Main-Class:'): + # Return the class name specified in the Main-Class entry + return line.split(':')[1].strip() + + return None + except Exception as e: + return None + + +def check_url_status(url): + try: + # Send a HEAD request to save bandwidth + response = requests.head(url, allow_redirects=True, timeout=5) + if response.status_code == 200: + return True + elif response.status_code == 404: + return False + else: + return False + except Exception as e: + return False + + +def pause(): + command = None + if os.name == "posix": + command = 'read -p "Press enter to continue..."' + elif os.name == "nt": + command = "pause" + + if command is not None: os.system(command) diff --git a/utils/common.py b/utils/common.py index d7641d8..3117c1c 100644 --- a/utils/common.py +++ b/utils/common.py @@ -8,6 +8,7 @@ PAPER_VERSION_API = "https://api.papermc.io/v2/projects/paper/versions/{}" PAPER_SERVER_JAR_API = "https://api.papermc.io/v2/projects/paper/versions/{}/builds/{}/downloads/paper-{}-{}.jar" MOJANG_VERSION_MANIFEST_V2 = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" + def read_server_properties(server_dir: Path) -> Properties: if not server_dir.exists(): raise FileNotFoundError("Server directory does not exist") @@ -25,6 +26,7 @@ def save_server_properties(properties: Properties, server_dir: Path) -> None: with path.open("wb") as config_file: properties.store(config_file, encoding='utf-8') + def activate_eula_file(server_dir: Path) -> bool: path = server_dir / 'eula.txt' @@ -35,7 +37,7 @@ def activate_eula_file(server_dir: Path) -> bool: lines = file.readlines() for line in lines: - print(f"{line}\n") + print(f"{line}") print("To activate eula file please read above text. If you agree this license, enter Y [not agree enter N]") result = str(input("Agree? :")) @@ -50,9 +52,9 @@ def activate_eula_file(server_dir: Path) -> bool: continue file.write(line) - return True + def jar_filename(filename: str | None, default: str) -> str: if filename: name = filename @@ -179,6 +181,25 @@ def get_latest_version_minecraft(release=True): return ver +def get_specific_version_minecraft_require_java_version(minecraft_version, release=True): + version_list = get_version_list(release=release) + + if release: + version_exists = minecraft_version in version_list + else: + version_exists = any(version.get("id") == minecraft_version for version in version_list) + + if not version_exists: + raise Exception("Specified Minecraft version does not exist.") + + metadata = get_minecraft_version_metadata(minecraft_version) + + if not metadata: + raise Exception("Unable to get Minecraft version metadata for {}.\n".format(minecraft_version)) + + return metadata.get("javaVersion", {}).get("majorVersion", None) + + def download_server_jar(minecraft_version: str, build_version: str, destination: Path, filename: str | None = None): """ Download server jar (paper server only) @@ -194,7 +215,8 @@ def download_server_jar(minecraft_version: str, build_version: str, destination: download_file(url, destination) return destination except Exception as e: - raise Exception("Unable to download server jar for version {}\nURL: {}\nError: {}".format(minecraft_version, url, e)) + raise Exception( + "Unable to download server jar for version {}\nURL: {}\nError: {}".format(minecraft_version, url, e)) def get_latest_build_of_version(minecraft_version: str) -> str: @@ -209,6 +231,7 @@ def download_latest_build_paper_jar(minecraft_version: str, destination_dir: Pat build = get_latest_build_of_version(minecraft_version) return download_server_jar(minecraft_version, build, destination_dir, filename=filename) + def version_exist_from_paper(minecraft_version: str) -> bool: try: get_specific_version_paper_builds(minecraft_version) @@ -223,14 +246,14 @@ def get_latest_paper_version(release) -> str: latest_paper_support_ver = None while latest_paper_support_ver is None: - if len(vers) < index+1: + if len(vers) < index + 1: raise Exception("No supported Minecraft version available for Paper support.") if version_exist_from_paper(minecraft_version=vers[index]): latest_paper_support_ver = vers[index] break - index+=1 + index += 1 return latest_paper_support_ver @@ -273,3 +296,53 @@ def download_vanilla_server_jar(minecraft_version: str, destination: Path, filen url, e, )) + +def find_system_java_executables(java_names): + """ + Find system Java executables + :param java_names: + :return: + """ + roots = [] + if os.name == "nt": + for env_name in ("ProgramFiles", "ProgramFiles(x86)"): + root = os.environ.get(env_name) + if root: + roots.append(os.path.join(root, "Java")) + elif os.name == "posix": + roots.extend(("/Library/Java/JavaVirtualMachines", "/opt/java", "/usr/lib/jvm", "/usr/local/java")) + + found = [] + for root in roots: + if not os.path.isdir(root): + continue + + for current_root, _, files in os.walk(root): + for java_name in java_names: + if java_name in files and os.path.basename(current_root).lower() == "bin": + found.append(os.path.join(current_root, java_name)) + break + + return found + +def major_version_from_runtime_dir(runtime_dir): + """ + Get major version from target runtime directory + (Only works if this runtime is created by launcher) + :param runtime_dir: + :return: + """ + name = runtime_dir.name + if name.lower().startswith("java_"): + return name.split("_", 1)[1] + + info_path = runtime_dir / "java.version.info" + if info_path.exists(): + try: + for line in info_path.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("JavaMajorVersion") and "=" in line: + return line.split("=", 1)[1].strip().strip('"').strip("'") + except Exception: + return "" + + return "" diff --git a/utils/java/java_info.py b/utils/java/java_info.py new file mode 100644 index 0000000..a6ff8a0 --- /dev/null +++ b/utils/java/java_info.py @@ -0,0 +1,232 @@ +""" +bk_core + +Copyright (c) 2024~2025 Techarerm/TedKai +Copyright (c) 2026 Kitee Contributors. All rights reserved. +""" +import os.path +import textwrap +import requests + +azul_packages_api = "https://api.azul.com/metadata/v1/zulu/packages" +java_manifest_url = 'https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json' + + +def get_java_build_download_url_from_azul(platform_name: str, full_arch: str, java_major_version: str): + full_arch = full_arch.lower() + platform_name = platform_name.lower() + + arch_map = { + "arm64": "aarch64", + "i686": "i686", + "amd64": "amd64", + "i386": "i686" + } + + platform_map = { + "macos": "macos", + "darwin": "macos", + "windows": "windows", + "linux": "linux" + } + + real_arch = arch_map.get(full_arch, full_arch) + platform = platform_map.get(platform_name, platform_name) + + version_type_list = ["jre", "jdk"] + + jre_url = ( + f"/?java_version={java_major_version}&os={platform}&arch={real_arch}&java_package_type=jre&javafx_bundled=true" + f"&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=100") + + full_jre_url = azul_packages_api + jre_url + + jdk_url = (f"/?java_version={java_major_version}&os={platform}&arch={real_arch}&java_package_type=jdk" + f"&javafx_bundled=true" + f"&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=100") + + full_jdk_url = azul_packages_api + jdk_url + + version_url_list = [full_jre_url, full_jdk_url] + for url, ver_type in zip(version_url_list, version_type_list): + try: + response = requests.get(url) + data = response.json() + java_ver_data = data[0] + download_url = java_ver_data.get("download_url", None) + + if download_url is not None: + return True, download_url, ver_type + + except Exception as e: + print("[ERROR] Getting support jvm download url failed: ", e) + return False, None, None + + return False, None, None + +def get_java_version_manifest_data(): + # Get java manifest + try: + response = requests.get(java_manifest_url) + if response.status_code == 200: + manifest_data = response.json() + return manifest_data + else: + return None + except Exception as e: + return None + + +def get_support_java_version(version_data): + """ + :param version_data: Special minecraft version data + :return Status, component, major_version + """ + try: + java_version_info = version_data['javaVersion'] + component = java_version_info['component'] + major_version = java_version_info['majorVersion'] + return True, component, major_version + except KeyError: + return False, None, None + + +def get_support_java_version_from_java_version_manifest(platform, full_arch): + """ + :return SupportList + """ + + platform_map = { + "darwin": "mac-os", + "windows": "windows", + "linux": "linux" + } + + architecture_map = { + "amd64": ["x64"], + "i586": ["x86", "i386"], + "x86": ["i386", "x86"], + "arm64": ["arm64"], + "aarch64": ["arm64"] + } + + platform_name = platform_map.get(platform.lower(), platform.lower()) + + architecture_list = architecture_map.get(full_arch.lower(), full_arch.lower()) + + try: + response = requests.get(java_manifest_url) + except Exception as e: + return False, None + + java_manifest_data = response.json() + java_manifest_data_cleaned = {} + organized_manifest_data_list = [] + + # Get all platform data (except item "gamecore") + for arch_and_platform, support_java_data in java_manifest_data.items(): + if arch_and_platform == "gamecore": + continue + + java_manifest_data_cleaned.update({arch_and_platform: support_java_data}) + + # Separate platform name and arch from arch_and_platform + for arch_and_platform, support_java_data in java_manifest_data_cleaned.items(): + if arch_and_platform == "mac-os": + # For mac os (x86-64) + java_platform = "mac-os" + java_arch = "amd64" + elif "-" in arch_and_platform: + parts = arch_and_platform.split("-") + java_platform = '-'.join(parts[:-1]) + java_arch = parts[-1] + else: + java_platform = arch_and_platform + java_arch = "amd64" + + for java_runtime_name, runtime_data in support_java_data.items(): + if not java_runtime_name.startswith("java"): + if not java_runtime_name.startswith("jre"): + continue + + # Skip some empty data + if len(runtime_data) == 0: + continue + + # If the platform and architecture are same, append data to list + if not java_platform.lower() == platform_name: + continue + + if java_arch.lower() not in architecture_list: + continue + + # Convert data to dict + result = {} + for item in runtime_data: + result.update(item) + organized_manifest_data_list.append(result) + + return organized_manifest_data_list + + +def get_support_java_runtime_version_data(organized_manifest_data_list, major_version): + """ + Warning: Require organized_manifest_data_list + """ + java_version_url = None + + for data in organized_manifest_data_list: + name = data.get("version", {}).get("name", None) + url = data.get("manifest", {}).get("url", None) + if name is None: + continue + + if name.startswith(str(major_version)): + if url is not None: + java_version_url = url + + if java_version_url is not None: + try: + response = requests.get(java_version_url) + data = response.json() + + return True, data + except Exception as e: + return False, None + + return False, None + + +def create_java_version_info(java_major_version, java_arch, jvm_runtimes_path): + java_info_data = textwrap.dedent(f"""\ + # Java Runtime Info + # This configuration is automatically generated by the BakeLauncher. + # Configuration stores Java for the launcher (Example: major_version, architecture). + # Do NOT edit this file or delete it! + + JavaMajorVersion = "{java_major_version}" + Architecture = "{java_arch}" + + """) + + if not os.path.exists(jvm_runtimes_path): + os.makedirs(jvm_runtimes_path) + + java_info_path = os.path.join(jvm_runtimes_path, "java.version.info") + + if not os.path.exists(java_info_path): + with open(java_info_path, 'w') as file: + file.write(java_info_data) + + +def read_java_info(info_file_path, item): + with open(info_file_path, "r") as file: + lines = file.readlines() + + for line in lines: + line = line.strip() + if item in line and '=' in line: + data = line.split('=', 1)[1].strip().strip("") + return str(data) + + return None \ No newline at end of file diff --git a/utils/java/jvm_installer.py b/utils/java/jvm_installer.py new file mode 100644 index 0000000..ad38e43 --- /dev/null +++ b/utils/java/jvm_installer.py @@ -0,0 +1,198 @@ +""" +bk_core + +Copyright (c) 2024~2025 Techarerm/TedKai +Copyright (c) 2026 Kitee Contributors. All rights reserved. +""" +import shutil +import os +import platform +import requests +from ..bk_utils.utils import download_file, extract_zip +from ..java.java_info import get_java_build_download_url_from_azul +from ..bk_utils.crypto import verify_checksum + + +def download_java_file(file_info, file_path, destination_folder): + download_type = "raw" + file_url = file_info["downloads"][download_type]["url"] + full_file_path = os.path.join(destination_folder, file_path) + expected_sha1 = file_info["downloads"][download_type].get("sha1") + + directory = os.path.join(destination_folder, os.path.dirname(file_path)) + os.makedirs(directory, exist_ok=True) + + if os.path.exists(full_file_path) and expected_sha1 and verify_checksum(full_file_path, expected_sha1): + return [] + + return download_file( + file_url, + full_file_path, + with_verify=True, + sha1=expected_sha1, + ) + + +def download_java_runtime_files(manifest, install_path): + if not os.path.exists(install_path): + return False, "InstallFolderAreNotExist" + + files = manifest.get("files", {}) + download_tasks = [] + + for file_path, file_info in files.items(): + if file_info.get("type") == "directory": + os.makedirs(os.path.join(install_path, file_path), exist_ok=True) + continue + + if "downloads" in file_info: + download_tasks.extend(download_java_file(file_info, file_path, install_path)) + + return True, download_tasks + + +def install_azul_build_version_jvm(java_major_version, install_dir, full_architecture, platform_name): + Status, download_url, version_type = get_java_build_download_url_from_azul(platform_name, full_architecture, + java_major_version) + + if not Status: + return False, "Get download url failed. Unsupported platform" + + tmp = os.path.join(os.path.dirname(install_dir), ".jvm_installer_tmp") + os.makedirs(tmp, exist_ok=True) + + jvm_zip_file_path = os.path.join(tmp, f"jvm-azul-{java_major_version}.zip") + + if os.path.exists(jvm_zip_file_path): + try: + os.remove(jvm_zip_file_path) + except Exception as e: + return False, "Cannot delete tmp file. ERR: {}".format(e) + + jvm_unzip_dest = os.path.join(tmp, f"jvm-azul-{java_major_version}-unzipped") + if os.path.exists(jvm_unzip_dest): + try: + shutil.rmtree(jvm_unzip_dest) + except Exception as e: + return False, "Cannot delete unzip tmp file. ERR: {}".format(e) + + if not os.path.exists(jvm_zip_file_path): + try: + _download_file_now(download_url, jvm_zip_file_path) + except Exception as e: + return False, "Download file failed. ERR: {}".format(e) + + extract_zip(jvm_zip_file_path, jvm_unzip_dest) + + if os.path.exists(install_dir): + try: + shutil.rmtree(install_dir) + except Exception as e: + return False, f"Cleaning install dir failed. ERR: {e}" + + if platform_name.lower() == "darwin": + unzip_list = os.listdir(jvm_unzip_dest) + jvm_app_folder_name = unzip_list[0] + home_folder_path = os.path.join(jvm_unzip_dest, jvm_app_folder_name, f"zulu-{java_major_version}.jre", + "Contents", "Home") + if not os.path.isdir(home_folder_path): + home_folder_path = _find_java_home(jvm_unzip_dest) + if home_folder_path is None: + return False, "JAVA_HOME not found in the unzip folder." + + else: + unzip_list = os.listdir(jvm_unzip_dest) + home_folder_name = unzip_list[0] + home_folder_path = os.path.join(jvm_unzip_dest, home_folder_name) + if not os.path.isdir(home_folder_path): + home_folder_path = _find_java_home(jvm_unzip_dest) + if home_folder_path is None: + return False, "JAVA_HOME not found in the unzip folder." + + install_root_dir = os.path.dirname(install_dir) + home_name = os.path.basename(home_folder_path) + dest_path = os.path.join(install_root_dir, home_name) + + try: + shutil.move(home_folder_path, install_root_dir) + except Exception as e: + return False, f"Move home folder to install dir failed. ERR: {e}" + + try: + os.rename(dest_path, install_dir) + except Exception as e: + return False, "Rename dest_path to require name failed. ERR: {}".format(e) + + return True, None + + +def _find_java_home(root): + for current_root, _, files in os.walk(root): + executable_names = {"java.exe", "javaw.exe"} if os.name == "nt" else {"java"} + if os.path.basename(current_root).lower() == "bin" and any(name in executable_names for name in files): + return os.path.dirname(current_root) + return None + + +def find_java_home_in_extracted_runtime(unzip_dir): + candidates = [] + for java_name in ("javaw.exe", "java.exe", "java"): + candidates.extend(unzip_dir.rglob(java_name)) + + for java_path in candidates: + parent = java_path.parent + if parent.name.lower() == "bin": + return parent.parent + + return None + + +def runtime_java_executable(install_dir): + bin_dir = install_dir / "bin" + if os.name == "nt": + javaw = bin_dir / "javaw.exe" + if javaw.exists(): + return javaw + return bin_dir / "java.exe" + return bin_dir / "java" + + +def find_java_runtime_candidates(java_manifest, manifest_platform, java_major_version): + platform_data = java_manifest.get(manifest_platform, {}) + candidates = [] + for runtime_entries in platform_data.values(): + for runtime_entry in runtime_entries: + version_name = str(runtime_entry.get("version", {}).get("name") or "") + if version_name.startswith(str(java_major_version)): + candidates.append(runtime_entry) + return candidates + + +def mojang_java_platform_key(): + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "windows": + if "arm" in machine or "aarch64" in machine: + return "windows-arm64" + if "64" in machine or "amd64" in machine: + return "windows-x64" + return "windows-x86" + + if system == "darwin": + return "mac-os-arm64" if "arm" in machine or "aarch64" in machine else "mac-os" + + if system == "linux": + return "linux-arm64" if "arm" in machine or "aarch64" in machine else "linux" + + return None + + +def _download_file_now(url, dest_path): + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() + with open(dest_path, "wb") as file: + for chunk in response.iter_content(chunk_size=1024 * 128): + if chunk: + file.write(chunk)