diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index ca5a5bea9..4f8b9018a 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -175,6 +175,8 @@ set(LAUNCH_SOURCES launch/LogModel.h launch/TaskStepWrapper.cpp launch/TaskStepWrapper.h + logs/LogParser.cpp + logs/LogParser.h ) # Old update system diff --git a/launcher/MessageLevel.cpp b/launcher/MessageLevel.cpp index 116e70c4b..d0e8809ec 100644 --- a/launcher/MessageLevel.cpp +++ b/launcher/MessageLevel.cpp @@ -4,6 +4,8 @@ MessageLevel::Enum MessageLevel::getLevel(const QString& levelName) { if (levelName == "Launcher") return MessageLevel::Launcher; + else if (levelName == "Trace") + return MessageLevel::Trace; else if (levelName == "Debug") return MessageLevel::Debug; else if (levelName == "Info") diff --git a/launcher/MessageLevel.h b/launcher/MessageLevel.h index fd12583f2..321af9d92 100644 --- a/launcher/MessageLevel.h +++ b/launcher/MessageLevel.h @@ -12,6 +12,7 @@ enum Enum { StdOut, /**< Undetermined stderr messages */ StdErr, /**< Undetermined stdout messages */ Launcher, /**< Launcher Messages */ + Trace, /**< Trace Messages */ Debug, /**< Debug Messages */ Info, /**< Info Messages */ Message, /**< Standard Messages */ diff --git a/launcher/launch/LaunchTask.cpp b/launcher/launch/LaunchTask.cpp index 9ec746641..e1eaf8b1f 100644 --- a/launcher/launch/LaunchTask.cpp +++ b/launcher/launch/LaunchTask.cpp @@ -37,11 +37,14 @@ #include "launch/LaunchTask.h" #include +#include +#include #include #include #include #include #include +#include #include "MessageLevel.h" #include "tasks/Task.h" @@ -213,6 +216,52 @@ shared_qobject_ptr LaunchTask::getLogModel() return m_logModel; } +bool LaunchTask::parseXmlLogs(QString const& line, MessageLevel::Enum level) +{ + LogParser* parser; + switch (level) { + case MessageLevel::StdErr: + parser = &m_stderrParser; + break; + case MessageLevel::StdOut: + parser = &m_stdoutParser; + break; + default: + return false; + } + + parser->appendLine(line); + auto items = parser->parseAvailable(); + if (auto err = parser->getError(); err.has_value()) { + auto& model = *getLogModel(); + model.append(MessageLevel::Error, tr("[Log4j Parse Error] Failed to parse log4j log event: %1").arg(err.value().errMessage)); + return false; + } else { + if (items.has_value()) { + auto& model = *getLogModel(); + for (auto const& item : items.value()) { + if (std::holds_alternative(item)) { + auto entry = std::get(item); + auto msg = QString("[%1] [%2/%3] [%4]: %5") + .arg(entry.timestamp.toString("HH:mm:ss")) + .arg(entry.thread) + .arg(entry.levelText) + .arg(entry.logger) + .arg(entry.message); + msg = censorPrivateInfo(msg); + model.append(entry.level, msg); + } else if (std::holds_alternative(item)) { + auto msg = std::get(item).message; + level = m_instance->guessLevel(msg, level); + msg = censorPrivateInfo(msg); + model.append(level, msg); + } + } + } + } + return true; +} + void LaunchTask::onLogLines(const QStringList& lines, MessageLevel::Enum defaultLevel) { for (auto& line : lines) { @@ -222,6 +271,10 @@ void LaunchTask::onLogLines(const QStringList& lines, MessageLevel::Enum default void LaunchTask::onLogLine(QString line, MessageLevel::Enum level) { + if (parseXmlLogs(line, level)) { + return; + } + // if the launcher part set a log level, use it auto innerLevel = MessageLevel::fromLine(line); if (innerLevel != MessageLevel::Unknown) { @@ -229,7 +282,7 @@ void LaunchTask::onLogLine(QString line, MessageLevel::Enum level) } // If the level is still undetermined, guess level - if (level == MessageLevel::StdErr || level == MessageLevel::StdOut || level == MessageLevel::Unknown) { + if (level == MessageLevel::Unknown) { level = m_instance->guessLevel(line, level); } diff --git a/launcher/launch/LaunchTask.h b/launcher/launch/LaunchTask.h index 2e87ece95..5effab980 100644 --- a/launcher/launch/LaunchTask.h +++ b/launcher/launch/LaunchTask.h @@ -43,6 +43,7 @@ #include "LaunchStep.h" #include "LogModel.h" #include "MessageLevel.h" +#include "logs/LogParser.h" class LaunchTask : public Task { Q_OBJECT @@ -114,6 +115,9 @@ class LaunchTask : public Task { private: /*methods */ void finalizeSteps(bool successful, const QString& error); + protected: + bool parseXmlLogs(QString const& line, MessageLevel::Enum level); + protected: /* data */ MinecraftInstancePtr m_instance; shared_qobject_ptr m_logModel; @@ -122,4 +126,6 @@ class LaunchTask : public Task { int currentStep = -1; State state = NotStarted; qint64 m_pid = -1; + LogParser m_stdoutParser; + LogParser m_stderrParser; }; diff --git a/launcher/logs/LogParser.cpp b/launcher/logs/LogParser.cpp new file mode 100644 index 000000000..294036134 --- /dev/null +++ b/launcher/logs/LogParser.cpp @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "LogParser.h" + +void LogParser::appendLine(QAnyStringView data) +{ + if (!m_partialData.isEmpty()) { + m_buffer = QString(m_partialData); + m_buffer.append("\n"); + m_buffer.append(data.toString()); + m_partialData.clear(); + } else { + m_buffer.append(data.toString()); + } +} + +std::optional LogParser::getError() +{ + return m_error; +} + +MessageLevel::Enum LogParser::parseLogLevel(const QString& level) +{ + auto test = level.trimmed().toUpper(); + if (test == "TRACE") { + return MessageLevel::Trace; + } else if (test == "DEBUG") { + return MessageLevel::Debug; + } else if (test == "INFO") { + return MessageLevel::Info; + } else if (test == "WARN") { + return MessageLevel::Warning; + } else if (test == "ERROR") { + return MessageLevel::Error; + } else if (test == "FATAL") { + return MessageLevel::Fatal; + } else { + return MessageLevel::Unknown; + } +} + +std::optional LogParser::parseAttributes() +{ + LogParser::LogEntry entry{ + "", + MessageLevel::Info, + }; + auto attributes = m_parser.attributes(); + + for (const auto& attr : attributes) { + auto name = attr.name(); + auto value = attr.value(); + if (name == "logger") { + entry.logger = value.trimmed().toString(); + } else if (name == "timestamp") { + if (value.trimmed().isEmpty()) { + m_parser.raiseError("log4j:Event Missing required attribute: timestamp"); + return {}; + } + entry.timestamp = QDateTime::fromSecsSinceEpoch(value.trimmed().toLongLong()); + } else if (name == "level") { + entry.levelText = value.trimmed().toString(); + entry.level = parseLogLevel(entry.levelText); + } else if (name == "thread") { + entry.thread = value.trimmed().toString(); + } + } + if (entry.logger.isEmpty()) { + m_parser.raiseError("log4j:Event Missing required attribute: logger"); + return {}; + } + + return entry; +} + +void LogParser::setError() +{ + m_error = { + m_parser.errorString(), + m_parser.error(), + }; +} + +void LogParser::clearError() +{ + m_error = {}; // clear previous error +} + +bool isPotentialLog4JStart(QStringView buffer) +{ + static QString target = QStringLiteral(" LogParser::parseNext() +{ + clearError(); + + if (m_buffer.isEmpty()) { + return {}; + } + + if (m_buffer.trimmed().isEmpty()) { + m_buffer.clear(); + return {}; + } + + // check if we have a full xml log4j event + bool isCompleteLog4j = false; + m_parser.clear(); + m_parser.setNamespaceProcessing(false); + m_parser.addData(m_buffer); + if (m_parser.readNextStartElement()) { + if (m_parser.qualifiedName() == "log4j:Event") { + int depth = 1; + bool eod = false; + while (depth > 0 && !eod) { + auto tok = m_parser.readNext(); + switch (tok) { + case QXmlStreamReader::TokenType::StartElement: { + depth += 1; + } break; + case QXmlStreamReader::TokenType::EndElement: { + depth -= 1; + } break; + case QXmlStreamReader::TokenType::EndDocument: { + eod = true; // break outer while loop + } break; + default: { + // no op + } + } + if (m_parser.hasError()) { + break; + } + } + + isCompleteLog4j = depth == 0; + } + } + + if (isCompleteLog4j) { + return parseLog4J(); + } else { + if (isPotentialLog4JStart(m_buffer)) { + m_partialData = QString(m_buffer); + return LogParser::Partial{ QString(m_buffer) }; + } + + int start = 0; + auto bufView = QStringView(m_buffer); + while (start < bufView.length()) { + if (qsizetype pos = bufView.right(bufView.length() - start).indexOf('<'); pos != -1) { + auto slicestart = start + pos; + auto slice = bufView.right(bufView.length() - slicestart); + if (isPotentialLog4JStart(slice)) { + if (slicestart > 0) { + auto text = m_buffer.left(slicestart); + m_buffer = m_buffer.right(m_buffer.length() - slicestart); + if (!text.trimmed().isEmpty()) { + return LogParser::PlainText{ text }; + } + } + m_partialData = QString(m_buffer); + return LogParser::Partial{ QString(m_buffer) }; + } + start = slicestart + 1; + } else { + break; + } + } + + // no log4j found, all plain text + auto text = QString(m_buffer); + m_buffer.clear(); + if (text.trimmed().isEmpty()) { + return {}; + } else { + return LogParser::PlainText{ text }; + } + } +} + +std::optional> LogParser::parseAvailable() +{ + QList items; + bool doNext = true; + while (doNext) { + auto item_ = parseNext(); + if (m_error.has_value()) { + return {}; + } + if (item_.has_value()) { + auto item = item_.value(); + if (std::holds_alternative(item)) { + break; + } else { + items.push_back(item); + } + } else { + doNext = false; + } + } + return items; +} + +std::optional LogParser::parseLog4J() +{ + m_parser.clear(); + m_parser.setNamespaceProcessing(false); + m_parser.addData(m_buffer); + + m_parser.readNextStartElement(); + if (m_parser.qualifiedName() == "log4j:Event") { + auto entry_ = parseAttributes(); + if (!entry_.has_value()) { + setError(); + return {}; + } + auto entry = entry_.value(); + + bool foundMessage = false; + int depth = 1; + + while (!m_parser.atEnd()) { + auto tok = m_parser.readNext(); + switch (tok) { + case QXmlStreamReader::TokenType::StartElement: { + depth += 1; + if (m_parser.qualifiedName() == "log4j:Message") { + QString message; + bool messageComplete = false; + + while (!messageComplete) { + auto tok = m_parser.readNext(); + + switch (tok) { + case QXmlStreamReader::TokenType::Characters: { + message.append(m_parser.text()); + } break; + case QXmlStreamReader::TokenType::EndElement: { + if (m_parser.qualifiedName() == "log4j:Message") { + messageComplete = true; + } + } break; + case QXmlStreamReader::TokenType::EndDocument: { + return {}; // parse fail + } break; + default: { + // no op + } + } + + if (m_parser.hasError()) { + return {}; + } + } + + entry.message = message; + foundMessage = true; + depth -= 1; + } + break; + case QXmlStreamReader::TokenType::EndElement: { + depth -= 1; + if (depth == 0 && m_parser.qualifiedName() == "log4j:Event") { + if (foundMessage) { + auto consumed = m_parser.characterOffset(); + if (consumed > 0 && consumed <= m_buffer.length()) { + m_buffer = m_buffer.right(m_buffer.length() - consumed); + + if (!m_buffer.isEmpty() && m_buffer.trimmed().isEmpty()) { + // only whitespace, dump it + m_buffer.clear(); + } + } + clearError(); + return entry; + } + m_parser.raiseError("log4j:Event Missing required attribute: message"); + setError(); + return {}; + } + } break; + case QXmlStreamReader::TokenType::EndDocument: { + return {}; + } break; + default: { + // no op + } + } + } + + if (m_parser.hasError()) { + return {}; + } + } + } + + throw std::runtime_error("unreachable: already verified this was a complete log4j:Event"); +} diff --git a/launcher/logs/LogParser.h b/launcher/logs/LogParser.h new file mode 100644 index 000000000..462ea43cf --- /dev/null +++ b/launcher/logs/LogParser.h @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (C) 2025 Rachel Powers <508861+Ryex@users.noreply.github.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include "MessageLevel.h" + +class LogParser { + public: + struct LogEntry { + QString logger; + MessageLevel::Enum level; + QString levelText; + QDateTime timestamp; + QString thread; + QString message; + }; + struct Partial { + QString data; + }; + struct PlainText { + QString message; + }; + struct Error { + QString errMessage; + QXmlStreamReader::Error error; + }; + + using ParsedItem = std::variant; + + public: + LogParser() = default; + + void appendLine(QAnyStringView data); + std::optional parseNext(); + std::optional> parseAvailable(); + std::optional getError(); + + protected: + MessageLevel::Enum parseLogLevel(const QString& level); + std::optional parseAttributes(); + void setError(); + void clearError(); + + std::optional parseLog4J(); + + private: + QString m_buffer; + QString m_partialData; + QXmlStreamReader m_parser; + std::optional m_error; +};