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
+62 -7
View File
@@ -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...")
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
#
# 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