MetaCore/Apps/MetaCoreLauncher/main.cpp

1076 lines
36 KiB
C++

#include "MetaCoreFoundation/MetaCoreProject.h"
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QCoreApplication>
#include <QDate>
#include <QDateTime>
#include <QDesktopServices>
#include <QDialog>
#include <QDir>
#include <QFileDialog>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMainWindow>
#include <QMessageBox>
#include <QMouseEvent>
#include <QProcess>
#include <QProcessEnvironment>
#include <QPushButton>
#include <QScrollArea>
#include <QSettings>
#include <QSizePolicy>
#include <QStackedWidget>
#include <QStandardPaths>
#include <QStringList>
#include <QStyle>
#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <functional>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
namespace {
struct LauncherProject {
QString Name{};
std::filesystem::path RootPath{};
QString UpdatedDate{};
};
constexpr const char* OrganizationName = "MetaCore";
constexpr const char* ApplicationName = "MetaCore Hub";
constexpr const char* NewProjectSettingsGroup = "NewProjectDialog";
constexpr const char* LastProjectPathKey = "lastProjectPath";
std::filesystem::path ToPath(const QString& text) {
return std::filesystem::path(text.toStdWString());
}
QString ToQString(const std::filesystem::path& path) {
return QString::fromStdWString(path.wstring());
}
std::filesystem::path GetExecutableDirectory() {
return ToPath(QCoreApplication::applicationDirPath());
}
std::filesystem::path GetDatabasePath() {
QString dataLocation = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
if (dataLocation.isEmpty()) {
dataLocation = QDir::homePath() + "/AppData/Local/MetaCore/Hub";
}
return ToPath(dataLocation) / "projects.tsv";
}
std::filesystem::path GetDefaultProjectLocation() {
QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
if (documents.isEmpty()) {
documents = QDir::homePath();
}
return ToPath(documents) / "MetaCore Projects";
}
bool IsProjectRoot(const std::filesystem::path& path) {
return std::filesystem::is_regular_file(MetaCore::MetaCoreGetProjectFilePath(path));
}
bool WriteTextFile(const std::filesystem::path& path, const std::string& text, QString& errorMessage) {
std::error_code error;
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path(), error);
if (error) {
errorMessage = QString("Unable to create folder for %1: %2")
.arg(ToQString(path))
.arg(QString::fromStdString(error.message()));
return false;
}
}
std::ofstream output(path, std::ios::binary | std::ios::trunc);
if (!output.is_open()) {
errorMessage = QString("Unable to write %1.").arg(ToQString(path));
return false;
}
output << text;
return true;
}
QPushButton* CreateButton(const QString& text, const QString& objectName = {}) {
auto* button = new QPushButton(text);
button->setCursor(Qt::PointingHandCursor);
if (!objectName.isEmpty()) {
button->setObjectName(objectName);
}
return button;
}
QLabel* CreateMutedLabel(const QString& text) {
auto* label = new QLabel(text);
label->setObjectName("mutedText");
return label;
}
void ClearLayout(QLayout* layout) {
if (layout == nullptr) {
return;
}
while (QLayoutItem* item = layout->takeAt(0)) {
if (QWidget* widget = item->widget()) {
widget->deleteLater();
}
if (QLayout* childLayout = item->layout()) {
ClearLayout(childLayout);
}
delete item;
}
}
bool CreateProjectOnDisk(const std::filesystem::path& rootPath, const QString& name, QString& errorMessage) {
if (rootPath.empty()) {
errorMessage = "Project location is required.";
return false;
}
std::error_code error;
if (std::filesystem::exists(rootPath, error) && !std::filesystem::is_empty(rootPath, error)) {
errorMessage = "Project folder already exists and is not empty.";
return false;
}
const std::vector<std::filesystem::path> directories = {
rootPath / "ProjectSettings",
rootPath / "Logs",
rootPath / "Library",
rootPath / "Assets",
rootPath / "Assets" / "Models",
rootPath / "Assets" / "Materials",
rootPath / "Assets" / "Textures",
rootPath / "Assets" / "Prefabs",
rootPath / "Scenes",
rootPath / "Runtime",
rootPath / "Ui",
rootPath / "Build",
rootPath / ".vscode"
};
for (const std::filesystem::path& directory : directories) {
std::filesystem::create_directories(directory, error);
if (error) {
errorMessage = QString("Unable to create project folder %1: %2")
.arg(ToQString(directory))
.arg(QString::fromStdString(error.message()));
return false;
}
}
MetaCore::MetaCoreProjectFileDocument document;
document.Name = name.toStdString();
document.Version = "0.1.0";
document.RuntimeDirectory = "Runtime";
document.UiDirectory = "Ui";
document.BuildDirectory = "Build";
document.StartupScenePath = std::filesystem::path("Scenes") / "Main.mcscene.json";
if (!MetaCore::MetaCoreWriteProjectFile(MetaCore::MetaCoreGetProjectFilePath(rootPath), document)) {
errorMessage = "Unable to write MetaCore.project.json.";
return false;
}
if (!WriteTextFile(
rootPath / "Assets" / "README.md",
"# Project Assets\n\nThis folder contains all assets for the MetaCore project.\n",
errorMessage
)) {
return false;
}
const std::string createdAt = QDateTime::currentDateTime().toString(Qt::ISODate).toStdString();
std::ostringstream ini;
ini << "[Project]\n";
ini << "name = " << name.toStdString() << "\n";
ini << "path = " << ToQString(rootPath).toStdString() << "\n";
ini << "created_at = " << createdAt << "\n";
ini << "changed_at = " << createdAt << "\n";
if (!WriteTextFile(rootPath / (name.toStdString() + ".ini"), ini.str(), errorMessage)) {
return false;
}
if (!WriteTextFile(
rootPath / ".vscode" / "settings.json",
"{\n"
" \"editor.formatOnSave\": true,\n"
" \"files.exclude\": {\n"
" \"Library\": true,\n"
" \"Logs\": true,\n"
" \"Build\": true\n"
" }\n"
"}\n",
errorMessage
)) {
return false;
}
return true;
}
class ProjectCard final : public QFrame {
public:
explicit ProjectCard(LauncherProject project, QWidget* parent = nullptr)
: QFrame(parent),
Project_(std::move(project)) {
setObjectName("projectCard");
setCursor(Qt::PointingHandCursor);
setMinimumHeight(72);
setMaximumHeight(72);
auto* rootLayout = new QHBoxLayout(this);
rootLayout->setContentsMargins(16, 10, 12, 10);
rootLayout->setSpacing(14);
Avatar_ = new QLabel(Project_.Name.left(1).toUpper());
Avatar_->setObjectName("projectAvatar");
Avatar_->setAlignment(Qt::AlignCenter);
Avatar_->setFixedSize(44, 44);
rootLayout->addWidget(Avatar_);
auto* textLayout = new QVBoxLayout();
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(2);
auto* nameLabel = new QLabel(Project_.Name);
nameLabel->setObjectName("projectName");
auto* pathLabel = CreateMutedLabel(ToQString(Project_.RootPath));
pathLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
pathLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
textLayout->addWidget(nameLabel);
textLayout->addWidget(pathLabel);
rootLayout->addLayout(textLayout, 1);
auto* dateLabel = CreateMutedLabel(Project_.UpdatedDate);
dateLabel->setMinimumWidth(88);
rootLayout->addWidget(dateLabel);
auto* openButton = CreateButton("Open");
openButton->setObjectName("smallButton");
openButton->setFixedWidth(64);
connect(openButton, &QPushButton::clicked, this, [this]() {
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(Project_.RootPath)));
});
rootLayout->addWidget(openButton);
}
void SetSelected(bool selected) {
setProperty("selected", selected);
style()->unpolish(this);
style()->polish(this);
}
const LauncherProject& Project() const {
return Project_;
}
protected:
void mousePressEvent(QMouseEvent* event) override {
emitSelected();
QFrame::mousePressEvent(event);
}
private:
void emitSelected() {
if (OnSelected) {
OnSelected();
}
}
public:
std::function<void()> OnSelected{};
private:
LauncherProject Project_{};
QLabel* Avatar_ = nullptr;
};
class NewProjectDialog final : public QDialog {
public:
explicit NewProjectDialog(QWidget* parent = nullptr)
: QDialog(parent) {
setWindowTitle("Create New Project");
setMinimumWidth(520);
setModal(true);
auto* rootLayout = new QVBoxLayout(this);
rootLayout->setContentsMargins(22, 20, 22, 18);
rootLayout->setSpacing(10);
rootLayout->addWidget(new QLabel("Project Name:"));
NameEdit_ = new QLineEdit();
NameEdit_->setPlaceholderText("Enter a name for your project");
rootLayout->addWidget(NameEdit_);
rootLayout->addWidget(new QLabel("Project Location:"));
auto* locationLayout = new QHBoxLayout();
LocationEdit_ = new QLineEdit();
LocationEdit_->setReadOnly(true);
LocationEdit_->setPlaceholderText("No path selected");
auto* browseButton = CreateButton("Browse...");
browseButton->setFixedWidth(90);
locationLayout->addWidget(LocationEdit_, 1);
locationLayout->addWidget(browseButton);
rootLayout->addLayout(locationLayout);
QSettings settings(OrganizationName, ApplicationName);
const QString lastPath = settings.value(
QString("%1/%2").arg(NewProjectSettingsGroup, LastProjectPathKey),
QString()
).toString();
if (!lastPath.isEmpty()) {
LocationEdit_->setText(lastPath);
}
connect(browseButton, &QPushButton::clicked, this, [this]() {
const QString currentPath = !LocationEdit_->text().isEmpty() ? LocationEdit_->text() : ToQString(GetDefaultProjectLocation());
const QString selectedPath = QFileDialog::getExistingDirectory(
this,
"Project Location",
currentPath
);
if (!selectedPath.isEmpty()) {
LocationEdit_->setText(selectedPath);
QSettings settings(OrganizationName, ApplicationName);
settings.setValue(QString("%1/%2").arg(NewProjectSettingsGroup, LastProjectPathKey), selectedPath);
UpdateCreateButtonState();
}
});
rootLayout->addWidget(new QLabel("Engine Version:"));
VersionCombo_ = new QComboBox();
VersionCombo_->setFixedHeight(32);
VersionCombo_->addItem("dev (current environment)", "dev-current");
rootLayout->addWidget(VersionCombo_);
ErrorLabel_ = new QLabel();
ErrorLabel_->setObjectName("errorText");
ErrorLabel_->setWordWrap(true);
ErrorLabel_->hide();
rootLayout->addWidget(ErrorLabel_);
auto* buttonsLayout = new QHBoxLayout();
buttonsLayout->addStretch(1);
auto* cancelButton = CreateButton("Cancel");
CreateButton_ = CreateButton("Create", "primaryButton");
cancelButton->setFixedWidth(90);
CreateButton_->setFixedWidth(90);
buttonsLayout->addWidget(cancelButton);
buttonsLayout->addWidget(CreateButton_);
rootLayout->addLayout(buttonsLayout);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
connect(CreateButton_, &QPushButton::clicked, this, [this]() {
accept();
});
connect(NameEdit_, &QLineEdit::textChanged, this, [this]() {
UpdateCreateButtonState();
});
connect(VersionCombo_, &QComboBox::currentTextChanged, this, [this]() {
UpdateCreateButtonState();
});
UpdateCreateButtonState();
}
QString ProjectName() const {
return NameEdit_->text().trimmed();
}
std::filesystem::path ProjectRootPath() const {
return ToPath(LocationEdit_->text()) / ToPath(ProjectName());
}
void accept() override {
UpdateCreateButtonState();
if (!CreateButton_->isEnabled()) {
ShowError("Project name, location, and engine version are required.");
return;
}
if (ProjectName().contains('/') || ProjectName().contains('\\')) {
ShowError("Project name cannot contain path separators.");
return;
}
QDialog::accept();
}
private:
void ShowError(const QString& text) {
ErrorLabel_->setText(text);
ErrorLabel_->show();
}
void UpdateCreateButtonState() {
if (CreateButton_ == nullptr) {
return;
}
const bool valid = !ProjectName().isEmpty() &&
!LocationEdit_->text().trimmed().isEmpty() &&
VersionCombo_->currentIndex() >= 0;
CreateButton_->setEnabled(valid);
if (valid) {
ErrorLabel_->hide();
}
}
QLineEdit* NameEdit_ = nullptr;
QLineEdit* LocationEdit_ = nullptr;
QComboBox* VersionCombo_ = nullptr;
QLabel* ErrorLabel_ = nullptr;
QPushButton* CreateButton_ = nullptr;
};
class MetaCoreLauncherWindow final : public QMainWindow {
public:
explicit MetaCoreLauncherWindow(QWidget* parent = nullptr)
: QMainWindow(parent),
ExecutableDirectory_(GetExecutableDirectory()),
DatabasePath_(GetDatabasePath()) {
setWindowTitle("MetaCore Hub");
resize(1080, 720);
setMinimumSize(960, 640);
setObjectName("launcherWindow");
LoadProjects();
BuildUi();
RefreshProjectList();
}
private:
void BuildUi() {
ApplyStyleSheet(true);
auto* central = new QWidget(this);
central->setObjectName("central");
setCentralWidget(central);
auto* rootLayout = new QHBoxLayout(central);
rootLayout->setContentsMargins(0, 0, 0, 0);
rootLayout->setSpacing(0);
rootLayout->addWidget(BuildSidebar());
Pages_ = new QStackedWidget();
Pages_->addWidget(BuildProjectsPage());
Pages_->addWidget(BuildInstallsPage());
rootLayout->addWidget(Pages_, 1);
}
QWidget* BuildSidebar() {
auto* sidebar = new QWidget();
sidebar->setObjectName("sidebar");
sidebar->setFixedWidth(220);
auto* layout = new QVBoxLayout(sidebar);
layout->setContentsMargins(18, 24, 18, 18);
layout->setSpacing(8);
auto* titleLabel = new QLabel("MetaCore");
titleLabel->setObjectName("hubTitle");
auto* subtitleLabel = CreateMutedLabel("Hub");
layout->addWidget(titleLabel);
layout->addWidget(subtitleLabel);
layout->addSpacing(28);
ProjectsNavButton_ = CreateButton("Projects", "navButton");
InstallsNavButton_ = CreateButton("Installs", "navButton");
ProjectsNavButton_->setProperty("selected", true);
layout->addWidget(ProjectsNavButton_);
layout->addWidget(InstallsNavButton_);
layout->addStretch(1);
auto* darkMode = new QCheckBox("Dark Mode");
darkMode->setChecked(true);
layout->addWidget(darkMode);
connect(ProjectsNavButton_, &QPushButton::clicked, this, [this]() {
SetPage(0);
});
connect(InstallsNavButton_, &QPushButton::clicked, this, [this]() {
SetPage(1);
});
connect(darkMode, &QCheckBox::toggled, this, [this](bool checked) {
ApplyStyleSheet(checked);
});
return sidebar;
}
QWidget* BuildProjectsPage() {
auto* page = new QWidget();
auto* layout = new QVBoxLayout(page);
layout->setContentsMargins(28, 24, 28, 24);
layout->setSpacing(14);
auto* headerLayout = new QHBoxLayout();
auto* titleLabel = new QLabel("Projects");
titleLabel->setObjectName("pageTitle");
headerLayout->addWidget(titleLabel);
headerLayout->addStretch(1);
auto* deleteButton = CreateButton("Delete", "dangerButton");
auto* newButton = CreateButton("+ New Project");
auto* launchButton = CreateButton("Launch", "primaryButton");
headerLayout->addWidget(deleteButton);
headerLayout->addWidget(newButton);
headerLayout->addWidget(launchButton);
layout->addLayout(headerLayout);
SearchEdit_ = new QLineEdit();
SearchEdit_->setPlaceholderText("Search projects...");
SearchEdit_->setFixedHeight(36);
layout->addWidget(SearchEdit_);
StatusLabel_ = CreateMutedLabel("");
StatusLabel_->hide();
layout->addWidget(StatusLabel_);
auto* scrollArea = new QScrollArea();
scrollArea->setObjectName("projectScroll");
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
ProjectListContainer_ = new QWidget();
ProjectListLayout_ = new QVBoxLayout(ProjectListContainer_);
ProjectListLayout_->setContentsMargins(0, 0, 0, 0);
ProjectListLayout_->setSpacing(10);
ProjectListLayout_->addStretch(1);
scrollArea->setWidget(ProjectListContainer_);
layout->addWidget(scrollArea, 1);
connect(SearchEdit_, &QLineEdit::textChanged, this, [this]() {
RefreshProjectList();
});
connect(newButton, &QPushButton::clicked, this, [this]() {
CreateNewProject();
});
connect(deleteButton, &QPushButton::clicked, this, [this]() {
RemoveSelectedProject();
});
connect(launchButton, &QPushButton::clicked, this, [this]() {
LaunchSelectedProject();
});
return page;
}
QWidget* BuildInstallsPage() {
auto* page = new QWidget();
auto* layout = new QVBoxLayout(page);
layout->setContentsMargins(28, 24, 28, 24);
layout->setSpacing(14);
auto* headerLayout = new QHBoxLayout();
auto* titleLabel = new QLabel("Installs");
titleLabel->setObjectName("pageTitle");
headerLayout->addWidget(titleLabel);
headerLayout->addStretch(1);
auto* refreshButton = CreateButton("Refresh");
headerLayout->addWidget(refreshButton);
layout->addLayout(headerLayout);
layout->addWidget(BuildInstallCard("MetaCore Editor", "", "Locate", [this]() {
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(ExecutableDirectory_)));
}, &EditorInstallStatusLabel_));
layout->addWidget(BuildInstallCard("MetaCore Player", "", "Locate", [this]() {
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(ExecutableDirectory_)));
}, &PlayerInstallStatusLabel_));
layout->addWidget(BuildInstallCard("Cook / Package Tool", "", "Locate", [this]() {
QDesktopServices::openUrl(QUrl::fromLocalFile(ToQString(ExecutableDirectory_)));
}, &CookToolInstallStatusLabel_));
layout->addWidget(BuildInstallCard("Runtime Stack", "C++20 / Filament / ImGui / RmlUi / Cook", "Refresh", [this]() {
RefreshInstallsPage();
}));
layout->addStretch(1);
connect(refreshButton, &QPushButton::clicked, this, [this]() {
RefreshInstallsPage();
});
RefreshInstallsPage();
return page;
}
QWidget* BuildInstallCard(
const QString& title,
const QString& subtitle,
const QString& action,
std::function<void()> callback,
QLabel** subtitleLabelOut = nullptr
) {
auto* card = new QFrame();
card->setObjectName("installCard");
card->setMinimumHeight(78);
card->setMaximumHeight(78);
auto* layout = new QHBoxLayout(card);
layout->setContentsMargins(16, 12, 16, 12);
layout->setSpacing(12);
auto* textLayout = new QVBoxLayout();
textLayout->setSpacing(3);
auto* titleLabel = new QLabel(title);
titleLabel->setObjectName("projectName");
textLayout->addWidget(titleLabel);
QLabel* subtitleLabel = CreateMutedLabel(subtitle);
subtitleLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
textLayout->addWidget(subtitleLabel);
if (subtitleLabelOut != nullptr) {
*subtitleLabelOut = subtitleLabel;
}
layout->addLayout(textLayout, 1);
auto* button = CreateButton(action);
button->setFixedWidth(90);
connect(button, &QPushButton::clicked, this, std::move(callback));
layout->addWidget(button);
return card;
}
void RefreshInstallsPage() {
auto describeExecutable = [this](const QString& label, const std::filesystem::path& executablePath) {
if (std::filesystem::is_regular_file(executablePath)) {
return QString("Installed: %1").arg(ToQString(executablePath));
}
return QString("Missing: %1 was not found next to the launcher. Build the %2 target first.")
.arg(ToQString(executablePath))
.arg(label);
};
if (EditorInstallStatusLabel_ != nullptr) {
const QString status = describeExecutable("MetaCoreEditorApp", ExecutableDirectory_ / "MetaCoreEditorApp.exe");
EditorInstallStatusLabel_->setText(status);
EditorInstallStatusLabel_->setToolTip(status);
}
if (PlayerInstallStatusLabel_ != nullptr) {
const QString status = describeExecutable("MetaCorePlayer", ExecutableDirectory_ / "MetaCorePlayer.exe");
PlayerInstallStatusLabel_->setText(status);
PlayerInstallStatusLabel_->setToolTip(status);
}
if (CookToolInstallStatusLabel_ != nullptr) {
const QString status = describeExecutable("MetaCoreBuildPackageTool", ExecutableDirectory_ / "MetaCoreBuildPackageTool.exe");
CookToolInstallStatusLabel_->setText(status);
CookToolInstallStatusLabel_->setToolTip(status);
}
}
void SetPage(int index) {
Pages_->setCurrentIndex(index);
ProjectsNavButton_->setProperty("selected", index == 0);
InstallsNavButton_->setProperty("selected", index == 1);
ProjectsNavButton_->style()->unpolish(ProjectsNavButton_);
ProjectsNavButton_->style()->polish(ProjectsNavButton_);
InstallsNavButton_->style()->unpolish(InstallsNavButton_);
InstallsNavButton_->style()->polish(InstallsNavButton_);
if (index == 1) {
RefreshInstallsPage();
}
}
void LoadProjects() {
Projects_.clear();
std::ifstream stream(DatabasePath_, std::ios::binary);
if (!stream.is_open()) {
return;
}
std::string line;
while (std::getline(stream, line)) {
std::stringstream lineStream(line);
std::string name;
std::string path;
std::string date;
if (!std::getline(lineStream, name, '\t') ||
!std::getline(lineStream, path, '\t') ||
!std::getline(lineStream, date, '\t')) {
continue;
}
LauncherProject project;
project.Name = QString::fromStdString(name);
project.RootPath = std::filesystem::path(path);
project.UpdatedDate = QString::fromStdString(date);
if (IsProjectRoot(project.RootPath)) {
Projects_.push_back(std::move(project));
}
}
if (!Projects_.empty()) {
SelectedProjectIndex_ = 0;
}
}
void SaveProjects() const {
std::error_code error;
std::filesystem::create_directories(DatabasePath_.parent_path(), error);
std::ofstream stream(DatabasePath_, std::ios::binary | std::ios::trunc);
if (!stream.is_open()) {
return;
}
for (const LauncherProject& project : Projects_) {
stream << project.Name.toStdString() << '\t'
<< project.RootPath.string() << '\t'
<< project.UpdatedDate.toStdString() << '\n';
}
}
void RefreshProjectList() {
ClearLayout(ProjectListLayout_);
const QString searchText = SearchEdit_ != nullptr ? SearchEdit_->text().trimmed().toLower() : QString();
for (int index = 0; index < static_cast<int>(Projects_.size()); ++index) {
const LauncherProject& project = Projects_[static_cast<std::size_t>(index)];
if (!searchText.isEmpty() &&
!project.Name.toLower().contains(searchText) &&
!ToQString(project.RootPath).toLower().contains(searchText)) {
continue;
}
auto* card = new ProjectCard(project);
card->SetSelected(index == SelectedProjectIndex_);
card->OnSelected = [this, index]() {
SelectedProjectIndex_ = index;
RefreshProjectList();
};
ProjectListLayout_->addWidget(card);
}
ProjectListLayout_->addStretch(1);
}
void CreateNewProject() {
NewProjectDialog dialog(this);
if (dialog.exec() != QDialog::Accepted) {
return;
}
if (FindProjectIndexByName(dialog.ProjectName()) >= 0) {
QMessageBox::critical(this, "Duplicate Name", QString("Project '%1' already exists.").arg(dialog.ProjectName()));
return;
}
QString errorMessage;
const std::filesystem::path projectRoot = dialog.ProjectRootPath();
if (!CreateProjectOnDisk(projectRoot, dialog.ProjectName(), errorMessage)) {
QMessageBox::warning(this, "Create New Project", errorMessage);
return;
}
LauncherProject project;
project.Name = dialog.ProjectName();
project.RootPath = projectRoot;
project.UpdatedDate = QDate::currentDate().toString(Qt::ISODate);
Projects_.insert(Projects_.begin(), std::move(project));
SelectedProjectIndex_ = 0;
SaveProjects();
RefreshProjectList();
ShowStatus("Created " + dialog.ProjectName());
}
void RemoveSelectedProject() {
if (!HasSelectedProject()) {
QMessageBox::warning(this, "No Selection", "Please select a project to delete.");
return;
}
const LauncherProject& project = Projects_[static_cast<std::size_t>(SelectedProjectIndex_)];
QString deleteSafetyError;
const bool projectFolderExists = std::filesystem::exists(project.RootPath);
if (projectFolderExists && !IsSafeProjectRootForDeletion(project.RootPath, deleteSafetyError)) {
QMessageBox::warning(this, "Delete Project", deleteSafetyError);
return;
}
const QMessageBox::StandardButton result = QMessageBox::question(
this,
"Confirm Deletion",
QString("Delete project '%1' and remove its folder from disk?\n\n%2")
.arg(project.Name, ToQString(project.RootPath))
);
if (result != QMessageBox::Yes) {
return;
}
if (projectFolderExists) {
std::error_code error;
std::filesystem::remove_all(project.RootPath, error);
if (error) {
QMessageBox::critical(
this,
"Project Deletion Failed",
QString("Failed to remove the project folder:\n%1\n\n%2")
.arg(ToQString(project.RootPath))
.arg(QString::fromStdString(error.message()))
);
return;
}
}
const QString deletedName = project.Name;
Projects_.erase(Projects_.begin() + SelectedProjectIndex_);
SelectedProjectIndex_ = std::min(SelectedProjectIndex_, static_cast<int>(Projects_.size()) - 1);
SaveProjects();
RefreshProjectList();
ShowStatus(projectFolderExists ? "Deleted " + deletedName : "Removed missing project from Hub.");
}
void LaunchSelectedProject() {
if (!HasSelectedProject()) {
ShowStatus("Select a project first.");
return;
}
LauncherProject& project = Projects_[static_cast<std::size_t>(SelectedProjectIndex_)];
const std::filesystem::path editorPath = ExecutableDirectory_ / "MetaCoreEditorApp.exe";
if (!std::filesystem::is_regular_file(editorPath)) {
QMessageBox::warning(this, "Launch", "MetaCoreEditorApp.exe was not found next to the launcher.");
return;
}
QProcess process;
process.setProgram(ToQString(editorPath));
process.setWorkingDirectory(ToQString(ExecutableDirectory_));
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
environment.insert("METACORE_PROJECT_PATH", ToQString(project.RootPath));
process.setProcessEnvironment(environment);
if (!process.startDetached()) {
QMessageBox::warning(this, "Launch", "Unable to launch MetaCoreEditorApp.exe.");
return;
}
project.UpdatedDate = QDate::currentDate().toString(Qt::ISODate);
SaveProjects();
RefreshProjectList();
ShowStatus("Launched " + project.Name);
}
bool HasSelectedProject() const {
return SelectedProjectIndex_ >= 0 && SelectedProjectIndex_ < static_cast<int>(Projects_.size());
}
int FindProjectIndexByName(const QString& name) const {
for (int index = 0; index < static_cast<int>(Projects_.size()); ++index) {
if (Projects_[static_cast<std::size_t>(index)].Name.compare(name, Qt::CaseInsensitive) == 0) {
return index;
}
}
return -1;
}
bool IsSafeProjectRootForDeletion(const std::filesystem::path& rootPath, QString& errorMessage) const {
std::error_code error;
const std::filesystem::path canonical = std::filesystem::weakly_canonical(rootPath, error);
const std::filesystem::path checkedPath = error ? rootPath.lexically_normal() : canonical;
if (!std::filesystem::is_directory(rootPath)) {
errorMessage = "Selected project path is not a folder.";
return false;
}
if (!IsProjectRoot(rootPath)) {
errorMessage = "Selected folder does not contain MetaCore.project.json, so it will not be deleted.";
return false;
}
if (checkedPath == checkedPath.root_path()) {
errorMessage = "Refusing to delete a filesystem root.";
return false;
}
if (checkedPath.parent_path() == checkedPath) {
errorMessage = "Refusing to delete an invalid project path.";
return false;
}
return true;
}
void ShowStatus(const QString& text) {
if (StatusLabel_ == nullptr) {
return;
}
StatusLabel_->setText(text);
StatusLabel_->show();
}
void ApplyStyleSheet(bool darkMode) {
if (!darkMode) {
qApp->setStyleSheet({});
return;
}
qApp->setStyleSheet(R"(
QMainWindow#launcherWindow, QWidget#central {
background: #191919;
color: #cfcfcf;
font-size: 13px;
}
QWidget#sidebar {
background: #141414;
border-right: 1px solid #222222;
}
QLabel#hubTitle {
color: #f0f0f0;
font-size: 22px;
font-weight: 700;
}
QLabel#pageTitle {
color: #f0f0f0;
font-size: 24px;
font-weight: 700;
}
QLabel#mutedText {
color: #707070;
}
QLabel#errorText {
color: #eb5757;
}
QLabel#projectName {
color: #e5e5e5;
font-weight: 600;
}
QLabel#projectAvatar {
background: #333333;
color: #f1f1f1;
border-radius: 6px;
font-size: 16px;
font-weight: 700;
}
QPushButton {
background: #202020;
color: #cfcfcf;
border: 1px solid #2f2f2f;
border-radius: 4px;
padding: 7px 12px;
}
QPushButton:hover {
background: #2a2a2a;
}
QPushButton:pressed, QPushButton[selected="true"] {
background: #333333;
}
QPushButton#primaryButton {
background: #f1f1f1;
color: #151515;
border-color: #f1f1f1;
}
QPushButton#primaryButton:hover {
background: #ffffff;
}
QPushButton#primaryButton:disabled {
background: #555555;
color: #9a9a9a;
border-color: #555555;
}
QPushButton#dangerButton {
background: #2a2020;
color: #f2b7b7;
border-color: #4a2d2d;
}
QPushButton#dangerButton:hover {
background: #3a2424;
}
QPushButton#navButton {
text-align: left;
min-height: 36px;
border: none;
padding-left: 12px;
background: #141414;
}
QPushButton#navButton:hover {
background: #1e1e1e;
}
QPushButton#navButton[selected="true"] {
background: #252525;
}
QPushButton#smallButton {
padding: 5px 8px;
}
QLineEdit, QComboBox {
background: #202020;
color: #d8d8d8;
border: 1px solid #2f2f2f;
border-radius: 4px;
padding: 8px 10px;
}
QLineEdit:focus, QComboBox:focus {
border-color: #555555;
}
QLineEdit:read-only {
color: #b8b8b8;
}
QFrame#projectCard, QFrame#installCard {
background: #202020;
border: 1px solid #2f2f2f;
border-radius: 6px;
}
QFrame#projectCard:hover {
background: #2a2a2a;
}
QFrame#projectCard[selected="true"] {
background: #333333;
border-color: #4a4a4a;
}
QScrollArea#projectScroll {
background: transparent;
}
QScrollArea#projectScroll > QWidget > QWidget {
background: transparent;
}
QDialog {
background: #191919;
color: #cfcfcf;
}
QMessageBox {
background: #191919;
color: #cfcfcf;
}
QCheckBox {
color: #cfcfcf;
}
)");
}
std::vector<LauncherProject> Projects_{};
int SelectedProjectIndex_ = -1;
std::filesystem::path ExecutableDirectory_{};
std::filesystem::path DatabasePath_{};
QStackedWidget* Pages_ = nullptr;
QPushButton* ProjectsNavButton_ = nullptr;
QPushButton* InstallsNavButton_ = nullptr;
QLineEdit* SearchEdit_ = nullptr;
QLabel* StatusLabel_ = nullptr;
QLabel* EditorInstallStatusLabel_ = nullptr;
QLabel* PlayerInstallStatusLabel_ = nullptr;
QLabel* CookToolInstallStatusLabel_ = nullptr;
QWidget* ProjectListContainer_ = nullptr;
QVBoxLayout* ProjectListLayout_ = nullptr;
};
} // namespace
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
QApplication::setApplicationName(ApplicationName);
QApplication::setOrganizationName(OrganizationName);
QApplication::setApplicationVersion("1.0.0");
MetaCoreLauncherWindow window;
window.show();
return app.exec();
}