diff --git a/CMakeLists.txt b/CMakeLists.txt index c88a32ba..33c53b82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -323,6 +323,7 @@ add_subdirectory(libraries/optional-bare) add_subdirectory(libraries/tomlc99) # toml parser add_subdirectory(libraries/katabasis) # An OAuth2 library that tried to do too much add_subdirectory(libraries/gamemode) +add_subdirectory(libraries/murmur2) # Hash for usage with the CurseForge API ############################### Built Artifacts ############################### diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index a970581f..cb509a00 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -397,6 +397,8 @@ set(TASKS_SOURCES tasks/ConcurrentTask.cpp tasks/SequentialTask.h tasks/SequentialTask.cpp + tasks/MultipleOptionsTask.h + tasks/MultipleOptionsTask.cpp ) ecm_add_test(tasks/Task_test.cpp LINK_LIBRARIES Launcher_logic Qt${QT_VERSION_MAJOR}::Test @@ -480,9 +482,15 @@ set(API_SOURCES modplatform/ModAPI.h - modplatform/flame/FlameAPI.h - modplatform/modrinth/ModrinthAPI.h + modplatform/EnsureMetadataTask.h + modplatform/EnsureMetadataTask.cpp + modplatform/CheckUpdateTask.h + + modplatform/flame/FlameAPI.h + modplatform/flame/FlameAPI.cpp + modplatform/modrinth/ModrinthAPI.h + modplatform/modrinth/ModrinthAPI.cpp modplatform/helpers/NetworkModAPI.h modplatform/helpers/NetworkModAPI.cpp ) @@ -508,6 +516,8 @@ set(FLAME_SOURCES modplatform/flame/PackManifest.cpp modplatform/flame/FileResolvingTask.h modplatform/flame/FileResolvingTask.cpp + modplatform/flame/FlameCheckUpdate.cpp + modplatform/flame/FlameCheckUpdate.h ) set(MODRINTH_SOURCES @@ -515,6 +525,8 @@ set(MODRINTH_SOURCES modplatform/modrinth/ModrinthPackIndex.h modplatform/modrinth/ModrinthPackManifest.cpp modplatform/modrinth/ModrinthPackManifest.h + modplatform/modrinth/ModrinthCheckUpdate.cpp + modplatform/modrinth/ModrinthCheckUpdate.h ) set(MODPACKSCH_SOURCES @@ -838,6 +850,10 @@ SET(LAUNCHER_SOURCES ui/dialogs/ScrollMessageBox.h ui/dialogs/BlockedModsDialog.cpp ui/dialogs/BlockedModsDialog.h + ui/dialogs/ChooseProviderDialog.h + ui/dialogs/ChooseProviderDialog.cpp + ui/dialogs/ModUpdateDialog.cpp + ui/dialogs/ModUpdateDialog.h # GUI - widgets ui/widgets/Common.cpp @@ -944,6 +960,7 @@ qt_wrap_ui(LAUNCHER_UI ui/dialogs/ReviewMessageBox.ui ui/dialogs/ScrollMessageBox.ui ui/dialogs/BlockedModsDialog.ui + ui/dialogs/ChooseProviderDialog.ui ) qt_add_resources(LAUNCHER_RESOURCES @@ -970,6 +987,7 @@ add_library(Launcher_logic STATIC ${LOGIC_SOURCES} ${LAUNCHER_SOURCES} ${LAUNCHE target_link_libraries(Launcher_logic systeminfo Launcher_classparser + Launcher_murmur2 nbt++ ${ZLIB_LIBRARIES} optional-bare diff --git a/launcher/MMCZip.cpp b/launcher/MMCZip.cpp index f20d6dff..1627ee07 100644 --- a/launcher/MMCZip.cpp +++ b/launcher/MMCZip.cpp @@ -127,7 +127,7 @@ bool MMCZip::compressDirFiles(QString fileCompressed, QString dir, QFileInfoList } // ours -bool MMCZip::createModdedJar(QString sourceJarPath, QString targetJarPath, const QList& mods) +bool MMCZip::createModdedJar(QString sourceJarPath, QString targetJarPath, const QList& mods) { QuaZip zipOut(targetJarPath); if (!zipOut.open(QuaZip::mdCreate)) @@ -141,42 +141,40 @@ bool MMCZip::createModdedJar(QString sourceJarPath, QString targetJarPath, const QSet addedFiles; // Modify the jar - QListIterator i(mods); - i.toBack(); - while (i.hasPrevious()) + for (auto i = mods.constEnd(); i != mods.constBegin(); --i) { - const Mod &mod = i.previous(); + const Mod* mod = *i; // do not merge disabled mods. - if (!mod.enabled()) + if (!mod->enabled()) continue; - if (mod.type() == Mod::MOD_ZIPFILE) + if (mod->type() == Mod::MOD_ZIPFILE) { - if (!mergeZipFiles(&zipOut, mod.fileinfo(), addedFiles)) + if (!mergeZipFiles(&zipOut, mod->fileinfo(), addedFiles)) { zipOut.close(); QFile::remove(targetJarPath); - qCritical() << "Failed to add" << mod.fileinfo().fileName() << "to the jar."; + qCritical() << "Failed to add" << mod->fileinfo().fileName() << "to the jar."; return false; } } - else if (mod.type() == Mod::MOD_SINGLEFILE) + else if (mod->type() == Mod::MOD_SINGLEFILE) { // FIXME: buggy - does not work with addedFiles - auto filename = mod.fileinfo(); + auto filename = mod->fileinfo(); if (!JlCompress::compressFile(&zipOut, filename.absoluteFilePath(), filename.fileName())) { zipOut.close(); QFile::remove(targetJarPath); - qCritical() << "Failed to add" << mod.fileinfo().fileName() << "to the jar."; + qCritical() << "Failed to add" << mod->fileinfo().fileName() << "to the jar."; return false; } addedFiles.insert(filename.fileName()); } - else if (mod.type() == Mod::MOD_FOLDER) + else if (mod->type() == Mod::MOD_FOLDER) { // untested, but seems to be unused / not possible to reach // FIXME: buggy - does not work with addedFiles - auto filename = mod.fileinfo(); + auto filename = mod->fileinfo(); QString what_to_zip = filename.absoluteFilePath(); QDir dir(what_to_zip); dir.cdUp(); @@ -193,7 +191,7 @@ bool MMCZip::createModdedJar(QString sourceJarPath, QString targetJarPath, const { zipOut.close(); QFile::remove(targetJarPath); - qCritical() << "Failed to add" << mod.fileinfo().fileName() << "to the jar."; + qCritical() << "Failed to add" << mod->fileinfo().fileName() << "to the jar."; return false; } qDebug() << "Adding folder " << filename.fileName() << " from " @@ -204,7 +202,7 @@ bool MMCZip::createModdedJar(QString sourceJarPath, QString targetJarPath, const // Make sure we do not continue launching when something is missing or undefined... zipOut.close(); QFile::remove(targetJarPath); - qCritical() << "Failed to add unknown mod type" << mod.fileinfo().fileName() << "to the jar."; + qCritical() << "Failed to add unknown mod type" << mod->fileinfo().fileName() << "to the jar."; return false; } } diff --git a/launcher/MMCZip.h b/launcher/MMCZip.h index bf90cd0b..7f43d158 100644 --- a/launcher/MMCZip.h +++ b/launcher/MMCZip.h @@ -75,7 +75,7 @@ namespace MMCZip /** * take a source jar, add mods to it, resulting in target jar */ - bool createModdedJar(QString sourceJarPath, QString targetJarPath, const QList& mods); + bool createModdedJar(QString sourceJarPath, QString targetJarPath, const QList& mods); /** * Find a single file in archive by file name (not path) diff --git a/launcher/ModDownloadTask.cpp b/launcher/ModDownloadTask.cpp index 41856fb5..2b0343f4 100644 --- a/launcher/ModDownloadTask.cpp +++ b/launcher/ModDownloadTask.cpp @@ -27,6 +27,7 @@ ModDownloadTask::ModDownloadTask(ModPlatform::IndexedPack mod, ModPlatform::Inde { if (is_indexed) { m_update_task.reset(new LocalModUpdateTask(mods->indexDir(), m_mod, m_mod_version)); + connect(m_update_task.get(), &LocalModUpdateTask::hasOldMod, this, &ModDownloadTask::hasOldMod); addTask(m_update_task); } @@ -40,12 +41,16 @@ ModDownloadTask::ModDownloadTask(ModPlatform::IndexedPack mod, ModPlatform::Inde connect(m_filesNetJob.get(), &NetJob::failed, this, &ModDownloadTask::downloadFailed); addTask(m_filesNetJob); - } void ModDownloadTask::downloadSucceeded() { m_filesNetJob.reset(); + auto name = std::get<0>(to_delete); + auto filename = std::get<1>(to_delete); + if (!name.isEmpty() && filename != m_mod_version.fileName) { + mods->uninstallMod(filename, true); + } } void ModDownloadTask::downloadFailed(QString reason) @@ -58,3 +63,10 @@ void ModDownloadTask::downloadProgressChanged(qint64 current, qint64 total) { emit progress(current, total); } + +// This indirection is done so that we don't delete a mod before being sure it was +// downloaded successfully! +void ModDownloadTask::hasOldMod(QString name, QString filename) +{ + to_delete = {name, filename}; +} diff --git a/launcher/ModDownloadTask.h b/launcher/ModDownloadTask.h index b3c25909..95020470 100644 --- a/launcher/ModDownloadTask.h +++ b/launcher/ModDownloadTask.h @@ -30,7 +30,7 @@ class ModFolderModel; class ModDownloadTask : public SequentialTask { Q_OBJECT public: - explicit ModDownloadTask(ModPlatform::IndexedPack mod, ModPlatform::IndexedVersion version, const std::shared_ptr mods, bool is_indexed); + explicit ModDownloadTask(ModPlatform::IndexedPack mod, ModPlatform::IndexedVersion version, const std::shared_ptr mods, bool is_indexed = true); const QString& getFilename() const { return m_mod_version.fileName; } private: @@ -46,6 +46,11 @@ private: void downloadFailed(QString reason); void downloadSucceeded(); + + std::tuple to_delete {"", ""}; + +private slots: + void hasOldMod(QString name, QString filename); }; diff --git a/launcher/minecraft/MinecraftInstance.cpp b/launcher/minecraft/MinecraftInstance.cpp index abc022b6..360e754d 100644 --- a/launcher/minecraft/MinecraftInstance.cpp +++ b/launcher/minecraft/MinecraftInstance.cpp @@ -700,24 +700,24 @@ QStringList MinecraftInstance::verboseDescription(AuthSessionPtr session, Minecr { out << QString("%1:").arg(label); auto modList = model.allMods(); - std::sort(modList.begin(), modList.end(), [](Mod &a, Mod &b) { - auto aName = a.fileinfo().completeBaseName(); - auto bName = b.fileinfo().completeBaseName(); + std::sort(modList.begin(), modList.end(), [](Mod::Ptr a, Mod::Ptr b) { + auto aName = a->fileinfo().completeBaseName(); + auto bName = b->fileinfo().completeBaseName(); return aName.localeAwareCompare(bName) < 0; }); - for(auto & mod: modList) + for(auto mod: modList) { - if(mod.type() == Mod::MOD_FOLDER) + if(mod->type() == Mod::MOD_FOLDER) { - out << u8" [📁] " + mod.fileinfo().completeBaseName() + " (folder)"; + out << u8" [📁] " + mod->fileinfo().completeBaseName() + " (folder)"; continue; } - if(mod.enabled()) { - out << u8" [✔️] " + mod.fileinfo().completeBaseName(); + if(mod->enabled()) { + out << u8" [✔️]" + mod->fileinfo().completeBaseName(); } else { - out << u8" [❌] " + mod.fileinfo().completeBaseName() + " (disabled)"; + out << u8" [❌] " + mod->fileinfo().completeBaseName() + " (disabled)"; } } @@ -1136,16 +1136,16 @@ std::shared_ptr MinecraftInstance::gameOptionsModel() const return m_game_options; } -QList< Mod > MinecraftInstance::getJarMods() const +QList MinecraftInstance::getJarMods() const { auto profile = m_components->getProfile(); - QList mods; + QList mods; for (auto jarmod : profile->getJarMods()) { QStringList jar, temp1, temp2, temp3; jarmod->getApplicableFiles(currentSystem, jar, temp1, temp2, temp3, jarmodsPath().absolutePath()); // QString filePath = jarmodsPath().absoluteFilePath(jarmod->filename(currentSystem)); - mods.push_back(Mod(QFileInfo(jar[0]))); + mods.push_back(new Mod(QFileInfo(jar[0]))); } return mods; } diff --git a/launcher/minecraft/MinecraftInstance.h b/launcher/minecraft/MinecraftInstance.h index 05450d41..8e1c67f2 100644 --- a/launcher/minecraft/MinecraftInstance.h +++ b/launcher/minecraft/MinecraftInstance.h @@ -81,7 +81,7 @@ public: shared_qobject_ptr createLaunchTask(AuthSessionPtr account, MinecraftServerTargetPtr serverToJoin) override; QStringList extraArguments() const override; QStringList verboseDescription(AuthSessionPtr session, MinecraftServerTargetPtr serverToJoin) override; - QList getJarMods() const; + QList getJarMods() const; QString createLaunchScript(AuthSessionPtr session, MinecraftServerTargetPtr serverToJoin); /// get arguments passed to java QStringList javaArguments() const; diff --git a/launcher/minecraft/mod/MetadataHandler.h b/launcher/minecraft/mod/MetadataHandler.h index 56962818..39723b49 100644 --- a/launcher/minecraft/mod/MetadataHandler.h +++ b/launcher/minecraft/mod/MetadataHandler.h @@ -37,9 +37,9 @@ class Metadata { return Packwiz::V1::createModFormat(index_dir, mod_pack, mod_version); } - static auto create(QDir& index_dir, Mod& internal_mod) -> ModStruct + static auto create(QDir& index_dir, Mod& internal_mod, QString mod_slug) -> ModStruct { - return Packwiz::V1::createModFormat(index_dir, internal_mod); + return Packwiz::V1::createModFormat(index_dir, internal_mod, mod_slug); } static void update(QDir& index_dir, ModStruct& mod) @@ -47,13 +47,23 @@ class Metadata { Packwiz::V1::updateModIndex(index_dir, mod); } - static void remove(QDir& index_dir, QString& mod_name) + static void remove(QDir& index_dir, QString mod_slug) { - Packwiz::V1::deleteModIndex(index_dir, mod_name); + Packwiz::V1::deleteModIndex(index_dir, mod_slug); } - static auto get(QDir& index_dir, QString& mod_name) -> ModStruct + static void remove(QDir& index_dir, QVariant& mod_id) { - return Packwiz::V1::getIndexForMod(index_dir, mod_name); + Packwiz::V1::deleteModIndex(index_dir, mod_id); + } + + static auto get(QDir& index_dir, QString mod_slug) -> ModStruct + { + return Packwiz::V1::getIndexForMod(index_dir, mod_slug); + } + + static auto get(QDir& index_dir, QVariant& mod_id) -> ModStruct + { + return Packwiz::V1::getIndexForMod(index_dir, mod_id); } }; diff --git a/launcher/minecraft/mod/Mod.cpp b/launcher/minecraft/mod/Mod.cpp index 742709e3..588d76e3 100644 --- a/launcher/minecraft/mod/Mod.cpp +++ b/launcher/minecraft/mod/Mod.cpp @@ -150,25 +150,30 @@ void Mod::setStatus(ModStatus status) m_temp_status = status; } } -void Mod::setMetadata(Metadata::ModStruct* metadata) +void Mod::setMetadata(const Metadata::ModStruct& metadata) { if (status() == ModStatus::NoMetadata) setStatus(ModStatus::Installed); if (m_localDetails) { - m_localDetails->metadata.reset(metadata); + m_localDetails->metadata = std::make_shared(std::move(metadata)); } else { - m_temp_metadata.reset(metadata); + m_temp_metadata = std::make_shared(std::move(metadata)); } } -auto Mod::destroy(QDir& index_dir) -> bool +auto Mod::destroy(QDir& index_dir, bool preserve_metadata) -> bool { - auto n = name(); - // FIXME: This can fail to remove the metadata if the - // "ModMetadataDisabled" setting is on, since there could - // be a name mismatch! - Metadata::remove(index_dir, n); + if (!preserve_metadata) { + qDebug() << QString("Destroying metadata for '%1' on purpose").arg(name()); + + if (metadata()) { + Metadata::remove(index_dir, metadata()->slug); + } else { + auto n = name(); + Metadata::remove(index_dir, n); + } + } m_type = MOD_UNKNOWN; return FS::deletePath(m_file.filePath()); @@ -182,9 +187,12 @@ auto Mod::details() const -> const ModDetails& auto Mod::name() const -> QString { auto d_name = details().name; - if (!d_name.isEmpty()) { + if (!d_name.isEmpty()) return d_name; - } + + if (metadata()) + return metadata()->name; + return m_name; } @@ -235,11 +243,10 @@ void Mod::finishResolvingWithDetails(std::shared_ptr details) m_resolved = true; m_localDetails = details; - if (m_localDetails && m_temp_metadata && m_temp_metadata->isValid()) { - m_localDetails->metadata = m_temp_metadata; - if (status() == ModStatus::NoMetadata) - setStatus(ModStatus::Installed); - } - setStatus(m_temp_status); + + if (m_localDetails && m_temp_metadata && m_temp_metadata->isValid()) { + setMetadata(*m_temp_metadata); + m_temp_metadata.reset(); + } } diff --git a/launcher/minecraft/mod/Mod.h b/launcher/minecraft/mod/Mod.h index 5f9c4684..7a13e44b 100644 --- a/launcher/minecraft/mod/Mod.h +++ b/launcher/minecraft/mod/Mod.h @@ -39,10 +39,12 @@ #include #include +#include "QObjectPtr.h" #include "ModDetails.h" -class Mod +class Mod : public QObject { + Q_OBJECT public: enum ModType { @@ -53,6 +55,8 @@ public: MOD_LITEMOD, //!< The mod is a litemod }; + using Ptr = shared_qobject_ptr; + Mod() = default; Mod(const QFileInfo &file); explicit Mod(const QDir& mods_dir, const Metadata::ModStruct& metadata); @@ -77,12 +81,12 @@ public: auto metadata() const -> const std::shared_ptr; void setStatus(ModStatus status); - void setMetadata(Metadata::ModStruct* metadata); + void setMetadata(const Metadata::ModStruct& metadata); auto enable(bool value) -> bool; // delete all the files of this mod - auto destroy(QDir& index_dir) -> bool; + auto destroy(QDir& index_dir, bool preserve_metadata = false) -> bool; // change the mod's filesystem path (used by mod lists for *MAGIC* purposes) void repath(const QFileInfo &file); @@ -111,7 +115,7 @@ protected: std::shared_ptr m_temp_metadata; /* Set the mod status while it doesn't have local details just yet */ - ModStatus m_temp_status = ModStatus::NotInstalled; + ModStatus m_temp_status = ModStatus::NoMetadata; std::shared_ptr m_localDetails; diff --git a/launcher/minecraft/mod/ModFolderModel.cpp b/launcher/minecraft/mod/ModFolderModel.cpp index 5ee08cbf..112d219e 100644 --- a/launcher/minecraft/mod/ModFolderModel.cpp +++ b/launcher/minecraft/mod/ModFolderModel.cpp @@ -65,15 +65,21 @@ void ModFolderModel::startWatching() update(); + // Watch the mods folder is_watching = m_watcher->addPath(m_dir.absolutePath()); - if (is_watching) - { + if (is_watching) { qDebug() << "Started watching " << m_dir.absolutePath(); - } - else - { + } else { qDebug() << "Failed to start watching " << m_dir.absolutePath(); } + + // Watch the mods index folder + is_watching = m_watcher->addPath(indexDir().absolutePath()); + if (is_watching) { + qDebug() << "Started watching " << indexDir().absolutePath(); + } else { + qDebug() << "Failed to start watching " << indexDir().absolutePath(); + } } void ModFolderModel::stopWatching() @@ -82,14 +88,18 @@ void ModFolderModel::stopWatching() return; is_watching = !m_watcher->removePath(m_dir.absolutePath()); - if (!is_watching) - { + if (!is_watching) { qDebug() << "Stopped watching " << m_dir.absolutePath(); - } - else - { + } else { qDebug() << "Failed to stop watching " << m_dir.absolutePath(); } + + is_watching = !m_watcher->removePath(indexDir().absolutePath()); + if (!is_watching) { + qDebug() << "Stopped watching " << indexDir().absolutePath(); + } else { + qDebug() << "Failed to stop watching " << indexDir().absolutePath(); + } } bool ModFolderModel::update() @@ -124,7 +134,7 @@ void ModFolderModel::finishUpdate() QSet newSet(newList.begin(), newList.end()); #else QSet currentSet = modsIndex.keys().toSet(); - auto & newMods = m_update->mods; + auto& newMods = m_update->mods; QSet newSet = newMods.keys().toSet(); #endif @@ -132,19 +142,20 @@ void ModFolderModel::finishUpdate() { QSet kept = currentSet; kept.intersect(newSet); - for(auto & keptMod: kept) { - auto & newMod = newMods[keptMod]; + for(auto& keptMod : kept) { + auto newMod = newMods[keptMod]; auto row = modsIndex[keptMod]; - auto & currentMod = mods[row]; - if(newMod.dateTimeChanged() == currentMod.dateTimeChanged()) { + auto currentMod = mods[row]; + if(newMod->dateTimeChanged() == currentMod->dateTimeChanged()) { // no significant change, ignore... continue; } - auto & oldMod = mods[row]; - if(oldMod.isResolving()) { - activeTickets.remove(oldMod.resolutionTicket()); + auto oldMod = mods[row]; + if(oldMod->isResolving()) { + activeTickets.remove(oldMod->resolutionTicket()); } - oldMod = newMod; + + mods[row] = newMod; resolveMod(mods[row]); emit dataChanged(index(row, 0), index(row, columnCount(QModelIndex()) - 1)); } @@ -163,9 +174,10 @@ void ModFolderModel::finishUpdate() int removedIndex = *iter; beginRemoveRows(QModelIndex(), removedIndex, removedIndex); auto removedIter = mods.begin() + removedIndex; - if(removedIter->isResolving()) { - activeTickets.remove(removedIter->resolutionTicket()); + if((*removedIter)->isResolving()) { + activeTickets.remove((*removedIter)->resolutionTicket()); } + mods.erase(removedIter); endRemoveRows(); } @@ -191,8 +203,8 @@ void ModFolderModel::finishUpdate() { modsIndex.clear(); int idx = 0; - for(auto & mod: mods) { - modsIndex[mod.internal_id()] = idx; + for(auto mod: mods) { + modsIndex[mod->internal_id()] = idx; idx++; } } @@ -207,17 +219,17 @@ void ModFolderModel::finishUpdate() } } -void ModFolderModel::resolveMod(Mod& m) +void ModFolderModel::resolveMod(Mod::Ptr m) { - if(!m.shouldResolve()) { + if(!m->shouldResolve()) { return; } - auto task = new LocalModParseTask(nextResolutionTicket, m.type(), m.fileinfo()); + auto task = new LocalModParseTask(nextResolutionTicket, m->type(), m->fileinfo()); auto result = task->result(); - result->id = m.internal_id(); + result->id = m->internal_id(); activeTickets.insert(nextResolutionTicket, result); - m.setResolving(true, nextResolutionTicket); + m->setResolving(true, nextResolutionTicket); nextResolutionTicket++; QThreadPool *threadPool = QThreadPool::globalInstance(); connect(task, &LocalModParseTask::finished, this, &ModFolderModel::finishModParse); @@ -233,8 +245,8 @@ void ModFolderModel::finishModParse(int token) auto result = *iter; activeTickets.remove(token); int row = modsIndex[result->id]; - auto & mod = mods[row]; - mod.finishResolvingWithDetails(result->details); + auto mod = mods[row]; + mod->finishResolvingWithDetails(result->details); emit dataChanged(index(row), index(row, columnCount(QModelIndex()) - 1)); } @@ -259,6 +271,18 @@ bool ModFolderModel::isValid() return m_dir.exists() && m_dir.isReadable(); } +auto ModFolderModel::selectedMods(QModelIndexList& indexes) -> QList +{ + QList selected_mods; + for (auto i : indexes) { + if(i.column() != 0) + continue; + + selected_mods.push_back(mods[i.row()]); + } + return selected_mods; +} + // FIXME: this does not take disabled mod (with extra .disable extension) into account... bool ModFolderModel::installMod(const QString &filename) { @@ -344,6 +368,20 @@ bool ModFolderModel::installMod(const QString &filename) return false; } +bool ModFolderModel::uninstallMod(const QString& filename, bool preserve_metadata) +{ + + for(auto mod : allMods()){ + if(mod->fileinfo().fileName() == filename){ + auto index_dir = indexDir(); + mod->destroy(index_dir, preserve_metadata); + return true; + } + } + + return false; +} + bool ModFolderModel::setModStatus(const QModelIndexList& indexes, ModStatusAction enable) { if(interaction_disabled) { @@ -377,9 +415,9 @@ bool ModFolderModel::deleteMods(const QModelIndexList& indexes) if(i.column() != 0) { continue; } - Mod &m = mods[i.row()]; + auto m = mods[i.row()]; auto index_dir = indexDir(); - m.destroy(index_dir); + m->destroy(index_dir); } return true; } @@ -406,9 +444,9 @@ QVariant ModFolderModel::data(const QModelIndex &index, int role) const switch (column) { case NameColumn: - return mods[row].name(); + return mods[row]->name(); case VersionColumn: { - switch(mods[row].type()) { + switch(mods[row]->type()) { case Mod::MOD_FOLDER: return tr("Folder"); case Mod::MOD_SINGLEFILE: @@ -416,23 +454,23 @@ QVariant ModFolderModel::data(const QModelIndex &index, int role) const default: break; } - return mods[row].version(); + return mods[row]->version(); } case DateColumn: - return mods[row].dateTimeChanged(); + return mods[row]->dateTimeChanged(); default: return QVariant(); } case Qt::ToolTipRole: - return mods[row].internal_id(); + return mods[row]->internal_id(); case Qt::CheckStateRole: switch (column) { case ActiveColumn: - return mods[row].enabled() ? Qt::Checked : Qt::Unchecked; + return mods[row]->enabled() ? Qt::Checked : Qt::Unchecked; default: return QVariant(); } @@ -472,20 +510,20 @@ bool ModFolderModel::setModStatus(int row, ModFolderModel::ModStatusAction actio break; case Toggle: default: - desiredStatus = !mod.enabled(); + desiredStatus = !mod->enabled(); break; } - if(desiredStatus == mod.enabled()) { + if(desiredStatus == mod->enabled()) { return true; } // preserve the row, but change its ID - auto oldId = mod.internal_id(); - if(!mod.enable(!mod.enabled())) { + auto oldId = mod->internal_id(); + if(!mod->enable(!mod->enabled())) { return false; } - auto newId = mod.internal_id(); + auto newId = mod->internal_id(); if(modsIndex.contains(newId)) { // NOTE: this could handle a corner case, where we are overwriting a file, because the same 'mod' exists both enabled and disabled // But is it necessary? diff --git a/launcher/minecraft/mod/ModFolderModel.h b/launcher/minecraft/mod/ModFolderModel.h index 24b4d358..a7d3ece0 100644 --- a/launcher/minecraft/mod/ModFolderModel.h +++ b/launcher/minecraft/mod/ModFolderModel.h @@ -101,13 +101,13 @@ public: { return size() == 0; } - Mod &operator[](size_t index) + Mod& operator[](size_t index) { - return mods[index]; + return *mods[index]; } - const Mod &at(size_t index) const + const Mod& at(size_t index) const { - return mods.at(index); + return *mods.at(index); } /// Reloads the mod list and returns true if the list changed. @@ -118,6 +118,8 @@ public: */ bool installMod(const QString& filename); + bool uninstallMod(const QString& filename, bool preserve_metadata = false); + /// Deletes all the selected mods bool deleteMods(const QModelIndexList &indexes); @@ -139,11 +141,13 @@ public: return { QString("%1/.index").arg(dir().absolutePath()) }; } - const QList & allMods() + const QList& allMods() { return mods; } + auto selectedMods(QModelIndexList& indexes) -> QList; + public slots: void disableInteraction(bool disabled); @@ -157,7 +161,7 @@ signals: void updateFinished(); private: - void resolveMod(Mod& m); + void resolveMod(Mod::Ptr m); bool setModStatus(int index, ModStatusAction action); protected: @@ -171,5 +175,5 @@ protected: QMap modsIndex; QMap activeTickets; int nextResolutionTicket = 0; - QList mods; + QList mods; }; diff --git a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp index 3354732b..1519f49d 100644 --- a/launcher/minecraft/mod/tasks/LocalModParseTask.cpp +++ b/launcher/minecraft/mod/tasks/LocalModParseTask.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -71,7 +72,13 @@ std::shared_ptr ReadMCModInfo(QByteArray contents) if(val.isUndefined()) { val = jsonDoc.object().value("modListVersion"); } - int version = val.toDouble(); + + int version = Json::ensureInteger(val, -1); + + // Some mods set the number with "", so it's a String instead + if (version < 0) + version = Json::ensureString(val, "").toInt(); + if (version != 2) { qCritical() << "BAD stuff happened to mod json:"; diff --git a/launcher/minecraft/mod/tasks/LocalModUpdateTask.cpp b/launcher/minecraft/mod/tasks/LocalModUpdateTask.cpp index 1bdecb8c..4b878918 100644 --- a/launcher/minecraft/mod/tasks/LocalModUpdateTask.cpp +++ b/launcher/minecraft/mod/tasks/LocalModUpdateTask.cpp @@ -44,10 +44,21 @@ void LocalModUpdateTask::executeTask() { setStatus(tr("Updating index for mod:\n%1").arg(m_mod.name)); - auto pw_mod = Metadata::create(m_index_dir, m_mod, m_mod_version); - Metadata::update(m_index_dir, pw_mod); + auto old_metadata = Metadata::get(m_index_dir, m_mod.addonId); + if (old_metadata.isValid()) { + emit hasOldMod(old_metadata.name, old_metadata.filename); + if (m_mod.slug.isEmpty()) + m_mod.slug = old_metadata.slug; + } - emitSucceeded(); + auto pw_mod = Metadata::create(m_index_dir, m_mod, m_mod_version); + if (pw_mod.isValid()) { + Metadata::update(m_index_dir, pw_mod); + emitSucceeded(); + } else { + qCritical() << "Tried to update an invalid mod!"; + emitFailed(tr("Invalid metadata")); + } } auto LocalModUpdateTask::abort() -> bool diff --git a/launcher/minecraft/mod/tasks/LocalModUpdateTask.h b/launcher/minecraft/mod/tasks/LocalModUpdateTask.h index 2db183e0..1d2f06a6 100644 --- a/launcher/minecraft/mod/tasks/LocalModUpdateTask.h +++ b/launcher/minecraft/mod/tasks/LocalModUpdateTask.h @@ -37,6 +37,9 @@ class LocalModUpdateTask : public Task { //! Entry point for tasks. void executeTask() override; + signals: + void hasOldMod(QString name, QString filename); + private: QDir m_index_dir; ModPlatform::IndexedPack& m_mod; diff --git a/launcher/minecraft/mod/tasks/ModFolderLoadTask.cpp b/launcher/minecraft/mod/tasks/ModFolderLoadTask.cpp index 276414e4..a2e055ba 100644 --- a/launcher/minecraft/mod/tasks/ModFolderLoadTask.cpp +++ b/launcher/minecraft/mod/tasks/ModFolderLoadTask.cpp @@ -36,7 +36,6 @@ #include "ModFolderLoadTask.h" -#include "Application.h" #include "minecraft/mod/MetadataHandler.h" ModFolderLoadTask::ModFolderLoadTask(QDir& mods_dir, QDir& index_dir, bool is_indexed) @@ -53,33 +52,33 @@ void ModFolderLoadTask::run() // Read JAR files that don't have metadata m_mods_dir.refresh(); for (auto entry : m_mods_dir.entryInfoList()) { - Mod mod(entry); + Mod::Ptr mod(new Mod(entry)); - if (mod.enabled()) { - if (m_result->mods.contains(mod.internal_id())) { - m_result->mods[mod.internal_id()].setStatus(ModStatus::Installed); + if (mod->enabled()) { + if (m_result->mods.contains(mod->internal_id())) { + m_result->mods[mod->internal_id()]->setStatus(ModStatus::Installed); } else { - m_result->mods[mod.internal_id()] = mod; - m_result->mods[mod.internal_id()].setStatus(ModStatus::NoMetadata); + m_result->mods[mod->internal_id()] = mod; + m_result->mods[mod->internal_id()]->setStatus(ModStatus::NoMetadata); } } else { - QString chopped_id = mod.internal_id().chopped(9); + QString chopped_id = mod->internal_id().chopped(9); if (m_result->mods.contains(chopped_id)) { - m_result->mods[mod.internal_id()] = mod; + m_result->mods[mod->internal_id()] = mod; - auto metadata = m_result->mods[chopped_id].metadata(); + auto metadata = m_result->mods[chopped_id]->metadata(); if (metadata) { - mod.setMetadata(new Metadata::ModStruct(*metadata)); + mod->setMetadata(*metadata); - m_result->mods[mod.internal_id()].setStatus(ModStatus::Installed); + m_result->mods[mod->internal_id()]->setStatus(ModStatus::Installed); m_result->mods.remove(chopped_id); } } else { - m_result->mods[mod.internal_id()] = mod; - m_result->mods[mod.internal_id()].setStatus(ModStatus::NoMetadata); + m_result->mods[mod->internal_id()] = mod; + m_result->mods[mod->internal_id()]->setStatus(ModStatus::NoMetadata); } } } @@ -97,8 +96,8 @@ void ModFolderLoadTask::getFromMetadata() return; } - Mod mod(m_mods_dir, metadata); - mod.setStatus(ModStatus::NotInstalled); - m_result->mods[mod.internal_id()] = mod; + auto* mod = new Mod(m_mods_dir, metadata); + mod->setStatus(ModStatus::NotInstalled); + m_result->mods[mod->internal_id()] = mod; } } diff --git a/launcher/minecraft/mod/tasks/ModFolderLoadTask.h b/launcher/minecraft/mod/tasks/ModFolderLoadTask.h index 088f873e..0b6bb6cc 100644 --- a/launcher/minecraft/mod/tasks/ModFolderLoadTask.h +++ b/launcher/minecraft/mod/tasks/ModFolderLoadTask.h @@ -48,7 +48,7 @@ class ModFolderLoadTask : public QObject, public QRunnable Q_OBJECT public: struct Result { - QMap mods; + QMap mods; }; using ResultPtr = std::shared_ptr; ResultPtr result() const { diff --git a/launcher/modplatform/CheckUpdateTask.h b/launcher/modplatform/CheckUpdateTask.h new file mode 100644 index 00000000..91922034 --- /dev/null +++ b/launcher/modplatform/CheckUpdateTask.h @@ -0,0 +1,51 @@ +#pragma once + +#include "minecraft/mod/Mod.h" +#include "modplatform/ModAPI.h" +#include "modplatform/ModIndex.h" +#include "tasks/Task.h" + +class ModDownloadTask; +class ModFolderModel; + +class CheckUpdateTask : public Task { + Q_OBJECT + + public: + CheckUpdateTask(QList& mods, std::list& mcVersions, ModAPI::ModLoaderTypes loaders, std::shared_ptr mods_folder) + : Task(nullptr), m_mods(mods), m_game_versions(mcVersions), m_loaders(loaders), m_mods_folder(mods_folder) {}; + + struct UpdatableMod { + QString name; + QString old_hash; + QString old_version; + QString new_version; + QString changelog; + ModPlatform::Provider provider; + ModDownloadTask* download; + + public: + UpdatableMod(QString name, QString old_h, QString old_v, QString new_v, QString changelog, ModPlatform::Provider p, ModDownloadTask* t) + : name(name), old_hash(old_h), old_version(old_v), new_version(new_v), changelog(changelog), provider(p), download(t) + {} + }; + + auto getUpdatable() -> std::vector&& { return std::move(m_updatable); } + + public slots: + bool abort() override = 0; + + protected slots: + void executeTask() override = 0; + + signals: + void checkFailed(Mod* failed, QString reason, QUrl recover_url = {}); + + protected: + QList& m_mods; + std::list& m_game_versions; + ModAPI::ModLoaderTypes m_loaders; + std::shared_ptr m_mods_folder; + + std::vector m_updatable; +}; diff --git a/launcher/modplatform/EnsureMetadataTask.cpp b/launcher/modplatform/EnsureMetadataTask.cpp new file mode 100644 index 00000000..60c54c4e --- /dev/null +++ b/launcher/modplatform/EnsureMetadataTask.cpp @@ -0,0 +1,534 @@ +#include "EnsureMetadataTask.h" + +#include +#include + +#include "FileSystem.h" +#include "Json.h" +#include "minecraft/mod/Mod.h" +#include "minecraft/mod/tasks/LocalModUpdateTask.h" +#include "modplatform/flame/FlameAPI.h" +#include "modplatform/flame/FlameModIndex.h" +#include "modplatform/modrinth/ModrinthAPI.h" +#include "modplatform/modrinth/ModrinthPackIndex.h" +#include "net/NetJob.h" +#include "tasks/MultipleOptionsTask.h" + +static ModPlatform::ProviderCapabilities ProviderCaps; + +static ModrinthAPI modrinth_api; +static FlameAPI flame_api; + +EnsureMetadataTask::EnsureMetadataTask(Mod* mod, QDir dir, ModPlatform::Provider prov) : Task(nullptr), m_index_dir(dir), m_provider(prov) +{ + auto hash = getHash(mod); + if (hash.isEmpty()) + emitFail(mod); + else + m_mods.insert(hash, mod); +} + +EnsureMetadataTask::EnsureMetadataTask(QList& mods, QDir dir, ModPlatform::Provider prov) + : Task(nullptr), m_index_dir(dir), m_provider(prov) +{ + for (auto* mod : mods) { + if (!mod->valid()) { + emitFail(mod); + continue; + } + + auto hash = getHash(mod); + if (hash.isEmpty()) { + emitFail(mod); + continue; + } + + m_mods.insert(hash, mod); + } +} + +QString EnsureMetadataTask::getHash(Mod* mod) +{ + /* Here we create a mapping hash -> mod, because we need that relationship to parse the API routes */ + QByteArray jar_data; + try { + jar_data = FS::read(mod->fileinfo().absoluteFilePath()); + } catch (FS::FileSystemException& e) { + qCritical() << QString("Failed to open / read JAR file of %1").arg(mod->name()); + qCritical() << QString("Reason: ") << e.cause(); + + return {}; + } + + switch (m_provider) { + case ModPlatform::Provider::MODRINTH: { + auto hash_type = ProviderCaps.hashType(ModPlatform::Provider::MODRINTH).first(); + + return QString(ProviderCaps.hash(ModPlatform::Provider::MODRINTH, jar_data, hash_type).toHex()); + } + case ModPlatform::Provider::FLAME: { + QByteArray jar_data_treated; + for (char c : jar_data) { + // CF-specific + if (!(c == 9 || c == 10 || c == 13 || c == 32)) + jar_data_treated.push_back(c); + } + + return QString::number(MurmurHash2(jar_data_treated, jar_data_treated.length())); + } + } + + return {}; +} + +bool EnsureMetadataTask::abort() +{ + // Prevent sending signals to a dead object + disconnect(this, 0, 0, 0); + + if (m_current_task) + return m_current_task->abort(); + return true; +} + +void EnsureMetadataTask::executeTask() +{ + setStatus(tr("Checking if mods have metadata...")); + + for (auto* mod : m_mods) { + if (!mod->valid()) { + qDebug() << "Mod" << mod->name() << "is invalid!"; + emitFail(mod); + continue; + } + + // They already have the right metadata :o + if (mod->status() != ModStatus::NoMetadata && mod->metadata() && mod->metadata()->provider == m_provider) { + qDebug() << "Mod" << mod->name() << "already has metadata!"; + emitReady(mod); + continue; + } + + // Folders don't have metadata + if (mod->type() == Mod::MOD_FOLDER) { + emitReady(mod); + } + } + + NetJob::Ptr version_task; + + switch (m_provider) { + case (ModPlatform::Provider::MODRINTH): + version_task = modrinthVersionsTask(); + break; + case (ModPlatform::Provider::FLAME): + version_task = flameVersionsTask(); + break; + } + + auto invalidade_leftover = [this] { + QMutableHashIterator mods_iter(m_mods); + while (mods_iter.hasNext()) { + auto mod = mods_iter.next(); + emitFail(mod.value()); + } + + emitSucceeded(); + }; + + connect(version_task.get(), &Task::finished, this, [this, invalidade_leftover] { + NetJob::Ptr project_task; + + switch (m_provider) { + case (ModPlatform::Provider::MODRINTH): + project_task = modrinthProjectsTask(); + break; + case (ModPlatform::Provider::FLAME): + project_task = flameProjectsTask(); + break; + } + + if (!project_task) { + invalidade_leftover(); + return; + } + + connect(project_task.get(), &Task::finished, this, [=] { + invalidade_leftover(); + project_task->deleteLater(); + m_current_task = nullptr; + }); + + m_current_task = project_task.get(); + project_task->start(); + }); + + connect(version_task.get(), &Task::finished, [=] { + version_task->deleteLater(); + m_current_task = nullptr; + }); + + if (m_mods.size() > 1) + setStatus(tr("Requesting metadata information from %1...").arg(ProviderCaps.readableName(m_provider))); + else if (!m_mods.empty()) + setStatus(tr("Requesting metadata information from %1 for '%2'...") + .arg(ProviderCaps.readableName(m_provider), m_mods.begin().value()->name())); + + m_current_task = version_task.get(); + version_task->start(); +} + +void EnsureMetadataTask::emitReady(Mod* m) +{ + qDebug() << QString("Generated metadata for %1").arg(m->name()); + emit metadataReady(m); + + m_mods.remove(getHash(m)); +} + +void EnsureMetadataTask::emitFail(Mod* m) +{ + qDebug() << QString("Failed to generate metadata for %1").arg(m->name()); + emit metadataFailed(m); + + m_mods.remove(getHash(m)); +} + +// Modrinth + +NetJob::Ptr EnsureMetadataTask::modrinthVersionsTask() +{ + auto hash_type = ProviderCaps.hashType(ModPlatform::Provider::MODRINTH).first(); + + auto* response = new QByteArray(); + auto ver_task = modrinth_api.currentVersions(m_mods.keys(), hash_type, response); + + // Prevents unfortunate timings when aborting the task + if (!ver_task) + return {}; + + connect(ver_task.get(), &NetJob::succeeded, this, [this, response] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth::CurrentVersions at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + + failed(parse_error.errorString()); + return; + } + + try { + auto entries = Json::requireObject(doc); + for (auto& hash : m_mods.keys()) { + auto mod = m_mods.find(hash).value(); + try { + auto entry = Json::requireObject(entries, hash); + + setStatus(tr("Parsing API response from Modrinth for '%1'...").arg(mod->name())); + qDebug() << "Getting version for" << mod->name() << "from Modrinth"; + + m_temp_versions.insert(hash, Modrinth::loadIndexedPackVersion(entry)); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + + emitFail(mod); + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return ver_task; +} + +NetJob::Ptr EnsureMetadataTask::modrinthProjectsTask() +{ + QHash addonIds; + for (auto const& data : m_temp_versions) + addonIds.insert(data.addonId.toString(), data.hash); + + auto response = new QByteArray(); + NetJob::Ptr proj_task; + + if (addonIds.isEmpty()) { + qWarning() << "No addonId found!"; + } else if (addonIds.size() == 1) { + proj_task = modrinth_api.getProject(*addonIds.keyBegin(), response); + } else { + proj_task = modrinth_api.getProjects(addonIds.keys(), response); + } + + // Prevents unfortunate timings when aborting the task + if (!proj_task) + return {}; + + connect(proj_task.get(), &NetJob::succeeded, this, [this, response, addonIds] { + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth projects task at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + QJsonArray entries; + if (addonIds.size() == 1) + entries = { doc.object() }; + else + entries = Json::requireArray(doc); + + for (auto entry : entries) { + auto entry_obj = Json::requireObject(entry); + + ModPlatform::IndexedPack pack; + Modrinth::loadIndexedPack(pack, entry_obj); + + auto hash = addonIds.find(pack.addonId.toString()).value(); + + auto mod_iter = m_mods.find(hash); + if (mod_iter == m_mods.end()) { + qWarning() << "Invalid project id from the API response."; + continue; + } + + auto* mod = mod_iter.value(); + + try { + setStatus(tr("Parsing API response from Modrinth for '%1'...").arg(mod->name())); + + modrinthCallback(pack, m_temp_versions.find(hash).value(), mod); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + + emitFail(mod); + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return proj_task; +} + +// Flame +NetJob::Ptr EnsureMetadataTask::flameVersionsTask() +{ + auto* response = new QByteArray(); + + QList fingerprints; + for (auto& murmur : m_mods.keys()) { + fingerprints.push_back(murmur.toUInt()); + } + + auto ver_task = flame_api.matchFingerprints(fingerprints, response); + + connect(ver_task.get(), &Task::succeeded, this, [this, response] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth::CurrentVersions at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + + failed(parse_error.errorString()); + return; + } + + try { + auto doc_obj = Json::requireObject(doc); + auto data_obj = Json::requireObject(doc_obj, "data"); + auto data_arr = Json::requireArray(data_obj, "exactMatches"); + + if (data_arr.isEmpty()) { + qWarning() << "No matches found for fingerprint search!"; + + return; + } + + for (auto match : data_arr) { + auto match_obj = Json::ensureObject(match, {}); + auto file_obj = Json::ensureObject(match_obj, "file", {}); + + if (match_obj.isEmpty() || file_obj.isEmpty()) { + qWarning() << "Fingerprint match is empty!"; + + return; + } + + auto fingerprint = QString::number(Json::ensureVariant(file_obj, "fileFingerprint").toUInt()); + auto mod = m_mods.find(fingerprint); + if (mod == m_mods.end()) { + qWarning() << "Invalid fingerprint from the API response."; + continue; + } + + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg((*mod)->name())); + + m_temp_versions.insert(fingerprint, FlameMod::loadIndexedPackVersion(file_obj)); + } + + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return ver_task; +} + +NetJob::Ptr EnsureMetadataTask::flameProjectsTask() +{ + QHash addonIds; + for (auto const& hash : m_mods.keys()) { + if (m_temp_versions.contains(hash)) { + auto const& data = m_temp_versions.find(hash).value(); + + auto id_str = data.addonId.toString(); + if (!id_str.isEmpty()) + addonIds.insert(data.addonId.toString(), hash); + } + } + + auto response = new QByteArray(); + NetJob::Ptr proj_task; + + if (addonIds.isEmpty()) { + qWarning() << "No addonId found!"; + } else if (addonIds.size() == 1) { + proj_task = flame_api.getProject(*addonIds.keyBegin(), response); + } else { + proj_task = flame_api.getProjects(addonIds.keys(), response); + } + + // Prevents unfortunate timings when aborting the task + if (!proj_task) + return {}; + + connect(proj_task.get(), &NetJob::succeeded, this, [this, response, addonIds] { + QJsonParseError parse_error{}; + auto doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Modrinth projects task at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + QJsonArray entries; + if (addonIds.size() == 1) + entries = { Json::requireObject(Json::requireObject(doc), "data") }; + else + entries = Json::requireArray(Json::requireObject(doc), "data"); + + for (auto entry : entries) { + auto entry_obj = Json::requireObject(entry); + + auto id = QString::number(Json::requireInteger(entry_obj, "id")); + auto hash = addonIds.find(id).value(); + auto mod = m_mods.find(hash).value(); + + try { + setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(mod->name())); + + ModPlatform::IndexedPack pack; + FlameMod::loadIndexedPack(pack, entry_obj); + + flameCallback(pack, m_temp_versions.find(hash).value(), mod); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << entries; + + emitFail(mod); + } + } + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + qDebug() << doc; + } + }); + + return proj_task; +} + +void EnsureMetadataTask::modrinthCallback(ModPlatform::IndexedPack& pack, ModPlatform::IndexedVersion& ver, Mod* mod) +{ + // Prevent file name mismatch + ver.fileName = mod->fileinfo().fileName(); + if (ver.fileName.endsWith(".disabled")) + ver.fileName.chop(9); + + QDir tmp_index_dir(m_index_dir); + + { + LocalModUpdateTask update_metadata(m_index_dir, pack, ver); + QEventLoop loop; + + QObject::connect(&update_metadata, &Task::finished, &loop, &QEventLoop::quit); + + update_metadata.start(); + + if (!update_metadata.isFinished()) + loop.exec(); + } + + auto metadata = Metadata::get(tmp_index_dir, pack.slug); + if (!metadata.isValid()) { + qCritical() << "Failed to generate metadata at last step!"; + emitFail(mod); + return; + } + + mod->setMetadata(metadata); + + emitReady(mod); +} + +void EnsureMetadataTask::flameCallback(ModPlatform::IndexedPack& pack, ModPlatform::IndexedVersion& ver, Mod* mod) +{ + try { + // Prevent file name mismatch + ver.fileName = mod->fileinfo().fileName(); + if (ver.fileName.endsWith(".disabled")) + ver.fileName.chop(9); + + QDir tmp_index_dir(m_index_dir); + + { + LocalModUpdateTask update_metadata(m_index_dir, pack, ver); + QEventLoop loop; + + QObject::connect(&update_metadata, &Task::finished, &loop, &QEventLoop::quit); + + update_metadata.start(); + + if (!update_metadata.isFinished()) + loop.exec(); + } + + auto metadata = Metadata::get(tmp_index_dir, pack.slug); + if (!metadata.isValid()) { + qCritical() << "Failed to generate metadata at last step!"; + emitFail(mod); + return; + } + + mod->setMetadata(metadata); + + emitReady(mod); + } catch (Json::JsonException& e) { + qDebug() << e.cause(); + + emitFail(mod); + } +} diff --git a/launcher/modplatform/EnsureMetadataTask.h b/launcher/modplatform/EnsureMetadataTask.h new file mode 100644 index 00000000..79db6976 --- /dev/null +++ b/launcher/modplatform/EnsureMetadataTask.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ModIndex.h" +#include "tasks/SequentialTask.h" +#include "net/NetJob.h" + +class Mod; +class QDir; +class MultipleOptionsTask; + +class EnsureMetadataTask : public Task { + Q_OBJECT + + public: + EnsureMetadataTask(Mod*, QDir, ModPlatform::Provider = ModPlatform::Provider::MODRINTH); + EnsureMetadataTask(QList&, QDir, ModPlatform::Provider = ModPlatform::Provider::MODRINTH); + + ~EnsureMetadataTask() = default; + + public slots: + bool abort() override; + protected slots: + void executeTask() override; + + private: + // FIXME: Move to their own namespace + auto modrinthVersionsTask() -> NetJob::Ptr; + auto modrinthProjectsTask() -> NetJob::Ptr; + + auto flameVersionsTask() -> NetJob::Ptr; + auto flameProjectsTask() -> NetJob::Ptr; + + // Helpers + void emitReady(Mod*); + void emitFail(Mod*); + + auto getHash(Mod*) -> QString; + + private slots: + void modrinthCallback(ModPlatform::IndexedPack& pack, ModPlatform::IndexedVersion& ver, Mod*); + void flameCallback(ModPlatform::IndexedPack& pack, ModPlatform::IndexedVersion& ver, Mod*); + + signals: + void metadataReady(Mod*); + void metadataFailed(Mod*); + + private: + QHash m_mods; + QDir m_index_dir; + ModPlatform::Provider m_provider; + + QHash m_temp_versions; + NetJob* m_current_task; +}; diff --git a/launcher/modplatform/ModAPI.h b/launcher/modplatform/ModAPI.h index cf116353..4114d83c 100644 --- a/launcher/modplatform/ModAPI.h +++ b/launcher/modplatform/ModAPI.h @@ -40,6 +40,7 @@ #include #include "Version.h" +#include "net/NetJob.h" namespace ModPlatform { class ListModel; @@ -74,6 +75,9 @@ class ModAPI { virtual void searchMods(CallerType* caller, SearchArgs&& args) const = 0; virtual void getModInfo(CallerType* caller, ModPlatform::IndexedPack& pack) = 0; + virtual auto getProject(QString addonId, QByteArray* response) const -> NetJob* = 0; + virtual auto getProjects(QStringList addonIds, QByteArray* response) const -> NetJob* = 0; + struct VersionSearchArgs { QString addonId; diff --git a/launcher/modplatform/ModIndex.h b/launcher/modplatform/ModIndex.h index c27643af..dc297d03 100644 --- a/launcher/modplatform/ModIndex.h +++ b/launcher/modplatform/ModIndex.h @@ -54,13 +54,16 @@ struct IndexedVersion { QVariant addonId; QVariant fileId; QString version; - QVector mcVersion; + QString version_number = {}; + QStringList mcVersion; QString downloadUrl; QString date; QString fileName; - QVector loaders = {}; + QStringList loaders = {}; QString hash_type; QString hash; + bool is_preferred = true; + QString changelog; }; struct ExtraPackData { @@ -76,6 +79,7 @@ struct IndexedPack { QVariant addonId; Provider provider; QString name; + QString slug; QString description; QList authors; QString logoName; diff --git a/launcher/modplatform/flame/FlameAPI.cpp b/launcher/modplatform/flame/FlameAPI.cpp new file mode 100644 index 00000000..0ff04f72 --- /dev/null +++ b/launcher/modplatform/flame/FlameAPI.cpp @@ -0,0 +1,148 @@ +#include "FlameAPI.h" +#include "FlameModIndex.h" + +#include "Application.h" +#include "BuildConfig.h" +#include "Json.h" + +#include "net/Upload.h" + +auto FlameAPI::matchFingerprints(const QList& fingerprints, QByteArray* response) -> NetJob::Ptr +{ + auto* netJob = new NetJob(QString("Flame::MatchFingerprints"), APPLICATION->network()); + + QJsonObject body_obj; + QJsonArray fingerprints_arr; + for (auto& fp : fingerprints) { + fingerprints_arr.append(QString("%1").arg(fp)); + } + + body_obj["fingerprints"] = fingerprints_arr; + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + netJob->addNetAction(Net::Upload::makeByteArray(QString("https://api.curseforge.com/v1/fingerprints"), response, body_raw)); + + QObject::connect(netJob, &NetJob::finished, [response] { delete response; }); + + return netJob; +} + +auto FlameAPI::getModFileChangelog(int modId, int fileId) -> QString +{ + QEventLoop lock; + QString changelog; + + auto* netJob = new NetJob(QString("Flame::FileChangelog"), APPLICATION->network()); + auto* response = new QByteArray(); + netJob->addNetAction(Net::Download::makeByteArray( + QString("https://api.curseforge.com/v1/mods/%1/files/%2/changelog") + .arg(QString::fromStdString(std::to_string(modId)), QString::fromStdString(std::to_string(fileId))), + response)); + + QObject::connect(netJob, &NetJob::succeeded, [netJob, response, &changelog] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from Flame::FileChangelog at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + + netJob->failed(parse_error.errorString()); + return; + } + + changelog = Json::ensureString(doc.object(), "data"); + }); + + QObject::connect(netJob, &NetJob::finished, [response, &lock] { + delete response; + lock.quit(); + }); + + netJob->start(); + lock.exec(); + + return changelog; +} + +auto FlameAPI::getLatestVersion(VersionSearchArgs&& args) -> ModPlatform::IndexedVersion +{ + QEventLoop loop; + + auto netJob = new NetJob(QString("Flame::GetLatestVersion(%1)").arg(args.addonId), APPLICATION->network()); + auto response = new QByteArray(); + ModPlatform::IndexedVersion ver; + + netJob->addNetAction(Net::Download::makeByteArray(getVersionsURL(args), response)); + + QObject::connect(netJob, &NetJob::succeeded, [response, args, &ver] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from latest mod version at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + auto obj = Json::requireObject(doc); + auto arr = Json::requireArray(obj, "data"); + + QJsonObject latest_file_obj; + ModPlatform::IndexedVersion ver_tmp; + + for (auto file : arr) { + auto file_obj = Json::requireObject(file); + auto file_tmp = FlameMod::loadIndexedPackVersion(file_obj); + if(file_tmp.date > ver_tmp.date) { + ver_tmp = file_tmp; + latest_file_obj = file_obj; + } + } + + ver = FlameMod::loadIndexedPackVersion(latest_file_obj); + } catch (Json::JsonException& e) { + qCritical() << "Failed to parse response from a version request."; + qCritical() << e.what(); + qDebug() << doc; + } + }); + + QObject::connect(netJob, &NetJob::finished, [response, netJob, &loop] { + netJob->deleteLater(); + delete response; + loop.quit(); + }); + + netJob->start(); + + loop.exec(); + + return ver; +} + +auto FlameAPI::getProjects(QStringList addonIds, QByteArray* response) const -> NetJob* +{ + auto* netJob = new NetJob(QString("Flame::GetProjects"), APPLICATION->network()); + + QJsonObject body_obj; + QJsonArray addons_arr; + for (auto& addonId : addonIds) { + addons_arr.append(addonId); + } + + body_obj["modIds"] = addons_arr; + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + netJob->addNetAction(Net::Upload::makeByteArray(QString("https://api.curseforge.com/v1/mods"), response, body_raw)); + + QObject::connect(netJob, &NetJob::finished, [response, netJob] { delete response; netJob->deleteLater(); }); + QObject::connect(netJob, &NetJob::failed, [body_raw] { qDebug() << body_raw; }); + + return netJob; +} diff --git a/launcher/modplatform/flame/FlameAPI.h b/launcher/modplatform/flame/FlameAPI.h index aea76ff1..336df387 100644 --- a/launcher/modplatform/flame/FlameAPI.h +++ b/launcher/modplatform/flame/FlameAPI.h @@ -4,6 +4,14 @@ #include "modplatform/helpers/NetworkModAPI.h" class FlameAPI : public NetworkModAPI { + public: + auto matchFingerprints(const QList& fingerprints, QByteArray* response) -> NetJob::Ptr; + auto getModFileChangelog(int modId, int fileId) -> QString; + + auto getLatestVersion(VersionSearchArgs&& args) -> ModPlatform::IndexedVersion; + + auto getProjects(QStringList addonIds, QByteArray* response) const -> NetJob* override; + private: inline auto getSortFieldInt(QString sortString) const -> int { diff --git a/launcher/modplatform/flame/FlameCheckUpdate.cpp b/launcher/modplatform/flame/FlameCheckUpdate.cpp new file mode 100644 index 00000000..68a4589b --- /dev/null +++ b/launcher/modplatform/flame/FlameCheckUpdate.cpp @@ -0,0 +1,179 @@ +#include "FlameCheckUpdate.h" +#include "FlameAPI.h" +#include "FlameModIndex.h" + +#include + +#include "FileSystem.h" +#include "Json.h" + +#include "ModDownloadTask.h" + +static FlameAPI api; + +bool FlameCheckUpdate::abort() +{ + m_was_aborted = true; + if (m_net_job) + return m_net_job->abort(); + return true; +} + +ModPlatform::IndexedPack getProjectInfo(ModPlatform::IndexedVersion& ver_info) +{ + ModPlatform::IndexedPack pack; + + QEventLoop loop; + + auto get_project_job = new NetJob("Flame::GetProjectJob", APPLICATION->network()); + + auto response = new QByteArray(); + auto url = QString("https://api.curseforge.com/v1/mods/%1").arg(ver_info.addonId.toString()); + auto dl = Net::Download::makeByteArray(url, response); + get_project_job->addNetAction(dl); + + QObject::connect(get_project_job, &NetJob::succeeded, [response, &pack]() { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from FlameCheckUpdate at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + auto doc_obj = Json::requireObject(doc); + auto data_obj = Json::requireObject(doc_obj, "data"); + FlameMod::loadIndexedPack(pack, data_obj); + } catch (Json::JsonException& e) { + qWarning() << e.cause(); + qDebug() << doc; + } + }); + + QObject::connect(get_project_job, &NetJob::finished, [&loop, get_project_job] { + get_project_job->deleteLater(); + loop.quit(); + }); + + get_project_job->start(); + loop.exec(); + + return pack; +} + +ModPlatform::IndexedVersion getFileInfo(int addonId, int fileId) +{ + ModPlatform::IndexedVersion ver; + + QEventLoop loop; + + auto get_file_info_job = new NetJob("Flame::GetFileInfoJob", APPLICATION->network()); + + auto response = new QByteArray(); + auto url = QString("https://api.curseforge.com/v1/mods/%1/files/%2").arg(QString::number(addonId), QString::number(fileId)); + auto dl = Net::Download::makeByteArray(url, response); + get_file_info_job->addNetAction(dl); + + QObject::connect(get_file_info_job, &NetJob::succeeded, [response, &ver]() { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from FlameCheckUpdate at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + return; + } + + try { + auto doc_obj = Json::requireObject(doc); + auto data_obj = Json::requireObject(doc_obj, "data"); + ver = FlameMod::loadIndexedPackVersion(data_obj); + } catch (Json::JsonException& e) { + qWarning() << e.cause(); + qDebug() << doc; + } + }); + + QObject::connect(get_file_info_job, &NetJob::finished, [&loop, get_file_info_job] { + get_file_info_job->deleteLater(); + loop.quit(); + }); + + get_file_info_job->start(); + loop.exec(); + + return ver; +} + +/* Check for update: + * - Get latest version available + * - Compare hash of the latest version with the current hash + * - If equal, no updates, else, there's updates, so add to the list + * */ +void FlameCheckUpdate::executeTask() +{ + setStatus(tr("Preparing mods for CurseForge...")); + + int i = 0; + for (auto* mod : m_mods) { + if (!mod->enabled()) { + emit checkFailed(mod, tr("Disabled mods won't be updated, to prevent mod duplication issues!")); + continue; + } + + setStatus(tr("Getting API response from CurseForge for '%1'").arg(mod->name())); + setProgress(i++, m_mods.size()); + + auto latest_ver = api.getLatestVersion({ mod->metadata()->project_id.toString(), m_game_versions, m_loaders }); + + // Check if we were aborted while getting the latest version + if (m_was_aborted) { + aborted(); + return; + } + + setStatus(tr("Parsing the API response from CurseForge for '%1'...").arg(mod->name())); + + if (!latest_ver.addonId.isValid()) { + emit checkFailed(mod, tr("No valid version found for this mod. It's probably unavailable for the current game " + "version / mod loader.")); + continue; + } + + if (latest_ver.downloadUrl.isEmpty() && latest_ver.fileId != mod->metadata()->file_id) { + auto pack = getProjectInfo(latest_ver); + auto recover_url = QString("%1/download/%2").arg(pack.websiteUrl, latest_ver.fileId.toString()); + emit checkFailed(mod, tr("Mod has a new update available, but is opted-out on CurseForge"), recover_url); + + continue; + } + + if (!latest_ver.hash.isEmpty() && (mod->metadata()->hash != latest_ver.hash || mod->status() == ModStatus::NotInstalled)) { + // Fake pack with the necessary info to pass to the download task :) + ModPlatform::IndexedPack pack; + pack.name = mod->name(); + pack.slug = mod->metadata()->slug; + pack.addonId = mod->metadata()->project_id; + pack.websiteUrl = mod->homeurl(); + for (auto& author : mod->authors()) + pack.authors.append({ author }); + pack.description = mod->description(); + pack.provider = ModPlatform::Provider::FLAME; + + auto old_version = mod->version(); + if (old_version.isEmpty() && mod->status() != ModStatus::NotInstalled) { + auto current_ver = getFileInfo(latest_ver.addonId.toInt(), mod->metadata()->file_id.toInt()); + old_version = current_ver.version; + } + + auto download_task = new ModDownloadTask(pack, latest_ver, m_mods_folder); + m_updatable.emplace_back(pack.name, mod->metadata()->hash, old_version, latest_ver.version, + api.getModFileChangelog(latest_ver.addonId.toInt(), latest_ver.fileId.toInt()), + ModPlatform::Provider::FLAME, download_task); + } + } + + emitSucceeded(); +} diff --git a/launcher/modplatform/flame/FlameCheckUpdate.h b/launcher/modplatform/flame/FlameCheckUpdate.h new file mode 100644 index 00000000..163c706c --- /dev/null +++ b/launcher/modplatform/flame/FlameCheckUpdate.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Application.h" +#include "modplatform/CheckUpdateTask.h" +#include "net/NetJob.h" + +class FlameCheckUpdate : public CheckUpdateTask { + Q_OBJECT + + public: + FlameCheckUpdate(QList& mods, std::list& mcVersions, ModAPI::ModLoaderTypes loaders, std::shared_ptr mods_folder) + : CheckUpdateTask(mods, mcVersions, loaders, mods_folder) + {} + + public slots: + bool abort() override; + + protected slots: + void executeTask() override; + + private: + NetJob* m_net_job = nullptr; + + bool m_was_aborted = false; +}; diff --git a/launcher/modplatform/flame/FlameModIndex.cpp b/launcher/modplatform/flame/FlameModIndex.cpp index b99bfb26..746018e2 100644 --- a/launcher/modplatform/flame/FlameModIndex.cpp +++ b/launcher/modplatform/flame/FlameModIndex.cpp @@ -7,20 +7,22 @@ #include "net/NetJob.h" static ModPlatform::ProviderCapabilities ProviderCaps; +static FlameAPI api; void FlameMod::loadIndexedPack(ModPlatform::IndexedPack& pack, QJsonObject& obj) { pack.addonId = Json::requireInteger(obj, "id"); pack.provider = ModPlatform::Provider::FLAME; pack.name = Json::requireString(obj, "name"); + pack.slug = Json::requireString(obj, "slug"); pack.websiteUrl = Json::ensureString(Json::ensureObject(obj, "links"), "websiteUrl", ""); pack.description = Json::ensureString(obj, "summary", ""); - QJsonObject logo = Json::requireObject(obj, "logo"); - pack.logoName = Json::requireString(logo, "title"); - pack.logoUrl = Json::requireString(logo, "thumbnailUrl"); + QJsonObject logo = Json::ensureObject(obj, "logo"); + pack.logoName = Json::ensureString(logo, "title"); + pack.logoUrl = Json::ensureString(logo, "thumbnailUrl"); - auto authors = Json::requireArray(obj, "authors"); + auto authors = Json::ensureArray(obj, "authors"); for (auto authorIter : authors) { auto author = Json::requireObject(authorIter); ModPlatform::ModpackAuthor packAuthor; @@ -91,7 +93,7 @@ void FlameMod::loadIndexedPackVersions(ModPlatform::IndexedPack& pack, pack.versionsLoaded = true; } -auto FlameMod::loadIndexedPackVersion(QJsonObject& obj) -> ModPlatform::IndexedVersion +auto FlameMod::loadIndexedPackVersion(QJsonObject& obj, bool load_changelog) -> ModPlatform::IndexedVersion { auto versionArray = Json::requireArray(obj, "gameVersions"); if (versionArray.isEmpty()) { @@ -110,7 +112,7 @@ auto FlameMod::loadIndexedPackVersion(QJsonObject& obj) -> ModPlatform::IndexedV file.fileId = Json::requireInteger(obj, "id"); file.date = Json::requireString(obj, "fileDate"); file.version = Json::requireString(obj, "displayName"); - file.downloadUrl = Json::requireString(obj, "downloadUrl"); + file.downloadUrl = Json::ensureString(obj, "downloadUrl"); file.fileName = Json::requireString(obj, "fileName"); auto hash_list = Json::ensureArray(obj, "hashes"); @@ -124,5 +126,9 @@ auto FlameMod::loadIndexedPackVersion(QJsonObject& obj) -> ModPlatform::IndexedV break; } } + + if(load_changelog) + file.changelog = api.getModFileChangelog(file.addonId.toInt(), file.fileId.toInt()); + return file; } diff --git a/launcher/modplatform/flame/FlameModIndex.h b/launcher/modplatform/flame/FlameModIndex.h index 9c6c1c6c..a839dd83 100644 --- a/launcher/modplatform/flame/FlameModIndex.h +++ b/launcher/modplatform/flame/FlameModIndex.h @@ -17,6 +17,6 @@ void loadIndexedPackVersions(ModPlatform::IndexedPack& pack, QJsonArray& arr, const shared_qobject_ptr& network, BaseInstance* inst); -auto loadIndexedPackVersion(QJsonObject& obj) -> ModPlatform::IndexedVersion; +auto loadIndexedPackVersion(QJsonObject& obj, bool load_changelog = false) -> ModPlatform::IndexedVersion; } // namespace FlameMod diff --git a/launcher/modplatform/helpers/NetworkModAPI.cpp b/launcher/modplatform/helpers/NetworkModAPI.cpp index d7abd10f..90edfe31 100644 --- a/launcher/modplatform/helpers/NetworkModAPI.cpp +++ b/launcher/modplatform/helpers/NetworkModAPI.cpp @@ -33,18 +33,14 @@ void NetworkModAPI::searchMods(CallerType* caller, SearchArgs&& args) const void NetworkModAPI::getModInfo(CallerType* caller, ModPlatform::IndexedPack& pack) { - auto id_str = pack.addonId.toString(); - auto netJob = new NetJob(QString("%1::ModInfo").arg(id_str), APPLICATION->network()); - auto searchUrl = getModInfoURL(id_str); - auto response = new QByteArray(); - netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), response)); + auto job = getProject(pack.addonId.toString(), response); - QObject::connect(netJob, &NetJob::succeeded, [response, &pack, caller] { + QObject::connect(job, &NetJob::succeeded, caller, [caller, &pack, response] { QJsonParseError parse_error{}; - auto doc = QJsonDocument::fromJson(*response, &parse_error); + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); if (parse_error.error != QJsonParseError::NoError) { - qWarning() << "Error while parsing JSON response for " << pack.name << " at " << parse_error.offset + qWarning() << "Error while parsing JSON response from " << caller->debugName() << " at " << parse_error.offset << " reason: " << parse_error.errorString(); qWarning() << *response; return; @@ -53,7 +49,7 @@ void NetworkModAPI::getModInfo(CallerType* caller, ModPlatform::IndexedPack& pac caller->infoRequestFinished(doc, pack); }); - netJob->start(); + job->start(); } void NetworkModAPI::getVersions(CallerType* caller, VersionSearchArgs&& args) const @@ -83,3 +79,18 @@ void NetworkModAPI::getVersions(CallerType* caller, VersionSearchArgs&& args) co netJob->start(); } + +auto NetworkModAPI::getProject(QString addonId, QByteArray* response) const -> NetJob* +{ + auto netJob = new NetJob(QString("%1::GetProject").arg(addonId), APPLICATION->network()); + auto searchUrl = getModInfoURL(addonId); + + netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), response)); + + QObject::connect(netJob, &NetJob::finished, [response, netJob] { + netJob->deleteLater(); + delete response; + }); + + return netJob; +} diff --git a/launcher/modplatform/helpers/NetworkModAPI.h b/launcher/modplatform/helpers/NetworkModAPI.h index 87d77ad1..989bcec4 100644 --- a/launcher/modplatform/helpers/NetworkModAPI.h +++ b/launcher/modplatform/helpers/NetworkModAPI.h @@ -8,6 +8,8 @@ class NetworkModAPI : public ModAPI { void getModInfo(CallerType* caller, ModPlatform::IndexedPack& pack) override; void getVersions(CallerType* caller, VersionSearchArgs&& args) const override; + auto getProject(QString addonId, QByteArray* response) const -> NetJob* override; + protected: virtual auto getModSearchURL(SearchArgs& args) const -> QString = 0; virtual auto getModInfoURL(QString& id) const -> QString = 0; diff --git a/launcher/modplatform/modrinth/ModrinthAPI.cpp b/launcher/modplatform/modrinth/ModrinthAPI.cpp new file mode 100644 index 00000000..747cf4c3 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthAPI.cpp @@ -0,0 +1,108 @@ +#include "ModrinthAPI.h" + +#include "Application.h" +#include "Json.h" +#include "net/Upload.h" + +auto ModrinthAPI::currentVersion(QString hash, QString hash_format, QByteArray* response) -> NetJob::Ptr +{ + auto* netJob = new NetJob(QString("Modrinth::GetCurrentVersion"), APPLICATION->network()); + + netJob->addNetAction(Net::Download::makeByteArray( + QString(BuildConfig.MODRINTH_PROD_URL + "/version_file/%1?algorithm=%2").arg(hash, hash_format), response)); + + QObject::connect(netJob, &NetJob::finished, [response] { delete response; }); + + return netJob; +} + +auto ModrinthAPI::currentVersions(const QStringList& hashes, QString hash_format, QByteArray* response) -> NetJob::Ptr +{ + auto* netJob = new NetJob(QString("Modrinth::GetCurrentVersions"), APPLICATION->network()); + + QJsonObject body_obj; + + Json::writeStringList(body_obj, "hashes", hashes); + Json::writeString(body_obj, "algorithm", hash_format); + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + netJob->addNetAction(Net::Upload::makeByteArray(QString(BuildConfig.MODRINTH_PROD_URL + "/version_files"), response, body_raw)); + + QObject::connect(netJob, &NetJob::finished, [response] { delete response; }); + + return netJob; +} + +auto ModrinthAPI::latestVersion(QString hash, + QString hash_format, + std::list mcVersions, + ModLoaderTypes loaders, + QByteArray* response) -> NetJob::Ptr +{ + auto* netJob = new NetJob(QString("Modrinth::GetLatestVersion"), APPLICATION->network()); + + QJsonObject body_obj; + + Json::writeStringList(body_obj, "loaders", getModLoaderStrings(loaders)); + + QStringList game_versions; + for (auto& ver : mcVersions) { + game_versions.append(ver.toString()); + } + Json::writeStringList(body_obj, "game_versions", game_versions); + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + netJob->addNetAction(Net::Upload::makeByteArray( + QString(BuildConfig.MODRINTH_PROD_URL + "/version_file/%1/update?algorithm=%2").arg(hash, hash_format), response, body_raw)); + + QObject::connect(netJob, &NetJob::finished, [response] { delete response; }); + + return netJob; +} + +auto ModrinthAPI::latestVersions(const QStringList& hashes, + QString hash_format, + std::list mcVersions, + ModLoaderTypes loaders, + QByteArray* response) -> NetJob::Ptr +{ + auto* netJob = new NetJob(QString("Modrinth::GetLatestVersions"), APPLICATION->network()); + + QJsonObject body_obj; + + Json::writeStringList(body_obj, "hashes", hashes); + Json::writeString(body_obj, "algorithm", hash_format); + + Json::writeStringList(body_obj, "loaders", getModLoaderStrings(loaders)); + + QStringList game_versions; + for (auto& ver : mcVersions) { + game_versions.append(ver.toString()); + } + Json::writeStringList(body_obj, "game_versions", game_versions); + + QJsonDocument body(body_obj); + auto body_raw = body.toJson(); + + netJob->addNetAction(Net::Upload::makeByteArray(QString(BuildConfig.MODRINTH_PROD_URL + "/version_files/update"), response, body_raw)); + + QObject::connect(netJob, &NetJob::finished, [response] { delete response; }); + + return netJob; +} + +auto ModrinthAPI::getProjects(QStringList addonIds, QByteArray* response) const -> NetJob* +{ + auto netJob = new NetJob(QString("Modrinth::GetProjects"), APPLICATION->network()); + auto searchUrl = getMultipleModInfoURL(addonIds); + + netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), response)); + + QObject::connect(netJob, &NetJob::finished, [response, netJob] { delete response; netJob->deleteLater(); }); + + return netJob; +} diff --git a/launcher/modplatform/modrinth/ModrinthAPI.h b/launcher/modplatform/modrinth/ModrinthAPI.h index 89e52d6c..e1a18681 100644 --- a/launcher/modplatform/modrinth/ModrinthAPI.h +++ b/launcher/modplatform/modrinth/ModrinthAPI.h @@ -26,6 +26,29 @@ #include class ModrinthAPI : public NetworkModAPI { + public: + auto currentVersion(QString hash, + QString hash_format, + QByteArray* response) -> NetJob::Ptr; + + auto currentVersions(const QStringList& hashes, + QString hash_format, + QByteArray* response) -> NetJob::Ptr; + + auto latestVersion(QString hash, + QString hash_format, + std::list mcVersions, + ModLoaderTypes loaders, + QByteArray* response) -> NetJob::Ptr; + + auto latestVersions(const QStringList& hashes, + QString hash_format, + std::list mcVersions, + ModLoaderTypes loaders, + QByteArray* response) -> NetJob::Ptr; + + auto getProjects(QStringList addonIds, QByteArray* response) const -> NetJob* override; + public: inline auto getAuthorURL(const QString& name) const -> QString { return "https://modrinth.com/user/" + name; }; @@ -81,6 +104,11 @@ class ModrinthAPI : public NetworkModAPI { return BuildConfig.MODRINTH_PROD_URL + "/project/" + id; }; + inline auto getMultipleModInfoURL(QStringList ids) const -> QString + { + return BuildConfig.MODRINTH_PROD_URL + QString("/projects?ids=[\"%1\"]").arg(ids.join("\",\"")); + }; + inline auto getVersionsURL(VersionSearchArgs& args) const -> QString override { return QString(BuildConfig.MODRINTH_PROD_URL + diff --git a/launcher/modplatform/modrinth/ModrinthCheckUpdate.cpp b/launcher/modplatform/modrinth/ModrinthCheckUpdate.cpp new file mode 100644 index 00000000..79d8edf7 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthCheckUpdate.cpp @@ -0,0 +1,174 @@ +#include "ModrinthCheckUpdate.h" +#include "ModrinthAPI.h" +#include "ModrinthPackIndex.h" + +#include "FileSystem.h" +#include "Json.h" + +#include "ModDownloadTask.h" + +static ModrinthAPI api; +static ModPlatform::ProviderCapabilities ProviderCaps; + +bool ModrinthCheckUpdate::abort() +{ + if (m_net_job) + return m_net_job->abort(); + return true; +} + +/* Check for update: + * - Get latest version available + * - Compare hash of the latest version with the current hash + * - If equal, no updates, else, there's updates, so add to the list + * */ +void ModrinthCheckUpdate::executeTask() +{ + setStatus(tr("Preparing mods for Modrinth...")); + setProgress(0, 3); + + QHash mappings; + + // Create all hashes + QStringList hashes; + auto best_hash_type = ProviderCaps.hashType(ModPlatform::Provider::MODRINTH).first(); + for (auto* mod : m_mods) { + if (!mod->enabled()) { + emit checkFailed(mod, tr("Disabled mods won't be updated, to prevent mod duplication issues!")); + continue; + } + + auto hash = mod->metadata()->hash; + + // Sadly the API can only handle one hash type per call, se we + // need to generate a new hash if the current one is innadequate + // (though it will rarely happen, if at all) + if (mod->metadata()->hash_format != best_hash_type) { + QByteArray jar_data; + + try { + jar_data = FS::read(mod->fileinfo().absoluteFilePath()); + } catch (FS::FileSystemException& e) { + qCritical() << QString("Failed to open / read JAR file of %1").arg(mod->name()); + qCritical() << QString("Reason: ") << e.cause(); + + failed(e.what()); + return; + } + + hash = QString(ProviderCaps.hash(ModPlatform::Provider::MODRINTH, jar_data, best_hash_type).toHex()); + } + + hashes.append(hash); + mappings.insert(hash, mod); + } + + auto* response = new QByteArray(); + auto job = api.latestVersions(hashes, best_hash_type, m_game_versions, m_loaders, response); + + QEventLoop lock; + + connect(job.get(), &Task::succeeded, this, [this, response, &mappings, best_hash_type, job] { + QJsonParseError parse_error{}; + QJsonDocument doc = QJsonDocument::fromJson(*response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) { + qWarning() << "Error while parsing JSON response from ModrinthCheckUpdate at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << *response; + + failed(parse_error.errorString()); + return; + } + + setStatus(tr("Parsing the API response from Modrinth...")); + setProgress(2, 3); + + try { + for (auto hash : mappings.keys()) { + auto project_obj = doc[hash].toObject(); + + // If the returned project is empty, but we have Modrinth metadata, + // it means this specific version is not available + if (project_obj.isEmpty()) { + qDebug() << "Mod " << mappings.find(hash).value()->name() << " got an empty response."; + qDebug() << "Hash: " << hash; + + emit checkFailed( + mappings.find(hash).value(), + tr("No valid version found for this mod. It's probably unavailable for the current game version / mod loader.")); + + continue; + } + + // Sometimes a version may have multiple files, one with "forge" and one with "fabric", + // so we may want to filter it + QString loader_filter; + static auto flags = { ModAPI::ModLoaderType::Forge, ModAPI::ModLoaderType::Fabric, ModAPI::ModLoaderType::Quilt }; + for (auto flag : flags) { + if (m_loaders.testFlag(flag)) { + loader_filter = api.getModLoaderString(flag); + break; + } + } + + // Currently, we rely on a couple heuristics to determine whether an update is actually available or not: + // - The file needs to be preferred: It is either the primary file, or the one found via (explicit) usage of the loader_filter + // - The version reported by the JAR is different from the version reported by the indexed version (it's usually the case) + // Such is the pain of having arbitrary files for a given version .-. + + auto project_ver = Modrinth::loadIndexedPackVersion(project_obj, best_hash_type, loader_filter); + if (project_ver.downloadUrl.isEmpty()) { + qCritical() << "Modrinth mod without download url!"; + qCritical() << project_ver.fileName; + + emit checkFailed(mappings.find(hash).value(), tr("Mod has an empty download URL")); + + continue; + } + + auto mod_iter = mappings.find(hash); + if (mod_iter == mappings.end()) { + qCritical() << "Failed to remap mod from Modrinth!"; + continue; + } + auto mod = *mod_iter; + + auto key = project_ver.hash; + if ((key != hash && project_ver.is_preferred) || (mod->status() == ModStatus::NotInstalled)) { + if (mod->version() == project_ver.version_number) + continue; + + // Fake pack with the necessary info to pass to the download task :) + ModPlatform::IndexedPack pack; + pack.name = mod->name(); + pack.slug = mod->metadata()->slug; + pack.addonId = mod->metadata()->project_id; + pack.websiteUrl = mod->homeurl(); + for (auto& author : mod->authors()) + pack.authors.append({ author }); + pack.description = mod->description(); + pack.provider = ModPlatform::Provider::MODRINTH; + + auto download_task = new ModDownloadTask(pack, project_ver, m_mods_folder); + + m_updatable.emplace_back(pack.name, hash, mod->version(), project_ver.version_number, project_ver.changelog, + ModPlatform::Provider::MODRINTH, download_task); + } + } + } catch (Json::JsonException& e) { + failed(e.cause() + " : " + e.what()); + } + }); + + connect(job.get(), &Task::finished, &lock, &QEventLoop::quit); + + setStatus(tr("Waiting for the API response from Modrinth...")); + setProgress(1, 3); + + m_net_job = job.get(); + job->start(); + + lock.exec(); + + emitSucceeded(); +} diff --git a/launcher/modplatform/modrinth/ModrinthCheckUpdate.h b/launcher/modplatform/modrinth/ModrinthCheckUpdate.h new file mode 100644 index 00000000..abf8ada1 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthCheckUpdate.h @@ -0,0 +1,23 @@ +#pragma once + +#include "Application.h" +#include "modplatform/CheckUpdateTask.h" +#include "net/NetJob.h" + +class ModrinthCheckUpdate : public CheckUpdateTask { + Q_OBJECT + + public: + ModrinthCheckUpdate(QList& mods, std::list& mcVersions, ModAPI::ModLoaderTypes loaders, std::shared_ptr mods_folder) + : CheckUpdateTask(mods, mcVersions, loaders, mods_folder) + {} + + public slots: + bool abort() override; + + protected slots: + void executeTask() override; + + private: + NetJob* m_net_job = nullptr; +}; diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.cpp b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp index b6f5490a..e50dd96d 100644 --- a/launcher/modplatform/modrinth/ModrinthPackIndex.cpp +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp @@ -29,13 +29,16 @@ static ModPlatform::ProviderCapabilities ProviderCaps; void Modrinth::loadIndexedPack(ModPlatform::IndexedPack& pack, QJsonObject& obj) { - pack.addonId = Json::requireString(obj, "project_id"); + pack.addonId = Json::ensureString(obj, "project_id"); + if (pack.addonId.toString().isEmpty()) + pack.addonId = Json::requireString(obj, "id"); + pack.provider = ModPlatform::Provider::MODRINTH; pack.name = Json::requireString(obj, "title"); - QString slug = Json::ensureString(obj, "slug", ""); - if (!slug.isEmpty()) - pack.websiteUrl = "https://modrinth.com/mod/" + Json::ensureString(obj, "slug", ""); + pack.slug = Json::ensureString(obj, "slug", ""); + if (!pack.slug.isEmpty()) + pack.websiteUrl = "https://modrinth.com/mod/" + pack.slug; else pack.websiteUrl = ""; @@ -45,7 +48,7 @@ void Modrinth::loadIndexedPack(ModPlatform::IndexedPack& pack, QJsonObject& obj) pack.logoName = pack.addonId.toString(); ModPlatform::ModpackAuthor modAuthor; - modAuthor.name = Json::requireString(obj, "author"); + modAuthor.name = Json::ensureString(obj, "author", QObject::tr("No author(s)")); modAuthor.url = api.getAuthorURL(modAuthor.name); pack.authors.append(modAuthor); @@ -111,7 +114,7 @@ void Modrinth::loadIndexedPackVersions(ModPlatform::IndexedPack& pack, pack.versionsLoaded = true; } -auto Modrinth::loadIndexedPackVersion(QJsonObject &obj) -> ModPlatform::IndexedVersion +auto Modrinth::loadIndexedPackVersion(QJsonObject &obj, QString preferred_hash_type, QString preferred_file_name) -> ModPlatform::IndexedVersion { ModPlatform::IndexedVersion file; @@ -130,6 +133,8 @@ auto Modrinth::loadIndexedPackVersion(QJsonObject &obj) -> ModPlatform::IndexedV file.loaders.append(loader.toString()); } file.version = Json::requireString(obj, "name"); + file.version_number = Json::requireString(obj, "version_number"); + file.changelog = Json::requireString(obj, "changelog"); auto files = Json::requireArray(obj, "files"); int i = 0; @@ -142,6 +147,11 @@ auto Modrinth::loadIndexedPackVersion(QJsonObject &obj) -> ModPlatform::IndexedV auto parent = files[i].toObject(); auto fileName = Json::requireString(parent, "filename"); + if (!preferred_file_name.isEmpty() && fileName.contains(preferred_file_name)) { + file.is_preferred = true; + break; + } + // Grab the primary file, if available if (Json::requireBoolean(parent, "primary")) break; @@ -153,13 +163,20 @@ auto Modrinth::loadIndexedPackVersion(QJsonObject &obj) -> ModPlatform::IndexedV if (parent.contains("url")) { file.downloadUrl = Json::requireString(parent, "url"); file.fileName = Json::requireString(parent, "filename"); + file.is_preferred = Json::requireBoolean(parent, "primary") || (files.count() == 1); auto hash_list = Json::requireObject(parent, "hashes"); - auto hash_types = ProviderCaps.hashType(ModPlatform::Provider::MODRINTH); - for (auto& hash_type : hash_types) { - if (hash_list.contains(hash_type)) { - file.hash = Json::requireString(hash_list, hash_type); - file.hash_type = hash_type; - break; + + if (hash_list.contains(preferred_hash_type)) { + file.hash = Json::requireString(hash_list, preferred_hash_type); + file.hash_type = preferred_hash_type; + } else { + auto hash_types = ProviderCaps.hashType(ModPlatform::Provider::MODRINTH); + for (auto& hash_type : hash_types) { + if (hash_list.contains(hash_type)) { + file.hash = Json::requireString(hash_list, hash_type); + file.hash_type = hash_type; + break; + } } } diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.h b/launcher/modplatform/modrinth/ModrinthPackIndex.h index b7936204..31881414 100644 --- a/launcher/modplatform/modrinth/ModrinthPackIndex.h +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.h @@ -30,6 +30,6 @@ void loadIndexedPackVersions(ModPlatform::IndexedPack& pack, QJsonArray& arr, const shared_qobject_ptr& network, BaseInstance* inst); -auto loadIndexedPackVersion(QJsonObject& obj) -> ModPlatform::IndexedVersion; +auto loadIndexedPackVersion(QJsonObject& obj, QString hash_type = "sha512", QString filename_prefer = "") -> ModPlatform::IndexedVersion; } // namespace Modrinth diff --git a/launcher/modplatform/packwiz/Packwiz.cpp b/launcher/modplatform/packwiz/Packwiz.cpp index 0782b9f4..c3561093 100644 --- a/launcher/modplatform/packwiz/Packwiz.cpp +++ b/launcher/modplatform/packwiz/Packwiz.cpp @@ -55,11 +55,11 @@ auto getRealIndexName(QDir& index_dir, QString normalized_fname, bool should_fin } // Helpers -static inline auto indexFileName(QString const& mod_name) -> QString +static inline auto indexFileName(QString const& mod_slug) -> QString { - if(mod_name.endsWith(".pw.toml")) - return mod_name; - return QString("%1.pw.toml").arg(mod_name); + if(mod_slug.endsWith(".pw.toml")) + return mod_slug; + return QString("%1.pw.toml").arg(mod_slug); } static ModPlatform::ProviderCapabilities ProviderCaps; @@ -95,6 +95,7 @@ auto V1::createModFormat(QDir& index_dir, ModPlatform::IndexedPack& mod_pack, Mo { Mod mod; + mod.slug = mod_pack.slug; mod.name = mod_pack.name; mod.filename = mod_version.fileName; @@ -116,12 +117,10 @@ auto V1::createModFormat(QDir& index_dir, ModPlatform::IndexedPack& mod_pack, Mo return mod; } -auto V1::createModFormat(QDir& index_dir, ::Mod& internal_mod) -> Mod +auto V1::createModFormat(QDir& index_dir, ::Mod& internal_mod, QString slug) -> Mod { - auto mod_name = internal_mod.name(); - // Try getting metadata if it exists - Mod mod { getIndexForMod(index_dir, mod_name) }; + Mod mod { getIndexForMod(index_dir, slug) }; if(mod.isValid()) return mod; @@ -139,11 +138,14 @@ void V1::updateModIndex(QDir& index_dir, Mod& mod) // Ensure the corresponding mod's info exists, and create it if not - auto normalized_fname = indexFileName(mod.name); + auto normalized_fname = indexFileName(mod.slug); auto real_fname = getRealIndexName(index_dir, normalized_fname); QFile index_file(index_dir.absoluteFilePath(real_fname)); + if (real_fname != normalized_fname) + index_file.rename(normalized_fname); + // There's already data on there! // TODO: We should do more stuff here, as the user is likely trying to // override a file. In this case, check versions and ask the user what @@ -184,33 +186,46 @@ void V1::updateModIndex(QDir& index_dir, Mod& mod) } } + index_file.flush(); index_file.close(); } -void V1::deleteModIndex(QDir& index_dir, QString& mod_name) +void V1::deleteModIndex(QDir& index_dir, QString& mod_slug) { - auto normalized_fname = indexFileName(mod_name); + auto normalized_fname = indexFileName(mod_slug); auto real_fname = getRealIndexName(index_dir, normalized_fname); if (real_fname.isEmpty()) return; QFile index_file(index_dir.absoluteFilePath(real_fname)); - if(!index_file.exists()){ - qWarning() << QString("Tried to delete non-existent mod metadata for %1!").arg(mod_name); + if (!index_file.exists()) { + qWarning() << QString("Tried to delete non-existent mod metadata for %1!").arg(mod_slug); return; } - if(!index_file.remove()){ - qWarning() << QString("Failed to remove metadata for mod %1!").arg(mod_name); + if (!index_file.remove()) { + qWarning() << QString("Failed to remove metadata for mod %1!").arg(mod_slug); } } -auto V1::getIndexForMod(QDir& index_dir, QString& index_file_name) -> Mod +void V1::deleteModIndex(QDir& index_dir, QVariant& mod_id) +{ + for (auto& file_name : index_dir.entryList(QDir::Filter::Files)) { + auto mod = getIndexForMod(index_dir, file_name); + + if (mod.mod_id() == mod_id) { + deleteModIndex(index_dir, mod.name); + break; + } + } +} + +auto V1::getIndexForMod(QDir& index_dir, QString slug) -> Mod { Mod mod; - auto normalized_fname = indexFileName(index_file_name); + auto normalized_fname = indexFileName(slug); auto real_fname = getRealIndexName(index_dir, normalized_fname, true); if (real_fname.isEmpty()) return {}; @@ -218,7 +233,7 @@ auto V1::getIndexForMod(QDir& index_dir, QString& index_file_name) -> Mod QFile index_file(index_dir.absoluteFilePath(real_fname)); if (!index_file.open(QIODevice::ReadOnly)) { - qWarning() << QString("Failed to open mod metadata for %1").arg(index_file_name); + qWarning() << QString("Failed to open mod metadata for %1").arg(slug); return {}; } @@ -232,11 +247,13 @@ auto V1::getIndexForMod(QDir& index_dir, QString& index_file_name) -> Mod index_file.close(); if (!table) { - qWarning() << QString("Could not open file %1!").arg(indexFileName(index_file_name)); + qWarning() << QString("Could not open file %1!").arg(normalized_fname); qWarning() << "Reason: " << QString(errbuf); return {}; } + mod.slug = slug; + { // Basic info mod.name = stringEntry(table, "name"); mod.filename = stringEntry(table, "filename"); @@ -286,4 +303,16 @@ auto V1::getIndexForMod(QDir& index_dir, QString& index_file_name) -> Mod return mod; } -} // namespace Packwiz +auto V1::getIndexForMod(QDir& index_dir, QVariant& mod_id) -> Mod +{ + for (auto& file_name : index_dir.entryList(QDir::Filter::Files)) { + auto mod = getIndexForMod(index_dir, file_name); + + if (mod.mod_id() == mod_id) + return mod; + } + + return {}; +} + +} // namespace Packwiz diff --git a/launcher/modplatform/packwiz/Packwiz.h b/launcher/modplatform/packwiz/Packwiz.h index 3c99769c..3ec80377 100644 --- a/launcher/modplatform/packwiz/Packwiz.h +++ b/launcher/modplatform/packwiz/Packwiz.h @@ -40,6 +40,7 @@ auto intEntry(toml_table_t* parent, const char* entry_name) -> int; class V1 { public: struct Mod { + QString slug {}; QString name {}; QString filename {}; // FIXME: make side an enum @@ -58,7 +59,7 @@ class V1 { public: // This is a totally heuristic, but should work for now. - auto isValid() const -> bool { return !name.isEmpty() && !project_id.isNull(); } + auto isValid() const -> bool { return !slug.isEmpty() && !project_id.isNull(); } // Different providers can use different names for the same thing // Modrinth-specific @@ -71,9 +72,9 @@ class V1 { * */ static auto createModFormat(QDir& index_dir, ModPlatform::IndexedPack& mod_pack, ModPlatform::IndexedVersion& mod_version) -> Mod; /* Generates the object representing the information in a mod.pw.toml file via - * its common representation in the launcher. + * its common representation in the launcher, plus a necessary slug. * */ - static auto createModFormat(QDir& index_dir, ::Mod& internal_mod) -> Mod; + static auto createModFormat(QDir& index_dir, ::Mod& internal_mod, QString slug) -> Mod; /* Updates the mod index for the provided mod. * This creates a new index if one does not exist already @@ -81,13 +82,21 @@ class V1 { * */ static void updateModIndex(QDir& index_dir, Mod& mod); - /* Deletes the metadata for the mod with the given name. If the metadata doesn't exist, it does nothing. */ - static void deleteModIndex(QDir& index_dir, QString& mod_name); + /* Deletes the metadata for the mod with the given slug. If the metadata doesn't exist, it does nothing. */ + static void deleteModIndex(QDir& index_dir, QString& mod_slug); - /* Gets the metadata for a mod with a particular name. + /* Deletes the metadata for the mod with the given id. If the metadata doesn't exist, it does nothing. */ + static void deleteModIndex(QDir& index_dir, QVariant& mod_id); + + /* Gets the metadata for a mod with a particular file name. * If the mod doesn't have a metadata, it simply returns an empty Mod object. * */ - static auto getIndexForMod(QDir& index_dir, QString& index_file_name) -> Mod; + static auto getIndexForMod(QDir& index_dir, QString slug) -> Mod; + + /* Gets the metadata for a mod with a particular id. + * If the mod doesn't have a metadata, it simply returns an empty Mod object. + * */ + static auto getIndexForMod(QDir& index_dir, QVariant& mod_id) -> Mod; }; } // namespace Packwiz diff --git a/launcher/modplatform/packwiz/Packwiz_test.cpp b/launcher/modplatform/packwiz/Packwiz_test.cpp index d6251148..aa0c35df 100644 --- a/launcher/modplatform/packwiz/Packwiz_test.cpp +++ b/launcher/modplatform/packwiz/Packwiz_test.cpp @@ -32,10 +32,11 @@ class PackwizTest : public QObject { QString source = QFINDTESTDATA("testdata"); QDir index_dir(source); - QString name_mod("borderless-mining.pw.toml"); - QVERIFY(index_dir.entryList().contains(name_mod)); + QString slug_mod("borderless-mining"); + QString file_name = slug_mod + ".pw.toml"; + QVERIFY(index_dir.entryList().contains(file_name)); - auto metadata = Packwiz::V1::getIndexForMod(index_dir, name_mod); + auto metadata = Packwiz::V1::getIndexForMod(index_dir, slug_mod); QVERIFY(metadata.isValid()); diff --git a/launcher/net/Upload.cpp b/launcher/net/Upload.cpp index 12dd1e78..cfda4b4e 100644 --- a/launcher/net/Upload.cpp +++ b/launcher/net/Upload.cpp @@ -43,6 +43,16 @@ namespace Net { + bool Upload::abort() + { + if (m_reply) { + m_reply->abort(); + } else { + m_state = State::AbortedByUser; + } + return true; + } + void Upload::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) { setProgress(bytesReceived, bytesTotal); } diff --git a/launcher/net/Upload.h b/launcher/net/Upload.h index 56687a31..7c194bbc 100644 --- a/launcher/net/Upload.h +++ b/launcher/net/Upload.h @@ -46,6 +46,8 @@ namespace Net { public: static Upload::Ptr makeByteArray(QUrl url, QByteArray *output, QByteArray m_post_data); + auto abort() -> bool override; + auto canAbort() const -> bool override { return true; }; protected slots: void downloadProgress(qint64 bytesReceived, qint64 bytesTotal) override; diff --git a/launcher/tasks/MultipleOptionsTask.cpp b/launcher/tasks/MultipleOptionsTask.cpp new file mode 100644 index 00000000..6e853568 --- /dev/null +++ b/launcher/tasks/MultipleOptionsTask.cpp @@ -0,0 +1,48 @@ +#include "MultipleOptionsTask.h" + +#include + +MultipleOptionsTask::MultipleOptionsTask(QObject* parent, const QString& task_name) : SequentialTask(parent, task_name) {} + +void MultipleOptionsTask::startNext() +{ + Task* previous = nullptr; + if (m_currentIndex != -1) { + previous = m_queue[m_currentIndex].get(); + disconnect(previous, 0, this, 0); + } + + m_currentIndex++; + if ((previous && previous->wasSuccessful())) { + emitSucceeded(); + return; + } + + Task::Ptr next = m_queue[m_currentIndex]; + + connect(next.get(), &Task::failed, this, &MultipleOptionsTask::subTaskFailed); + connect(next.get(), &Task::succeeded, this, &MultipleOptionsTask::startNext); + + connect(next.get(), &Task::status, this, &MultipleOptionsTask::subTaskStatus); + connect(next.get(), &Task::stepStatus, this, &MultipleOptionsTask::subTaskStatus); + + connect(next.get(), &Task::progress, this, &MultipleOptionsTask::subTaskProgress); + + qDebug() << QString("Making attemp %1 out of %2").arg(m_currentIndex + 1).arg(m_queue.size()); + setStatus(tr("Making attempt #%1 out of %2").arg(m_currentIndex + 1).arg(m_queue.size())); + setStepStatus(next->isMultiStep() ? next->getStepStatus() : next->getStatus()); + + next->start(); +} + +void MultipleOptionsTask::subTaskFailed(QString const& reason) +{ + qDebug() << QString("Failed attempt #%1 of %2. Reason: %3").arg(m_currentIndex + 1).arg(m_queue.size()).arg(reason); + if(m_currentIndex < m_queue.size() - 1) { + startNext(); + return; + } + + qWarning() << QString("All attempts have failed!"); + emitFailed(); +} diff --git a/launcher/tasks/MultipleOptionsTask.h b/launcher/tasks/MultipleOptionsTask.h new file mode 100644 index 00000000..7c508b00 --- /dev/null +++ b/launcher/tasks/MultipleOptionsTask.h @@ -0,0 +1,19 @@ +#pragma once + +#include "SequentialTask.h" + +/* This task type will attempt to do run each of it's subtasks in sequence, + * until one of them succeeds. When that happens, the remaining tasks will not run. + * */ +class MultipleOptionsTask : public SequentialTask +{ + Q_OBJECT +public: + explicit MultipleOptionsTask(QObject *parent = nullptr, const QString& task_name = ""); + virtual ~MultipleOptionsTask() = default; + +private +slots: + void startNext() override; + void subTaskFailed(const QString &msg) override; +}; diff --git a/launcher/tasks/SequentialTask.cpp b/launcher/tasks/SequentialTask.cpp index 7f03ad2e..f1e1a889 100644 --- a/launcher/tasks/SequentialTask.cpp +++ b/launcher/tasks/SequentialTask.cpp @@ -1,5 +1,7 @@ #include "SequentialTask.h" +#include + SequentialTask::SequentialTask(QObject* parent, const QString& task_name) : Task(parent), m_name(task_name), m_currentIndex(-1) {} SequentialTask::~SequentialTask() @@ -39,14 +41,15 @@ bool SequentialTask::abort() emit aborted(); emit finished(); } - m_queue.clear(); + + m_aborted = true; return true; } bool succeeded = m_queue[m_currentIndex]->abort(); - m_queue.clear(); + m_aborted = succeeded; - if(succeeded) + if (succeeded) emitAborted(); return succeeded; @@ -54,10 +57,14 @@ bool SequentialTask::abort() void SequentialTask::startNext() { - if (m_currentIndex != -1) { - Task::Ptr previous = m_queue[m_currentIndex]; + if (m_aborted) + return; + + if (m_currentIndex != -1 && m_currentIndex < m_queue.size()) { + Task::Ptr previous = m_queue.at(m_currentIndex); disconnect(previous.get(), 0, this, 0); } + m_currentIndex++; if (m_queue.isEmpty() || m_currentIndex >= m_queue.size()) { emitSucceeded(); @@ -76,6 +83,8 @@ void SequentialTask::startNext() setStatus(tr("Executing task %1 out of %2").arg(m_currentIndex + 1).arg(m_queue.size())); setStepStatus(next->isMultiStep() ? next->getStepStatus() : next->getStatus()); + setProgress(m_currentIndex + 1, m_queue.count()); + next->start(); } @@ -93,7 +102,6 @@ void SequentialTask::subTaskProgress(qint64 current, qint64 total) setProgress(0, 100); return; } - setProgress(m_currentIndex + 1, m_queue.count()); m_stepProgress = current; m_stepTotalProgress = total; diff --git a/launcher/tasks/SequentialTask.h b/launcher/tasks/SequentialTask.h index e10cb6f7..f5a58b1b 100644 --- a/launcher/tasks/SequentialTask.h +++ b/launcher/tasks/SequentialTask.h @@ -20,17 +20,17 @@ public: void addTask(Task::Ptr task); -protected slots: - void executeTask() override; public slots: bool abort() override; -private +protected slots: - void startNext(); - void subTaskFailed(const QString &msg); - void subTaskStatus(const QString &msg); - void subTaskProgress(qint64 current, qint64 total); + void executeTask() override; + + virtual void startNext(); + virtual void subTaskFailed(const QString &msg); + virtual void subTaskStatus(const QString &msg); + virtual void subTaskProgress(qint64 current, qint64 total); protected: void setStepStatus(QString status) { m_step_status = status; emit stepStatus(status); }; @@ -44,4 +44,6 @@ protected: qint64 m_stepProgress = 0; qint64 m_stepTotalProgress = 100; + + bool m_aborted = false; }; diff --git a/launcher/ui/dialogs/ChooseProviderDialog.cpp b/launcher/ui/dialogs/ChooseProviderDialog.cpp new file mode 100644 index 00000000..89935d9a --- /dev/null +++ b/launcher/ui/dialogs/ChooseProviderDialog.cpp @@ -0,0 +1,96 @@ +#include "ChooseProviderDialog.h" +#include "ui_ChooseProviderDialog.h" + +#include +#include + +#include "modplatform/ModIndex.h" + +static ModPlatform::ProviderCapabilities ProviderCaps; + +ChooseProviderDialog::ChooseProviderDialog(QWidget* parent, bool single_choice, bool allow_skipping) + : QDialog(parent), ui(new Ui::ChooseProviderDialog) +{ + ui->setupUi(this); + + addProviders(); + m_providers.button(0)->click(); + + connect(ui->skipOneButton, &QPushButton::clicked, this, &ChooseProviderDialog::skipOne); + connect(ui->skipAllButton, &QPushButton::clicked, this, &ChooseProviderDialog::skipAll); + + connect(ui->confirmOneButton, &QPushButton::clicked, this, &ChooseProviderDialog::confirmOne); + connect(ui->confirmAllButton, &QPushButton::clicked, this, &ChooseProviderDialog::confirmAll); + + if (single_choice) { + ui->providersLayout->removeWidget(ui->skipAllButton); + ui->providersLayout->removeWidget(ui->confirmAllButton); + } + + if (!allow_skipping) { + ui->providersLayout->removeWidget(ui->skipOneButton); + ui->providersLayout->removeWidget(ui->skipAllButton); + } +} + +ChooseProviderDialog::~ChooseProviderDialog() +{ + delete ui; +} + +void ChooseProviderDialog::setDescription(QString desc) +{ + ui->explanationLabel->setText(desc); +} + +void ChooseProviderDialog::skipOne() +{ + reject(); +} +void ChooseProviderDialog::skipAll() +{ + m_response.skip_all = true; + reject(); +} + +void ChooseProviderDialog::confirmOne() +{ + m_response.chosen = getSelectedProvider(); + m_response.try_others = ui->tryOthersCheckbox->isChecked(); + accept(); +} +void ChooseProviderDialog::confirmAll() +{ + m_response.chosen = getSelectedProvider(); + m_response.confirm_all = true; + m_response.try_others = ui->tryOthersCheckbox->isChecked(); + accept(); +} + +auto ChooseProviderDialog::getSelectedProvider() const -> ModPlatform::Provider +{ + return ModPlatform::Provider(m_providers.checkedId()); +} + +void ChooseProviderDialog::addProviders() +{ + int btn_index = 0; + QRadioButton* btn; + + for (auto& provider : { ModPlatform::Provider::MODRINTH, ModPlatform::Provider::FLAME }) { + btn = new QRadioButton(ProviderCaps.readableName(provider), this); + m_providers.addButton(btn, btn_index++); + ui->providersLayout->addWidget(btn); + } +} + +void ChooseProviderDialog::disableInput() +{ + for (auto& btn : m_providers.buttons()) + btn->setEnabled(false); + + ui->skipOneButton->setEnabled(false); + ui->skipAllButton->setEnabled(false); + ui->confirmOneButton->setEnabled(false); + ui->confirmAllButton->setEnabled(false); +} diff --git a/launcher/ui/dialogs/ChooseProviderDialog.h b/launcher/ui/dialogs/ChooseProviderDialog.h new file mode 100644 index 00000000..4a3b9f29 --- /dev/null +++ b/launcher/ui/dialogs/ChooseProviderDialog.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +namespace Ui { +class ChooseProviderDialog; +} + +namespace ModPlatform { +enum class Provider; +} + +class Mod; +class NetJob; +class ModUpdateDialog; + +class ChooseProviderDialog : public QDialog { + Q_OBJECT + + struct Response { + bool skip_all = false; + bool confirm_all = false; + + bool try_others = false; + + ModPlatform::Provider chosen; + }; + + public: + explicit ChooseProviderDialog(QWidget* parent, bool single_choice = false, bool allow_skipping = true); + ~ChooseProviderDialog(); + + auto getResponse() const -> Response { return m_response; } + + void setDescription(QString desc); + + private slots: + void skipOne(); + void skipAll(); + void confirmOne(); + void confirmAll(); + + private: + void addProviders(); + void disableInput(); + + auto getSelectedProvider() const -> ModPlatform::Provider; + + private: + Ui::ChooseProviderDialog* ui; + + QButtonGroup m_providers; + + Response m_response; +}; diff --git a/launcher/ui/dialogs/ChooseProviderDialog.ui b/launcher/ui/dialogs/ChooseProviderDialog.ui new file mode 100644 index 00000000..78cd9613 --- /dev/null +++ b/launcher/ui/dialogs/ChooseProviderDialog.ui @@ -0,0 +1,89 @@ + + + ChooseProviderDialog + + + + 0 + 0 + 453 + 197 + + + + Choose a mod provider + + + + + + Qt::AlignJustify|Qt::AlignTop + + + true + + + -1 + + + + + + + Qt::AlignHCenter|Qt::AlignTop + + + Qt::AlignHCenter|Qt::AlignTop + + + + + + + + + Skip this mod + + + + + + + Skip all + + + + + + + Confirm for all + + + + + + + Confirm + + + true + + + + + + + + + Try to automatically use other providers if the chosen one fails + + + true + + + + + + + + diff --git a/launcher/ui/dialogs/ModUpdateDialog.cpp b/launcher/ui/dialogs/ModUpdateDialog.cpp new file mode 100644 index 00000000..b6e76ff1 --- /dev/null +++ b/launcher/ui/dialogs/ModUpdateDialog.cpp @@ -0,0 +1,408 @@ +#include "ModUpdateDialog.h" +#include "ChooseProviderDialog.h" +#include "CustomMessageBox.h" +#include "ProgressDialog.h" +#include "ScrollMessageBox.h" +#include "ui_ReviewMessageBox.h" + +#include "FileSystem.h" +#include "Json.h" + +#include "tasks/ConcurrentTask.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "modplatform/EnsureMetadataTask.h" +#include "modplatform/flame/FlameCheckUpdate.h" +#include "modplatform/modrinth/ModrinthCheckUpdate.h" + +#include +#include +#include + +static ModPlatform::ProviderCapabilities ProviderCaps; + +static std::list mcVersions(BaseInstance* inst) +{ + return { static_cast(inst)->getPackProfile()->getComponent("net.minecraft")->getVersion() }; +} + +static ModAPI::ModLoaderTypes mcLoaders(BaseInstance* inst) +{ + return { static_cast(inst)->getPackProfile()->getModLoaders() }; +} + +ModUpdateDialog::ModUpdateDialog(QWidget* parent, + BaseInstance* instance, + const std::shared_ptr mods, + QList& search_for) + : ReviewMessageBox(parent, tr("Confirm mods to update"), "") + , m_parent(parent) + , m_mod_model(mods) + , m_candidates(search_for) + , m_second_try_metadata(new ConcurrentTask()) + , m_instance(instance) +{ + ReviewMessageBox::setGeometry(0, 0, 800, 600); + + ui->explainLabel->setText(tr("You're about to update the following mods:")); + ui->onlyCheckedLabel->setText(tr("Only mods with a check will be updated!")); +} + +void ModUpdateDialog::checkCandidates() +{ + // Ensure mods have valid metadata + auto went_well = ensureMetadata(); + if (!went_well) { + m_aborted = true; + return; + } + + // Report failed metadata generation + if (!m_failed_metadata.empty()) { + QString text; + for (const auto& failed : m_failed_metadata) { + const auto& mod = std::get<0>(failed); + const auto& reason = std::get<1>(failed); + text += tr("Mod name: %1
File name: %2
Reason: %3

