diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 56fc4d1ad..0e4204cfb 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -918,7 +918,13 @@ SET(LAUNCHER_SOURCES ui/pages/instance/ServersPage.h ui/pages/instance/WorldListPage.cpp ui/pages/instance/WorldListPage.h - + ui/pages/instance/McClient.cpp + ui/pages/instance/McClient.h + ui/pages/instance/McResolver.cpp + ui/pages/instance/McResolver.h + ui/pages/instance/ServerPingTask.cpp + ui/pages/instance/ServerPingTask.h + # GUI - global settings pages ui/pages/global/AccountListPage.cpp ui/pages/global/AccountListPage.h diff --git a/launcher/tasks/ConcurrentTask.h b/launcher/tasks/ConcurrentTask.h index d988623b9..cc6256cf8 100644 --- a/launcher/tasks/ConcurrentTask.h +++ b/launcher/tasks/ConcurrentTask.h @@ -43,6 +43,10 @@ #include "tasks/Task.h" +/*! + * Runs a list of tasks concurrently (according to `max_concurrent` parameter). + * Behaviour is the same as regular Task (e.g. starts using start()) + */ class ConcurrentTask : public Task { Q_OBJECT public: @@ -59,6 +63,7 @@ class ConcurrentTask : public Task { inline auto isMultiStep() const -> bool override { return totalSize() > 1; } auto getStepProgress() const -> TaskStepProgressList override; + //! Adds a task to execute in this ConcurrentTask void addTask(Task::Ptr task); public slots: diff --git a/launcher/tasks/Task.h b/launcher/tasks/Task.h index e712700a1..503d6a6b6 100644 --- a/launcher/tasks/Task.h +++ b/launcher/tasks/Task.h @@ -79,6 +79,13 @@ Q_DECLARE_METATYPE(TaskStepProgress) using TaskStepProgressList = QList>; + +/*! + * Represents a task that has to be done. + * To create a task, you need to subclass this class, implement the executeTask() method and call + * emitSucceeded() or emitFailed() when the task is done. + * the caller needs to call start() to start the task. + */ class Task : public QObject, public QRunnable { Q_OBJECT public: @@ -130,23 +137,27 @@ class Task : public QObject, public QRunnable { signals: void started(); void progress(qint64 current, qint64 total); + //! called when a task has either succeeded, aborted or failed. void finished(); + //! called when a task has succeeded void succeeded(); + //! called when a task has been aborted by calling abort() void aborted(); void failed(QString reason); void status(QString status); void details(QString details); void stepProgress(TaskStepProgress const& task_progress); - /** Emitted when the canAbort() status has changed. - */ + //! Emitted when the canAbort() status has changed. */ void abortStatusChanged(bool can_abort); public slots: // QRunnable's interface void run() override { start(); } + //! used by the task caller to start the task virtual void start(); + //! used by external code to ask the task to abort virtual bool abort() { if (canAbort()) @@ -161,11 +172,16 @@ class Task : public QObject, public QRunnable { } protected: + //! The task subclass must implement this method. This method is called to start to run the task. + //! The task is not finished when this method returns. the subclass must manually call emitSucceeded() or emitFailed() instead. virtual void executeTask() = 0; protected slots: + //! The Task subclass must call this method when the task has succeeded virtual void emitSucceeded(); + //! **The Task subclass** must call this method when the task has aborted. External code should call abort() instead. virtual void emitAborted(); + //! The Task subclass must call this method when the task has failed virtual void emitFailed(QString reason = ""); virtual void propagateStepProgress(TaskStepProgress const& task_progress); diff --git a/launcher/ui/pages/instance/McClient.cpp b/launcher/ui/pages/instance/McClient.cpp new file mode 100644 index 000000000..90813ac18 --- /dev/null +++ b/launcher/ui/pages/instance/McClient.cpp @@ -0,0 +1,164 @@ +#include +#include +#include +#include +#include + +#include +#include "McClient.h" +#include "Json.h" + +// 7 first bits +#define SEGMENT_BITS 0x7F +// last bit +#define CONTINUE_BIT 0x80 + +McClient::McClient(QObject *parent, QString domain, QString ip, short port): QObject(parent), m_domain(domain), m_ip(ip), m_port(port) {} + +void McClient::getStatusData() { + qDebug() << "Connecting to socket.."; + + connect(&m_socket, &QTcpSocket::connected, this, [this]() { + qDebug() << "Connected to socket successfully"; + sendRequest(); + + connect(&m_socket, &QTcpSocket::readyRead, this, &McClient::readRawResponse); + }); + + connect(&m_socket, &QTcpSocket::errorOccurred, this, [this]() { + emitFail("Socket disconnected: " + m_socket.errorString()); + }); + + m_socket.connectToHost(m_ip, m_port); +} + +void McClient::sendRequest() { + QByteArray data; + writeVarInt(data, 0x00); // packet ID + writeVarInt(data, 763); // hardcoded protocol version (763 = 1.20.1) + writeVarInt(data, m_domain.size()); // server address length + writeString(data, m_domain.toStdString()); // server address + writeFixedInt(data, m_port, 2); // server port + writeVarInt(data, 0x01); // next state + writePacketToSocket(data); // send handshake packet + + writeVarInt(data, 0x00); // packet ID + writePacketToSocket(data); // send status packet +} + +void McClient::readRawResponse() { + if (m_responseReadState == 2) { + return; + } + + m_resp.append(m_socket.readAll()); + if (m_responseReadState == 0 && m_resp.size() >= 5) { + m_wantedRespLength = readVarInt(m_resp); + m_responseReadState = 1; + } + + if (m_responseReadState == 1 && m_resp.size() >= m_wantedRespLength) { + if (m_resp.size() > m_wantedRespLength) { + qDebug() << "Warning: Packet length doesn't match actual packet size (" << m_wantedRespLength << " expected vs " << m_resp.size() << " received)"; + } + parseResponse(); + m_responseReadState = 2; + } +} + +void McClient::parseResponse() { + qDebug() << "Received response successfully"; + + int packetID = readVarInt(m_resp); + if (packetID != 0x00) { + throw Exception( + QString("Packet ID doesn't match expected value (0x00 vs 0x%1)") + .arg(packetID, 0, 16) + ); + } + + Q_UNUSED(readVarInt(m_resp)); // json length + + // 'resp' should now be the JSON string + QJsonDocument doc = QJsonDocument::fromJson(m_resp); + emitSucceed(doc.object()); +} + +// From https://wiki.vg/Protocol#VarInt_and_VarLong +void McClient::writeVarInt(QByteArray &data, int value) { + while ((value & ~SEGMENT_BITS)) { // check if the value is too big to fit in 7 bits + // Write 7 bits + data.append((value & SEGMENT_BITS) | CONTINUE_BIT); + + // Erase theses 7 bits from the value to write + // Note: >>> means that the sign bit is shifted with the rest of the number rather than being left alone + value >>= 7; + } + data.append(value); +} + +// From https://wiki.vg/Protocol#VarInt_and_VarLong +int McClient::readVarInt(QByteArray &data) { + int value = 0; + int position = 0; + char currentByte; + + while (position < 32) { + currentByte = readByte(data); + value |= (currentByte & SEGMENT_BITS) << position; + + if ((currentByte & CONTINUE_BIT) == 0) break; + + position += 7; + } + + if (position >= 32) throw Exception("VarInt is too big"); + + return value; +} + +char McClient::readByte(QByteArray &data) { + if (data.isEmpty()) { + throw Exception("No more bytes to read"); + } + + char byte = data.at(0); + data.remove(0, 1); + return byte; +} + +// write number with specified size in big endian format +void McClient::writeFixedInt(QByteArray &data, int value, int size) { + for (int i = size - 1; i >= 0; i--) { + data.append((value >> (i * 8)) & 0xFF); + } +} + +void McClient::writeString(QByteArray &data, const std::string &value) { + data.append(value.c_str()); +} + +void McClient::writePacketToSocket(QByteArray &data) { + // we prefix the packet with its length + QByteArray dataWithSize; + writeVarInt(dataWithSize, data.size()); + dataWithSize.append(data); + + // write it to the socket + m_socket.write(dataWithSize); + m_socket.flush(); + + data.clear(); +} + + +void McClient::emitFail(QString error) { + qDebug() << "Minecraft server ping for status error:" << error; + emit failed(error); + emit finished(); +} + +void McClient::emitSucceed(QJsonObject data) { + emit succeeded(data); + emit finished(); +} diff --git a/launcher/ui/pages/instance/McClient.h b/launcher/ui/pages/instance/McClient.h new file mode 100644 index 000000000..59834dfb7 --- /dev/null +++ b/launcher/ui/pages/instance/McClient.h @@ -0,0 +1,51 @@ +#include +#include +#include +#include +#include + +#include + +// Client for the Minecraft protocol +class McClient : public QObject { + Q_OBJECT + + QString m_domain; + QString m_ip; + short m_port; + QTcpSocket m_socket; + + // 0: did not start reading the response yet + // 1: read the response length, still reading the response + // 2: finished reading the response + unsigned m_responseReadState = 0; + unsigned m_wantedRespLength = 0; + QByteArray m_resp; + +public: + explicit McClient(QObject *parent, QString domain, QString ip, short port); + //! Read status data of the server, and calls the succeeded() signal with the parsed JSON data + void getStatusData(); +private: + void sendRequest(); + //! Accumulate data until we have a full response, then call parseResponse() once + void readRawResponse(); + void parseResponse(); + + void writeVarInt(QByteArray &data, int value); + int readVarInt(QByteArray &data); + char readByte(QByteArray &data); + //! write number with specified size in big endian format + void writeFixedInt(QByteArray &data, int value, int size); + void writeString(QByteArray &data, const std::string &value); + + void writePacketToSocket(QByteArray &data); + + void emitFail(QString error); + void emitSucceed(QJsonObject data); + +signals: + void succeeded(QJsonObject data); + void failed(QString error); + void finished(); +}; diff --git a/launcher/ui/pages/instance/McResolver.cpp b/launcher/ui/pages/instance/McResolver.cpp new file mode 100644 index 000000000..48c2a72fd --- /dev/null +++ b/launcher/ui/pages/instance/McResolver.cpp @@ -0,0 +1,73 @@ +#include +#include +#include +#include + +#include "McResolver.h" + +McResolver::McResolver(QObject *parent, QString domain, int port): QObject(parent), m_constrDomain(domain), m_constrPort(port) {} + +void McResolver::ping() { + pingWithDomainSRV(m_constrDomain, m_constrPort); +} + +void McResolver::pingWithDomainSRV(QString domain, int port) { + QDnsLookup *lookup = new QDnsLookup(this); + lookup->setName(QString("_minecraft._tcp.%1").arg(domain)); + lookup->setType(QDnsLookup::SRV); + + connect(lookup, &QDnsLookup::finished, this, [this, domain, port]() { + QDnsLookup *lookup = qobject_cast(sender()); + + lookup->deleteLater(); + + if (lookup->error() != QDnsLookup::NoError) { + qDebug() << QString("Warning: SRV record lookup failed (%1), trying A record lookup").arg(lookup->errorString()); + pingWithDomainA(domain, port); + return; + } + + auto records = lookup->serviceRecords(); + if (records.isEmpty()) { + qDebug() << "Warning: no SRV entries found for domain, trying A record lookup"; + pingWithDomainA(domain, port); + return; + } + + const auto& firstRecord = records.at(0); + QString domain = firstRecord.target(); + int port = firstRecord.port(); + pingWithDomainA(domain, port); + }); + + lookup->lookup(); +} + +void McResolver::pingWithDomainA(QString domain, int port) { + QHostInfo::lookupHost(domain, this, [this, port](const QHostInfo &hostInfo){ + if (hostInfo.error() != QHostInfo::NoError) { + emitFail("A record lookup failed"); + return; + } + + auto records = hostInfo.addresses(); + if (records.isEmpty()) { + emitFail("No A entries found for domain"); + return; + } + + const auto& firstRecord = records.at(0); + emitSucceed(firstRecord.toString(), port); + }); +} + +void McResolver::emitFail(QString error) { + qDebug() << "DNS resolver error:" << error; + emit failed(error); + emit finished(); +} + +void McResolver::emitSucceed(QString ip, int port) { + emit succeeded(ip, port); + emit finished(); +} diff --git a/launcher/ui/pages/instance/McResolver.h b/launcher/ui/pages/instance/McResolver.h new file mode 100644 index 000000000..06b4b7b38 --- /dev/null +++ b/launcher/ui/pages/instance/McResolver.h @@ -0,0 +1,28 @@ +#include +#include +#include +#include +#include + +// resolve the IP and port of a Minecraft server +class McResolver : public QObject { + Q_OBJECT + + QString m_constrDomain; + int m_constrPort; + +public: + explicit McResolver(QObject *parent, QString domain, int port); + void ping(); + +private: + void pingWithDomainSRV(QString domain, int port); + void pingWithDomainA(QString domain, int port); + void emitFail(QString error); + void emitSucceed(QString ip, int port); + +signals: + void succeeded(QString ip, int port); + void failed(QString error); + void finished(); +}; diff --git a/launcher/ui/pages/instance/ServerPingTask.cpp b/launcher/ui/pages/instance/ServerPingTask.cpp new file mode 100644 index 000000000..3ec9308ca --- /dev/null +++ b/launcher/ui/pages/instance/ServerPingTask.cpp @@ -0,0 +1,47 @@ +#include + +#include "ServerPingTask.h" +#include "McResolver.h" +#include "McClient.h" +#include + +unsigned getOnlinePlayers(QJsonObject data) { + return Json::requireInteger(Json::requireObject(data, "players"), "online"); +} + +void ServerPingTask::executeTask() { + qDebug() << "Querying status of " << QString("%1:%2").arg(m_domain).arg(m_port); + + // Resolve the actual IP and port for the server + McResolver *resolver = new McResolver(nullptr, m_domain, m_port); + QObject::connect(resolver, &McResolver::succeeded, this, [this, resolver](QString ip, int port) { + qDebug() << "Resolved Address for" << m_domain << ": " << ip << ":" << port; + + // Now that we have the IP and port, query the server + McClient *client = new McClient(nullptr, m_domain, ip, port); + + QObject::connect(client, &McClient::succeeded, this, [this](QJsonObject data) { + m_outputOnlinePlayers = getOnlinePlayers(data); + qDebug() << "Online players: " << m_outputOnlinePlayers; + emitSucceeded(); + }); + QObject::connect(client, &McClient::failed, this, [this](QString error) { + emitFailed(error); + }); + + // Delete McClient object when done + QObject::connect(client, &McClient::finished, this, [this, client]() { + client->deleteLater(); + }); + client->getStatusData(); + }); + QObject::connect(resolver, &McResolver::failed, this, [this](QString error) { + emitFailed(error); + }); + + // Delete McResolver object when done + QObject::connect(resolver, &McResolver::finished, [resolver]() { + resolver->deleteLater(); + }); + resolver->ping(); +} \ No newline at end of file diff --git a/launcher/ui/pages/instance/ServerPingTask.h b/launcher/ui/pages/instance/ServerPingTask.h new file mode 100644 index 000000000..0956a4f63 --- /dev/null +++ b/launcher/ui/pages/instance/ServerPingTask.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + + +class ServerPingTask : public Task { + Q_OBJECT + public: + explicit ServerPingTask(QString domain, int port) : Task(), m_domain(domain), m_port(port) {} + ~ServerPingTask() override = default; + int m_outputOnlinePlayers = -1; + + private: + QString m_domain; + int m_port; + + protected: + virtual void executeTask() override; +}; diff --git a/launcher/ui/pages/instance/ServersPage.cpp b/launcher/ui/pages/instance/ServersPage.cpp index d8035e73e..4bc2e6998 100644 --- a/launcher/ui/pages/instance/ServersPage.cpp +++ b/launcher/ui/pages/instance/ServersPage.cpp @@ -38,6 +38,7 @@ #include "ServersPage.h" #include "ui/dialogs/CustomMessageBox.h" #include "ui_ServersPage.h" +#include "ServerPingTask.h" #include #include @@ -51,8 +52,9 @@ #include #include #include +#include -static const int COLUMN_COUNT = 2; // 3 , TBD: latency and other nice things. +static const int COLUMN_COUNT = 3; // 3 , TBD: latency and other nice things. struct Server { // Types @@ -112,8 +114,7 @@ struct Server { bool m_checked = false; bool m_up = false; QString m_motd; // https://mctools.org/motd-creator - int m_ping = 0; - int m_currentPlayers = 0; + std::optional m_currentPlayers; // nullopt if not calculated/calculating int m_maxPlayers = 0; }; @@ -296,7 +297,7 @@ class ServersModel : public QAbstractListModel { case 1: return tr("Address"); case 2: - return tr("Latency"); + return tr("Online"); } } @@ -345,7 +346,11 @@ class ServersModel : public QAbstractListModel { case 2: switch (role) { case Qt::DisplayRole: - return m_servers[row].m_ping; + if (m_servers[row].m_currentPlayers) { + return *m_servers[row].m_currentPlayers; + } else { + return "..."; + } default: return QVariant(); } @@ -433,6 +438,40 @@ class ServersModel : public QAbstractListModel { } } + void queryServersStatus() + { + // Abort the currently running task if present + if (m_currentQueryTask != nullptr) { + m_currentQueryTask->abort(); + qDebug() << "Aborted previous server query task"; + } + + m_currentQueryTask = ConcurrentTask::Ptr( + new ConcurrentTask("Query servers status", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt()) + ); + int row = 0; + for (Server &server : m_servers) { + // reset current players + server.m_currentPlayers = {}; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + + // Start task to query server status + auto target = MinecraftTarget::parse(server.m_address, false); + auto *task = new ServerPingTask(target.address, target.port); + m_currentQueryTask->addTask(Task::Ptr(task)); + + // Update the model when the task is done + connect(task, &Task::finished, this, [this, task, row]() { + if (m_servers.size() < row) return; + m_servers[row].m_currentPlayers = task->m_outputOnlinePlayers; + emit dataChanged(index(row, 0), index(row, COLUMN_COUNT - 1)); + }); + row++; + } + + m_currentQueryTask->start(); + } + public slots: void dirChanged(const QString& path) { @@ -520,6 +559,7 @@ class ServersModel : public QAbstractListModel { QList m_servers; QFileSystemWatcher* m_watcher = nullptr; QTimer m_saveTimer; + ConcurrentTask::Ptr m_currentQueryTask = nullptr; }; ServersPage::ServersPage(InstancePtr inst, QWidget* parent) : QMainWindow(parent), ui(new Ui::ServersPage) @@ -676,6 +716,9 @@ void ServersPage::openedImpl() m_wide_bar_setting = APPLICATION->settings()->getSetting(setting_name); ui->toolBar->setVisibilityState(m_wide_bar_setting->get().toByteArray()); + + // ping servers + m_model->queryServersStatus(); } void ServersPage::closedImpl() @@ -734,4 +777,9 @@ void ServersPage::on_actionJoin_triggered() APPLICATION->launch(m_inst, true, false, std::make_shared(MinecraftTarget::parse(address, false))); } +void ServersPage::on_actionRefresh_triggered() +{ + m_model->queryServersStatus(); +} + #include "ServersPage.moc" diff --git a/launcher/ui/pages/instance/ServersPage.h b/launcher/ui/pages/instance/ServersPage.h index a27d1d297..77710d6cc 100644 --- a/launcher/ui/pages/instance/ServersPage.h +++ b/launcher/ui/pages/instance/ServersPage.h @@ -85,6 +85,7 @@ class ServersPage : public QMainWindow, public BasePage { void on_actionMove_Up_triggered(); void on_actionMove_Down_triggered(); void on_actionJoin_triggered(); + void on_actionRefresh_triggered(); void runningStateChanged(bool running); diff --git a/launcher/ui/pages/instance/ServersPage.ui b/launcher/ui/pages/instance/ServersPage.ui index e8f79cf2e..d330835c8 100644 --- a/launcher/ui/pages/instance/ServersPage.ui +++ b/launcher/ui/pages/instance/ServersPage.ui @@ -149,6 +149,8 @@ + + @@ -175,6 +177,11 @@ Join + + + Refresh + +