// SPDX-License-Identifier: GPL-3.0-only /* * Prism Launcher - Minecraft Launcher * Copyright (C) 2023 TheKodeToad * Copyright (c) 2023 Trial97 * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "FlamePackExportTask.h" #include #include #include #include #include #include #include #include #include #include "Application.h" #include "Json.h" #include "minecraft/PackProfile.h" #include "minecraft/mod/ModFolderModel.h" #include "modplatform/ModIndex.h" #include "modplatform/flame/FlameModIndex.h" #include "modplatform/helpers/HashUtils.h" #include "tasks/Task.h" #include "archive/ExportToZipTask.h" const QString FlamePackExportTask::TEMPLATE = "
  • {name}{authors}
  • \n"; const QStringList FlamePackExportTask::FILE_EXTENSIONS({ "jar", "zip" }); FlamePackExportTask::FlamePackExportTask(FlamePackExportOptions&& options) : m_options(std::move(options)), m_gameRoot(m_options.instance->gameRoot()) {} void FlamePackExportTask::executeTask() { setStatus(tr("Searching for files...")); setProgress(0, 5); collectFiles(); } bool FlamePackExportTask::abort() { if (task) { task->abort(); return true; } return false; } void FlamePackExportTask::collectFiles() { setAbortable(false); QCoreApplication::processEvents(); m_files.clear(); if (!MMCZip::collectFileListRecursively(m_options.instance->gameRoot(), nullptr, &m_files, m_options.filter)) { emitFailed(tr("Could not search for files")); return; } pendingHashes.clear(); resolvedFiles.clear(); m_options.instance->loaderModList()->update(); connect(m_options.instance->loaderModList(), &ModFolderModel::updateFinished, this, &FlamePackExportTask::collectHashes); } void FlamePackExportTask::collectHashes() { setAbortable(true); setStatus(tr("Finding file hashes...")); setProgress(1, 5); auto allMods = m_options.instance->loaderModList()->allMods(); ConcurrentTask::Ptr hashingTask(new ConcurrentTask("MakeHashesTask", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt())); task.reset(hashingTask); for (const QFileInfo& file : m_files) { const QString relative = m_gameRoot.relativeFilePath(file.absoluteFilePath()); // require sensible file types if (!std::any_of(FILE_EXTENSIONS.begin(), FILE_EXTENSIONS.end(), [&relative](const QString& extension) { return relative.endsWith('.' + extension) || relative.endsWith('.' + extension + ".disabled"); })) continue; if (relative.startsWith("resourcepacks/") && (relative.endsWith(".zip") || relative.endsWith(".zip.disabled"))) { // is resourcepack auto hashTask = Hashing::createHasher(file.absoluteFilePath(), ModPlatform::ResourceProvider::FLAME); connect(hashTask.get(), &Hashing::Hasher::resultsReady, this, [this, relative, file](QString hash) { if (m_state == Task::State::Running) { pendingHashes.insert(hash, { relative, file.absoluteFilePath(), relative.endsWith(".zip") }); } }); connect(hashTask.get(), &Task::failed, this, &FlamePackExportTask::emitFailed); hashingTask->addTask(hashTask); continue; } if (auto modIter = std::find_if(allMods.begin(), allMods.end(), [&file](Mod* mod) { return mod->fileinfo() == file; }); modIter != allMods.end()) { const Mod* mod = *modIter; if (!mod || mod->type() == ResourceType::FOLDER) { continue; } if (mod->metadata() && mod->metadata()->provider == ModPlatform::ResourceProvider::FLAME) { resolvedFiles.insert(mod->fileinfo().absoluteFilePath(), { mod->metadata()->project_id.toInt(), mod->metadata()->file_id.toInt(), mod->enabled(), true, mod->metadata()->name, mod->metadata()->slug, mod->authors().join(", ") }); continue; } auto hashTask = Hashing::createHasher(mod->fileinfo().absoluteFilePath(), ModPlatform::ResourceProvider::FLAME); connect(hashTask.get(), &Hashing::Hasher::resultsReady, this, [this, mod](QString hash) { if (m_state == Task::State::Running) { pendingHashes.insert(hash, { mod->name(), mod->fileinfo().absoluteFilePath(), mod->enabled(), true }); } }); connect(hashTask.get(), &Task::failed, this, &FlamePackExportTask::emitFailed); hashingTask->addTask(hashTask); } } auto progressStep = std::make_shared(); connect(hashingTask.get(), &Task::finished, this, [this, progressStep] { progressStep->state = TaskStepState::Succeeded; stepProgress(*progressStep); }); connect(hashingTask.get(), &Task::succeeded, this, &FlamePackExportTask::makeApiRequest); connect(hashingTask.get(), &Task::failed, this, [this, progressStep](QString reason) { progressStep->state = TaskStepState::Failed; stepProgress(*progressStep); emitFailed(reason); }); connect(hashingTask.get(), &Task::stepProgress, this, &FlamePackExportTask::propagateStepProgress); connect(hashingTask.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { progressStep->update(current, total); stepProgress(*progressStep); }); connect(hashingTask.get(), &Task::status, this, [this, progressStep](QString status) { progressStep->status = status; stepProgress(*progressStep); }); connect(hashingTask.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); hashingTask->start(); } void FlamePackExportTask::makeApiRequest() { if (pendingHashes.isEmpty()) { buildZip(); return; } setStatus(tr("Finding versions for hashes...")); setProgress(2, 5); QList fingerprints; for (auto& murmur : pendingHashes.keys()) { fingerprints.push_back(murmur.toUInt()); } auto [matchTask, response] = FlameAPI::matchFingerprints(fingerprints); task = matchTask; connect(task.get(), &Task::succeeded, this, [this, response] { auto doc = Json::requireObject(*response) .and_then([](const auto& v) { return Json::requireObject(v, "data", "data"); }) .and_then([](const auto& v) { return Json::requireArray(v, "exactMatches", "exactMatches"); }); if (!doc) { qWarning() << "Error while parsing JSON response from CurseForge::CurrentVersions:" << doc.error(); qWarning() << *response; emitFailed(doc.error()); return; } if (doc->isEmpty()) { qWarning() << "No matches found for fingerprint search!"; getProjectsInfo(); return; } for (auto match : doc.value()) { auto matchObj = match.toObject(); auto fileObj = matchObj["file"].toObject(); if (matchObj.isEmpty() || fileObj.isEmpty()) { qWarning() << "Fingerprint match is empty!"; continue; } auto fingerprint = QString::number(fileObj["fileFingerprint"].toInteger()); auto mod = pendingHashes.find(fingerprint); if (mod == pendingHashes.end()) { qWarning() << "Invalid fingerprint from the API response."; continue; } setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(mod->name)); if (fileObj["isAvailable"].toBool()) { auto parse = [&fileObj, this, &mod] -> Result<> { auto modid = Json::requireInteger(fileObj, "modId"); TRY(modid) auto id = Json::requireInteger(fileObj, "id"); TRY(id) resolvedFiles.insert(mod->path, { .addonId = modid.value(), .version = id.value(), .enabled = mod->enabled, .isMod = mod->isMod }); return {}; }; if (auto res = parse(); !res) { qDebug() << res.error(); qDebug() << *doc; break; } } } pendingHashes.clear(); getProjectsInfo(); }); connect(task.get(), &Task::failed, this, &FlamePackExportTask::getProjectsInfo); connect(task.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); task->start(); } void FlamePackExportTask::getProjectsInfo() { setStatus(tr("Finding project info from CurseForge...")); setProgress(3, 5); QStringList addonIds; for (const auto& resolved : resolvedFiles) { if (resolved.slug.isEmpty()) { addonIds << QString::number(resolved.addonId); } } Task::Ptr projTask; QByteArray* response = nullptr; if (addonIds.isEmpty()) { buildZip(); return; } if (addonIds.size() == 1) { std::tie(projTask, response) = FlameAPI::get().getProject(*addonIds.begin()); } else { std::tie(projTask, response) = FlameAPI::get().getProjects(addonIds); } connect(projTask.get(), &Task::succeeded, this, [this, response, addonIds] { auto doc = Json::requireObject(*response).and_then([addonIds](const auto& v) -> Result { if (addonIds.size() == 1) { auto obj = Json::requireObject(v, "data", "data"); TRY(obj) return { { obj.value() } }; } return Json::requireArray(v, "data"); }); if (!doc) { qWarning() << "Error while parsing JSON response from CurseForge projects task:" << doc.error(); qWarning() << *response; emitFailed(doc.error()); return; } for (auto entry : doc.value()) { auto parse = [&entry, this] -> Result<> { auto entryObj = Json::requireObject(entry); TRY(entryObj) auto name = Json::requireString(entryObj.value(), "name"); TRY(name) setStatus(tr("Parsing API response from CurseForge for '%1'...").arg(*name)); ModPlatform::IndexedPack pack; auto loadRes = FlameMod::loadIndexedPack(pack, entryObj.value()); TRY(loadRes) for (const auto& key : resolvedFiles.keys()) { auto val = resolvedFiles.value(key); if (val.addonId == pack.addonId) { val.name = pack.name; val.slug = pack.slug; QStringList authors; for (const auto& author : pack.authors) { authors << author.name; } val.authors = authors.join(", "); resolvedFiles[key] = val; } } return {}; }; if (auto res = parse(); !res) { qDebug() << res.error(); qDebug() << *doc; continue; } } buildZip(); }); connect(projTask.get(), &Task::failed, this, &FlamePackExportTask::emitFailed); connect(projTask.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); task.reset(projTask); task->start(); } void FlamePackExportTask::buildZip() { setStatus(tr("Adding files...")); setProgress(4, 5); auto zipTask = makeShared(m_options.output, m_gameRoot, m_files, "overrides/", true); zipTask->addExtraFile("manifest.json", generateIndex()); zipTask->addExtraFile("modlist.html", generateHTML()); QStringList exclude; std::transform(resolvedFiles.keyBegin(), resolvedFiles.keyEnd(), std::back_insert_iterator(exclude), [this](QString file) { return m_gameRoot.relativeFilePath(file); }); zipTask->setExcludeFiles(exclude); auto progressStep = std::make_shared(); connect(zipTask.get(), &Task::finished, this, [this, progressStep] { progressStep->state = TaskStepState::Succeeded; stepProgress(*progressStep); }); connect(zipTask.get(), &Task::succeeded, this, &FlamePackExportTask::emitSucceeded); connect(zipTask.get(), &Task::aborted, this, &FlamePackExportTask::emitAborted); connect(zipTask.get(), &Task::failed, this, [this, progressStep](QString reason) { progressStep->state = TaskStepState::Failed; stepProgress(*progressStep); emitFailed(reason); }); connect(zipTask.get(), &Task::stepProgress, this, &FlamePackExportTask::propagateStepProgress); connect(zipTask.get(), &Task::progress, this, [this, progressStep](qint64 current, qint64 total) { progressStep->update(current, total); stepProgress(*progressStep); }); connect(zipTask.get(), &Task::status, this, [this, progressStep](QString status) { progressStep->status = status; stepProgress(*progressStep); }); task.reset(zipTask); zipTask->start(); } QByteArray FlamePackExportTask::generateIndex() { QJsonObject obj; obj["manifestType"] = "minecraftModpack"; obj["manifestVersion"] = 1; obj["name"] = m_options.name; obj["version"] = m_options.version; obj["author"] = m_options.author; obj["overrides"] = "overrides"; QJsonObject version; auto profile = m_options.instance->getPackProfile(); // collect all supported components const ComponentPtr minecraft = profile->getComponent("net.minecraft"); const ComponentPtr quilt = profile->getComponent("org.quiltmc.quilt-loader"); const ComponentPtr fabric = profile->getComponent("net.fabricmc.fabric-loader"); const ComponentPtr forge = profile->getComponent("net.minecraftforge"); const ComponentPtr neoforge = profile->getComponent("net.neoforged"); // convert all available components to mrpack dependencies if (minecraft != nullptr) version["version"] = minecraft->m_version; QString id; if (quilt != nullptr) id = "quilt-" + quilt->m_version; else if (fabric != nullptr) id = "fabric-" + fabric->m_version; else if (forge != nullptr) id = "forge-" + forge->m_version; else if (neoforge != nullptr) { id = "neoforge-"; if (minecraft->m_version == "1.20.1") id += "1.20.1-"; id += neoforge->m_version; } version["modLoaders"] = QJsonArray(); if (!id.isEmpty()) { QJsonObject loader; loader["id"] = id; loader["primary"] = true; version["modLoaders"] = QJsonArray({ loader }); } if (m_options.recommendedRAM > 0) version["recommendedRam"] = m_options.recommendedRAM; obj["minecraft"] = version; QJsonArray files; for (auto mod : resolvedFiles) { QJsonObject file; file["projectID"] = mod.addonId; file["fileID"] = mod.version; file["required"] = mod.enabled || !m_options.optionalFiles; files << file; } obj["files"] = files; return QJsonDocument(obj).toJson(QJsonDocument::Compact); } QByteArray FlamePackExportTask::generateHTML() { QString content = ""; for (auto mod : resolvedFiles) { if (mod.isMod) { content += QString(TEMPLATE) .replace("{name}", mod.name.toHtmlEscaped()) .replace("{url}", ModPlatform::getMetaURL(ModPlatform::ResourceProvider::FLAME, mod.addonId).toHtmlEscaped()) .replace("{authors}", !mod.authors.isEmpty() ? QString(" (by %1)").arg(mod.authors).toHtmlEscaped() : ""); } } content = "
      " + content + "
    "; return content.toUtf8(); }