").arg(mod->name(), mod->fileinfo().fileName(), reason); + } + + ScrollMessageBox message_dialog(m_parent, tr("Metadata generation failed"), + tr("Could not generate metadata for the following mods:
" + "Do you wish to proceed without those mods?"), + text); + message_dialog.setModal(true); + if (message_dialog.exec() == QDialog::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + } + + auto versions = mcVersions(m_instance); + auto loaders = mcLoaders(m_instance); + + SequentialTask check_task(m_parent, tr("Checking for updates")); + + if (!m_modrinth_to_update.empty()) { + m_modrinth_check_task = new ModrinthCheckUpdate(m_modrinth_to_update, versions, loaders, m_mod_model); + connect(m_modrinth_check_task, &CheckUpdateTask::checkFailed, this, + [this](Mod* mod, QString reason, QUrl recover_url) { m_failed_check_update.append({mod, reason, recover_url}); }); + check_task.addTask(m_modrinth_check_task); + } + + if (!m_flame_to_update.empty()) { + m_flame_check_task = new FlameCheckUpdate(m_flame_to_update, versions, loaders, m_mod_model); + connect(m_flame_check_task, &CheckUpdateTask::checkFailed, this, + [this](Mod* mod, QString reason, QUrl recover_url) { m_failed_check_update.append({mod, reason, recover_url}); }); + check_task.addTask(m_flame_check_task); + } + + connect(&check_task, &Task::failed, this, + [&](QString reason) { CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->exec(); }); + + connect(&check_task, &Task::succeeded, this, [&]() { + QStringList warnings = check_task.warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->exec(); + } + }); + + // Check for updates + ProgressDialog progress_dialog(m_parent); + progress_dialog.setSkipButton(true, tr("Abort")); + progress_dialog.setWindowTitle(tr("Checking for updates...")); + auto ret = progress_dialog.execWithTask(&check_task); + + // If the dialog was skipped / some download error happened + if (ret == QDialog::DialogCode::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + + // Add found updates for Modrinth + if (m_modrinth_check_task) { + auto modrinth_updates = m_modrinth_check_task->getUpdatable(); + for (auto& updatable : modrinth_updates) { + qDebug() << QString("Mod %1 has an update available!").arg(updatable.name); + + appendMod(updatable); + m_tasks.insert(updatable.name, updatable.download); + } + } + + // Add found updated for Flame + if (m_flame_check_task) { + auto flame_updates = m_flame_check_task->getUpdatable(); + for (auto& updatable : flame_updates) { + qDebug() << QString("Mod %1 has an update available!").arg(updatable.name); + + appendMod(updatable); + m_tasks.insert(updatable.name, updatable.download); + } + } + + // Report failed update checking + if (!m_failed_check_update.empty()) { + QString text; + for (const auto& failed : m_failed_check_update) { + const auto& mod = std::get<0>(failed); + const auto& reason = std::get<1>(failed); + const auto& recover_url = std::get<2>(failed); + + qDebug() << mod->name() << " failed to check for updates!"; + + text += tr("Mod name: %1").arg(mod->name()) + "
"; + if (!reason.isEmpty()) + text += tr("Reason: %1").arg(reason) + "
"; + if (!recover_url.isEmpty()) + text += tr("Possible solution: ") + tr("Getting the latest version manually:") + "
" + + QString("").arg(recover_url.toString()) + recover_url.toString() + "
"; + text += "
"; + } + + ScrollMessageBox message_dialog(m_parent, tr("Failed to check for updates"), + tr("Could not check or get the following mods for updates:
" + "Do you wish to proceed without those mods?"), + text); + message_dialog.setModal(true); + if (message_dialog.exec() == QDialog::Rejected) { + m_aborted = true; + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); + return; + } + } + + // If there's no mod to be updated + if (ui->modTreeWidget->topLevelItemCount() == 0) { + m_no_updates = true; + } else { + // FIXME: Find a more efficient way of doing this! + + // Sort major items in alphabetical order (also sorts the children unfortunately) + ui->modTreeWidget->sortItems(0, Qt::SortOrder::AscendingOrder); + + // Re-sort the children + auto* item = ui->modTreeWidget->topLevelItem(0); + for (int i = 1; item != nullptr; ++i) { + item->sortChildren(0, Qt::SortOrder::DescendingOrder); + item = ui->modTreeWidget->topLevelItem(i); + } + } + + if (m_aborted || m_no_updates) + QMetaObject::invokeMethod(this, "reject", Qt::QueuedConnection); +} + +// Part 1: Ensure we have a valid metadata +auto ModUpdateDialog::ensureMetadata() -> bool +{ + auto index_dir = indexDir(); + + SequentialTask seq(m_parent, tr("Looking for metadata")); + + // A better use of data structures here could remove the need for this QHash + QHash should_try_others; + QList modrinth_tmp; + QList flame_tmp; + + bool confirm_rest = false; + bool try_others_rest = false; + bool skip_rest = false; + ModPlatform::Provider provider_rest = ModPlatform::Provider::MODRINTH; + + auto addToTmp = [&](Mod* m, ModPlatform::Provider p) { + switch (p) { + case ModPlatform::Provider::MODRINTH: + modrinth_tmp.push_back(m); + break; + case ModPlatform::Provider::FLAME: + flame_tmp.push_back(m); + break; + } + }; + + for (auto candidate : m_candidates) { + auto* candidate_ptr = candidate.get(); + if (candidate->status() != ModStatus::NoMetadata) { + onMetadataEnsured(candidate_ptr); + continue; + } + + if (skip_rest) + continue; + + if (confirm_rest) { + addToTmp(candidate_ptr, provider_rest); + should_try_others.insert(candidate->internal_id(), try_others_rest); + continue; + } + + ChooseProviderDialog chooser(this); + chooser.setDescription(tr("This mod (%1) does not have a metadata yet. We need to create one in order to keep relevant " + "information on how to update this " + "mod. To do this, please select a mod provider from which we can search for updates for %1.") + .arg(candidate->name())); + auto confirmed = chooser.exec() == QDialog::DialogCode::Accepted; + + auto response = chooser.getResponse(); + + if (response.skip_all) + skip_rest = true; + if (response.confirm_all) { + confirm_rest = true; + provider_rest = response.chosen; + try_others_rest = response.try_others; + } + + should_try_others.insert(candidate->internal_id(), response.try_others); + + if (confirmed) + addToTmp(candidate_ptr, response.chosen); + } + + if (!modrinth_tmp.empty()) { + auto* modrinth_task = new EnsureMetadataTask(modrinth_tmp, index_dir, ModPlatform::Provider::MODRINTH); + connect(modrinth_task, &EnsureMetadataTask::metadataReady, [this](Mod* candidate) { onMetadataEnsured(candidate); }); + connect(modrinth_task, &EnsureMetadataTask::metadataFailed, [this, &should_try_others](Mod* candidate) { + onMetadataFailed(candidate, should_try_others.find(candidate->internal_id()).value(), ModPlatform::Provider::MODRINTH); + }); + seq.addTask(modrinth_task); + } + + if (!flame_tmp.empty()) { + auto* flame_task = new EnsureMetadataTask(flame_tmp, index_dir, ModPlatform::Provider::FLAME); + connect(flame_task, &EnsureMetadataTask::metadataReady, [this](Mod* candidate) { onMetadataEnsured(candidate); }); + connect(flame_task, &EnsureMetadataTask::metadataFailed, [this, &should_try_others](Mod* candidate) { + onMetadataFailed(candidate, should_try_others.find(candidate->internal_id()).value(), ModPlatform::Provider::FLAME); + }); + seq.addTask(flame_task); + } + + seq.addTask(m_second_try_metadata); + + ProgressDialog checking_dialog(m_parent); + checking_dialog.setSkipButton(true, tr("Abort")); + checking_dialog.setWindowTitle(tr("Generating metadata...")); + auto ret_metadata = checking_dialog.execWithTask(&seq); + + return (ret_metadata != QDialog::DialogCode::Rejected); +} + +void ModUpdateDialog::onMetadataEnsured(Mod* mod) +{ + // When the mod is a folder, for instance + if (!mod->metadata()) + return; + + switch (mod->metadata()->provider) { + case ModPlatform::Provider::MODRINTH: + m_modrinth_to_update.push_back(mod); + break; + case ModPlatform::Provider::FLAME: + m_flame_to_update.push_back(mod); + break; + } +} + +ModPlatform::Provider next(ModPlatform::Provider p) +{ + switch (p) { + case ModPlatform::Provider::MODRINTH: + return ModPlatform::Provider::FLAME; + case ModPlatform::Provider::FLAME: + return ModPlatform::Provider::MODRINTH; + } + + return ModPlatform::Provider::FLAME; +} + +void ModUpdateDialog::onMetadataFailed(Mod* mod, bool try_others, ModPlatform::Provider first_choice) +{ + if (try_others) { + auto index_dir = indexDir(); + + auto* task = new EnsureMetadataTask(mod, index_dir, next(first_choice)); + connect(task, &EnsureMetadataTask::metadataReady, [this](Mod* candidate) { onMetadataEnsured(candidate); }); + connect(task, &EnsureMetadataTask::metadataFailed, [this](Mod* candidate) { onMetadataFailed(candidate, false); }); + + m_second_try_metadata->addTask(task); + } else { + QString reason{ tr("Didn't find a valid version on the selected mod provider(s)") }; + + m_failed_metadata.append({mod, reason}); + } +} + +void ModUpdateDialog::appendMod(CheckUpdateTask::UpdatableMod const& info) +{ + auto item_top = new QTreeWidgetItem(ui->modTreeWidget); + item_top->setCheckState(0, Qt::CheckState::Checked); + item_top->setText(0, info.name); + item_top->setExpanded(true); + + auto provider_item = new QTreeWidgetItem(item_top); + provider_item->setText(0, tr("Provider: %1").arg(ProviderCaps.readableName(info.provider))); + + auto old_version_item = new QTreeWidgetItem(item_top); + old_version_item->setText(0, tr("Old version: %1").arg(info.old_version.isEmpty() ? tr("Not installed") : info.old_version)); + + auto new_version_item = new QTreeWidgetItem(item_top); + new_version_item->setText(0, tr("New version: %1").arg(info.new_version)); + + auto changelog_item = new QTreeWidgetItem(item_top); + changelog_item->setText(0, tr("Changelog of the latest version")); + + auto changelog = new QTreeWidgetItem(changelog_item); + auto changelog_area = new QTextBrowser(); + + switch (info.provider) { + case ModPlatform::Provider::MODRINTH: { + HoeDown h; + // HoeDown bug?: \n aren't converted to
+ auto text = h.process(info.changelog.toUtf8()); + + // Don't convert if there's an HTML tag right after (Qt rendering weirdness) + text.remove(QRegularExpression("(\n+)(?=<)")); + text.replace('\n', "
"); + + changelog_area->setHtml(text); + break; + } + case ModPlatform::Provider::FLAME: { + changelog_area->setHtml(info.changelog); + break; + } + } + + changelog_area->setOpenExternalLinks(true); + changelog_area->setLineWrapMode(QTextBrowser::LineWrapMode::NoWrap); + changelog_area->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAsNeeded); + + // HACK: Is there a better way of achieving this? + auto font_height = QFontMetrics(changelog_area->font()).height(); + changelog_area->setMaximumHeight((changelog_area->toPlainText().count(QRegularExpression("\n|
")) + 2) * font_height); + + ui->modTreeWidget->setItemWidget(changelog, 0, changelog_area); + + ui->modTreeWidget->addTopLevelItem(item_top); +} + +auto ModUpdateDialog::getTasks() -> const QList +{ + QList list; + + auto* item = ui->modTreeWidget->topLevelItem(0); + + for (int i = 1; item != nullptr; ++i) { + if (item->checkState(0) == Qt::CheckState::Checked) { + list.push_back(m_tasks.find(item->text(0)).value()); + } + + item = ui->modTreeWidget->topLevelItem(i); + } + + return list; +} diff --git a/launcher/ui/dialogs/ModUpdateDialog.h b/launcher/ui/dialogs/ModUpdateDialog.h new file mode 100644 index 00000000..76aaab36 --- /dev/null +++ b/launcher/ui/dialogs/ModUpdateDialog.h @@ -0,0 +1,62 @@ +#pragma once + +#include "BaseInstance.h" +#include "ModDownloadTask.h" +#include "ReviewMessageBox.h" + +#include "minecraft/mod/ModFolderModel.h" + +#include "modplatform/CheckUpdateTask.h" + +class Mod; +class ModrinthCheckUpdate; +class FlameCheckUpdate; +class ConcurrentTask; + +class ModUpdateDialog final : public ReviewMessageBox { + Q_OBJECT + public: + explicit ModUpdateDialog(QWidget* parent, + BaseInstance* instance, + const std::shared_ptr mod_model, + QList& search_for); + + void checkCandidates(); + + void appendMod(const CheckUpdateTask::UpdatableMod& info); + + const QList getTasks(); + auto indexDir() const -> QDir { return m_mod_model->indexDir(); } + + auto noUpdates() const -> bool { return m_no_updates; }; + auto aborted() const -> bool { return m_aborted; }; + + private: + auto ensureMetadata() -> bool; + + private slots: + void onMetadataEnsured(Mod*); + void onMetadataFailed(Mod*, bool try_others = false, ModPlatform::Provider first_choice = ModPlatform::Provider::MODRINTH); + + private: + QWidget* m_parent; + + ModrinthCheckUpdate* m_modrinth_check_task = nullptr; + FlameCheckUpdate* m_flame_check_task = nullptr; + + const std::shared_ptr m_mod_model; + + QList& m_candidates; + QList m_modrinth_to_update; + QList m_flame_to_update; + + ConcurrentTask* m_second_try_metadata; + QList> m_failed_metadata; + QList> m_failed_check_update; + + QHash m_tasks; + BaseInstance* m_instance; + + bool m_no_updates = false; + bool m_aborted = false; +}; diff --git a/launcher/ui/dialogs/ProgressDialog.cpp b/launcher/ui/dialogs/ProgressDialog.cpp index e5226016..a79bc837 100644 --- a/launcher/ui/dialogs/ProgressDialog.cpp +++ b/launcher/ui/dialogs/ProgressDialog.cpp @@ -62,24 +62,24 @@ void ProgressDialog::updateSize() int ProgressDialog::execWithTask(Task* task) { this->task = task; - QDialog::DialogCode result; if (!task) { - qDebug() << "Programmer error: progress dialog created with null task."; - return Accepted; + qDebug() << "Programmer error: Progress dialog created with null task."; + return QDialog::DialogCode::Accepted; } + QDialog::DialogCode result; if (handleImmediateResult(result)) { return result; } // Connect signals. - connect(task, SIGNAL(started()), SLOT(onTaskStarted())); - connect(task, SIGNAL(failed(QString)), SLOT(onTaskFailed(QString))); - connect(task, SIGNAL(succeeded()), SLOT(onTaskSucceeded())); - connect(task, SIGNAL(status(QString)), SLOT(changeStatus(const QString&))); - connect(task, SIGNAL(stepStatus(QString)), SLOT(changeStatus(const QString&))); - connect(task, SIGNAL(progress(qint64, qint64)), SLOT(changeProgress(qint64, qint64))); + connect(task, &Task::started, this, &ProgressDialog::onTaskStarted); + connect(task, &Task::failed, this, &ProgressDialog::onTaskFailed); + connect(task, &Task::succeeded, this, &ProgressDialog::onTaskSucceeded); + connect(task, &Task::status, this, &ProgressDialog::changeStatus); + connect(task, &Task::stepStatus, this, &ProgressDialog::changeStatus); + connect(task, &Task::progress, this, &ProgressDialog::changeProgress); connect(task, &Task::aborted, [this] { onTaskFailed(tr("Aborted by user")); }); @@ -89,19 +89,15 @@ int ProgressDialog::execWithTask(Task* task) ui->globalProgressBar->setHidden(true); } - // if this didn't connect to an already running task, invoke start + // It's a good idea to start the task after we entered the dialog's event loop :^) if (!task->isRunning()) { - task->start(); - } - if (task->isRunning()) { - changeProgress(task->getProgress(), task->getTotalProgress()); - changeStatus(task->getStatus()); - return QDialog::exec(); - } else if (handleImmediateResult(result)) { - return result; + QMetaObject::invokeMethod(task, &Task::start, Qt::QueuedConnection); } else { - return QDialog::Rejected; + changeStatus(task->getStatus()); + changeProgress(task->getProgress(), task->getTotalProgress()); } + + return QDialog::exec(); } // TODO: only provide the unique_ptr overloads diff --git a/launcher/ui/dialogs/ReviewMessageBox.cpp b/launcher/ui/dialogs/ReviewMessageBox.cpp index c92234a4..e664e566 100644 --- a/launcher/ui/dialogs/ReviewMessageBox.cpp +++ b/launcher/ui/dialogs/ReviewMessageBox.cpp @@ -40,7 +40,7 @@ auto ReviewMessageBox::deselectedMods() -> QStringList auto* item = ui->modTreeWidget->topLevelItem(0); - for (int i = 0; item != nullptr; ++i) { + for (int i = 1; item != nullptr; ++i) { if (item->checkState(0) == Qt::CheckState::Unchecked) { list.append(item->text(0)); } diff --git a/launcher/ui/dialogs/ScrollMessageBox.ui b/launcher/ui/dialogs/ScrollMessageBox.ui index 299d2ecc..e684185f 100644 --- a/launcher/ui/dialogs/ScrollMessageBox.ui +++ b/launcher/ui/dialogs/ScrollMessageBox.ui @@ -6,7 +6,7 @@ 0 0 - 400 + 500 455 diff --git a/launcher/ui/dialogs/SkinUploadDialog.cpp b/launcher/ui/dialogs/SkinUploadDialog.cpp index b5b78690..8180ac1f 100644 --- a/launcher/ui/dialogs/SkinUploadDialog.cpp +++ b/launcher/ui/dialogs/SkinUploadDialog.cpp @@ -57,68 +57,72 @@ void SkinUploadDialog::on_buttonBox_accepted() { QString fileName; QString input = ui->skinPathTextBox->text(); - QRegularExpression urlPrefixMatcher(QRegularExpression::anchoredPattern("^([a-z]+)://.+$")); - bool isLocalFile = false; - // it has an URL prefix -> it is an URL - if(urlPrefixMatcher.match(input).hasMatch()) - { - QUrl fileURL = input; - if(fileURL.isValid()) + ProgressDialog prog(this); + SequentialTask skinUpload; + + if (!input.isEmpty()) { + QRegularExpression urlPrefixMatcher(QRegularExpression::anchoredPattern("^([a-z]+)://.+$")); + bool isLocalFile = false; + // it has an URL prefix -> it is an URL + if(urlPrefixMatcher.match(input).hasMatch()) { - // local? - if(fileURL.isLocalFile()) + QUrl fileURL = input; + if(fileURL.isValid()) { - isLocalFile = true; - fileName = fileURL.toLocalFile(); + // local? + if(fileURL.isLocalFile()) + { + isLocalFile = true; + fileName = fileURL.toLocalFile(); + } + else + { + CustomMessageBox::selectable( + this, + tr("Skin Upload"), + tr("Using remote URLs for setting skins is not implemented yet."), + QMessageBox::Warning + )->exec(); + close(); + return; + } } else { CustomMessageBox::selectable( this, tr("Skin Upload"), - tr("Using remote URLs for setting skins is not implemented yet."), + tr("You cannot use an invalid URL for uploading skins."), QMessageBox::Warning - )->exec(); + )->exec(); close(); return; } } else { - CustomMessageBox::selectable( - this, - tr("Skin Upload"), - tr("You cannot use an invalid URL for uploading skins."), - QMessageBox::Warning - )->exec(); + // just assume it's a path then + isLocalFile = true; + fileName = ui->skinPathTextBox->text(); + } + if (isLocalFile && !QFile::exists(fileName)) + { + CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Skin file does not exist!"), QMessageBox::Warning)->exec(); close(); return; } + SkinUpload::Model model = SkinUpload::STEVE; + if (ui->steveBtn->isChecked()) + { + model = SkinUpload::STEVE; + } + else if (ui->alexBtn->isChecked()) + { + model = SkinUpload::ALEX; + } + skinUpload.addTask(shared_qobject_ptr(new SkinUpload(this, m_acct->accessToken(), FS::read(fileName), model))); } - else - { - // just assume it's a path then - isLocalFile = true; - fileName = ui->skinPathTextBox->text(); - } - if (isLocalFile && !QFile::exists(fileName)) - { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Skin file does not exist!"), QMessageBox::Warning)->exec(); - close(); - return; - } - SkinUpload::Model model = SkinUpload::STEVE; - if (ui->steveBtn->isChecked()) - { - model = SkinUpload::STEVE; - } - else if (ui->alexBtn->isChecked()) - { - model = SkinUpload::ALEX; - } - ProgressDialog prog(this); - SequentialTask skinUpload; - skinUpload.addTask(shared_qobject_ptr(new SkinUpload(this, m_acct->accessToken(), FS::read(fileName), model))); + auto selectedCape = ui->capeCombo->currentData().toString(); if(selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { skinUpload.addTask(shared_qobject_ptr(new CapeChange(this, m_acct->accessToken(), selectedCape))); diff --git a/launcher/ui/dialogs/SkinUploadDialog.ui b/launcher/ui/dialogs/SkinUploadDialog.ui index f4b0ed0a..c7b16645 100644 --- a/launcher/ui/dialogs/SkinUploadDialog.ui +++ b/launcher/ui/dialogs/SkinUploadDialog.ui @@ -21,7 +21,11 @@ - + + + Leave empty to keep current skin + + diff --git a/launcher/ui/pages/instance/ExternalResourcesPage.ui b/launcher/ui/pages/instance/ExternalResourcesPage.ui index 17bf455a..8edcfd64 100644 --- a/launcher/ui/pages/instance/ExternalResourcesPage.ui +++ b/launcher/ui/pages/instance/ExternalResourcesPage.ui @@ -147,6 +147,17 @@ Download a new resource + + + false + + + Check for &Updates + + + "Tries to find / update all selected resources (all resources if none is selected)" + + diff --git a/launcher/ui/pages/instance/ModFolderPage.cpp b/launcher/ui/pages/instance/ModFolderPage.cpp index 4432ccc8..b190e51a 100644 --- a/launcher/ui/pages/instance/ModFolderPage.cpp +++ b/launcher/ui/pages/instance/ModFolderPage.cpp @@ -49,6 +49,7 @@ #include "ui/GuiUtil.h" #include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/ModDownloadDialog.h" +#include "ui/dialogs/ModUpdateDialog.h" #include "DesktopServices.h" @@ -78,6 +79,23 @@ ModFolderPage::ModFolderPage(BaseInstance* inst, std::shared_ptr ui->actionsToolbar->insertActionBefore(ui->actionAddItem, ui->actionDownloadItem); connect(ui->actionDownloadItem, &QAction::triggered, this, &ModFolderPage::installMods); + + ui->actionUpdateItem->setToolTip(tr("Tries to find / update all selected mods (all mods if none is selected)")); + ui->actionsToolbar->insertActionAfter(ui->actionAddItem, ui->actionUpdateItem); + connect(ui->actionUpdateItem, &QAction::triggered, this, &ModFolderPage::updateMods); + + connect(ui->treeView->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this] { ui->actionUpdateItem->setEnabled(ui->treeView->selectionModel()->hasSelection() || !m_model->empty()); }); + + connect(mods.get(), &ModFolderModel::rowsInserted, this, + [this] { ui->actionUpdateItem->setEnabled(ui->treeView->selectionModel()->hasSelection() || !m_model->empty()); }); + + connect(mods.get(), &ModFolderModel::updateFinished, this, [this, mods] { + ui->actionUpdateItem->setEnabled(ui->treeView->selectionModel()->hasSelection() || !m_model->empty()); + + // Prevent a weird crash when trying to open the mods page twice in a session o.O + disconnect(mods.get(), &ModFolderModel::updateFinished, this, 0); + }); } } @@ -107,7 +125,6 @@ bool CoreModFolderPage::shouldDisplay() const return false; if (version->getComponent("net.minecraft")->getReleaseDateTime() < g_VersionFilterData.legacyCutoffDate) return true; - } return false; } @@ -118,7 +135,7 @@ void ModFolderPage::installMods() return; if (m_instance->typeName() != "Minecraft") return; // this is a null instance or a legacy instance - + auto profile = static_cast(m_instance)->getPackProfile(); if (profile->getModLoaders() == ModAPI::Unspecified) { QMessageBox::critical(this, tr("Error"), tr("Please install a mod loader first!")); @@ -140,7 +157,7 @@ void ModFolderPage::installMods() QStringList warnings = tasks->warnings(); if (warnings.count()) CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); - + tasks->deleteLater(); }); @@ -155,3 +172,58 @@ void ModFolderPage::installMods() m_model->update(); } } + +void ModFolderPage::updateMods() +{ + auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); + + auto mods_list = m_model->selectedMods(selection); + bool use_all = mods_list.empty(); + if (use_all) + mods_list = m_model->allMods(); + + ModUpdateDialog update_dialog(this, m_instance, m_model, mods_list); + update_dialog.checkCandidates(); + + if (update_dialog.aborted()) { + CustomMessageBox::selectable(this, tr("Aborted"), tr("The mod updater was aborted!"), QMessageBox::Warning)->show(); + return; + } + if (update_dialog.noUpdates()) { + CustomMessageBox::selectable(this, tr("Update checker"), + (mods_list.size() == 1) + ? tr("'%1' is up-to-date! :)").arg(mods_list.front()->name()) + : tr("All %1mods are up-to-date! :)").arg(use_all ? "" : (tr("selected") + " "))) + ->exec(); + return; + } + + if (update_dialog.exec()) { + ConcurrentTask* tasks = new ConcurrentTask(this); + connect(tasks, &Task::failed, [this, tasks](QString reason) { + CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::aborted, [this, tasks]() { + CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); + tasks->deleteLater(); + }); + connect(tasks, &Task::succeeded, [this, tasks]() { + QStringList warnings = tasks->warnings(); + if (warnings.count()) { + CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); + } + tasks->deleteLater(); + }); + + for (auto task : update_dialog.getTasks()) { + tasks->addTask(task); + } + + ProgressDialog loadDialog(this); + loadDialog.setSkipButton(true, tr("Abort")); + loadDialog.execWithTask(tasks); + + m_model->update(); + } +} diff --git a/launcher/ui/pages/instance/ModFolderPage.h b/launcher/ui/pages/instance/ModFolderPage.h index 19caa732..0a7fc9fa 100644 --- a/launcher/ui/pages/instance/ModFolderPage.h +++ b/launcher/ui/pages/instance/ModFolderPage.h @@ -56,6 +56,7 @@ class ModFolderPage : public ExternalResourcesPage { private slots: void installMods(); + void updateMods(); }; class CoreModFolderPage : public ModFolderPage { diff --git a/launcher/ui/pages/modplatform/flame/FlameModPage.cpp b/launcher/ui/pages/modplatform/flame/FlameModPage.cpp index 10d34218..772fd2e0 100644 --- a/launcher/ui/pages/modplatform/flame/FlameModPage.cpp +++ b/launcher/ui/pages/modplatform/flame/FlameModPage.cpp @@ -64,7 +64,7 @@ FlameModPage::FlameModPage(ModDownloadDialog* dialog, BaseInstance* instance) auto FlameModPage::validateVersion(ModPlatform::IndexedVersion& ver, QString mineVer, ModAPI::ModLoaderTypes loaders) const -> bool { Q_UNUSED(loaders); - return ver.mcVersion.contains(mineVer); + return ver.mcVersion.contains(mineVer) && !ver.downloadUrl.isEmpty(); } // I don't know why, but doing this on the parent class makes it so that diff --git a/launcher/ui/widgets/WideBar.cpp b/launcher/ui/widgets/WideBar.cpp index 8d5bd12d..79f1e0c9 100644 --- a/launcher/ui/widgets/WideBar.cpp +++ b/launcher/ui/widgets/WideBar.cpp @@ -76,13 +76,20 @@ void WideBar::addSeparator() m_entries.push_back(entry); } -void WideBar::insertActionBefore(QAction* before, QAction* action){ - auto iter = std::find_if(m_entries.begin(), m_entries.end(), [before](BarEntry * entry) { - return entry->wideAction == before; +auto WideBar::getMatching(QAction* act) -> QList::iterator +{ + auto iter = std::find_if(m_entries.begin(), m_entries.end(), [act](BarEntry * entry) { + return entry->wideAction == act; }); - if(iter == m_entries.end()) { + + return iter; +} + +void WideBar::insertActionBefore(QAction* before, QAction* action){ + auto iter = getMatching(before); + if(iter == m_entries.end()) return; - } + auto entry = new BarEntry(); entry->qAction = insertWidget((*iter)->qAction, new ActionButton(action, this)); entry->wideAction = action; @@ -90,14 +97,24 @@ void WideBar::insertActionBefore(QAction* before, QAction* action){ m_entries.insert(iter, entry); } +void WideBar::insertActionAfter(QAction* after, QAction* action){ + auto iter = getMatching(after); + if(iter == m_entries.end()) + return; + + auto entry = new BarEntry(); + entry->qAction = insertWidget((*(iter+1))->qAction, new ActionButton(action, this)); + entry->wideAction = action; + entry->type = BarEntry::Action; + m_entries.insert(iter + 1, entry); +} + void WideBar::insertSpacer(QAction* action) { - auto iter = std::find_if(m_entries.begin(), m_entries.end(), [action](BarEntry * entry) { - return entry->wideAction == action; - }); - if(iter == m_entries.end()) { + auto iter = getMatching(action); + if(iter == m_entries.end()) return; - } + QWidget* spacer = new QWidget(); spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -107,6 +124,18 @@ void WideBar::insertSpacer(QAction* action) m_entries.insert(iter, entry); } +void WideBar::insertSeparator(QAction* before) +{ + auto iter = getMatching(before); + if(iter == m_entries.end()) + return; + + auto entry = new BarEntry(); + entry->qAction = QToolBar::insertSeparator(before); + entry->type = BarEntry::Separator; + m_entries.insert(iter, entry); +} + QMenu * WideBar::createContextMenu(QWidget *parent, const QString & title) { QMenu *contextMenu = new QMenu(title, parent); diff --git a/launcher/ui/widgets/WideBar.h b/launcher/ui/widgets/WideBar.h index 2b676a8c..8ff62ef2 100644 --- a/launcher/ui/widgets/WideBar.h +++ b/launcher/ui/widgets/WideBar.h @@ -1,27 +1,34 @@ #pragma once -#include #include #include +#include class QMenu; -class WideBar : public QToolBar -{ +class WideBar : public QToolBar { Q_OBJECT -public: - explicit WideBar(const QString &title, QWidget * parent = nullptr); - explicit WideBar(QWidget * parent = nullptr); + public: + explicit WideBar(const QString& title, QWidget* parent = nullptr); + explicit WideBar(QWidget* parent = nullptr); virtual ~WideBar(); - void addAction(QAction *action); + void addAction(QAction* action); void addSeparator(); - void insertSpacer(QAction *action); - void insertActionBefore(QAction *before, QAction *action); - QMenu *createContextMenu(QWidget *parent = nullptr, const QString & title = QString()); -private: + void insertSpacer(QAction* action); + void insertSeparator(QAction* before); + void insertActionBefore(QAction* before, QAction* action); + void insertActionAfter(QAction* after, QAction* action); + + QMenu* createContextMenu(QWidget* parent = nullptr, const QString& title = QString()); + + private: struct BarEntry; - QList m_entries; + + auto getMatching(QAction* act) -> QList::iterator; + + private: + QList m_entries; }; diff --git a/libraries/README.md b/libraries/README.md index 360c34b1..946e34d8 100644 --- a/libraries/README.md +++ b/libraries/README.md @@ -149,6 +149,12 @@ BSD licensed, derived from [QtSingleApplication](https://github.com/qtproject/qt Changes are made to make the code more generic and useful in less usual conditions. +## murmur2 + +Canonical implementation of the murmur2 hash, taken from [SMHasher](https://github.com/aappleby/smhasher). + +Public domain (the author disclaimed the copyright). + ## optional-bare A simple single-file header-only version of a C++17-like optional for default-constructible, copyable types, for C++98 and later. diff --git a/libraries/murmur2/CMakeLists.txt b/libraries/murmur2/CMakeLists.txt new file mode 100644 index 00000000..f3068201 --- /dev/null +++ b/libraries/murmur2/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.9.4) +project(murmur2) + +set(MURMUR_SOURCES + src/MurmurHash2.h + src/MurmurHash2.cpp +) + +add_library(Launcher_murmur2 STATIC ${MURMUR_SOURCES}) +target_include_directories(Launcher_murmur2 PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} "src" ) + +generate_export_header(Launcher_murmur2) diff --git a/libraries/murmur2/src/MurmurHash2.cpp b/libraries/murmur2/src/MurmurHash2.cpp new file mode 100644 index 00000000..3e52e6d1 --- /dev/null +++ b/libraries/murmur2/src/MurmurHash2.cpp @@ -0,0 +1,86 @@ +//----------------------------------------------------------------------------- +// MurmurHash2 was written by Austin Appleby, and is placed in the public +// domain. The author hereby disclaims copyright to this source code. + +// Note - This code makes a few assumptions about how your machine behaves - + +// 1. We can read a 4-byte value from any address without crashing +// 2. sizeof(int) == 4 + +// And it has a few limitations - + +// 1. It will not work incrementally. +// 2. It will not produce the same results on little-endian and big-endian +// machines. + +#include "MurmurHash2.h" + +//----------------------------------------------------------------------------- +// Platform-specific functions and macros + +// Microsoft Visual Studio + +#if defined(_MSC_VER) + +#define BIG_CONSTANT(x) (x) + +// Other compilers + +#else // defined(_MSC_VER) + +#define BIG_CONSTANT(x) (x##LLU) + +#endif // !defined(_MSC_VER) + +//----------------------------------------------------------------------------- + +uint64_t MurmurHash2 ( const void* key, int len, uint32_t seed ) +{ + // 'm' and 'r' are mixing constants generated offline. + // They're not really 'magic', they just happen to work well. + + const uint32_t m = 0x5bd1e995; + const int r = 24; + + // Initialize the hash to a 'random' value + + uint32_t h = seed ^ len; + + // Mix 4 bytes at a time into the hash + const auto* data = (const unsigned char*) key; + while(len >= 4) + { + uint32_t k = *(uint32_t*)data; + + k *= m; + k ^= k >> r; + k *= m; + + h *= m; + h ^= k; + + data += 4*sizeof(char); + len -= 4; + } + + // Handle the last few bytes of the input array + + switch(len) + { + case 3: h ^= data[2] << 16; + case 2: h ^= data[1] << 8; + case 1: h ^= data[0]; + h *= m; + }; + + // Do a few final mixes of the hash to ensure the last few + // bytes are well-incorporated. + + h ^= h >> 13; + h *= m; + h ^= h >> 15; + + return h; +} + +//----------------------------------------------------------------------------- diff --git a/libraries/murmur2/src/MurmurHash2.h b/libraries/murmur2/src/MurmurHash2.h new file mode 100644 index 00000000..c7b83bca --- /dev/null +++ b/libraries/murmur2/src/MurmurHash2.h @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// MurmurHash2 was written by Austin Appleby, and is placed in the public +// domain. The author hereby disclaims copyright to this source code. + +#pragma once + +//----------------------------------------------------------------------------- +// Platform-specific functions and macros + +// Microsoft Visual Studio + +#if defined(_MSC_VER) && (_MSC_VER < 1600) + +typedef unsigned char uint8_t; +typedef unsigned int uint32_t; +typedef unsigned __int64 uint64_t; + +// Other compilers + +#else // defined(_MSC_VER) + +#include + +#endif // !defined(_MSC_VER) + +//----------------------------------------------------------------------------- + +uint64_t MurmurHash2 ( const void* key, int len, uint32_t seed = 1 ); + +//-----------------------------------------------------------------------------