added server
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
||||
namespace {
|
||||
constexpr int kServerNameMaxLength = 64;
|
||||
constexpr int kServerDescriptionMaxLength = 256;
|
||||
}
|
||||
|
||||
ConfigDialog::ConfigDialog(const QString &configPath, QWidget *parent)
|
||||
: QDialog(parent)
|
||||
, configPath(configPath)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Server Settings"));
|
||||
setModal(true);
|
||||
setMinimumWidth(480);
|
||||
setupUI();
|
||||
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configPath)) {
|
||||
if (!loadFromFile(configPath, &data, &error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Config"),
|
||||
QStringLiteral("Could not load config; using defaults.\n%1").arg(error));
|
||||
data = ServerConfigData{};
|
||||
}
|
||||
}
|
||||
loadIntoForm(data);
|
||||
}
|
||||
|
||||
ServerConfigData ConfigDialog::config() const {
|
||||
ServerConfigData data;
|
||||
data.serverName = serverNameEdit->text().trimmed();
|
||||
data.serverDescription = descriptionEdit->toPlainText().trimmed();
|
||||
data.host = hostEdit->text().trimmed();
|
||||
data.port = portSpin->value();
|
||||
data.adminPort = adminPortSpin->value();
|
||||
data.maxPlayers = maxPlayersSpin->value();
|
||||
data.logVerbosity = logVerbosityCombo->currentData().toString();
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ConfigDialog::loadFromFile(const QString &configPath, ServerConfigData *out, QString *error) {
|
||||
if (!out) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Output config pointer is null");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not open %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
file.close();
|
||||
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Invalid JSON: %1").arg(parseError.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject obj = doc.object();
|
||||
out->host = obj.value(QStringLiteral("host")).toString(QStringLiteral("0.0.0.0"));
|
||||
out->port = obj.value(QStringLiteral("port")).toInt(7777);
|
||||
out->adminPort = obj.value(QStringLiteral("admin_port")).toInt(7779);
|
||||
out->serverName = obj.value(QStringLiteral("server_name")).toString(QStringLiteral("Commonwealth Online Server"));
|
||||
out->serverDescription = obj.value(QStringLiteral("server_description")).toString();
|
||||
out->maxPlayers = obj.value(QStringLiteral("max_players")).toInt(16);
|
||||
out->logVerbosity = obj.value(QStringLiteral("log_verbosity")).toString(QStringLiteral("info"));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigDialog::saveToFile(const QString &configPath, const ServerConfigData &config, QString *error) {
|
||||
QFileInfo info(configPath);
|
||||
if (!info.dir().exists() && !QDir().mkpath(info.absolutePath())) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not create directory for %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("host"), config.host);
|
||||
obj.insert(QStringLiteral("port"), config.port);
|
||||
obj.insert(QStringLiteral("admin_port"), config.adminPort);
|
||||
obj.insert(QStringLiteral("server_name"), config.serverName);
|
||||
obj.insert(QStringLiteral("server_description"), config.serverDescription);
|
||||
obj.insert(QStringLiteral("max_players"), config.maxPlayers);
|
||||
obj.insert(QStringLiteral("log_verbosity"), config.logVerbosity);
|
||||
|
||||
QFile file(configPath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Could not write %1").arg(configPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
file.write(QJsonDocument(obj).toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::setupUI() {
|
||||
auto *mainLayout = new QVBoxLayout(this);
|
||||
|
||||
auto *form = new QFormLayout();
|
||||
form->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
|
||||
serverNameEdit = new QLineEdit();
|
||||
serverNameEdit->setMaxLength(kServerNameMaxLength);
|
||||
serverNameEdit->setPlaceholderText(QStringLiteral("Commonwealth Online Server"));
|
||||
form->addRow(QStringLiteral("Server name"), serverNameEdit);
|
||||
|
||||
descriptionEdit = new QPlainTextEdit();
|
||||
descriptionEdit->setPlaceholderText(QStringLiteral("Short description shown in the server browser"));
|
||||
descriptionEdit->setMaximumHeight(80);
|
||||
form->addRow(QStringLiteral("Description"), descriptionEdit);
|
||||
|
||||
hostEdit = new QLineEdit();
|
||||
hostEdit->setPlaceholderText(QStringLiteral("0.0.0.0"));
|
||||
form->addRow(QStringLiteral("Bind address"), hostEdit);
|
||||
|
||||
portSpin = new QSpinBox();
|
||||
portSpin->setRange(1, 65535);
|
||||
portSpin->setValue(7777);
|
||||
form->addRow(QStringLiteral("Port"), portSpin);
|
||||
|
||||
adminPortSpin = new QSpinBox();
|
||||
adminPortSpin->setRange(1, 65535);
|
||||
adminPortSpin->setValue(7779);
|
||||
form->addRow(QStringLiteral("Admin port (localhost)"), adminPortSpin);
|
||||
|
||||
maxPlayersSpin = new QSpinBox();
|
||||
maxPlayersSpin->setRange(1, 128);
|
||||
maxPlayersSpin->setValue(16);
|
||||
form->addRow(QStringLiteral("Max players"), maxPlayersSpin);
|
||||
|
||||
logVerbosityCombo = new QComboBox();
|
||||
logVerbosityCombo->addItem(QStringLiteral("Debug"), QStringLiteral("debug"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Info"), QStringLiteral("info"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Warning"), QStringLiteral("warning"));
|
||||
logVerbosityCombo->addItem(QStringLiteral("Error"), QStringLiteral("error"));
|
||||
form->addRow(QStringLiteral("Log level"), logVerbosityCombo);
|
||||
|
||||
mainLayout->addLayout(form);
|
||||
|
||||
pathLabel = new QLabel();
|
||||
pathLabel->setWordWrap(true);
|
||||
pathLabel->setStyleSheet(QStringLiteral("QLabel { color: #666666; font-size: 11px; }"));
|
||||
pathLabel->setText(QStringLiteral("Config file: %1").arg(configPath));
|
||||
mainLayout->addWidget(pathLabel);
|
||||
|
||||
auto *hint = new QLabel(QStringLiteral("Changes apply the next time the server is started."));
|
||||
hint->setStyleSheet(QStringLiteral("QLabel { color: #555555; font-size: 11px; }"));
|
||||
hint->setWordWrap(true);
|
||||
mainLayout->addWidget(hint);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel);
|
||||
buttons->button(QDialogButtonBox::Save)->setText(QStringLiteral("Save"));
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &ConfigDialog::onAccepted);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &ConfigDialog::reject);
|
||||
mainLayout->addWidget(buttons);
|
||||
}
|
||||
|
||||
void ConfigDialog::loadIntoForm(const ServerConfigData &config) {
|
||||
serverNameEdit->setText(config.serverName);
|
||||
descriptionEdit->setPlainText(config.serverDescription);
|
||||
hostEdit->setText(config.host);
|
||||
portSpin->setValue(config.port > 0 ? config.port : 7777);
|
||||
adminPortSpin->setValue(config.adminPort > 0 ? config.adminPort : 7779);
|
||||
maxPlayersSpin->setValue(config.maxPlayers > 0 ? config.maxPlayers : 16);
|
||||
|
||||
const int idx = logVerbosityCombo->findData(config.logVerbosity.toLower());
|
||||
logVerbosityCombo->setCurrentIndex(idx >= 0 ? idx : logVerbosityCombo->findData(QStringLiteral("info")));
|
||||
}
|
||||
|
||||
bool ConfigDialog::validateForm(QString *error) const {
|
||||
const ServerConfigData data = config();
|
||||
|
||||
if (data.serverName.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverName.size() > kServerNameMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Server name must be at most %1 characters.").arg(kServerNameMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.serverDescription.size() > kServerDescriptionMaxLength) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Description must be at most %1 characters.").arg(kServerDescriptionMaxLength);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.host.isEmpty()) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Bind address cannot be empty.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.port < 1 || data.port > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort < 1 || data.adminPort > 65535) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must be between 1 and 65535.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.adminPort == data.port) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Admin port must differ from the game port.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (data.maxPlayers < 1) {
|
||||
if (error) {
|
||||
*error = QStringLiteral("Max players must be at least 1.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigDialog::onAccepted() {
|
||||
QString error;
|
||||
if (!validateForm(&error)) {
|
||||
QMessageBox::warning(this, QStringLiteral("Invalid settings"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!saveToFile(configPath, config(), &error)) {
|
||||
QMessageBox::critical(this, QStringLiteral("Save failed"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
accept();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#ifndef CONFIGDIALOG_H
|
||||
#define CONFIGDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
|
||||
struct ServerConfigData {
|
||||
QString host = QStringLiteral("0.0.0.0");
|
||||
int port = 7777;
|
||||
int adminPort = 7779;
|
||||
QString serverName = QStringLiteral("Commonwealth Online Server");
|
||||
QString serverDescription;
|
||||
int maxPlayers = 16;
|
||||
QString logVerbosity = QStringLiteral("info");
|
||||
};
|
||||
|
||||
class ConfigDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ConfigDialog(const QString &configPath, QWidget *parent = nullptr);
|
||||
|
||||
ServerConfigData config() const;
|
||||
|
||||
static bool loadFromFile(const QString &configPath, ServerConfigData *out, QString *error = nullptr);
|
||||
static bool saveToFile(const QString &configPath, const ServerConfigData &config, QString *error = nullptr);
|
||||
|
||||
private slots:
|
||||
void onAccepted();
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void loadIntoForm(const ServerConfigData &config);
|
||||
bool validateForm(QString *error) const;
|
||||
|
||||
QString configPath;
|
||||
|
||||
QLineEdit *serverNameEdit;
|
||||
QPlainTextEdit *descriptionEdit;
|
||||
QLineEdit *hostEdit;
|
||||
QSpinBox *portSpin;
|
||||
QSpinBox *adminPortSpin;
|
||||
QSpinBox *maxPlayersSpin;
|
||||
QComboBox *logVerbosityCombo;
|
||||
QLabel *pathLabel;
|
||||
};
|
||||
|
||||
#endif // CONFIGDIALOG_H
|
||||
@@ -0,0 +1,846 @@
|
||||
#include "MainWindow.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QStatusBar>
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QHeaderView>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QStyle>
|
||||
#include <QInputDialog>
|
||||
#include <QTimer>
|
||||
#include <QPalette>
|
||||
#include <QColor>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, isServerRunning(false)
|
||||
, serverName("Commonwealth Online Server")
|
||||
, configFilePath("commonwealth-server.json")
|
||||
{
|
||||
setWindowTitle("Commonwealth Online — Server Host");
|
||||
setWindowIcon(QIcon(":/icons/app.ico"));
|
||||
setGeometry(100, 100, 960, 640);
|
||||
setMinimumSize(780, 520);
|
||||
|
||||
setupUI();
|
||||
setupStyles();
|
||||
setupConnections();
|
||||
setupTimer();
|
||||
|
||||
serverProcess = new ServerProcess(this);
|
||||
connect(serverProcess, &ServerProcess::started, this, &MainWindow::onServerStarted);
|
||||
connect(serverProcess, &ServerProcess::stopped, this, &MainWindow::onServerStopped);
|
||||
connect(serverProcess, &ServerProcess::logMessage, this, &MainWindow::onServerLog);
|
||||
connect(serverProcess, &ServerProcess::clientsUpdated, this, &MainWindow::onUpdateClients);
|
||||
connect(serverProcess, &ServerProcess::statsUpdated, this, &MainWindow::onStatsUpdated);
|
||||
connect(serverProcess, &ServerProcess::error, this, &MainWindow::onServerError);
|
||||
connect(serverProcess, &ServerProcess::adminCommandFinished, this, &MainWindow::onAdminCommandFinished);
|
||||
|
||||
resolveConfigPath();
|
||||
loadConfigIntoUi();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupUI() {
|
||||
centralWidget = new QWidget(this);
|
||||
setCentralWidget(centralWidget);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||
mainLayout->setContentsMargins(0, 0, 0, 0);
|
||||
mainLayout->setSpacing(0);
|
||||
|
||||
// ===== TOOLBAR =====
|
||||
auto *toolbar = new QFrame();
|
||||
toolbar->setObjectName("toolBar");
|
||||
auto *toolbarLayout = new QHBoxLayout(toolbar);
|
||||
toolbarLayout->setContentsMargins(8, 6, 8, 6);
|
||||
toolbarLayout->setSpacing(6);
|
||||
|
||||
serverNameLabel = new QLabel(serverName);
|
||||
serverNameLabel->setObjectName("serverNameLabel");
|
||||
|
||||
startButton = new QPushButton("Start");
|
||||
startButton->setObjectName("startButton");
|
||||
startButton->setFixedWidth(72);
|
||||
startButton->setToolTip("Start the relay server");
|
||||
|
||||
stopButton = new QPushButton("Stop");
|
||||
stopButton->setObjectName("stopButton");
|
||||
stopButton->setFixedWidth(72);
|
||||
stopButton->setEnabled(false);
|
||||
stopButton->setToolTip("Stop the relay server");
|
||||
|
||||
configButton = new QPushButton("Settings");
|
||||
configButton->setFixedWidth(80);
|
||||
configButton->setToolTip("Edit server settings");
|
||||
|
||||
statusDot = new QLabel();
|
||||
statusDot->setObjectName("statusDot");
|
||||
statusDot->setFixedSize(10, 10);
|
||||
statusDot->setProperty("running", false);
|
||||
|
||||
statusIndicator = new QLabel("Stopped");
|
||||
statusIndicator->setObjectName("statusText");
|
||||
|
||||
toolbarLayout->addWidget(startButton);
|
||||
toolbarLayout->addWidget(stopButton);
|
||||
toolbarLayout->addWidget(configButton);
|
||||
toolbarLayout->addSpacing(12);
|
||||
toolbarLayout->addWidget(serverNameLabel, 1);
|
||||
toolbarLayout->addWidget(statusDot);
|
||||
toolbarLayout->addWidget(statusIndicator);
|
||||
mainLayout->addWidget(toolbar);
|
||||
|
||||
// ===== STATUS STRIP =====
|
||||
auto *statusStrip = new QFrame();
|
||||
statusStrip->setObjectName("statusStrip");
|
||||
auto *statusLayout = new QHBoxLayout(statusStrip);
|
||||
statusLayout->setContentsMargins(10, 5, 10, 5);
|
||||
statusLayout->setSpacing(16);
|
||||
|
||||
bindAddressLabel = new QLabel("Bind: 0.0.0.0:7777");
|
||||
lanAddressLabel = new QLabel("LAN: —");
|
||||
clientsLabel = new QLabel("Clients: 0");
|
||||
uptimeLabel = new QLabel("Uptime: 0s");
|
||||
transformPacketsLabel = new QLabel("Transform: 0 / 0");
|
||||
worldStatePacketsLabel = new QLabel("WorldState: 0 / 0");
|
||||
|
||||
for (QLabel *label : {bindAddressLabel, lanAddressLabel, clientsLabel, uptimeLabel,
|
||||
transformPacketsLabel, worldStatePacketsLabel}) {
|
||||
label->setObjectName("statLabel");
|
||||
statusLayout->addWidget(label);
|
||||
}
|
||||
statusLayout->addStretch();
|
||||
mainLayout->addWidget(statusStrip);
|
||||
|
||||
// ===== MAIN SPLITTER (clients + log) =====
|
||||
mainSplitter = new QSplitter(Qt::Vertical);
|
||||
mainSplitter->setObjectName("mainSplitter");
|
||||
mainSplitter->setChildrenCollapsible(false);
|
||||
|
||||
auto *clientsPane = new QFrame();
|
||||
clientsPane->setObjectName("pane");
|
||||
auto *clientsLayout = new QVBoxLayout(clientsPane);
|
||||
clientsLayout->setContentsMargins(8, 6, 8, 4);
|
||||
clientsLayout->setSpacing(4);
|
||||
|
||||
auto *clientsHeaderRow = new QHBoxLayout();
|
||||
clientsHeaderRow->setContentsMargins(0, 0, 0, 0);
|
||||
clientsHeaderRow->setSpacing(8);
|
||||
|
||||
auto *clientsHeader = new QLabel("Connected Clients");
|
||||
clientsHeader->setObjectName("paneHeader");
|
||||
clientsHeaderRow->addWidget(clientsHeader, 1);
|
||||
|
||||
kickButton = new QPushButton(QStringLiteral("Kick"));
|
||||
kickButton->setEnabled(false);
|
||||
kickButton->setToolTip(QStringLiteral("Disconnect the selected player without banning"));
|
||||
banButton = new QPushButton(QStringLiteral("Ban IP"));
|
||||
banButton->setEnabled(false);
|
||||
banButton->setToolTip(QStringLiteral("Disconnect and permanently ban the selected player's IP"));
|
||||
bansButton = new QPushButton(QStringLiteral("Bans…"));
|
||||
bansButton->setEnabled(false);
|
||||
bansButton->setToolTip(QStringLiteral("View and unban banned IP addresses"));
|
||||
clientsHeaderRow->addWidget(kickButton);
|
||||
clientsHeaderRow->addWidget(banButton);
|
||||
clientsHeaderRow->addWidget(bansButton);
|
||||
clientsLayout->addLayout(clientsHeaderRow);
|
||||
|
||||
clientsTable = new QTableWidget();
|
||||
clientsTable->setColumnCount(5);
|
||||
clientsTable->setHorizontalHeaderLabels({"Player ID", "Address", "Label", "Connected", "Packets"});
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
|
||||
clientsTable->horizontalHeader()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
|
||||
clientsTable->verticalHeader()->setVisible(false);
|
||||
clientsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
clientsTable->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
clientsTable->setAlternatingRowColors(true);
|
||||
clientsTable->setShowGrid(false);
|
||||
clientsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
clientsTable->setFocusPolicy(Qt::StrongFocus);
|
||||
clientsLayout->addWidget(clientsTable);
|
||||
|
||||
mainSplitter->addWidget(clientsPane);
|
||||
|
||||
auto *logPane = new QFrame();
|
||||
logPane->setObjectName("pane");
|
||||
auto *logLayout = new QVBoxLayout(logPane);
|
||||
logLayout->setContentsMargins(8, 4, 8, 8);
|
||||
logLayout->setSpacing(4);
|
||||
|
||||
auto *logHeader = new QLabel("Server Log");
|
||||
logHeader->setObjectName("paneHeader");
|
||||
logLayout->addWidget(logHeader);
|
||||
|
||||
logViewer = new QTextEdit();
|
||||
logViewer->setReadOnly(true);
|
||||
logViewer->setLineWrapMode(QTextEdit::NoWrap);
|
||||
logViewer->setFont(QFont("Consolas", 9));
|
||||
logViewer->setPlaceholderText("Server output appears here…");
|
||||
logLayout->addWidget(logViewer);
|
||||
mainSplitter->addWidget(logPane);
|
||||
|
||||
mainSplitter->setStretchFactor(0, 2);
|
||||
mainSplitter->setStretchFactor(1, 3);
|
||||
mainSplitter->setSizes({220, 320});
|
||||
mainLayout->addWidget(mainSplitter, 1);
|
||||
|
||||
statusBar()->showMessage("Ready");
|
||||
}
|
||||
|
||||
void MainWindow::setupStyles() {
|
||||
// Force a light application palette so Windows dark mode cannot leave
|
||||
// white text on the Host GUI's light backgrounds (status strip, dialogs).
|
||||
QPalette lightPalette;
|
||||
const QColor windowBg(243, 243, 243);
|
||||
const QColor baseBg(255, 255, 255);
|
||||
const QColor text(31, 31, 31);
|
||||
const QColor muted(68, 68, 68);
|
||||
const QColor disabled(153, 153, 153);
|
||||
const QColor highlight(204, 228, 247);
|
||||
lightPalette.setColor(QPalette::Window, windowBg);
|
||||
lightPalette.setColor(QPalette::WindowText, text);
|
||||
lightPalette.setColor(QPalette::Base, baseBg);
|
||||
lightPalette.setColor(QPalette::AlternateBase, QColor(247, 247, 247));
|
||||
lightPalette.setColor(QPalette::Text, text);
|
||||
lightPalette.setColor(QPalette::Button, baseBg);
|
||||
lightPalette.setColor(QPalette::ButtonText, text);
|
||||
lightPalette.setColor(QPalette::BrightText, text);
|
||||
lightPalette.setColor(QPalette::ToolTipBase, baseBg);
|
||||
lightPalette.setColor(QPalette::ToolTipText, text);
|
||||
lightPalette.setColor(QPalette::PlaceholderText, muted);
|
||||
lightPalette.setColor(QPalette::Highlight, highlight);
|
||||
lightPalette.setColor(QPalette::HighlightedText, text);
|
||||
lightPalette.setColor(QPalette::Link, QColor(59, 121, 183));
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::WindowText, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::Text, disabled);
|
||||
lightPalette.setColor(QPalette::Disabled, QPalette::ButtonText, disabled);
|
||||
qApp->setPalette(lightPalette);
|
||||
|
||||
const QString stylesheet = R"(
|
||||
* {
|
||||
font-family: "Segoe UI", "Segoe UI Variable", sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QInputDialog, QMessageBox {
|
||||
background-color: #f3f3f3;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QStatusBar {
|
||||
background-color: #e8e8e8;
|
||||
color: #444444;
|
||||
border-top: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
QStatusBar QLabel {
|
||||
color: #444444;
|
||||
}
|
||||
|
||||
QFrame#toolBar {
|
||||
background-color: #e8e8e8;
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip {
|
||||
background-color: #ececec;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
QFrame#statusStrip QLabel {
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
QFrame#pane {
|
||||
background-color: #f3f3f3;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QLabel#serverNameLabel {
|
||||
color: #1f1f1f;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QLabel#statusText {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
QLabel#statusDot {
|
||||
background-color: #c44;
|
||||
border: 1px solid #a33;
|
||||
border-radius: 5px;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
QLabel#statusDot[running="true"] {
|
||||
background-color: #2e8b4e;
|
||||
border: 1px solid #246b3c;
|
||||
}
|
||||
|
||||
QLabel#paneHeader {
|
||||
color: #444444;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QLabel#statLabel {
|
||||
color: #1f1f1f;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
QInputDialog QLabel, QMessageBox QLabel {
|
||||
color: #1f1f1f;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 4px 10px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #eef5fc;
|
||||
border-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #dceaf8;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #999999;
|
||||
background-color: #f0f0f0;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
QPushButton#startButton {
|
||||
background-color: #2e8b4e;
|
||||
color: #ffffff;
|
||||
border-color: #246b3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#startButton:hover {
|
||||
background-color: #359957;
|
||||
}
|
||||
|
||||
QPushButton#startButton:pressed {
|
||||
background-color: #246b3c;
|
||||
}
|
||||
|
||||
QPushButton#startButton:disabled {
|
||||
background-color: #9cbcab;
|
||||
border-color: #8aa996;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QPushButton#stopButton {
|
||||
background-color: #c44;
|
||||
color: #ffffff;
|
||||
border-color: #a33;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:hover {
|
||||
background-color: #d25555;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:pressed {
|
||||
background-color: #a33;
|
||||
}
|
||||
|
||||
QPushButton#stopButton:disabled {
|
||||
background-color: #d0a0a0;
|
||||
border-color: #b88888;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background-color: #ffffff;
|
||||
alternate-background-color: #f7f7f7;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #c8c8c8;
|
||||
gridline-color: #e6e6e6;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #ececec;
|
||||
color: #333333;
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
border-right: 1px solid #d4d4d4;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QTextEdit {
|
||||
background-color: #ffffff;
|
||||
color: #1a1a1a;
|
||||
border: 1px solid #c8c8c8;
|
||||
font-family: "Consolas", "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
background-color: #d0d0d0;
|
||||
height: 3px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical:hover {
|
||||
background-color: #7aa7d4;
|
||||
}
|
||||
|
||||
QLineEdit, QPlainTextEdit, QSpinBox, QComboBox {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QComboBox:focus {
|
||||
border-color: #3b79b7;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #ffffff;
|
||||
color: #1f1f1f;
|
||||
selection-background-color: #cce4f7;
|
||||
selection-color: #1f1f1f;
|
||||
border: 1px solid #adadad;
|
||||
}
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {
|
||||
background-color: #ececec;
|
||||
border: none;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
QDialogButtonBox QPushButton {
|
||||
min-width: 72px;
|
||||
}
|
||||
)";
|
||||
|
||||
qApp->setStyle("Fusion");
|
||||
qApp->setStyleSheet(stylesheet);
|
||||
}
|
||||
|
||||
void MainWindow::setupConnections() {
|
||||
connect(startButton, &QPushButton::clicked, this, &MainWindow::onStartServer);
|
||||
connect(stopButton, &QPushButton::clicked, this, &MainWindow::onStopServer);
|
||||
connect(configButton, &QPushButton::clicked, this, &MainWindow::onOpenConfig);
|
||||
connect(kickButton, &QPushButton::clicked, this, &MainWindow::onKickSelectedClient);
|
||||
connect(banButton, &QPushButton::clicked, this, &MainWindow::onBanSelectedClient);
|
||||
connect(bansButton, &QPushButton::clicked, this, &MainWindow::onManageBans);
|
||||
connect(clientsTable, &QTableWidget::itemSelectionChanged, this, &MainWindow::onClientSelectionChanged);
|
||||
}
|
||||
|
||||
void MainWindow::resolveConfigPath() {
|
||||
const QString serverDir = serverProcess ? serverProcess->serverDirectory() : QString();
|
||||
if (!serverDir.isEmpty()) {
|
||||
configFilePath = QDir(serverDir).absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
} else {
|
||||
configFilePath = QDir(QCoreApplication::applicationDirPath())
|
||||
.absoluteFilePath(QStringLiteral("commonwealth-server.json"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadConfigIntoUi() {
|
||||
ServerConfigData data;
|
||||
QString error;
|
||||
if (QFileInfo::exists(configFilePath) && ConfigDialog::loadFromFile(configFilePath, &data, &error)) {
|
||||
applyConfigToUi(data);
|
||||
} else {
|
||||
applyConfigToUi(ServerConfigData{});
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::applyConfigToUi(const ServerConfigData &config) {
|
||||
serverName = config.serverName;
|
||||
serverNameLabel->setText(serverName);
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(config.host).arg(config.port));
|
||||
if (serverProcess) {
|
||||
serverProcess->setAdminPort(config.adminPort);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setupTimer() {
|
||||
statsTimer = new QTimer(this);
|
||||
connect(statsTimer, &QTimer::timeout, this, &MainWindow::onUpdateStats);
|
||||
}
|
||||
|
||||
void MainWindow::onStartServer() {
|
||||
startButton->setEnabled(false);
|
||||
statusBar()->showMessage("Starting server...");
|
||||
loadConfigIntoUi();
|
||||
serverProcess->start(configFilePath);
|
||||
}
|
||||
|
||||
void MainWindow::onStopServer() {
|
||||
stopButton->setEnabled(false);
|
||||
statusBar()->showMessage("Stopping server...");
|
||||
serverProcess->stop();
|
||||
}
|
||||
|
||||
void MainWindow::onOpenConfig() {
|
||||
resolveConfigPath();
|
||||
|
||||
if (serverProcess && serverProcess->serverDirectory().isEmpty()) {
|
||||
QMessageBox::warning(
|
||||
this,
|
||||
QStringLiteral("Server Settings"),
|
||||
QStringLiteral("Server directory not found. Settings cannot be saved until the Python server folder is available."));
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigDialog dialog(configFilePath, this);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ServerConfigData data = dialog.config();
|
||||
applyConfigToUi(data);
|
||||
addLogMessage(QStringLiteral("[GUI] Saved server settings to %1").arg(configFilePath));
|
||||
|
||||
if (isServerRunning) {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved — restart the server to apply"));
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Settings saved"),
|
||||
QStringLiteral("Settings were saved.\n\nStop and start the server for them to take effect."));
|
||||
} else {
|
||||
statusBar()->showMessage(QStringLiteral("Settings saved"));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerStarted() {
|
||||
isServerRunning = true;
|
||||
updateServerStatus(true);
|
||||
bansButton->setEnabled(true);
|
||||
statsTimer->start(1000); // Update every second
|
||||
statusBar()->showMessage("Server running");
|
||||
addLogMessage("[GUI] Server started successfully");
|
||||
// Give the Python admin port a moment to bind before the first poll.
|
||||
QTimer *startupPoll = new QTimer(this);
|
||||
startupPoll->setSingleShot(true);
|
||||
connect(startupPoll, &QTimer::timeout, this, [this, startupPoll]() {
|
||||
onUpdateStats();
|
||||
startupPoll->deleteLater();
|
||||
});
|
||||
startupPoll->start(750);
|
||||
}
|
||||
|
||||
void MainWindow::onServerStopped() {
|
||||
isServerRunning = false;
|
||||
updateServerStatus(false);
|
||||
statsTimer->stop();
|
||||
clientsTable->setRowCount(0);
|
||||
bansButton->setEnabled(false);
|
||||
onClientSelectionChanged();
|
||||
clientsLabel->setText(QStringLiteral("Clients: 0"));
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: 0s"));
|
||||
transformPacketsLabel->setText(QStringLiteral("Transform: 0 / 0"));
|
||||
worldStatePacketsLabel->setText(QStringLiteral("WorldState: 0 / 0"));
|
||||
lanAddressLabel->setText(QStringLiteral("LAN: —"));
|
||||
statusBar()->showMessage("Server stopped");
|
||||
addLogMessage("[GUI] Server stopped");
|
||||
}
|
||||
|
||||
void MainWindow::updateServerStatus(bool running) {
|
||||
statusDot->setProperty("running", running);
|
||||
statusDot->style()->unpolish(statusDot);
|
||||
statusDot->style()->polish(statusDot);
|
||||
|
||||
if (running) {
|
||||
statusIndicator->setText("Running");
|
||||
startButton->setEnabled(false);
|
||||
stopButton->setEnabled(true);
|
||||
} else {
|
||||
statusIndicator->setText("Stopped");
|
||||
startButton->setEnabled(true);
|
||||
stopButton->setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerLog(const QString &message) {
|
||||
addLogMessage(message);
|
||||
}
|
||||
|
||||
void MainWindow::addLogMessage(const QString &message) {
|
||||
QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss");
|
||||
logViewer->append(QString("[%1] %2").arg(timestamp, message));
|
||||
|
||||
// Auto-scroll to bottom
|
||||
QTextCursor cursor = logViewer->textCursor();
|
||||
cursor.movePosition(QTextCursor::End);
|
||||
logViewer->setTextCursor(cursor);
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateStats() {
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onStatsUpdated(const QJsonObject &stats) {
|
||||
applyStatsToUi(stats);
|
||||
}
|
||||
|
||||
void MainWindow::applyStatsToUi(const QJsonObject &stats) {
|
||||
const int connected = stats.value(QStringLiteral("connected_clients")).toInt();
|
||||
clientsLabel->setText(QStringLiteral("Clients: %1").arg(connected));
|
||||
|
||||
const double uptime = stats.value(QStringLiteral("uptime_seconds")).toDouble();
|
||||
if (uptime < 60.0) {
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1s").arg(static_cast<int>(uptime)));
|
||||
} else if (uptime < 3600.0) {
|
||||
const int minutes = static_cast<int>(uptime / 60.0);
|
||||
const int seconds = static_cast<int>(uptime) % 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1m %2s").arg(minutes).arg(seconds));
|
||||
} else {
|
||||
const int hours = static_cast<int>(uptime / 3600.0);
|
||||
const int minutes = (static_cast<int>(uptime) % 3600) / 60;
|
||||
uptimeLabel->setText(QStringLiteral("Uptime: %1h %2m").arg(hours).arg(minutes));
|
||||
}
|
||||
|
||||
transformPacketsLabel->setText(
|
||||
QStringLiteral("Transform: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("transform_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("transform_packets_broadcast")).toInt()));
|
||||
worldStatePacketsLabel->setText(
|
||||
QStringLiteral("WorldState: %1 / %2")
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_received")).toInt())
|
||||
.arg(stats.value(QStringLiteral("world_state_packets_broadcast")).toInt()));
|
||||
|
||||
const QString host = stats.value(QStringLiteral("host")).toString();
|
||||
const QString port = stats.value(QStringLiteral("port")).toString();
|
||||
if (!host.isEmpty() && !port.isEmpty()) {
|
||||
bindAddressLabel->setText(QStringLiteral("Bind: %1:%2").arg(host, port));
|
||||
}
|
||||
|
||||
// LAN addresses are not currently in the stats snapshot; leave placeholder.
|
||||
}
|
||||
|
||||
void MainWindow::onUpdateClients(const QJsonArray &clients) {
|
||||
const int selectedRow = clientsTable->currentRow();
|
||||
int selectedPlayerId = -1;
|
||||
if (selectedRow >= 0 && clientsTable->item(selectedRow, 0)) {
|
||||
selectedPlayerId = clientsTable->item(selectedRow, 0)->text().toInt();
|
||||
}
|
||||
|
||||
clientsTable->setRowCount(0);
|
||||
|
||||
int restoreRow = -1;
|
||||
for (int i = 0; i < clients.size(); ++i) {
|
||||
QJsonObject client = clients[i].toObject();
|
||||
|
||||
int row = clientsTable->rowCount();
|
||||
clientsTable->insertRow(row);
|
||||
|
||||
const int playerId = client.value(QStringLiteral("player_id")).toInt();
|
||||
QString connectedText = QStringLiteral("?");
|
||||
const QJsonValue connectedValue = client.value(QStringLiteral("connected_at"));
|
||||
if (connectedValue.isDouble()) {
|
||||
connectedText = QDateTime::fromSecsSinceEpoch(
|
||||
static_cast<qint64>(connectedValue.toDouble())).toString(QStringLiteral("HH:mm:ss"));
|
||||
} else if (connectedValue.isString()) {
|
||||
connectedText = connectedValue.toString();
|
||||
}
|
||||
|
||||
clientsTable->setItem(row, 0, new QTableWidgetItem(QString::number(playerId)));
|
||||
clientsTable->setItem(row, 1, new QTableWidgetItem(client.value(QStringLiteral("address")).toString()));
|
||||
clientsTable->setItem(row, 2, new QTableWidgetItem(client.value(QStringLiteral("label")).toString()));
|
||||
clientsTable->setItem(row, 3, new QTableWidgetItem(connectedText));
|
||||
clientsTable->setItem(row, 4, new QTableWidgetItem(QString::number(client.value(QStringLiteral("packets_sent")).toInt())));
|
||||
|
||||
if (playerId == selectedPlayerId) {
|
||||
restoreRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
if (restoreRow >= 0) {
|
||||
clientsTable->selectRow(restoreRow);
|
||||
}
|
||||
onClientSelectionChanged();
|
||||
}
|
||||
|
||||
void MainWindow::onClientSelectionChanged() {
|
||||
const bool hasSelection = isServerRunning && clientsTable->currentRow() >= 0;
|
||||
kickButton->setEnabled(hasSelection);
|
||||
banButton->setEnabled(hasSelection);
|
||||
}
|
||||
|
||||
void MainWindow::onKickSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Kick player"),
|
||||
QStringLiteral("Kick player %1 (%2)? They can reconnect.").arg(playerId).arg(address));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->kickPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onBanSelectedClient() {
|
||||
const int row = clientsTable->currentRow();
|
||||
if (row < 0 || !serverProcess) {
|
||||
return;
|
||||
}
|
||||
const int playerId = clientsTable->item(row, 0)->text().toInt();
|
||||
const QString address = clientsTable->item(row, 1)->text();
|
||||
const QString ip = address.section(QLatin1Char(':'), 0, 0);
|
||||
const auto result = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Ban IP"),
|
||||
QStringLiteral(
|
||||
"Ban IP %1 (player %2)?\n\n"
|
||||
"They will be disconnected and cannot reconnect until unbanned.")
|
||||
.arg(ip)
|
||||
.arg(playerId));
|
||||
if (result != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->banPlayer(playerId);
|
||||
}
|
||||
|
||||
void MainWindow::onManageBans() {
|
||||
if (!serverProcess || !isServerRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray bans = serverProcess->listBans();
|
||||
if (bans.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
QStringList lines;
|
||||
QStringList ips;
|
||||
for (const QJsonValue &value : bans) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString ip = entry.value(QStringLiteral("ip")).toString();
|
||||
if (ip.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
ips.append(ip);
|
||||
const QString reason = entry.value(QStringLiteral("reason")).toString();
|
||||
lines.append(reason.isEmpty() ? ip : QStringLiteral("%1 — %2").arg(ip, reason));
|
||||
}
|
||||
|
||||
if (ips.isEmpty()) {
|
||||
QMessageBox::information(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("No IPs are currently banned."));
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
const QString chosen = QInputDialog::getItem(
|
||||
this,
|
||||
QStringLiteral("Banned IPs"),
|
||||
QStringLiteral("Select an IP to unban:"),
|
||||
lines,
|
||||
0,
|
||||
false,
|
||||
&ok);
|
||||
if (!ok || chosen.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int index = lines.indexOf(chosen);
|
||||
if (index < 0 || index >= ips.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString ip = ips.at(index);
|
||||
const auto confirm = QMessageBox::question(
|
||||
this,
|
||||
QStringLiteral("Unban IP"),
|
||||
QStringLiteral("Remove %1 from the ban list?").arg(ip));
|
||||
if (confirm != QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
serverProcess->unbanIp(ip);
|
||||
}
|
||||
|
||||
void MainWindow::onAdminCommandFinished(bool ok, const QString &message) {
|
||||
if (ok) {
|
||||
addLogMessage(QStringLiteral("[ADMIN] %1").arg(message));
|
||||
statusBar()->showMessage(message);
|
||||
if (serverProcess) {
|
||||
serverProcess->fetchStats();
|
||||
}
|
||||
} else {
|
||||
addLogMessage(QStringLiteral("[ADMIN ERROR] %1").arg(message));
|
||||
statusBar()->showMessage(QStringLiteral("Admin error: %1").arg(message));
|
||||
QMessageBox::warning(this, QStringLiteral("Admin command failed"), message);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onServerError(const QString &error) {
|
||||
addLogMessage(QString("[ERROR] %1").arg(error));
|
||||
statusBar()->showMessage(QString("Error: %1").arg(error));
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
if (isServerRunning) {
|
||||
serverProcess->stop();
|
||||
}
|
||||
event->accept();
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#ifndef MAINWINDOW_H
|
||||
#define MAINWINDOW_H
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QTableWidget>
|
||||
#include <QTextEdit>
|
||||
#include <QSplitter>
|
||||
#include <QTimer>
|
||||
#include "ServerProcess.h"
|
||||
#include "ConfigDialog.h"
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void onStartServer();
|
||||
void onStopServer();
|
||||
void onOpenConfig();
|
||||
void onServerStarted();
|
||||
void onServerStopped();
|
||||
void onServerLog(const QString &message);
|
||||
void onUpdateStats();
|
||||
void onUpdateClients(const QJsonArray &clients);
|
||||
void onServerError(const QString &error);
|
||||
void onKickSelectedClient();
|
||||
void onBanSelectedClient();
|
||||
void onManageBans();
|
||||
void onClientSelectionChanged();
|
||||
void onAdminCommandFinished(bool ok, const QString &message);
|
||||
void onStatsUpdated(const QJsonObject &stats);
|
||||
|
||||
private:
|
||||
void setupUI();
|
||||
void setupStyles();
|
||||
void setupConnections();
|
||||
void setupTimer();
|
||||
|
||||
void updateServerStatus(bool running);
|
||||
void addLogMessage(const QString &message);
|
||||
void applyStatsToUi(const QJsonObject &stats);
|
||||
void resolveConfigPath();
|
||||
void loadConfigIntoUi();
|
||||
void applyConfigToUi(const ServerConfigData &config);
|
||||
|
||||
// UI Components
|
||||
QWidget *centralWidget;
|
||||
|
||||
// Toolbar
|
||||
QLabel *serverNameLabel;
|
||||
QLabel *statusDot;
|
||||
QLabel *statusIndicator;
|
||||
QPushButton *startButton;
|
||||
QPushButton *stopButton;
|
||||
QPushButton *configButton;
|
||||
|
||||
// Status strip
|
||||
QLabel *uptimeLabel;
|
||||
QLabel *clientsLabel;
|
||||
QLabel *transformPacketsLabel;
|
||||
QLabel *worldStatePacketsLabel;
|
||||
QLabel *bindAddressLabel;
|
||||
QLabel *lanAddressLabel;
|
||||
|
||||
// Main panes
|
||||
QSplitter *mainSplitter;
|
||||
QTableWidget *clientsTable;
|
||||
QPushButton *kickButton;
|
||||
QPushButton *banButton;
|
||||
QPushButton *bansButton;
|
||||
QTextEdit *logViewer;
|
||||
|
||||
// Server Process
|
||||
ServerProcess *serverProcess;
|
||||
|
||||
// Timer for stats updates
|
||||
QTimer *statsTimer;
|
||||
|
||||
// State
|
||||
bool isServerRunning;
|
||||
QString serverName;
|
||||
QString configFilePath;
|
||||
};
|
||||
|
||||
#endif // MAINWINDOW_H
|
||||
@@ -0,0 +1,336 @@
|
||||
#include "ServerProcess.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QHostAddress>
|
||||
|
||||
namespace {
|
||||
constexpr int kDefaultAdminPort = 7779;
|
||||
constexpr int kAdminTimeoutMs = 3000;
|
||||
}
|
||||
|
||||
ServerProcess::ServerProcess(QObject *parent)
|
||||
: QObject(parent)
|
||||
, process(nullptr)
|
||||
, running(false)
|
||||
, m_adminPort(kDefaultAdminPort)
|
||||
, adminFailCount(0)
|
||||
{
|
||||
pythonPath = findPythonExecutable();
|
||||
serverDir = findServerDirectory();
|
||||
}
|
||||
|
||||
ServerProcess::~ServerProcess() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void ServerProcess::setAdminPort(int port) {
|
||||
m_adminPort = (port > 0 && port <= 65535) ? port : kDefaultAdminPort;
|
||||
}
|
||||
|
||||
int ServerProcess::adminPort() const {
|
||||
return m_adminPort;
|
||||
}
|
||||
|
||||
void ServerProcess::start(const QString &configPath) {
|
||||
if (running) {
|
||||
emit error("Server is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pythonPath.isEmpty()) {
|
||||
emit error("Python not found. Please install Python 3.9+ and add it to PATH");
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverDir.isEmpty()) {
|
||||
emit error("Server directory not found");
|
||||
return;
|
||||
}
|
||||
|
||||
process = new QProcess(this);
|
||||
connect(process, SIGNAL(finished(int, QProcess::ExitStatus)),
|
||||
this, SLOT(onProcessFinished(int, QProcess::ExitStatus)));
|
||||
connect(process, SIGNAL(error(QProcess::ProcessError)),
|
||||
this, SLOT(onProcessError(QProcess::ProcessError)));
|
||||
connect(process, SIGNAL(readyReadStandardOutput()),
|
||||
this, SLOT(onReadyReadStandardOutput()));
|
||||
connect(process, SIGNAL(readyReadStandardError()),
|
||||
this, SLOT(onReadyReadStandardError()));
|
||||
connect(process, SIGNAL(started()),
|
||||
this, SLOT(onProcessStarted()));
|
||||
|
||||
QStringList arguments;
|
||||
arguments << "consumer_server_cli.py" << "serve" << "--config" << configPath;
|
||||
|
||||
process->setWorkingDirectory(serverDir);
|
||||
process->start(pythonPath, arguments);
|
||||
}
|
||||
|
||||
void ServerProcess::stop() {
|
||||
if (!process) return;
|
||||
|
||||
if (process->state() == QProcess::Running) {
|
||||
process->terminate();
|
||||
if (!process->waitForFinished(3000)) {
|
||||
process->kill();
|
||||
process->waitForFinished();
|
||||
}
|
||||
}
|
||||
|
||||
running = false;
|
||||
adminFailCount = 0;
|
||||
}
|
||||
|
||||
QJsonObject ServerProcess::sendAdminCommand(const QJsonObject &request) {
|
||||
QTcpSocket socket;
|
||||
// Force IPv4 loopback — matches server admin bind on 127.0.0.1.
|
||||
socket.connectToHost(QStringLiteral("127.0.0.1"), static_cast<quint16>(m_adminPort));
|
||||
if (!socket.waitForConnected(kAdminTimeoutMs)) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"),
|
||||
QStringLiteral("Could not connect to admin port 127.0.0.1:%1 (%2)")
|
||||
.arg(m_adminPort)
|
||||
.arg(socket.errorString())}
|
||||
};
|
||||
}
|
||||
|
||||
const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact) + '\n';
|
||||
if (socket.write(payload) < 0 || !socket.waitForBytesWritten(kAdminTimeoutMs)) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Failed to send admin command")}
|
||||
};
|
||||
}
|
||||
|
||||
QByteArray buffer;
|
||||
while (!buffer.contains('\n')) {
|
||||
if (!socket.waitForReadyRead(kAdminTimeoutMs)) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Timed out waiting for admin response")}
|
||||
};
|
||||
}
|
||||
buffer += socket.readAll();
|
||||
}
|
||||
|
||||
const int newline = buffer.indexOf('\n');
|
||||
const QByteArray line = buffer.left(newline);
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(line, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
return QJsonObject{
|
||||
{QStringLiteral("ok"), false},
|
||||
{QStringLiteral("error"), QStringLiteral("Invalid admin response JSON")}
|
||||
};
|
||||
}
|
||||
return doc.object();
|
||||
}
|
||||
|
||||
void ServerProcess::fetchStats() {
|
||||
if (!running) return;
|
||||
|
||||
const QJsonObject statsResponse = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("stats")}});
|
||||
if (statsResponse.value(QStringLiteral("ok")).toBool()) {
|
||||
adminFailCount = 0;
|
||||
emit statsUpdated(statsResponse.value(QStringLiteral("data")).toObject());
|
||||
} else {
|
||||
++adminFailCount;
|
||||
// Avoid spamming the log every second while the admin port is still starting.
|
||||
if (adminFailCount == 3 || adminFailCount == 10 || (adminFailCount % 30) == 0) {
|
||||
emit logMessage(QStringLiteral("[ADMIN] %1")
|
||||
.arg(statsResponse.value(QStringLiteral("error"))
|
||||
.toString(QStringLiteral("Admin stats request failed."))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject clientsResponse = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("clients")}});
|
||||
if (clientsResponse.value(QStringLiteral("ok")).toBool()) {
|
||||
const QJsonObject data = clientsResponse.value(QStringLiteral("data")).toObject();
|
||||
emit clientsUpdated(data.value(QStringLiteral("clients")).toArray());
|
||||
} else {
|
||||
emit logMessage(QStringLiteral("[ADMIN] %1")
|
||||
.arg(clientsResponse.value(QStringLiteral("error"))
|
||||
.toString(QStringLiteral("Admin clients request failed."))));
|
||||
}
|
||||
}
|
||||
|
||||
bool ServerProcess::kickPlayer(int playerId, const QString &reason) {
|
||||
QJsonObject request{
|
||||
{QStringLiteral("cmd"), QStringLiteral("kick")},
|
||||
{QStringLiteral("playerId"), playerId},
|
||||
{QStringLiteral("reason"), reason}
|
||||
};
|
||||
const QJsonObject response = sendAdminCommand(request);
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
const QString message = ok
|
||||
? response.value(QStringLiteral("message")).toString(QStringLiteral("Player kicked."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Kick failed."));
|
||||
emit adminCommandFinished(ok, message);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ServerProcess::banPlayer(int playerId, const QString &reason) {
|
||||
QJsonObject request{
|
||||
{QStringLiteral("cmd"), QStringLiteral("ban")},
|
||||
{QStringLiteral("playerId"), playerId},
|
||||
{QStringLiteral("reason"), reason}
|
||||
};
|
||||
const QJsonObject response = sendAdminCommand(request);
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
const QString message = ok
|
||||
? response.value(QStringLiteral("message")).toString(QStringLiteral("Player banned."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Ban failed."));
|
||||
emit adminCommandFinished(ok, message);
|
||||
if (ok) {
|
||||
fetchStats();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ServerProcess::unbanIp(const QString &ip) {
|
||||
QJsonObject request{
|
||||
{QStringLiteral("cmd"), QStringLiteral("unban")},
|
||||
{QStringLiteral("ip"), ip}
|
||||
};
|
||||
const QJsonObject response = sendAdminCommand(request);
|
||||
const bool ok = response.value(QStringLiteral("ok")).toBool();
|
||||
const QString message = ok
|
||||
? response.value(QStringLiteral("message")).toString(QStringLiteral("IP unbanned."))
|
||||
: response.value(QStringLiteral("error")).toString(QStringLiteral("Unban failed."));
|
||||
emit adminCommandFinished(ok, message);
|
||||
return ok;
|
||||
}
|
||||
|
||||
QJsonArray ServerProcess::listBans() {
|
||||
const QJsonObject response = sendAdminCommand(QJsonObject{{QStringLiteral("cmd"), QStringLiteral("bans")}});
|
||||
if (!response.value(QStringLiteral("ok")).toBool()) {
|
||||
emit adminCommandFinished(
|
||||
false,
|
||||
response.value(QStringLiteral("error")).toString(QStringLiteral("Could not list bans.")));
|
||||
return {};
|
||||
}
|
||||
return response.value(QStringLiteral("data")).toObject().value(QStringLiteral("bans")).toArray();
|
||||
}
|
||||
|
||||
bool ServerProcess::isRunning() const {
|
||||
return running && process && process->state() == QProcess::Running;
|
||||
}
|
||||
|
||||
QString ServerProcess::serverDirectory() const {
|
||||
return serverDir;
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessStarted() {
|
||||
running = true;
|
||||
adminFailCount = 0;
|
||||
emit started();
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus) {
|
||||
running = false;
|
||||
adminFailCount = 0;
|
||||
emit stopped();
|
||||
|
||||
if (exitStatus == QProcess::NormalExit) {
|
||||
emit logMessage(QString("Server exited with code %1").arg(exitCode));
|
||||
} else {
|
||||
emit error("Server process crashed");
|
||||
}
|
||||
}
|
||||
|
||||
void ServerProcess::onProcessError(QProcess::ProcessError error) {
|
||||
QString errorString;
|
||||
switch (error) {
|
||||
case QProcess::FailedToStart:
|
||||
errorString = "Failed to start Python process";
|
||||
break;
|
||||
case QProcess::Crashed:
|
||||
errorString = "Server process crashed";
|
||||
break;
|
||||
case QProcess::Timedout:
|
||||
errorString = "Server process timed out";
|
||||
break;
|
||||
default:
|
||||
errorString = "Unknown process error";
|
||||
}
|
||||
emit this->error(errorString);
|
||||
}
|
||||
|
||||
void ServerProcess::onReadyReadStandardOutput() {
|
||||
if (!process) return;
|
||||
|
||||
outputBuffer += process->readAllStandardOutput();
|
||||
|
||||
while (outputBuffer.contains('\n')) {
|
||||
int newlinePos = outputBuffer.indexOf('\n');
|
||||
QString line = outputBuffer.left(newlinePos);
|
||||
outputBuffer = outputBuffer.mid(newlinePos + 1);
|
||||
|
||||
if (!line.isEmpty()) {
|
||||
line = line.trimmed();
|
||||
parseLogLine(line);
|
||||
emit logMessage(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServerProcess::onReadyReadStandardError() {
|
||||
if (!process) return;
|
||||
|
||||
QString errorOutput = process->readAllStandardError();
|
||||
emit logMessage("[STDERR] " + errorOutput);
|
||||
}
|
||||
|
||||
QString ServerProcess::findPythonExecutable() {
|
||||
QProcess proc;
|
||||
proc.start("python", QStringList() << "--version");
|
||||
if (proc.waitForFinished(2000)) {
|
||||
return "python";
|
||||
}
|
||||
|
||||
proc.start("python3", QStringList() << "--version");
|
||||
if (proc.waitForFinished(2000)) {
|
||||
return "python3";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
QString ServerProcess::findServerDirectory() {
|
||||
QDir dir(QCoreApplication::applicationDirPath());
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
|
||||
if (QFileInfo::exists(candidate + QStringLiteral("/consumer_server_cli.py")) &&
|
||||
QFileInfo::exists(candidate + QStringLiteral("/admin_server.py"))) {
|
||||
return QFileInfo(candidate).absoluteFilePath();
|
||||
}
|
||||
if (!dir.cdUp()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: accept server dirs without admin_server.py so older layouts still launch,
|
||||
// but prefer ones that include the admin channel when present.
|
||||
dir = QDir(QCoreApplication::applicationDirPath());
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const QString candidate = dir.absoluteFilePath(QStringLiteral("server"));
|
||||
if (QFileInfo::exists(candidate + QStringLiteral("/consumer_server_cli.py"))) {
|
||||
return QFileInfo(candidate).absoluteFilePath();
|
||||
}
|
||||
if (!dir.cdUp()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return QString();
|
||||
}
|
||||
|
||||
void ServerProcess::parseLogLine(const QString &line) {
|
||||
Q_UNUSED(line);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#ifndef SERVERPROCESS_H
|
||||
#define SERVERPROCESS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QProcess>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QTcpSocket>
|
||||
|
||||
class ServerProcess : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ServerProcess(QObject *parent = nullptr);
|
||||
~ServerProcess();
|
||||
|
||||
void start(const QString &configPath);
|
||||
void stop();
|
||||
void fetchStats();
|
||||
bool kickPlayer(int playerId, const QString &reason = QString());
|
||||
bool banPlayer(int playerId, const QString &reason = QString());
|
||||
bool unbanIp(const QString &ip);
|
||||
QJsonArray listBans();
|
||||
bool isRunning() const;
|
||||
QString serverDirectory() const;
|
||||
void setAdminPort(int port);
|
||||
int adminPort() const;
|
||||
|
||||
signals:
|
||||
void started();
|
||||
void stopped();
|
||||
void logMessage(const QString &message);
|
||||
void clientsUpdated(const QJsonArray &clients);
|
||||
void statsUpdated(const QJsonObject &stats);
|
||||
void error(const QString &message);
|
||||
void adminCommandFinished(bool ok, const QString &message);
|
||||
|
||||
private slots:
|
||||
void onProcessStarted();
|
||||
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||
void onProcessError(QProcess::ProcessError error);
|
||||
void onReadyReadStandardOutput();
|
||||
void onReadyReadStandardError();
|
||||
|
||||
private:
|
||||
QString findPythonExecutable();
|
||||
QString findServerDirectory();
|
||||
void parseLogLine(const QString &line);
|
||||
QJsonObject sendAdminCommand(const QJsonObject &request);
|
||||
|
||||
QProcess *process;
|
||||
QString pythonPath;
|
||||
QString serverDir;
|
||||
bool running;
|
||||
QString outputBuffer;
|
||||
int m_adminPort;
|
||||
int adminFailCount;
|
||||
};
|
||||
|
||||
#endif // SERVERPROCESS_H
|
||||
@@ -0,0 +1,16 @@
|
||||
#include <QApplication>
|
||||
#include "MainWindow.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
|
||||
app.setApplicationName("Commonwealth Online Host");
|
||||
app.setApplicationVersion("1.0.0");
|
||||
app.setApplicationDisplayName("Commonwealth Online — Server Host");
|
||||
|
||||
MainWindow window;
|
||||
window.show();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
IDI_ICON1 ICON "icons/app.ico"
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Placeholder for app.ico - Qt will handle resource loading gracefully if missing -->
|
||||
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>icons/app.ico</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
Reference in New Issue
Block a user