Now launcher has a nice launch ui and it can launch MiNeCRaFt.

This commit is contained in:
wei
2026-08-01 02:34:27 +08:00
parent 338a5efe89
commit eff990f7e4
7 changed files with 714 additions and 105 deletions
+385 -2
View File
@@ -1,7 +1,10 @@
import json
import os
import subprocess
import time
import traceback
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from uuid import uuid4
from PySide6.QtCore import QObject
@@ -9,8 +12,16 @@ from PySide6.QtCore import QObject
from core_lib.common.common import FileObject
from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \
NoSpecifiedVersionKeyException
from core_lib.game.argument import ArgumentMappings, generate_launch_command
from core_lib.game.asset import check_assets_exist, download_assets, find_correct_assets_dir
from core_lib.game.library_core import check_full_libraries_exists, download_full_libraries, generate_classpath
from core_lib.game.mod.mod_core import merge_manifest
from core_lib.game.version_info import fetch_and_save_version_manifest, get_specific_version_manifest_url_and_hash, \
save_version_manifest
save_version_manifest, find_useful_part_in_specific_version_manifest, download_core_file, get_latest_version, \
LATEST_VERSION_ALIASES
from core_lib.runtime.java import find_java_installations
from manager.profile import LaunchProfile
from manager.widgets import SelectPathRequest, AskForRequest
class ManifestManager:
@@ -206,10 +217,382 @@ class ManifestManager:
return callback, task_id
@dataclass(frozen=True)
class ResolvedVersionManifest:
manifest: dict
version_id: str
client_version_id: str
def resolve_launch_manifest(self, version_id: str, visited: set[str] | None = None) -> ResolvedVersionManifest | None:
visited = set() if visited is None else visited
if version_id in visited:
self.widget_mgr.signal.show_error_message.emit(
f"Circular version inheritance detected: {version_id}"
)
return None
visited.add(version_id)
manifest_path = self.get_specific_version_manifest_path(version_id)
# Read custom (or mod loader) manifest first. If not exist, get manifest from official data
if manifest_path.is_file():
manifest = self._read_cached_version_manifest(version_id)
else:
manifest = self.get_cached_version_manifest(version_id)
if not manifest:
return None
parent_id = manifest.get("inheritsFrom")
# Return if the version don't need to merge manifest
if not parent_id:
return self.ResolvedVersionManifest(
manifest=manifest,
version_id=version_id,
client_version_id=version_id,
)
# Get inherits if needed
parent = self.resolve_launch_manifest(parent_id, visited)
if parent is None:
return None
try:
# Merge official and loader manifest
merged = merge_manifest(
inherits_version_manifest=parent.manifest,
mod_version_manifest=manifest,
)
except Exception as exc:
self.widget_mgr.signal.show_error_message.emit(
f"Unable to merge version {version_id} with {parent_id}: {exc}"
)
return None
return self.ResolvedVersionManifest(
manifest=merged,
version_id=version_id,
client_version_id=parent.client_version_id,
)
def resolve_version_alias(self, version_id: str) -> str | None:
"""
Resolve version alias to related version_id (Such as latest-release)
:param version_id:
:return:
"""
if version_id not in LATEST_VERSION_ALIASES:
return version_id
root_manifest = self.get_cached_version_manifest()
if not root_manifest:
return None
return get_latest_version(
root_manifest,
is_snapshot=version_id == "latest-snapshot",
)
class JavaManager:
def __init__(self, app):
self.app = app
self.widget_mgr = app.widget_manager
#
# JVM Management
#
@property
def jvm_settings_path(self) -> Path:
return Path(self.app.work_dir) / "jvms.json"
def _find_and_save_jvm_search_result(self):
installations, errors = find_java_installations()
if not installations:
self.widget_mgr.signal.show_warning_message.emit(
"There are no Java installations available in you system.\n"
)
return []
if errors:
request = AskForRequest("Java Virtual Machine Finder", message = (
"Unable to detect below java installation version:\n"
))
request.details = "\n".join(path.as_posix() for path in errors)
request.use_plain_text = True
self.widget_mgr.signal.askyesno.emit(request)
self.jvm_settings_path.parent.mkdir(parents=True, exist_ok=True)
try:
with self.jvm_settings_path.open("w") as file:
json.dump(installations, file, indent=4)
return installations
except Exception as e:
self.widget_mgr.signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Settings Error",
"message": "An error occurred while attempting to save jvm settings. Please try again.",
}
)
def _read_exist_jvm_settings(self):
try:
return json.loads(self.jvm_settings_path.read_text())
except Exception as e:
self.widget_mgr.signal.show_and_print_error_message.emit(
{
"error": traceback.format_exception(e),
"title": "Settings Error",
"message": "An error occurred while reading jvm settings.",
}
)
return None
def get_or_cache_jvm_settings(self) -> list:
if self.jvm_settings_path.exists():
exists = self._read_exist_jvm_settings()
if exists:
return exists
return self._find_and_save_jvm_search_result()
def get_support_runtime_jvm_executable(self, major_version: int | str, no_error: bool=False) -> str | None:
result = self.get_or_cache_jvm_settings()
if not result:
return None
for item in result:
version = item.get("major", None)
executable = item.get("executable", None)
try:
version = str(version)
major_version = str(major_version)
except Exception as e:
self.app.logger.warning(
f"Unable to convert version {version} to string: {e}"
)
if major_version == version and executable:
return executable
if not no_error:
self.widget_mgr.signal.show_error_message.emit(
"No Java executable found for major version {}.".format(major_version)
)
return None
@dataclass
class LaunchResult:
process: subprocess.Popen | None = None
error_message: str | None = None
warnings: list[str] = field(default_factory=list)
@property
def success(self) -> bool:
return self.process is not None and self.error_message is None
def fail(self, message: str) -> "LaunchResult":
self.error_message = message
return self
def add_warning(self, message: str) -> None:
self.warnings.append(message)
class LaunchManager:
def __init__(self, app, manifest_manager: ManifestManager, java_manager: JavaManager):
self.app = app
self.manifest_mgr = manifest_manager
self.widget_mgr = app.widget_manager
self.java_mgr = java_manager
self.launched_profiles: list[str] = []
def launch(self, profile: LaunchProfile) -> LaunchResult:
result = LaunchResult()
try:
version_id = profile.version_id
version_id = self.manifest_mgr.resolve_version_alias(version_id)
# Version manifest
resolved = self.manifest_mgr.resolve_launch_manifest(version_id)
if resolved is None:
return result.fail(
f"Unable to resolve version manifest: {version_id}"
)
version_manifest = resolved.manifest
client_version_id = resolved.client_version_id
arguments, asset_index, downloads, java_version, libraries, main_class, old_args = (
find_useful_part_in_specific_version_manifest(version_manifest)
)
# Vars
asset_id = asset_index.get("id")
java_major_version = java_version.get("majorVersion")
# Game paths
core_file_path = Path(self.app.work_dir, "versions", client_version_id, f"{client_version_id}.jar")
natives_dir = Path(self.app.work_dir, "versions", version_id, "natives")
libraries_dir = Path(self.app.work_dir, "libraries")
game_dir = profile.game_dir
if not game_dir:
game_dir = Path(self.app.temp_dir, "minecraft")
elif not game_dir.exists():
game_dir.mkdir(parents=True, exist_ok=True)
# Check client
if not core_file_path.exists():
download_core_file(version_manifest, core_file_path, raise_for_null_hash=True)
# Libraries
if not check_full_libraries_exists(version_manifest, libraries_dir, natives_dir):
download_full_libraries(version_manifest, libraries_dir, natives_dir)
# Asset
default_assets_dir = Path(self.app.work_dir, "assets")
if not check_assets_exist(version_manifest, default_assets_dir, launcher_root=self.app.work_dir):
callback, task_id = self.manifest_mgr.create_download_progress_callback(
"Downloading missing assets"
)
try:
download_assets(
version_manifest,
default_assets_dir,
no_hash_check=True,
progress_callback=callback,
launcher_root=self.app.work_dir,
)
except Exception as e:
self.widget_mgr.download_signal.failed.emit(task_id, str(e))
result.add_warning(
"Some assets were not downloaded. Although is not necessary for launching game."
" But it may cause some texture issue when you playing. (Such as missing textures)"
" Try relaunching to automatic redownload assets."
)
else:
self.widget_mgr.download_signal.finished.emit(task_id)
assets_dir = find_correct_assets_dir(asset_id, default_assets_dir, self.app.work_dir)
classpath = generate_classpath(
version_manifest,
libraries_dir,
core_file_path,
)
# Mappings
arg_maps = ArgumentMappings(
player_name="Player",
version_name=version_id,
game_directory=game_dir.as_posix(),
assets_root=assets_dir.as_posix(),
legacy_assets_root=assets_dir,
assets_index_name=asset_id,
auth_uuid="00000000000000000000000000000000",
auth_access_token="0",
user_type="legacy",
version_type=profile.version_type,
natives_directory=natives_dir.as_posix(),
launcher_name="TestLauncher",
launcher_version="0.1",
classpath=classpath,
classpath_separator=os.pathsep,
library_directory=libraries_dir.as_posix(),
)
target_args = arguments if arguments else old_args
if not target_args:
return result.fail(
"Unable to parse arguments because data are invalid."
)
# Use found java runtime (if available)
java_path = self.java_mgr.get_support_runtime_jvm_executable(
java_major_version,
no_error=True
)
if not java_path:
# Ask for a support java version. If not, use system environment java
request = SelectPathRequest(
title="Require a java executable path to launch game. (Major Version: {})".format(
java_major_version),
message=None,
filter_raw="Java executable (java.exe javaw.exe);;All files (*)"
)
self.widget_mgr.signal.ask_for_path.emit(request)
if not request.wait(120):
return result.fail(
"Timed out while waiting for Java executable selection."
)
java_path = request.result()
if java_path:
self.app.logger.debug(f"Use specified java executable path: {java_path}")
else:
self.app.logger.debug("Use system environment java.")
java_path = "java"
# Finally, generate launch command
cmd = generate_launch_command(
arg_maps=arg_maps,
main_class=main_class,
java_path=java_path,
full_args=target_args,
)
self.app.logger.debug(f"Launch command: {cmd}")
# run it
process = subprocess.Popen(
cmd,
cwd=game_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
)
self.launched_profiles.append(profile.profile_id)
result.process = process
return result
except Exception as e:
result.fail(
"Unable to launch. An unknown error occurred:\n{}".format(
"".join(traceback.format_exception(e))
)
)
return result
def is_profile_running(self, profile_id):
return profile_id in self.launched_profiles
class GameManager(QObject):
def __init__(self, app, parent=None):
super(GameManager, self).__init__(parent)
self.app = app
self.widget_mgr = app.widget_manager
self.manifest = ManifestManager(self.app)
self.manifest = ManifestManager(self.app)
self.java = JavaManager(self.app)
self.launch = LaunchManager(self.app, self.manifest, self.java)
+32 -3
View File
@@ -1,5 +1,7 @@
"""
IMPORTANT: Still under construction!
Profile Manager
A Minecraft profile manager that completely support official Minecraft Launcher.
"""
import copy
from dataclasses import dataclass
@@ -11,14 +13,16 @@ from datetime import datetime
from core_lib.common.exception import ProfileException, ProfileLoadException, ProfileSaveException
from core_lib.game.profile import read_profile_file, is_profiles_valid, \
get_profile_sample, create_or_save_profile_file, list_profiles as list_profiles_raw, convert_profile_data_to_object, \
is_profile_exists, create_profile as create_profile_raw, update_profile as update_profile_raw, is_profile_valid, \
is_profile_exists, create_profile as create_profile_raw, update_profile as update_profile_raw, \
get_current_profile_id, set_current_profile_id, remove_profile, duplicate_profile
from core_lib.game.version_info import LATEST_VERSION_ALIASES
from manager.widgets import AskForRequest
DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json")
@dataclass(frozen=True)
class ProfileSummary:
display_name: str
id: str
name: str
version_id: str
@@ -62,6 +66,8 @@ class UpdateProfileRequest:
@dataclass(frozen=True)
class LaunchProfile:
display_name: str
name: str
profile_id: str
version_id: str
version_type: str
@@ -235,6 +241,7 @@ class ProfileManager(QObject):
for profile_id in profiles:
profile = convert_profile_data_to_object(self.profiles, profile_id)
profile_summary = ProfileSummary(
display_name=self._get_profile_display_name(profile.name, profile.version_id),
icon=str(profile.icon),
id=profile_id,
last_used=profile.last_used,
@@ -265,6 +272,7 @@ class ProfileManager(QObject):
)
return ProfileDetail(
display_name=self._get_profile_display_name(profile.name, profile.version_id),
id=profile_id,
name=profile.name,
version_id=profile.version_id,
@@ -357,6 +365,22 @@ class ProfileManager(QObject):
)
return None
def _get_profile_display_name(self, existing_name: str, version_id: str) -> str:
if existing_name.strip():
return existing_name.strip()
return self._get_profile_alias_display_name(version_id)
@staticmethod
def _get_profile_alias_display_name(version_id: str) -> str:
alias = LATEST_VERSION_ALIASES.get(version_id)
if alias:
return alias.get("displayName", version_id)
return "Unnamed Profile"
def update_profile(self, profile_id: str, changes: UpdateProfileRequest) -> bool:
# Check profile exists
if not self.contains(profile_id, show_error=True):
@@ -566,6 +590,11 @@ class ProfileManager(QObject):
)
return LaunchProfile(
display_name=self._get_profile_display_name(
profile.name,
profile.version_id,
),
name=profile.name,
profile_id=profile.id,
version_id=profile.version_id,
version_type=profile.version_type,
@@ -671,4 +700,4 @@ class ProfileManager(QObject):
@property
def is_modified(self) -> bool:
return self._is_modified
return self._is_modified