Add save log support to process log dialog.
This commit is contained in:
+159
-7
@@ -2,6 +2,7 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Signal
|
from PySide6.QtCore import QObject, Signal
|
||||||
from PySide6.QtGui import QCloseEvent
|
from PySide6.QtGui import QCloseEvent
|
||||||
@@ -12,6 +13,8 @@ from PySide6.QtWidgets import (
|
|||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
|
QFileDialog,
|
||||||
|
QSizePolicy,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,7 +22,7 @@ from core_lib.qt import messagebox
|
|||||||
|
|
||||||
|
|
||||||
class _ProcessSignals(QObject):
|
class _ProcessSignals(QObject):
|
||||||
output = Signal(str)
|
output = Signal(str, str)
|
||||||
# Windows may return an unsigned 32-bit exit code (for example
|
# Windows may return an unsigned 32-bit exit code (for example
|
||||||
# 0xFFFFFFFF), which does not fit in Qt's signed Signal(int).
|
# 0xFFFFFFFF), which does not fit in Qt's signed Signal(int).
|
||||||
finished = Signal(object)
|
finished = Signal(object)
|
||||||
@@ -31,6 +34,9 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
A Simple client log view
|
A Simple client log view
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
MAX_LOG_LINES = 100_000
|
||||||
|
LOG_TRIM_LINES = 10_000
|
||||||
|
|
||||||
def __init__(self, app, process: subprocess.Popen, profile_id: str, title: str = "Game Log",
|
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:
|
parent: QWidget | None = None, finished_callback: Callable[[str, int], None]=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -39,6 +45,8 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
self.profile_id = profile_id
|
self.profile_id = profile_id
|
||||||
self._signals = _ProcessSignals(self)
|
self._signals = _ProcessSignals(self)
|
||||||
self._last_error_message: str | None = None
|
self._last_error_message: str | None = None
|
||||||
|
self.lines: list[tuple[str, str]] = [] # type, message
|
||||||
|
self._trimmed_line_count = 0
|
||||||
self._stderr_lines: deque[str] = deque(maxlen=500)
|
self._stderr_lines: deque[str] = deque(maxlen=500)
|
||||||
|
|
||||||
self.setWindowTitle(self.app.tr_text(title))
|
self.setWindowTitle(self.app.tr_text(title))
|
||||||
@@ -48,23 +56,100 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
layout = QVBoxLayout(container)
|
layout = QVBoxLayout(container)
|
||||||
controls = QHBoxLayout()
|
controls = QHBoxLayout()
|
||||||
|
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
controls.setContentsMargins(8, 8, 8, 0)
|
||||||
|
|
||||||
self.status_label = QLabel(self.app.tr_text("Running (PID {pid})", pid=process.pid))
|
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 = QPushButton(self.app.tr_text("Kill Process"))
|
||||||
|
self.save_log_button = QPushButton(self.app.tr_text("Save Log"))
|
||||||
self.kill_button.clicked.connect(self.kill_process)
|
self.kill_button.clicked.connect(self.kill_process)
|
||||||
|
self.save_log_button.clicked.connect(self.save_log)
|
||||||
|
|
||||||
self.log_output = QPlainTextEdit()
|
self.log_output = QPlainTextEdit()
|
||||||
self.log_output.setReadOnly(True)
|
self.log_output.setReadOnly(True)
|
||||||
self.log_output.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
self.log_output.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||||
self.log_output.setMaximumBlockCount(10000)
|
self.log_output.setMaximumBlockCount(self.MAX_LOG_LINES)
|
||||||
|
self.log_output.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
|
||||||
controls.addWidget(self.status_label)
|
controls.addWidget(self.status_label)
|
||||||
controls.addStretch()
|
controls.addStretch()
|
||||||
|
controls.addWidget(self.save_log_button)
|
||||||
controls.addWidget(self.kill_button)
|
controls.addWidget(self.kill_button)
|
||||||
layout.addLayout(controls)
|
layout.addLayout(controls)
|
||||||
layout.addWidget(self.log_output)
|
layout.addWidget(self.log_output)
|
||||||
self.setCentralWidget(container)
|
self.setCentralWidget(container)
|
||||||
|
|
||||||
self._signals.output.connect(self.log_output.appendPlainText)
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
|
QMainWindow {
|
||||||
|
spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPlainTextEdit {
|
||||||
|
background-color: #0f0f10;
|
||||||
|
color: "#e5e5e5";
|
||||||
|
font-family: Consolas;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background-color: #0f0f10;
|
||||||
|
width: 5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-line:vertical,
|
||||||
|
QScrollBar::sub-line:vertical {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-page:vertical,
|
||||||
|
QScrollBar::sub-page:vertical {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Horizontal scrollbar */
|
||||||
|
|
||||||
|
QScrollBar:horizontal {
|
||||||
|
background-color: #0f0f10;
|
||||||
|
height: 5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:horizontal {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
min-width: 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::handle:horizontal:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-line:horizontal,
|
||||||
|
QScrollBar::sub-line:horizontal {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QScrollBar::add-page:horizontal,
|
||||||
|
QScrollBar::sub-page:horizontal {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
self._signals.output.connect(self._append_output)
|
||||||
self._signals.finished.connect(self._process_finished)
|
self._signals.finished.connect(self._process_finished)
|
||||||
self._signals.read_failed.connect(self._read_failed)
|
self._signals.read_failed.connect(self._read_failed)
|
||||||
|
|
||||||
@@ -90,6 +175,35 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
self._waiter.start()
|
self._waiter.start()
|
||||||
|
|
||||||
def append_message(self, message: str) -> None:
|
def append_message(self, message: str) -> None:
|
||||||
|
self._append_output("launcher", message)
|
||||||
|
|
||||||
|
def _append_output(self, line_type: str, message: str) -> None:
|
||||||
|
"""Store and display one line, trimming old output in bounded chunks."""
|
||||||
|
if len(self.lines) >= self.MAX_LOG_LINES:
|
||||||
|
trim_count = min(self.LOG_TRIM_LINES, len(self.lines))
|
||||||
|
|
||||||
|
# Replace the previous truncation marker; it is not process output.
|
||||||
|
if self.lines and self.lines[0][0] == "truncated":
|
||||||
|
del self.lines[0]
|
||||||
|
trim_count = min(trim_count - 1, len(self.lines))
|
||||||
|
|
||||||
|
del self.lines[:trim_count]
|
||||||
|
self._trimmed_line_count += trim_count
|
||||||
|
|
||||||
|
trimmed_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
marker = "[launcher] " + self.app.tr_text(
|
||||||
|
"Log truncated at {time}; {count} older lines removed.",
|
||||||
|
time=trimmed_at,
|
||||||
|
count=self._trimmed_line_count,
|
||||||
|
)
|
||||||
|
self.lines.insert(0, ("truncated", marker))
|
||||||
|
|
||||||
|
# Keep the widget and the saved-log buffer identical after trimming.
|
||||||
|
self.log_output.setPlainText(
|
||||||
|
"\n".join(stored_message for _, stored_message in self.lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lines.append((line_type, message))
|
||||||
self.log_output.appendPlainText(message)
|
self.log_output.appendPlainText(message)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -100,7 +214,7 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
if self.process.stdout is not None:
|
if self.process.stdout is not None:
|
||||||
for line in self.process.stdout:
|
for line in self.process.stdout:
|
||||||
self._signals.output.emit(line.rstrip("\r\n"))
|
self._signals.output.emit("normal", line.rstrip("\r\n"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._signals.read_failed.emit(str(exc))
|
self._signals.read_failed.emit(str(exc))
|
||||||
|
|
||||||
@@ -108,10 +222,11 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
try:
|
try:
|
||||||
if self.process.stderr is not None:
|
if self.process.stderr is not None:
|
||||||
for line in self.process.stderr:
|
for line in self.process.stderr:
|
||||||
message = line.rstrip("\r\n").strip()
|
message = line.rstrip("\r\n")
|
||||||
if message:
|
if message.strip():
|
||||||
self._last_error_message = message
|
self._last_error_message = message.strip()
|
||||||
self._stderr_lines.append(message)
|
self._stderr_lines.append(message)
|
||||||
|
self._signals.output.emit("error", message)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._signals.read_failed.emit(str(exc))
|
self._signals.read_failed.emit(str(exc))
|
||||||
|
|
||||||
@@ -145,6 +260,43 @@ class ProcessLogWindow(QMainWindow):
|
|||||||
self.status_label.setText(self.app.tr_text("Unable to kill process"))
|
self.status_label.setText(self.app.tr_text("Unable to kill process"))
|
||||||
self.append_message(f"[launcher] Unable to kill process: {exc}")
|
self.append_message(f"[launcher] Unable to kill process: {exc}")
|
||||||
|
|
||||||
|
def save_log(self) -> None:
|
||||||
|
file_name, _ = QFileDialog.getSaveFileName(
|
||||||
|
None,
|
||||||
|
self.app.tr_text("Select where to save the log file"),
|
||||||
|
"game-output.txt",
|
||||||
|
self.app.tr_text("Text Files (*.txt);;All Files (*)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# cancel
|
||||||
|
if not file_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.lines:
|
||||||
|
messagebox.warning(
|
||||||
|
self,
|
||||||
|
self.app.tr_text("Warning"),
|
||||||
|
self.app.tr_text("Log output is empty. No need to save it."),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_name, "w", encoding="utf-8", newline="\n") as log_file:
|
||||||
|
for _, message in self.lines:
|
||||||
|
log_file.write(message + "\n")
|
||||||
|
|
||||||
|
messagebox.info(
|
||||||
|
self,
|
||||||
|
self.app.tr_text("Log Viewer"),
|
||||||
|
self.app.tr_text("Log output saved to {file_name}", file_name=file_name),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.error(
|
||||||
|
self,
|
||||||
|
self.app.tr_text("Error"),
|
||||||
|
self.app.tr_text("Unable to save log file: {error}", error=exc),
|
||||||
|
)
|
||||||
|
|
||||||
def _process_finished(self, exit_code: int) -> None:
|
def _process_finished(self, exit_code: int) -> None:
|
||||||
self.kill_button.setEnabled(False)
|
self.kill_button.setEnabled(False)
|
||||||
self.status_label.setText(self.app.tr_text(
|
self.status_label.setText(self.app.tr_text(
|
||||||
|
|||||||
@@ -271,5 +271,13 @@
|
|||||||
"Failed to fetch version manifest: {error}": "Failed to fetch version manifest: {error}",
|
"Failed to fetch version manifest: {error}": "Failed to fetch version manifest: {error}",
|
||||||
"An unexpected error occurred while fetching version manifest: {error}": "An unexpected error occurred while fetching version manifest: {error}",
|
"An unexpected error occurred while fetching version manifest: {error}": "An unexpected error occurred while fetching version manifest: {error}",
|
||||||
"Unable to launch profile {profile}: {error}": "Unable to launch profile {profile}: {error}",
|
"Unable to launch profile {profile}: {error}": "Unable to launch profile {profile}: {error}",
|
||||||
"To make all page apply new language. Is recommend to restart the launcher after change it.": "To make all page apply new language. Is recommend to restart the launcher after change it."
|
"To make all page apply new language. Is recommend to restart the launcher after change it.": "To make all page apply new language. Is recommend to restart the launcher after change it.",
|
||||||
|
"Save Log": "Save Log",
|
||||||
|
"Select where to save the log file": "Select where to save the log file",
|
||||||
|
"Text Files (*.txt);;All Files (*)": "Text Files (*.txt);;All Files (*)",
|
||||||
|
"Log output is empty. No need to save it.": "Log output is empty. No need to save it.",
|
||||||
|
"Log Viewer": "Log Viewer",
|
||||||
|
"Log output saved to {file_name}": "Log output saved to {file_name}",
|
||||||
|
"Unable to save log file: {error}": "Unable to save log file: {error}",
|
||||||
|
"Log truncated at {time}; {count} older lines removed.": "Log truncated at {time}; {count} older lines removed."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,5 +271,13 @@
|
|||||||
"Failed to fetch version manifest: {error}": "無法取得版本資訊:{error}",
|
"Failed to fetch version manifest: {error}": "無法取得版本資訊:{error}",
|
||||||
"An unexpected error occurred while fetching version manifest: {error}": "取得版本資訊時發生非預期錯誤:{error}",
|
"An unexpected error occurred while fetching version manifest: {error}": "取得版本資訊時發生非預期錯誤:{error}",
|
||||||
"Unable to launch profile {profile}: {error}": "無法啟動設定檔 {profile}:{error}",
|
"Unable to launch profile {profile}: {error}": "無法啟動設定檔 {profile}:{error}",
|
||||||
"To make all page apply new language. Is recommend to restart the launcher after change it.": "為了讓所有頁面套用語言更新,建議在修改後重新啟動啟動器。"
|
"To make all page apply new language. Is recommend to restart the launcher after change it.": "為了讓所有頁面套用語言更新,建議在修改後重新啟動啟動器。",
|
||||||
|
"Save Log": "儲存紀錄",
|
||||||
|
"Select where to save the log file": "選擇紀錄檔的儲存位置",
|
||||||
|
"Text Files (*.txt);;All Files (*)": "文字檔案 (*.txt);;所有檔案 (*)",
|
||||||
|
"Log output is empty. No need to save it.": "紀錄內容為空,無需儲存。",
|
||||||
|
"Log Viewer": "紀錄檢視器",
|
||||||
|
"Log output saved to {file_name}": "紀錄已儲存至 {file_name}",
|
||||||
|
"Unable to save log file: {error}": "無法儲存紀錄檔:{error}",
|
||||||
|
"Log truncated at {time}; {count} older lines removed.": "紀錄已於 {time} 裁切;已移除較舊的 {count} 行。"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user