Files
Launcher/pages/process_log.py
T

191 lines
6.6 KiB
Python
Raw Normal View History

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
"""
2026-08-04 17:39:38 +08:00
def __init__(self, app, 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)
2026-08-04 17:39:38 +08:00
self.app = app
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)
2026-08-04 17:39:38 +08:00
self.setWindowTitle(self.app.tr_text(title))
self.resize(700, 720)
container = QWidget(self)
layout = QVBoxLayout(container)
controls = QHBoxLayout()
2026-08-04 17:39:38 +08:00
self.status_label = QLabel(self.app.tr_text("Running (PID {pid})", pid=process.pid))
self.kill_button = QPushButton(self.app.tr_text("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)
2026-08-04 17:39:38 +08:00
self.status_label.setText(self.app.tr_text("Killing process..."))
try:
self.process.kill()
except Exception as exc:
self.kill_button.setEnabled(True)
2026-08-04 17:39:38 +08:00
self.status_label.setText(self.app.tr_text("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)
2026-08-04 17:39:38 +08:00
self.status_label.setText(self.app.tr_text(
"Process exited with code {exit_code}", exit_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,
2026-08-04 17:39:38 +08:00
title=self.app.tr_text("Minecraft Client Error"),
message=self.app.tr_text(
"Minecraft exited with code {exit_code}.", exit_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()