From eff990f7e4d39470769f73352a6783fb445204a7 Mon Sep 17 00:00:00 2001 From: Wei Hong Date: Sat, 1 Aug 2026 02:34:27 +0800 Subject: [PATCH] Now launcher has a nice launch ui and it can launch MiNeCRaFt. --- core_lib/game/version_info.py | 21 +- core_lib/qt/messagebox.py | 32 +++ manager/game.py | 387 +++++++++++++++++++++++++++++++++- manager/profile.py | 35 ++- pages/launch_profile.py | 69 +++++- pages/process_log.py | 185 ++++++++++++++++ pages/test.py | 90 -------- 7 files changed, 714 insertions(+), 105 deletions(-) create mode 100644 pages/process_log.py diff --git a/core_lib/game/version_info.py b/core_lib/game/version_info.py index 5a552df..a51f20b 100644 --- a/core_lib/game/version_info.py +++ b/core_lib/game/version_info.py @@ -306,11 +306,16 @@ def fetch_specific_version_manifest(version_manifest: dict, spec_version: str) - # mapping get_specific_version_manifest = fetch_specific_version_manifest -def find_useful_part_in_specific_version_manifest(spec_version_manifest: dict)\ +def default_java_version(): + return {"component": "jre-legacy", "majorVersion": 8} + +def find_useful_part_in_specific_version_manifest(spec_version_manifest: dict, + failback_java_version: dict | None=None) \ -> tuple[dict | None, dict | None, dict | None, dict | None, list | None, str | None, str | None]: """ Return useful part from version manifest. :param spec_version_manifest: Specific version's data + :param failback_java_version: Failback java version data (default is Java 8) :return: arguments: dict (contains jvm and game args, for newer version of Minecraft) asset_index: dict @@ -323,11 +328,15 @@ def find_useful_part_in_specific_version_manifest(spec_version_manifest: dict)\ arguments = spec_version_manifest.get("arguments", {}) asset_index = spec_version_manifest.get("assetIndex", {}) downloads = spec_version_manifest.get("downloads", {}) - java_version = spec_version_manifest.get("javaVersion", {}) libraries = spec_version_manifest.get("libraries", []) main_class = spec_version_manifest.get("mainClass", None) legacy_game_args = spec_version_manifest.get("minecraftArguments", None) + if failback_java_version is None: + failback_java_version = default_java_version() + + java_version = spec_version_manifest.get("javaVersion", failback_java_version) + if not arguments and not legacy_game_args: raise VersionDataKeyNotFoundException( "No version manifest available in version manifest. (\"arguments\" or \"minecraftArguments\")" @@ -500,4 +509,10 @@ def download_core_file(manifest: dict, destination: Path, is_server=False, raise "Core file url is None. Unable to download it." ) - return file \ No newline at end of file + return file + + +LATEST_VERSION_ALIASES = { + "latest-release": {"displayName": "Latest Release"}, + "latest-snapshot": {"displayName": "Latest Snapshot"}, +} diff --git a/core_lib/qt/messagebox.py b/core_lib/qt/messagebox.py index 4996262..d885896 100644 --- a/core_lib/qt/messagebox.py +++ b/core_lib/qt/messagebox.py @@ -46,6 +46,38 @@ def warning(parent: QWidget, title: str, message: str, ).exec() +def error_with_plain_text( + parent: QWidget, + title: str, + message: str, + content: str, +) -> None: + dialog = QDialog(parent) + dialog.setWindowTitle(title) + dialog.resize(720, 420) + + layout = QVBoxLayout(dialog) + + label = QLabel(message) + 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.Ok) + buttons.accepted.connect(dialog.accept) + + layout.addWidget(label) + layout.addWidget(details_box, 1) + layout.addWidget(buttons) + dialog.exec() + + def ask_yes_no_with_plain_text(parent: QWidget, title: str, message: str, content: str): dialog = QDialog(parent) dialog.setWindowTitle(title) diff --git a/manager/game.py b/manager/game.py index 02b898f..8629f53 100644 --- a/manager/game.py +++ b/manager/game.py @@ -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) \ No newline at end of file + self.manifest = ManifestManager(self.app) + self.java = JavaManager(self.app) + self.launch = LaunchManager(self.app, self.manifest, self.java) diff --git a/manager/profile.py b/manager/profile.py index ec08602..1545065 100644 --- a/manager/profile.py +++ b/manager/profile.py @@ -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 \ No newline at end of file + return self._is_modified diff --git a/pages/launch_profile.py b/pages/launch_profile.py index 638d019..778b396 100644 --- a/pages/launch_profile.py +++ b/pages/launch_profile.py @@ -1,11 +1,15 @@ from pathlib import Path -from PySide6.QtCore import Qt, Signal, QPointF -from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter, QColor -from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QGridLayout, \ - QComboBox, QFrame, QSizePolicy, QApplication, QGraphicsDropShadowEffect +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter +from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QFrame, + QSizePolicy, QApplication) +from manager.game import LaunchManager +from core_lib.game.version_info import LATEST_VERSION_ALIASES from manager.profile import ProfileSummary +from manager.widgets import AskForRequest, WidgetManager +from pages.process_log import ProcessLogWindow class ProfileButton(QFrame): @@ -61,7 +65,7 @@ class ProfileButton(QFrame): :return: """ self.setProperty("profile_id", profile.id) - self.name_label.setText(profile.name) + self.name_label.setText(profile.display_name) self.version_label.setText(profile.version_id) icon_path = profile.icon @@ -103,7 +107,9 @@ class ProfileListItem(QPushButton): text_layout = QVBoxLayout() text_layout.setSpacing(1) - name_label = QLabel(profile.name) + + name_label = QLabel(profile.display_name) + name_label.setObjectName("itemName") version_label = QLabel(profile.version_id) version_label.setObjectName("itemVersion") @@ -180,7 +186,11 @@ class LaunchProfilePage(QWidget): def __init__(self, app, parent=None): super(LaunchProfilePage, self).__init__(parent) self.app = app + self.widget_mgr: WidgetManager = app.widget_manager self.profile_manager = app.profile_manager + self.game_manager = app.game_manager + self.launch_manager: LaunchManager = self.game_manager.launch + self.process_log_windows: list[ProcessLogWindow] = [] # Layout self.layout = QVBoxLayout(self) @@ -354,4 +364,49 @@ class LaunchProfilePage(QWidget): def launch_profile(self) -> None: profile_id = self.profile_button.property("profile_id") - self.status_label.setText(f"Preparing to launch...") \ No newline at end of file + self.status_label.setText("Preparing to launch...") + launch_profile = self.app.profile_manager.get_launch_profile(profile_id) + + if launch_profile is None: + self.status_label.setText("Unable to load the selected profile.") + return + + if self.launch_manager.is_profile_running(launch_profile.profile_id): + request = AskForRequest( + "Launch Request", + "Target profile {} is running. Are you sure you want to launch it again?".format( + launch_profile.display_name + ), + ) + self.widget_mgr.signal.askyesno.emit(request) + + if not request.wait(60): + self.widget_mgr.signal.show_error_message.emit( + "Time out waiting for request for launch." + ) + return + + if not request.result() is True: + return + + result = self.launch_manager.launch(launch_profile) + + if result.success: + self.status_label.setText("Launched!") + log_window = ProcessLogWindow( + result.process, + launch_profile.profile_id, + title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})", + parent=None, + ) + for warning in result.warnings: + log_window.append_message(f"[launcher warning] {warning}") + self.process_log_windows.append(log_window) + log_window.show() + else: + self.widget_mgr.signal.show_error_message.emit( + "Unable to launch profile {}: {}".format( + launch_profile.display_name, + result.error_message, + ) + ) diff --git a/pages/process_log.py b/pages/process_log.py new file mode 100644 index 0000000..e6dd595 --- /dev/null +++ b/pages/process_log.py @@ -0,0 +1,185 @@ +import subprocess +import threading +from collections import deque +from collections.abc import Callable + +from PySide6.QtCore import QObject, Signal +from PySide6.QtGui import QCloseEvent +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QMainWindow, + QPlainTextEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from core_lib.qt import messagebox + + +class _ProcessSignals(QObject): + output = Signal(str) + # Windows may return an unsigned 32-bit exit code (for example + # 0xFFFFFFFF), which does not fit in Qt's signed Signal(int). + finished = Signal(object) + read_failed = Signal(str) + + +class ProcessLogWindow(QMainWindow): + """ + A Simple client log view + """ + + def __init__(self, process: subprocess.Popen, profile_id: str, title: str = "Game Log", + parent: QWidget | None = None, finished_callback: Callable[[str, int], None]=None) -> None: + super().__init__(parent) + self.process = process + self.profile_id = profile_id + self._signals = _ProcessSignals(self) + self._last_error_message: str | None = None + self._stderr_lines: deque[str] = deque(maxlen=500) + + self.setWindowTitle(title) + self.resize(700, 720) + + container = QWidget(self) + layout = QVBoxLayout(container) + controls = QHBoxLayout() + + self.status_label = QLabel(f"Running (PID {process.pid})") + self.kill_button = QPushButton("Kill Process") + self.kill_button.clicked.connect(self.kill_process) + + self.log_output = QPlainTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.log_output.setMaximumBlockCount(10000) + + controls.addWidget(self.status_label) + controls.addStretch() + controls.addWidget(self.kill_button) + layout.addLayout(controls) + layout.addWidget(self.log_output) + self.setCentralWidget(container) + + self._signals.output.connect(self.log_output.appendPlainText) + self._signals.finished.connect(self._process_finished) + self._signals.read_failed.connect(self._read_failed) + + self.finished_callback = finished_callback + + self._stdout_reader = threading.Thread( + target=self._read_stdout, + name=f"process-stdout-{process.pid}", + daemon=True, + ) + self._stderr_reader = threading.Thread( + target=self._read_stderr, + name=f"process-stderr-{process.pid}", + daemon=True, + ) + self._waiter = threading.Thread( + target=self._wait_for_process, + name=f"process-wait-{process.pid}", + daemon=True, + ) + self._stdout_reader.start() + self._stderr_reader.start() + self._waiter.start() + + def append_message(self, message: str) -> None: + self.log_output.appendPlainText(message) + + @property + def last_error_message(self) -> str | None: + return self._last_error_message + + def _read_stdout(self) -> None: + try: + if self.process.stdout is not None: + for line in self.process.stdout: + self._signals.output.emit(line.rstrip("\r\n")) + except Exception as exc: + self._signals.read_failed.emit(str(exc)) + + def _read_stderr(self) -> None: + try: + if self.process.stderr is not None: + for line in self.process.stderr: + message = line.rstrip("\r\n").strip() + if message: + self._last_error_message = message + self._stderr_lines.append(message) + except Exception as exc: + self._signals.read_failed.emit(str(exc)) + + def _wait_for_process(self) -> None: + exit_code = self._normalize_exit_code(self.process.wait()) + self._stdout_reader.join() + self._stderr_reader.join() + self._signals.finished.emit(exit_code) + + @staticmethod + def _normalize_exit_code(exit_code: int) -> int: + # Avoid some system (WINDOWS) give exit code that overflow in 32bit integer + if exit_code > 0x7FFFFFFF: + return exit_code - 0x100000000 + return exit_code + + def kill_process(self) -> None: + if self.process.poll() is not None: + self._process_finished( + self._normalize_exit_code(self.process.returncode or 0) + ) + return + + self.kill_button.setEnabled(False) + self.status_label.setText("Killing process...") + + try: + self.process.kill() + except Exception as exc: + self.kill_button.setEnabled(True) + self.status_label.setText("Unable to kill process") + self.append_message(f"[launcher] Unable to kill process: {exc}") + + def _process_finished(self, exit_code: int) -> None: + self.kill_button.setEnabled(False) + self.status_label.setText(f"Process exited with code {exit_code}") + self.append_message(f"[launcher] Process exited with code {exit_code}.") + + if exit_code != 0 and self._last_error_message: + self.append_message( + f"[launcher] Last error: {self._last_error_message}" + ) + + if exit_code != 0: + error_content = "\n".join(self._stderr_lines) + if not error_content: + error_content = ( + "The client did not write an error message to stderr.\n" + f"Process exit code: {exit_code}" + ) + + messagebox.error_with_plain_text( + self, + title="Minecraft Client Error", + message=f"Minecraft exited with code {exit_code}.", + content=error_content, + ) + + if callable(self.finished_callback): + self.finished_callback(self.profile_id, exit_code) + + def _read_failed(self, message: str) -> None: + self.append_message(f"[launcher] Unable to read process output: {message}") + if self.process.poll() is not None: + self._process_finished( + self._normalize_exit_code(self.process.returncode or 0) + ) + + def closeEvent(self, event: QCloseEvent) -> None: + # Closing the log window must not stop the game. The process remains + # available through this window instance and can be shown again. + event.accept() diff --git a/pages/test.py b/pages/test.py index da0998f..335b60e 100644 --- a/pages/test.py +++ b/pages/test.py @@ -906,93 +906,3 @@ class TestPage(QWidget): return False, pending - # - # JVM Management - # - @property - def jvm_settings_path(self) -> Path: - return Path(self.app.work_dir) / "jvms.json" - - def _find_and_save_jvm_search_result(self): - installations, errors = find_java_installations() - if not installations: - self.widget_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 -