Rework curseforge download (#611)
* Use the bulk endpoint on mod resolution for faster download * Search on modrinth for api blocked mods * Display a dialog for manually downloading blocked mods
This commit is contained in:
		| @@ -128,6 +128,8 @@ set(NET_SOURCES | ||||
|     net/PasteUpload.h | ||||
|     net/Sink.h | ||||
|     net/Validator.h | ||||
|     net/Upload.cpp | ||||
|     net/Upload.h | ||||
| ) | ||||
|  | ||||
| # Game launch logic | ||||
| @@ -837,6 +839,8 @@ SET(LAUNCHER_SOURCES | ||||
|     ui/dialogs/SkinUploadDialog.h | ||||
|     ui/dialogs/ModDownloadDialog.cpp | ||||
|     ui/dialogs/ModDownloadDialog.h | ||||
|     ui/dialogs/ScrollMessageBox.cpp | ||||
|     ui/dialogs/ScrollMessageBox.h | ||||
|  | ||||
|     # GUI - widgets | ||||
|     ui/widgets/Common.cpp | ||||
| @@ -940,6 +944,7 @@ qt5_wrap_ui(LAUNCHER_UI | ||||
|     ui/dialogs/LoginDialog.ui | ||||
|     ui/dialogs/EditAccountDialog.ui | ||||
|     ui/dialogs/ReviewMessageBox.ui | ||||
|     ui/dialogs/ScrollMessageBox.ui | ||||
| ) | ||||
|  | ||||
| qt5_add_resources(LAUNCHER_RESOURCES | ||||
|   | ||||
| @@ -60,9 +60,9 @@ | ||||
| #include "net/ChecksumValidator.h" | ||||
|  | ||||
| #include "ui/dialogs/CustomMessageBox.h" | ||||
| #include "ui/dialogs/ScrollMessageBox.h" | ||||
|  | ||||
| #include <algorithm> | ||||
| #include <iterator> | ||||
|  | ||||
| InstanceImportTask::InstanceImportTask(const QUrl sourceUrl, QWidget* parent) | ||||
| { | ||||
| @@ -394,61 +394,136 @@ void InstanceImportTask::processFlame() | ||||
|     connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, [&]() | ||||
|     { | ||||
|         auto results = m_modIdResolver->getResults(); | ||||
|         m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); | ||||
|         for(const auto& result: results.files) | ||||
|         { | ||||
|             QString filename = result.fileName; | ||||
|             if(!result.required) | ||||
|             { | ||||
|                 filename += ".disabled"; | ||||
|             } | ||||
|  | ||||
|             auto relpath = FS::PathCombine("minecraft", result.targetFolder, filename); | ||||
|             auto path = FS::PathCombine(m_stagingPath , relpath); | ||||
|  | ||||
|             switch(result.type) | ||||
|             { | ||||
|                 case Flame::File::Type::Folder: | ||||
|                 { | ||||
|                     logWarning(tr("This 'Folder' may need extracting: %1").arg(relpath)); | ||||
|                     // fall-through intentional, we treat these as plain old mods and dump them wherever. | ||||
|                 } | ||||
|                 case Flame::File::Type::SingleFile: | ||||
|                 case Flame::File::Type::Mod: | ||||
|                 { | ||||
|                     qDebug() << "Will download" << result.url << "to" << path; | ||||
|                     auto dl = Net::Download::makeFile(result.url, path); | ||||
|                     m_filesNetJob->addNetAction(dl); | ||||
|                     break; | ||||
|                 } | ||||
|                 case Flame::File::Type::Modpack: | ||||
|                     logWarning(tr("Nesting modpacks in modpacks is not implemented, nothing was downloaded: %1").arg(relpath)); | ||||
|                     break; | ||||
|                 case Flame::File::Type::Cmod2: | ||||
|                 case Flame::File::Type::Ctoc: | ||||
|                 case Flame::File::Type::Unknown: | ||||
|                     logWarning(tr("Unrecognized/unhandled PackageType for: %1").arg(relpath)); | ||||
|                     break; | ||||
|         //first check for blocked mods | ||||
|         QString text; | ||||
|         auto anyBlocked = false; | ||||
|         for(const auto& result: results.files.values()) { | ||||
|             if (!result.resolved || result.url.isEmpty()) { | ||||
|                 text += QString("%1: <a href='%2'>%2</a><br/>").arg(result.fileName, result.websiteUrl); | ||||
|                 anyBlocked = true; | ||||
|             } | ||||
|         } | ||||
|         m_modIdResolver.reset(); | ||||
|         connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() | ||||
|         { | ||||
|             m_filesNetJob.reset(); | ||||
|             emitSucceeded(); | ||||
|         if(anyBlocked) { | ||||
|             qWarning() << "Blocked mods found, displaying mod list"; | ||||
|  | ||||
|             auto message_dialog = new ScrollMessageBox(m_parent, | ||||
|                                                        tr("Blocked mods found"), | ||||
|                                                        tr("The following mods were blocked on third party launchers.<br/>" | ||||
|                                                           "You will need to manually download them and add them to the modpack"), | ||||
|                                                        text); | ||||
|             message_dialog->setModal(true); | ||||
|             message_dialog->show(); | ||||
|             connect(message_dialog, &QDialog::rejected, [&]() { | ||||
|                 m_modIdResolver.reset(); | ||||
|                 emitFailed("Canceled"); | ||||
|             }); | ||||
|             connect(message_dialog, &QDialog::accepted, [&]() { | ||||
|                 m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); | ||||
|                 for (const auto &result: m_modIdResolver->getResults().files) { | ||||
|                     QString filename = result.fileName; | ||||
|                     if (!result.required) { | ||||
|                         filename += ".disabled"; | ||||
|                     } | ||||
|  | ||||
|                     auto relpath = FS::PathCombine("minecraft", result.targetFolder, filename); | ||||
|                     auto path = FS::PathCombine(m_stagingPath, relpath); | ||||
|  | ||||
|                     switch (result.type) { | ||||
|                         case Flame::File::Type::Folder: { | ||||
|                             logWarning(tr("This 'Folder' may need extracting: %1").arg(relpath)); | ||||
|                             // fall-through intentional, we treat these as plain old mods and dump them wherever. | ||||
|                         } | ||||
|                         case Flame::File::Type::SingleFile: | ||||
|                         case Flame::File::Type::Mod: { | ||||
|                             if (!result.url.isEmpty()) { | ||||
|                                 qDebug() << "Will download" << result.url << "to" << path; | ||||
|                                 auto dl = Net::Download::makeFile(result.url, path); | ||||
|                                 m_filesNetJob->addNetAction(dl); | ||||
|                             } | ||||
|                             break; | ||||
|                         } | ||||
|                         case Flame::File::Type::Modpack: | ||||
|                             logWarning( | ||||
|                                     tr("Nesting modpacks in modpacks is not implemented, nothing was downloaded: %1").arg( | ||||
|                                             relpath)); | ||||
|                             break; | ||||
|                         case Flame::File::Type::Cmod2: | ||||
|                         case Flame::File::Type::Ctoc: | ||||
|                         case Flame::File::Type::Unknown: | ||||
|                             logWarning(tr("Unrecognized/unhandled PackageType for: %1").arg(relpath)); | ||||
|                             break; | ||||
|                     } | ||||
|                 } | ||||
|                 m_modIdResolver.reset(); | ||||
|                 connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() { | ||||
|                             m_filesNetJob.reset(); | ||||
|                             emitSucceeded(); | ||||
|                         } | ||||
|                 ); | ||||
|                 connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) { | ||||
|                     m_filesNetJob.reset(); | ||||
|                     emitFailed(reason); | ||||
|                 }); | ||||
|                 connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) { | ||||
|                     setProgress(current, total); | ||||
|                 }); | ||||
|                 setStatus(tr("Downloading mods...")); | ||||
|                 m_filesNetJob->start(); | ||||
|             }); | ||||
|         }else{ | ||||
|             //TODO extract to function ? | ||||
|             m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); | ||||
|             for (const auto &result: m_modIdResolver->getResults().files) { | ||||
|                 QString filename = result.fileName; | ||||
|                 if (!result.required) { | ||||
|                     filename += ".disabled"; | ||||
|                 } | ||||
|  | ||||
|                 auto relpath = FS::PathCombine("minecraft", result.targetFolder, filename); | ||||
|                 auto path = FS::PathCombine(m_stagingPath, relpath); | ||||
|  | ||||
|                 switch (result.type) { | ||||
|                     case Flame::File::Type::Folder: { | ||||
|                         logWarning(tr("This 'Folder' may need extracting: %1").arg(relpath)); | ||||
|                         // fall-through intentional, we treat these as plain old mods and dump them wherever. | ||||
|                     } | ||||
|                     case Flame::File::Type::SingleFile: | ||||
|                     case Flame::File::Type::Mod: { | ||||
|                         if (!result.url.isEmpty()) { | ||||
|                             qDebug() << "Will download" << result.url << "to" << path; | ||||
|                             auto dl = Net::Download::makeFile(result.url, path); | ||||
|                             m_filesNetJob->addNetAction(dl); | ||||
|                         } | ||||
|                         break; | ||||
|                     } | ||||
|                     case Flame::File::Type::Modpack: | ||||
|                         logWarning( | ||||
|                                 tr("Nesting modpacks in modpacks is not implemented, nothing was downloaded: %1").arg( | ||||
|                                         relpath)); | ||||
|                         break; | ||||
|                     case Flame::File::Type::Cmod2: | ||||
|                     case Flame::File::Type::Ctoc: | ||||
|                     case Flame::File::Type::Unknown: | ||||
|                         logWarning(tr("Unrecognized/unhandled PackageType for: %1").arg(relpath)); | ||||
|                         break; | ||||
|                 } | ||||
|             } | ||||
|             m_modIdResolver.reset(); | ||||
|             connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() { | ||||
|                         m_filesNetJob.reset(); | ||||
|                         emitSucceeded(); | ||||
|                     } | ||||
|             ); | ||||
|             connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) { | ||||
|                 m_filesNetJob.reset(); | ||||
|                 emitFailed(reason); | ||||
|             }); | ||||
|             connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) { | ||||
|                 setProgress(current, total); | ||||
|             }); | ||||
|             setStatus(tr("Downloading mods...")); | ||||
|             m_filesNetJob->start(); | ||||
|         } | ||||
|         ); | ||||
|         connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) | ||||
|         { | ||||
|             m_filesNetJob.reset(); | ||||
|             emitFailed(reason); | ||||
|         }); | ||||
|         connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) | ||||
|         { | ||||
|             setProgress(current, total); | ||||
|         }); | ||||
|         setStatus(tr("Downloading mods...")); | ||||
|         m_filesNetJob->start(); | ||||
|     } | ||||
|     ); | ||||
|     connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, [&](QString reason) | ||||
| @@ -524,11 +599,11 @@ void InstanceImportTask::processModrinth() | ||||
|  | ||||
|             auto jsonFiles = Json::requireIsArrayOf<QJsonObject>(obj, "files", "modrinth.index.json"); | ||||
|             bool had_optional = false; | ||||
|             for (auto& obj : jsonFiles) { | ||||
|             for (auto& modInfo : jsonFiles) { | ||||
|                 Modrinth::File file; | ||||
|                 file.path = Json::requireString(obj, "path"); | ||||
|                 file.path = Json::requireString(modInfo, "path"); | ||||
|  | ||||
|                 auto env = Json::ensureObject(obj, "env"); | ||||
|                 auto env = Json::ensureObject(modInfo, "env"); | ||||
|                 QString support = Json::ensureString(env, "client", "unsupported"); | ||||
|                 if (support == "unsupported") { | ||||
|                     continue; | ||||
| @@ -546,7 +621,7 @@ void InstanceImportTask::processModrinth() | ||||
|                         file.path += ".disabled"; | ||||
|                 } | ||||
|  | ||||
|                 QJsonObject hashes = Json::requireObject(obj, "hashes"); | ||||
|                 QJsonObject hashes = Json::requireObject(modInfo, "hashes"); | ||||
|                 QString hash; | ||||
|                 QCryptographicHash::Algorithm hashAlgorithm; | ||||
|                 hash = Json::ensureString(hashes, "sha1"); | ||||
| @@ -566,7 +641,7 @@ void InstanceImportTask::processModrinth() | ||||
|                 file.hashAlgorithm = hashAlgorithm; | ||||
|                 // Do not use requireUrl, which uses StrictMode, instead use QUrl's default TolerantMode | ||||
|                 // (as Modrinth seems to incorrectly handle spaces) | ||||
|                 file.download = Json::requireString(Json::ensureArray(obj, "downloads").first(), "Download URL for " + file.path); | ||||
|                 file.download = Json::requireString(Json::ensureArray(modInfo, "downloads").first(), "Download URL for " + file.path); | ||||
|                 if (!file.download.isValid() || !Modrinth::validateDownloadUrl(file.download)) { | ||||
|                     throw JSONValidationError("Download URL for " + file.path + " is not a correctly formatted URL"); | ||||
|                 } | ||||
|   | ||||
| @@ -42,6 +42,7 @@ | ||||
| #include <QFutureWatcher> | ||||
| #include "settings/SettingsObject.h" | ||||
| #include "QObjectPtr.h" | ||||
| #include "modplatform/flame/PackManifest.h" | ||||
|  | ||||
| #include <nonstd/optional> | ||||
|  | ||||
| @@ -59,6 +60,10 @@ public: | ||||
|  | ||||
|     bool canAbort() const override { return true; } | ||||
|     bool abort() override; | ||||
|     const QVector<Flame::File> &getBlockedFiles() const | ||||
|     { | ||||
|         return m_blockedMods; | ||||
|     } | ||||
|  | ||||
| protected: | ||||
|     //! Entry point for tasks. | ||||
| @@ -87,6 +92,7 @@ private: /* data */ | ||||
|     std::unique_ptr<QuaZip> m_packZip; | ||||
|     QFuture<nonstd::optional<QStringList>> m_extractFuture; | ||||
|     QFutureWatcher<nonstd::optional<QStringList>> m_extractFutureWatcher; | ||||
|     QVector<Flame::File> m_blockedMods; | ||||
|     enum class ModpackType{ | ||||
|         Unknown, | ||||
|         MultiMC, | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| #include "FileResolvingTask.h" | ||||
| #include "Json.h" | ||||
|  | ||||
| Flame::FileResolvingTask::FileResolvingTask(shared_qobject_ptr<QNetworkAccessManager> network, Flame::Manifest& toProcess) | ||||
| #include "Json.h" | ||||
| #include "net/Upload.h" | ||||
|  | ||||
| Flame::FileResolvingTask::FileResolvingTask(const shared_qobject_ptr<QNetworkAccessManager>& network, Flame::Manifest& toProcess) | ||||
|     : m_network(network), m_toProcess(toProcess) | ||||
| {} | ||||
|  | ||||
| @@ -10,40 +12,116 @@ void Flame::FileResolvingTask::executeTask() | ||||
|     setStatus(tr("Resolving mod IDs...")); | ||||
|     setProgress(0, m_toProcess.files.size()); | ||||
|     m_dljob = new NetJob("Mod id resolver", m_network); | ||||
|     results.resize(m_toProcess.files.size()); | ||||
|     int index = 0; | ||||
|     for (auto& file : m_toProcess.files) { | ||||
|         auto projectIdStr = QString::number(file.projectId); | ||||
|         auto fileIdStr = QString::number(file.fileId); | ||||
|         QString metaurl = QString("https://api.curseforge.com/v1/mods/%1/files/%2").arg(projectIdStr, fileIdStr); | ||||
|         auto dl = Net::Download::makeByteArray(QUrl(metaurl), &results[index]); | ||||
|         m_dljob->addNetAction(dl); | ||||
|         index++; | ||||
|     } | ||||
|     result.reset(new QByteArray()); | ||||
|     //build json data to send | ||||
|     QJsonObject object; | ||||
|  | ||||
|     object["fileIds"] = QJsonArray::fromVariantList(std::accumulate(m_toProcess.files.begin(), m_toProcess.files.end(), QVariantList(), [](QVariantList& l, const File& s) { | ||||
|         l.push_back(s.fileId); | ||||
|         return l; | ||||
|     })); | ||||
|     QByteArray data = Json::toText(object); | ||||
|     auto dl = Net::Upload::makeByteArray(QUrl("https://api.curseforge.com/v1/mods/files"), result.get(), data); | ||||
|     m_dljob->addNetAction(dl); | ||||
|     connect(m_dljob.get(), &NetJob::finished, this, &Flame::FileResolvingTask::netJobFinished); | ||||
|     m_dljob->start(); | ||||
| } | ||||
|  | ||||
| void Flame::FileResolvingTask::netJobFinished() | ||||
| { | ||||
|     bool failed = false; | ||||
|     int index = 0; | ||||
|     for (auto& bytes : results) { | ||||
|         auto& out = m_toProcess.files[index]; | ||||
|     // job to check modrinth for blocked projects | ||||
|     auto job = new NetJob("Modrinth check", m_network); | ||||
|     blockedProjects = QMap<File *,QByteArray *>(); | ||||
|     auto doc = Json::requireDocument(*result); | ||||
|     auto array = Json::requireArray(doc.object()["data"]); | ||||
|     for (QJsonValueRef file : array) { | ||||
|         auto fileid = Json::requireInteger(Json::requireObject(file)["id"]); | ||||
|         auto& out = m_toProcess.files[fileid]; | ||||
|         try { | ||||
|             failed &= (!out.parseFromBytes(bytes)); | ||||
|            out.parseFromObject(Json::requireObject(file)); | ||||
|         } catch (const JSONValidationError& e) { | ||||
|             qCritical() << "Resolving of" << out.projectId << out.fileId << "failed because of a parsing error:"; | ||||
|             qCritical() << e.cause(); | ||||
|             qCritical() << "JSON:"; | ||||
|             qCritical() << bytes; | ||||
|             failed = true; | ||||
|             qDebug() << "Blocked mod on curseforge" << out.fileName; | ||||
|             auto hash = out.hash; | ||||
|             if(!hash.isEmpty()) { | ||||
|                 auto url = QString("https://api.modrinth.com/v2/version_file/%1?algorithm=sha1").arg(hash); | ||||
|                 auto output = new QByteArray(); | ||||
|                 auto dl = Net::Download::makeByteArray(QUrl(url), output); | ||||
|                 QObject::connect(dl.get(), &Net::Download::succeeded, [&out]() { | ||||
|                     out.resolved = true; | ||||
|                 }); | ||||
|  | ||||
|                 job->addNetAction(dl); | ||||
|                 blockedProjects.insert(&out, output); | ||||
|             } | ||||
|         } | ||||
|         index++; | ||||
|     } | ||||
|     if (!failed) { | ||||
|         emitSucceeded(); | ||||
|     connect(job, &NetJob::finished, this, &Flame::FileResolvingTask::modrinthCheckFinished); | ||||
|  | ||||
|     job->start(); | ||||
| } | ||||
|  | ||||
| void Flame::FileResolvingTask::modrinthCheckFinished() { | ||||
|     qDebug() << "Finished with blocked mods : " << blockedProjects.size(); | ||||
|  | ||||
|     for (auto it = blockedProjects.keyBegin(); it != blockedProjects.keyEnd(); it++) { | ||||
|         auto &out = *it; | ||||
|         auto bytes = blockedProjects[out]; | ||||
|         if (!out->resolved) { | ||||
|             delete bytes; | ||||
|             continue; | ||||
|         } | ||||
|         QJsonDocument doc = QJsonDocument::fromJson(*bytes); | ||||
|         auto obj = doc.object(); | ||||
|         auto array = Json::requireArray(obj,"files"); | ||||
|         for (auto file: array) { | ||||
|             auto fileObj = Json::requireObject(file); | ||||
|             auto primary = Json::requireBoolean(fileObj,"primary"); | ||||
|             if (primary) { | ||||
|                 out->url = Json::requireUrl(fileObj,"url"); | ||||
|                 qDebug() << "Found alternative on modrinth " << out->fileName; | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|         delete bytes; | ||||
|     } | ||||
|     //copy to an output list and filter out projects found on modrinth | ||||
|     auto block = new QList<File *>(); | ||||
|     auto it = blockedProjects.keys(); | ||||
|     std::copy_if(it.begin(), it.end(), std::back_inserter(*block), [](File *f) { | ||||
|         return !f->resolved; | ||||
|     }); | ||||
|     //Display not found mods early | ||||
|     if (!block->empty()) { | ||||
|         //blocked mods found, we need the slug for displaying.... we need another job :D ! | ||||
|         auto slugJob = new NetJob("Slug Job", m_network); | ||||
|         auto slugs = QVector<QByteArray>(block->size()); | ||||
|         auto index = 0; | ||||
|         for (auto fileInfo: *block) { | ||||
|             auto projectId = fileInfo->projectId; | ||||
|             slugs[index] = QByteArray(); | ||||
|             auto url = QString("https://api.curseforge.com/v1/mods/%1").arg(projectId); | ||||
|             auto dl = Net::Download::makeByteArray(url, &slugs[index]); | ||||
|             slugJob->addNetAction(dl); | ||||
|             index++; | ||||
|         } | ||||
|         connect(slugJob, &NetJob::succeeded, this, [slugs, this, slugJob, block]() { | ||||
|             slugJob->deleteLater(); | ||||
|             auto index = 0; | ||||
|             for (const auto &slugResult: slugs) { | ||||
|                 auto json = QJsonDocument::fromJson(slugResult); | ||||
|                 auto base = Json::requireString(Json::requireObject(Json::requireObject(Json::requireObject(json),"data"),"links"), | ||||
|                         "websiteUrl"); | ||||
|                 auto mod = block->at(index); | ||||
|                 auto link = QString("%1/download/%2").arg(base, QString::number(mod->fileId)); | ||||
|                 mod->websiteUrl = link; | ||||
|                 index++; | ||||
|             } | ||||
|             emitSucceeded(); | ||||
|         }); | ||||
|         slugJob->start(); | ||||
|     } else { | ||||
|         emitFailed(tr("Some mod ID resolving tasks failed.")); | ||||
|         emitSucceeded(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -10,7 +10,7 @@ class FileResolvingTask : public Task | ||||
| { | ||||
|     Q_OBJECT | ||||
| public: | ||||
|     explicit FileResolvingTask(shared_qobject_ptr<QNetworkAccessManager> network, Flame::Manifest &toProcess); | ||||
|     explicit FileResolvingTask(const shared_qobject_ptr<QNetworkAccessManager>& network, Flame::Manifest &toProcess); | ||||
|     virtual ~FileResolvingTask() {}; | ||||
|  | ||||
|     const Flame::Manifest &getResults() const | ||||
| @@ -27,7 +27,11 @@ protected slots: | ||||
| private: /* data */ | ||||
|     shared_qobject_ptr<QNetworkAccessManager> m_network; | ||||
|     Flame::Manifest m_toProcess; | ||||
|     QVector<QByteArray> results; | ||||
|     std::shared_ptr<QByteArray> result; | ||||
|     NetJob::Ptr m_dljob; | ||||
|  | ||||
|     void modrinthCheckFinished(); | ||||
|  | ||||
|     QMap<File *, QByteArray *> blockedProjects; | ||||
| }; | ||||
| } | ||||
|   | ||||
| @@ -41,7 +41,7 @@ static void loadManifestV1(Flame::Manifest& m, QJsonObject& manifest) | ||||
|         auto obj = Json::requireObject(item); | ||||
|         Flame::File file; | ||||
|         loadFileV1(file, obj); | ||||
|         m.files.append(file); | ||||
|         m.files.insert(file.fileId,file); | ||||
|     } | ||||
|     m.overrides = Json::ensureString(manifest, "overrides", "overrides"); | ||||
| } | ||||
| @@ -61,21 +61,9 @@ void Flame::loadManifest(Flame::Manifest& m, const QString& filepath) | ||||
|     loadManifestV1(m, obj); | ||||
| } | ||||
|  | ||||
| bool Flame::File::parseFromBytes(const QByteArray& bytes) | ||||
| bool Flame::File::parseFromObject(const QJsonObject& obj) | ||||
| { | ||||
|     auto doc = Json::requireDocument(bytes); | ||||
|     if (!doc.isObject()) { | ||||
|         throw JSONValidationError(QString("data is not an object? that's not supposed to happen")); | ||||
|     } | ||||
|     auto obj = Json::ensureObject(doc.object(), "data"); | ||||
|  | ||||
|     fileName = Json::requireString(obj, "fileName"); | ||||
|  | ||||
|     QString rawUrl = Json::requireString(obj, "downloadUrl"); | ||||
|     url = QUrl(rawUrl, QUrl::TolerantMode); | ||||
|     if (!url.isValid()) { | ||||
|         throw JSONValidationError(QString("Invalid URL: %1").arg(rawUrl)); | ||||
|     } | ||||
|     // This is a piece of a Flame project JSON pulled out into the file metadata (here) for convenience | ||||
|     // It is also optional | ||||
|     type = File::Type::SingleFile; | ||||
| @@ -87,6 +75,25 @@ bool Flame::File::parseFromBytes(const QByteArray& bytes) | ||||
|         // this is probably a mod, dunno what else could modpacks download | ||||
|         targetFolder = "mods"; | ||||
|     } | ||||
|     // get the hash | ||||
|     hash = QString(); | ||||
|     auto hashes = Json::ensureArray(obj, "hashes"); | ||||
|     for(QJsonValueRef item : hashes) { | ||||
|         auto hobj = Json::requireObject(item); | ||||
|         auto algo = Json::requireInteger(hobj, "algo"); | ||||
|         auto value = Json::requireString(hobj, "value"); | ||||
|         if (algo == 1) { | ||||
|             hash = value; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // may throw, if the project is blocked | ||||
|     QString rawUrl = Json::ensureString(obj, "downloadUrl"); | ||||
|     url = QUrl(rawUrl, QUrl::TolerantMode); | ||||
|     if (!url.isValid()) { | ||||
|         throw JSONValidationError(QString("Invalid URL: %1").arg(rawUrl)); | ||||
|     } | ||||
|  | ||||
|     resolved = true; | ||||
|     return true; | ||||
|   | ||||
| @@ -2,19 +2,24 @@ | ||||
|  | ||||
| #include <QString> | ||||
| #include <QVector> | ||||
| #include <QMap> | ||||
| #include <QUrl> | ||||
| #include <QJsonObject> | ||||
|  | ||||
| namespace Flame | ||||
| { | ||||
| struct File | ||||
| { | ||||
|     // NOTE: throws JSONValidationError | ||||
|     bool parseFromBytes(const QByteArray &bytes); | ||||
|     bool parseFromObject(const QJsonObject& object); | ||||
|  | ||||
|     int projectId = 0; | ||||
|     int fileId = 0; | ||||
|     // NOTE: the opposite to 'optional'. This is at the time of writing unused. | ||||
|     bool required = true; | ||||
|     QString hash; | ||||
|     // NOTE: only set on blocked files ! Empty otherwise. | ||||
|     QString websiteUrl; | ||||
|  | ||||
|     // our | ||||
|     bool resolved = false; | ||||
| @@ -54,7 +59,8 @@ struct Manifest | ||||
|     QString name; | ||||
|     QString version; | ||||
|     QString author; | ||||
|     QVector<Flame::File> files; | ||||
|     //File id -> File | ||||
|     QMap<int,Flame::File> files; | ||||
|     QString overrides; | ||||
| }; | ||||
|  | ||||
|   | ||||
							
								
								
									
										199
									
								
								launcher/net/Upload.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								launcher/net/Upload.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,199 @@ | ||||
| // | ||||
| // Created by timoreo on 20/05/22. | ||||
| // | ||||
|  | ||||
| #include "Upload.h" | ||||
|  | ||||
| #include <utility> | ||||
| #include "ByteArraySink.h" | ||||
| #include "BuildConfig.h" | ||||
| #include "Application.h" | ||||
|  | ||||
| namespace Net { | ||||
|  | ||||
|     void Upload::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) { | ||||
|         setProgress(bytesReceived, bytesTotal); | ||||
|     } | ||||
|  | ||||
|     void Upload::downloadError(QNetworkReply::NetworkError error) { | ||||
|         if (error == QNetworkReply::OperationCanceledError) { | ||||
|             qCritical() << "Aborted " << m_url.toString(); | ||||
|             m_state = State::AbortedByUser; | ||||
|         } else { | ||||
|             // error happened during download. | ||||
|             qCritical() << "Failed " << m_url.toString() << " with reason " << error; | ||||
|             m_state = State::Failed; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     void Upload::sslErrors(const QList<QSslError> &errors) { | ||||
|         int i = 1; | ||||
|         for (const auto& error : errors) { | ||||
|             qCritical() << "Upload" << m_url.toString() << "SSL Error #" << i << " : " << error.errorString(); | ||||
|             auto cert = error.certificate(); | ||||
|             qCritical() << "Certificate in question:\n" << cert.toText(); | ||||
|             i++; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     bool Upload::handleRedirect() | ||||
|     { | ||||
|         QUrl redirect = m_reply->header(QNetworkRequest::LocationHeader).toUrl(); | ||||
|         if (!redirect.isValid()) { | ||||
|             if (!m_reply->hasRawHeader("Location")) { | ||||
|                 // no redirect -> it's fine to continue | ||||
|                 return false; | ||||
|             } | ||||
|             // there is a Location header, but it's not correct. we need to apply some workarounds... | ||||
|             QByteArray redirectBA = m_reply->rawHeader("Location"); | ||||
|             if (redirectBA.size() == 0) { | ||||
|                 // empty, yet present redirect header? WTF? | ||||
|                 return false; | ||||
|             } | ||||
|             QString redirectStr = QString::fromUtf8(redirectBA); | ||||
|  | ||||
|             if (redirectStr.startsWith("//")) { | ||||
|                 /* | ||||
|                  * IF the URL begins with //, we need to insert the URL scheme. | ||||
|                  * See: https://bugreports.qt.io/browse/QTBUG-41061 | ||||
|                  * See: http://tools.ietf.org/html/rfc3986#section-4.2 | ||||
|                  */ | ||||
|                 redirectStr = m_reply->url().scheme() + ":" + redirectStr; | ||||
|             } else if (redirectStr.startsWith("/")) { | ||||
|                 /* | ||||
|                  * IF the URL begins with /, we need to process it as a relative URL | ||||
|                  */ | ||||
|                 auto url = m_reply->url(); | ||||
|                 url.setPath(redirectStr, QUrl::TolerantMode); | ||||
|                 redirectStr = url.toString(); | ||||
|             } | ||||
|  | ||||
|             /* | ||||
|              * Next, make sure the URL is parsed in tolerant mode. Qt doesn't parse the location header in tolerant mode, which causes issues. | ||||
|              * FIXME: report Qt bug for this | ||||
|              */ | ||||
|             redirect = QUrl(redirectStr, QUrl::TolerantMode); | ||||
|             if (!redirect.isValid()) { | ||||
|                 qWarning() << "Failed to parse redirect URL:" << redirectStr; | ||||
|                 downloadError(QNetworkReply::ProtocolFailure); | ||||
|                 return false; | ||||
|             } | ||||
|             qDebug() << "Fixed location header:" << redirect; | ||||
|         } else { | ||||
|             qDebug() << "Location header:" << redirect; | ||||
|         } | ||||
|  | ||||
|         m_url = QUrl(redirect.toString()); | ||||
|         qDebug() << "Following redirect to " << m_url.toString(); | ||||
|         startAction(m_network); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     void Upload::downloadFinished() { | ||||
|         // handle HTTP redirection first | ||||
|         // very unlikely for post requests, still can happen | ||||
|         if (handleRedirect()) { | ||||
|             qDebug() << "Upload redirected:" << m_url.toString(); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // if the download failed before this point ... | ||||
|         if (m_state == State::Succeeded) { | ||||
|             qDebug() << "Upload failed but we are allowed to proceed:" << m_url.toString(); | ||||
|             m_sink->abort(); | ||||
|             m_reply.reset(); | ||||
|             emit succeeded(); | ||||
|             return; | ||||
|         } else if (m_state == State::Failed) { | ||||
|             qDebug() << "Upload failed in previous step:" << m_url.toString(); | ||||
|             m_sink->abort(); | ||||
|             m_reply.reset(); | ||||
|             emit failed(""); | ||||
|             return; | ||||
|         } else if (m_state == State::AbortedByUser) { | ||||
|             qDebug() << "Upload aborted in previous step:" << m_url.toString(); | ||||
|             m_sink->abort(); | ||||
|             m_reply.reset(); | ||||
|             emit aborted(); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // make sure we got all the remaining data, if any | ||||
|         auto data = m_reply->readAll(); | ||||
|         if (data.size()) { | ||||
|             qDebug() << "Writing extra" << data.size() << "bytes"; | ||||
|             m_state = m_sink->write(data); | ||||
|         } | ||||
|  | ||||
|         // otherwise, finalize the whole graph | ||||
|         m_state = m_sink->finalize(*m_reply.get()); | ||||
|         if (m_state != State::Succeeded) { | ||||
|             qDebug() << "Upload failed to finalize:" << m_url.toString(); | ||||
|             m_sink->abort(); | ||||
|             m_reply.reset(); | ||||
|             emit failed(""); | ||||
|             return; | ||||
|         } | ||||
|         m_reply.reset(); | ||||
|         qDebug() << "Upload succeeded:" << m_url.toString(); | ||||
|         emit succeeded(); | ||||
|     } | ||||
|  | ||||
|     void Upload::downloadReadyRead() { | ||||
|         if (m_state == State::Running) { | ||||
|             auto data = m_reply->readAll(); | ||||
|             m_state = m_sink->write(data); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     void Upload::executeTask() { | ||||
|         setStatus(tr("Uploading %1").arg(m_url.toString())); | ||||
|  | ||||
|         if (m_state == State::AbortedByUser) { | ||||
|             qWarning() << "Attempt to start an aborted Upload:" << m_url.toString(); | ||||
|             emit aborted(); | ||||
|             return; | ||||
|         } | ||||
|         QNetworkRequest request(m_url); | ||||
|         m_state = m_sink->init(request); | ||||
|         switch (m_state) { | ||||
|             case State::Succeeded: | ||||
|                 emitSucceeded(); | ||||
|                 qDebug() << "Upload cache hit " << m_url.toString(); | ||||
|                 return; | ||||
|             case State::Running: | ||||
|                 qDebug() << "Uploading " << m_url.toString(); | ||||
|                 break; | ||||
|             case State::Inactive: | ||||
|             case State::Failed: | ||||
|                 emitFailed(""); | ||||
|                 return; | ||||
|             case State::AbortedByUser: | ||||
|                 emitAborted(); | ||||
|                 return; | ||||
|         } | ||||
|  | ||||
|         request.setHeader(QNetworkRequest::UserAgentHeader, BuildConfig.USER_AGENT); | ||||
|         if (request.url().host().contains("api.curseforge.com")) { | ||||
|             request.setRawHeader("x-api-key", APPLICATION->getCurseKey().toUtf8()); | ||||
|         } | ||||
|         //TODO other types of post requests ? | ||||
|         request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); | ||||
|         QNetworkReply* rep = m_network->post(request, m_post_data); | ||||
|  | ||||
|         m_reply.reset(rep); | ||||
|         connect(rep, SIGNAL(downloadProgress(qint64, qint64)), SLOT(downloadProgress(qint64, qint64))); | ||||
|         connect(rep, SIGNAL(finished()), SLOT(downloadFinished())); | ||||
|         connect(rep, SIGNAL(error(QNetworkReply::NetworkError)), SLOT(downloadError(QNetworkReply::NetworkError))); | ||||
|         connect(rep, &QNetworkReply::sslErrors, this, &Upload::sslErrors); | ||||
|         connect(rep, &QNetworkReply::readyRead, this, &Upload::downloadReadyRead); | ||||
|     } | ||||
|  | ||||
|     Upload::Ptr Upload::makeByteArray(QUrl url, QByteArray *output, QByteArray m_post_data) { | ||||
|         auto* up = new Upload(); | ||||
|         up->m_url = std::move(url); | ||||
|         up->m_sink.reset(new ByteArraySink(output)); | ||||
|         up->m_post_data = std::move(m_post_data); | ||||
|         return up; | ||||
|     } | ||||
| } // Net | ||||
							
								
								
									
										31
									
								
								launcher/net/Upload.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								launcher/net/Upload.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "NetAction.h" | ||||
| #include "Sink.h" | ||||
|  | ||||
| namespace Net { | ||||
|  | ||||
|     class Upload : public NetAction { | ||||
|         Q_OBJECT | ||||
|  | ||||
|     public: | ||||
|         static Upload::Ptr makeByteArray(QUrl url, QByteArray *output, QByteArray m_post_data); | ||||
|  | ||||
|     protected slots: | ||||
|         void downloadProgress(qint64 bytesReceived, qint64 bytesTotal) override; | ||||
|         void downloadError(QNetworkReply::NetworkError error) override; | ||||
|         void sslErrors(const QList<QSslError> & errors); | ||||
|         void downloadFinished() override; | ||||
|         void downloadReadyRead() override; | ||||
|  | ||||
|     public slots: | ||||
|         void executeTask() override; | ||||
|     private: | ||||
|         std::unique_ptr<Sink> m_sink; | ||||
|         QByteArray m_post_data; | ||||
|  | ||||
|         bool handleRedirect(); | ||||
|     }; | ||||
|  | ||||
| } // Net | ||||
|  | ||||
							
								
								
									
										15
									
								
								launcher/ui/dialogs/ScrollMessageBox.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								launcher/ui/dialogs/ScrollMessageBox.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| #include "ScrollMessageBox.h" | ||||
| #include "ui_ScrollMessageBox.h" | ||||
|  | ||||
|  | ||||
| ScrollMessageBox::ScrollMessageBox(QWidget *parent, const QString &title, const QString &text, const QString &body) : | ||||
|         QDialog(parent), ui(new Ui::ScrollMessageBox) { | ||||
|     ui->setupUi(this); | ||||
|     this->setWindowTitle(title); | ||||
|     ui->label->setText(text); | ||||
|     ui->textBrowser->setText(body); | ||||
| } | ||||
|  | ||||
| ScrollMessageBox::~ScrollMessageBox() { | ||||
|     delete ui; | ||||
| } | ||||
							
								
								
									
										20
									
								
								launcher/ui/dialogs/ScrollMessageBox.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								launcher/ui/dialogs/ScrollMessageBox.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include <QDialog> | ||||
|  | ||||
|  | ||||
| QT_BEGIN_NAMESPACE | ||||
| namespace Ui { class ScrollMessageBox; } | ||||
| QT_END_NAMESPACE | ||||
|  | ||||
| class ScrollMessageBox : public QDialog { | ||||
| Q_OBJECT | ||||
|  | ||||
| public: | ||||
|     ScrollMessageBox(QWidget *parent, const QString &title, const QString &text, const QString &body); | ||||
|  | ||||
|     ~ScrollMessageBox() override; | ||||
|  | ||||
| private: | ||||
|     Ui::ScrollMessageBox *ui; | ||||
| }; | ||||
							
								
								
									
										84
									
								
								launcher/ui/dialogs/ScrollMessageBox.ui
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								launcher/ui/dialogs/ScrollMessageBox.ui
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <ui version="4.0"> | ||||
|  <class>ScrollMessageBox</class> | ||||
|  <widget class="QDialog" name="ScrollMessageBox"> | ||||
|   <property name="geometry"> | ||||
|    <rect> | ||||
|     <x>0</x> | ||||
|     <y>0</y> | ||||
|     <width>400</width> | ||||
|     <height>455</height> | ||||
|    </rect> | ||||
|   </property> | ||||
|   <property name="windowTitle"> | ||||
|    <string>ScrollMessageBox</string> | ||||
|   </property> | ||||
|   <layout class="QGridLayout" name="gridLayout"> | ||||
|    <item row="0" column="0"> | ||||
|     <widget class="QLabel" name="label"> | ||||
|      <property name="text"> | ||||
|       <string notr="true"/> | ||||
|      </property> | ||||
|      <property name="textFormat"> | ||||
|       <enum>Qt::RichText</enum> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|    <item row="2" column="0"> | ||||
|     <widget class="QDialogButtonBox" name="buttonBox"> | ||||
|      <property name="orientation"> | ||||
|       <enum>Qt::Horizontal</enum> | ||||
|      </property> | ||||
|      <property name="standardButtons"> | ||||
|       <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|    <item row="1" column="0"> | ||||
|     <widget class="QTextBrowser" name="textBrowser"> | ||||
|      <property name="acceptRichText"> | ||||
|       <bool>true</bool> | ||||
|      </property> | ||||
|      <property name="openExternalLinks"> | ||||
|       <bool>true</bool> | ||||
|      </property> | ||||
|     </widget> | ||||
|    </item> | ||||
|   </layout> | ||||
|  </widget> | ||||
|  <resources/> | ||||
|  <connections> | ||||
|   <connection> | ||||
|    <sender>buttonBox</sender> | ||||
|    <signal>accepted()</signal> | ||||
|    <receiver>ScrollMessageBox</receiver> | ||||
|    <slot>accept()</slot> | ||||
|    <hints> | ||||
|     <hint type="sourcelabel"> | ||||
|      <x>199</x> | ||||
|      <y>425</y> | ||||
|     </hint> | ||||
|     <hint type="destinationlabel"> | ||||
|      <x>199</x> | ||||
|      <y>227</y> | ||||
|     </hint> | ||||
|    </hints> | ||||
|   </connection> | ||||
|   <connection> | ||||
|    <sender>buttonBox</sender> | ||||
|    <signal>rejected()</signal> | ||||
|    <receiver>ScrollMessageBox</receiver> | ||||
|    <slot>reject()</slot> | ||||
|    <hints> | ||||
|     <hint type="sourcelabel"> | ||||
|      <x>199</x> | ||||
|      <y>425</y> | ||||
|     </hint> | ||||
|     <hint type="destinationlabel"> | ||||
|      <x>199</x> | ||||
|      <y>227</y> | ||||
|     </hint> | ||||
|    </hints> | ||||
|   </connection> | ||||
|  </connections> | ||||
| </ui> | ||||
| @@ -117,7 +117,7 @@ void ImportPage::updateState() | ||||
|             if(fi.exists() && (zip || fi.suffix() == "mrpack")) | ||||
|             { | ||||
|                 QFileInfo fi(url.fileName()); | ||||
|                 dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url)); | ||||
|                 dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url,this)); | ||||
|                 dialog->setSuggestedIcon("default"); | ||||
|             } | ||||
|         } | ||||
| @@ -130,7 +130,7 @@ void ImportPage::updateState() | ||||
|             } | ||||
|             // hook, line and sinker. | ||||
|             QFileInfo fi(url.fileName()); | ||||
|             dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url)); | ||||
|             dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url,this)); | ||||
|             dialog->setSuggestedIcon("default"); | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -201,7 +201,7 @@ void FlamePage::suggestCurrent() | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     dialog->setSuggestedPack(current.name, new InstanceImportTask(selectedVersion)); | ||||
|     dialog->setSuggestedPack(current.name, new InstanceImportTask(selectedVersion,this)); | ||||
|     QString editedLogoName; | ||||
|     editedLogoName = "curseforge_" + current.logoName.section(".", 0, 0); | ||||
|     listModel->getLogo(current.logoName, current.logoUrl, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user