Now launcher has a nice launch ui and it can launch MiNeCRaFt.
This commit is contained in:
+62
-7
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user