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
+17 -2
View File
@@ -306,11 +306,16 @@ def fetch_specific_version_manifest(version_manifest: dict, spec_version: str) -
# mapping # mapping
get_specific_version_manifest = fetch_specific_version_manifest 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]: -> tuple[dict | None, dict | None, dict | None, dict | None, list | None, str | None, str | None]:
""" """
Return useful part from version manifest. Return useful part from version manifest.
:param spec_version_manifest: Specific version's data :param spec_version_manifest: Specific version's data
:param failback_java_version: Failback java version data (default is Java 8)
:return: :return:
arguments: dict (contains jvm and game args, for newer version of Minecraft) arguments: dict (contains jvm and game args, for newer version of Minecraft)
asset_index: dict 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", {}) arguments = spec_version_manifest.get("arguments", {})
asset_index = spec_version_manifest.get("assetIndex", {}) asset_index = spec_version_manifest.get("assetIndex", {})
downloads = spec_version_manifest.get("downloads", {}) downloads = spec_version_manifest.get("downloads", {})
java_version = spec_version_manifest.get("javaVersion", {})
libraries = spec_version_manifest.get("libraries", []) libraries = spec_version_manifest.get("libraries", [])
main_class = spec_version_manifest.get("mainClass", None) main_class = spec_version_manifest.get("mainClass", None)
legacy_game_args = spec_version_manifest.get("minecraftArguments", 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: if not arguments and not legacy_game_args:
raise VersionDataKeyNotFoundException( raise VersionDataKeyNotFoundException(
"No version manifest available in version manifest. (\"arguments\" or \"minecraftArguments\")" "No version manifest available in version manifest. (\"arguments\" or \"minecraftArguments\")"
@@ -501,3 +510,9 @@ def download_core_file(manifest: dict, destination: Path, is_server=False, raise
) )
return file return file
LATEST_VERSION_ALIASES = {
"latest-release": {"displayName": "Latest Release"},
"latest-snapshot": {"displayName": "Latest Snapshot"},
}
+32
View File
@@ -46,6 +46,38 @@ def warning(parent: QWidget, title: str, message: str,
).exec() ).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): def ask_yes_no_with_plain_text(parent: QWidget, title: str, message: str, content: str):
dialog = QDialog(parent) dialog = QDialog(parent)
dialog.setWindowTitle(title) dialog.setWindowTitle(title)
+384 -1
View File
@@ -1,7 +1,10 @@
import json import json
import os
import subprocess
import time import time
import traceback import traceback
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from PySide6.QtCore import QObject 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.common import FileObject
from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \ from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \
NoSpecifiedVersionKeyException 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, \ 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: class ManifestManager:
@@ -206,6 +217,376 @@ class ManifestManager:
return callback, task_id 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): class GameManager(QObject):
def __init__(self, app, parent=None): def __init__(self, app, parent=None):
@@ -213,3 +594,5 @@ class GameManager(QObject):
self.app = app self.app = app
self.widget_mgr = app.widget_manager 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)
+31 -2
View File
@@ -1,5 +1,7 @@
""" """
IMPORTANT: Still under construction! Profile Manager
A Minecraft profile manager that completely support official Minecraft Launcher.
""" """
import copy import copy
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,14 +13,16 @@ from datetime import datetime
from core_lib.common.exception import ProfileException, ProfileLoadException, ProfileSaveException from core_lib.common.exception import ProfileException, ProfileLoadException, ProfileSaveException
from core_lib.game.profile import read_profile_file, is_profiles_valid, \ 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, \ 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 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 from manager.widgets import AskForRequest
DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json") DEFAuLT_PROFILE_RELATIVE_PATH = Path("launcher_profiles.json")
@dataclass(frozen=True) @dataclass(frozen=True)
class ProfileSummary: class ProfileSummary:
display_name: str
id: str id: str
name: str name: str
version_id: str version_id: str
@@ -62,6 +66,8 @@ class UpdateProfileRequest:
@dataclass(frozen=True) @dataclass(frozen=True)
class LaunchProfile: class LaunchProfile:
display_name: str
name: str
profile_id: str profile_id: str
version_id: str version_id: str
version_type: str version_type: str
@@ -235,6 +241,7 @@ class ProfileManager(QObject):
for profile_id in profiles: for profile_id in profiles:
profile = convert_profile_data_to_object(self.profiles, profile_id) profile = convert_profile_data_to_object(self.profiles, profile_id)
profile_summary = ProfileSummary( profile_summary = ProfileSummary(
display_name=self._get_profile_display_name(profile.name, profile.version_id),
icon=str(profile.icon), icon=str(profile.icon),
id=profile_id, id=profile_id,
last_used=profile.last_used, last_used=profile.last_used,
@@ -265,6 +272,7 @@ class ProfileManager(QObject):
) )
return ProfileDetail( return ProfileDetail(
display_name=self._get_profile_display_name(profile.name, profile.version_id),
id=profile_id, id=profile_id,
name=profile.name, name=profile.name,
version_id=profile.version_id, version_id=profile.version_id,
@@ -357,6 +365,22 @@ class ProfileManager(QObject):
) )
return None 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: def update_profile(self, profile_id: str, changes: UpdateProfileRequest) -> bool:
# Check profile exists # Check profile exists
if not self.contains(profile_id, show_error=True): if not self.contains(profile_id, show_error=True):
@@ -566,6 +590,11 @@ class ProfileManager(QObject):
) )
return LaunchProfile( return LaunchProfile(
display_name=self._get_profile_display_name(
profile.name,
profile.version_id,
),
name=profile.name,
profile_id=profile.id, profile_id=profile.id,
version_id=profile.version_id, version_id=profile.version_id,
version_type=profile.version_type, version_type=profile.version_type,
+62 -7
View File
@@ -1,11 +1,15 @@
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, Signal, QPointF from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter, QColor from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QGridLayout, \ from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QFrame,
QComboBox, QFrame, QSizePolicy, QApplication, QGraphicsDropShadowEffect QSizePolicy, QApplication)
from manager.game import LaunchManager
from core_lib.game.version_info import LATEST_VERSION_ALIASES
from manager.profile import ProfileSummary from manager.profile import ProfileSummary
from manager.widgets import AskForRequest, WidgetManager
from pages.process_log import ProcessLogWindow
class ProfileButton(QFrame): class ProfileButton(QFrame):
@@ -61,7 +65,7 @@ class ProfileButton(QFrame):
:return: :return:
""" """
self.setProperty("profile_id", profile.id) 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) self.version_label.setText(profile.version_id)
icon_path = profile.icon icon_path = profile.icon
@@ -103,7 +107,9 @@ class ProfileListItem(QPushButton):
text_layout = QVBoxLayout() text_layout = QVBoxLayout()
text_layout.setSpacing(1) text_layout.setSpacing(1)
name_label = QLabel(profile.name)
name_label = QLabel(profile.display_name)
name_label.setObjectName("itemName") name_label.setObjectName("itemName")
version_label = QLabel(profile.version_id) version_label = QLabel(profile.version_id)
version_label.setObjectName("itemVersion") version_label.setObjectName("itemVersion")
@@ -180,7 +186,11 @@ class LaunchProfilePage(QWidget):
def __init__(self, app, parent=None): def __init__(self, app, parent=None):
super(LaunchProfilePage, self).__init__(parent) super(LaunchProfilePage, self).__init__(parent)
self.app = app self.app = app
self.widget_mgr: WidgetManager = app.widget_manager
self.profile_manager = app.profile_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 # Layout
self.layout = QVBoxLayout(self) self.layout = QVBoxLayout(self)
@@ -354,4 +364,49 @@ class LaunchProfilePage(QWidget):
def launch_profile(self) -> None: def launch_profile(self) -> None:
profile_id = self.profile_button.property("profile_id") profile_id = self.profile_button.property("profile_id")
self.status_label.setText(f"Preparing to launch...") 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,
)
)
+185
View File
@@ -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()
-90
View File
@@ -906,93 +906,3 @@ class TestPage(QWidget):
return False, pending return False, pending
#
# JVM Management
#
@property
def jvm_settings_path(self) -> Path:
return Path(self.app.work_dir) / "jvms.json"
def _find_and_save_jvm_search_result(self):
installations, errors = find_java_installations()
if not installations:
self.widget_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