#include "LocalModParseTask.h" #include #include #include #include #include #include #include #include #include #include "FileSystem.h" #include "Json.h" #include "minecraft/mod/ModDetails.h" #include "settings/INIFile.h" namespace ModUtils { // NEW format // https://github.com/MinecraftForge/FML/wiki/FML-mod-information-file/c8d8f1929aff9979e322af79a59ce81f3e02db6a // OLD format: // https://github.com/MinecraftForge/FML/wiki/FML-mod-information-file/5bf6a2d05145ec79387acc0d45c958642fb049fc ModDetails ReadMCModInfo(QByteArray contents) { auto getInfoFromArray = [&](QJsonArray arr) -> ModDetails { if (!arr.at(0).isObject()) { return {}; } ModDetails details; auto firstObj = arr.at(0).toObject(); details.mod_id = firstObj.value("modid").toString(); auto name = firstObj.value("name").toString(); // NOTE: ignore stupid example mods copies where the author didn't even bother to change the name if (name != "Example Mod") { details.name = name; } details.version = firstObj.value("version").toString(); auto homeurl = firstObj.value("url").toString().trimmed(); if (!homeurl.isEmpty()) { // fix up url. if (!homeurl.startsWith("http://") && !homeurl.startsWith("https://") && !homeurl.startsWith("ftp://")) { homeurl.prepend("http://"); } } details.homeurl = homeurl; details.description = firstObj.value("description").toString(); QJsonArray authors = firstObj.value("authorList").toArray(); if (authors.size() == 0) { // FIXME: what is the format of this? is there any? authors = firstObj.value("authors").toArray(); } if (firstObj.contains("logoFile")) { details.icon_file = firstObj.value("logoFile").toString(); } for (auto author : authors) { details.authors.append(author.toString()); } return details; }; QJsonParseError jsonError; QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); // this is the very old format that had just the array if (jsonDoc.isArray()) { return getInfoFromArray(jsonDoc.array()); } else if (jsonDoc.isObject()) { auto val = jsonDoc.object().value("modinfoversion"); if (val.isUndefined()) { val = jsonDoc.object().value("modListVersion"); } 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) { qWarning() << QString(R"(The value of 'modListVersion' is "%1" (expected "2")! The file may be corrupted.)").arg(version); qWarning() << "The contents of 'mcmod.info' are as follows:"; qWarning() << contents; } auto arrVal = jsonDoc.object().value("modlist"); if (arrVal.isUndefined()) { arrVal = jsonDoc.object().value("modList"); } if (arrVal.isArray()) { return getInfoFromArray(arrVal.toArray()); } } return {}; } // https://github.com/MinecraftForge/Documentation/blob/5ab4ba6cf9abc0ac4c0abd96ad187461aefd72af/docs/gettingstarted/structuring.md ModDetails ReadMCModTOML(QByteArray contents) { ModDetails details; toml::table tomlData; #if TOML_EXCEPTIONS try { tomlData = toml::parse(contents.toStdString()); } catch ([[maybe_unused]] const toml::parse_error& err) { return {}; } #else toml::parse_result result = toml::parse(contents.toStdString()); if (!result) { return {}; } tomlData = result.table(); #endif // array defined by [[mods]] auto tomlModsArr = tomlData["mods"].as_array(); if (!tomlModsArr) { qWarning() << "Corrupted mods.toml? Couldn't find [[mods]] array!"; return {}; } // we only really care about the first element, since multiple mods in one file is not supported by us at the moment auto tomlModsTable0 = tomlModsArr->get(0); if (!tomlModsTable0) { qWarning() << "Corrupted mods.toml? [[mods]] didn't have an element at index 0!"; return {}; } auto modsTable = tomlModsTable0->as_table(); if (!modsTable) { qWarning() << "Corrupted mods.toml? [[mods]] was not a table!"; return {}; } // mandatory properties - always in [[mods]] if (auto modIdDatum = (*modsTable)["modId"].as_string()) { details.mod_id = QString::fromStdString(modIdDatum->get()); } if (auto versionDatum = (*modsTable)["version"].as_string()) { details.version = QString::fromStdString(versionDatum->get()); } if (auto displayNameDatum = (*modsTable)["displayName"].as_string()) { details.name = QString::fromStdString(displayNameDatum->get()); } if (auto descriptionDatum = (*modsTable)["description"].as_string()) { details.description = QString::fromStdString(descriptionDatum->get()); } // optional properties - can be in the root table or [[mods]] QString authors = ""; if (auto authorsDatum = tomlData["authors"].as_string()) { authors = QString::fromStdString(authorsDatum->get()); } else if (auto authorsDatumMods = (*modsTable)["authors"].as_string()) { authors = QString::fromStdString(authorsDatumMods->get()); } if (!authors.isEmpty()) { details.authors.append(authors); } QString homeurl = ""; if (auto homeurlDatum = tomlData["displayURL"].as_string()) { homeurl = QString::fromStdString(homeurlDatum->get()); } else if (auto homeurlDatumMods = (*modsTable)["displayURL"].as_string()) { homeurl = QString::fromStdString(homeurlDatumMods->get()); } // fix up url. if (!homeurl.isEmpty() && !homeurl.startsWith("http://") && !homeurl.startsWith("https://") && !homeurl.startsWith("ftp://")) { homeurl.prepend("http://"); } details.homeurl = homeurl; QString issueTrackerURL = ""; if (auto issueTrackerURLDatum = tomlData["issueTrackerURL"].as_string()) { issueTrackerURL = QString::fromStdString(issueTrackerURLDatum->get()); } else if (auto issueTrackerURLDatumMods = (*modsTable)["issueTrackerURL"].as_string()) { issueTrackerURL = QString::fromStdString(issueTrackerURLDatumMods->get()); } details.issue_tracker = issueTrackerURL; QString license = ""; if (auto licenseDatum = tomlData["license"].as_string()) { license = QString::fromStdString(licenseDatum->get()); } else if (auto licenseDatumMods = (*modsTable)["license"].as_string()) { license = QString::fromStdString(licenseDatumMods->get()); } if (!license.isEmpty()) details.licenses.append(ModLicense(license)); QString logoFile = ""; if (auto logoFileDatum = tomlData["logoFile"].as_string()) { logoFile = QString::fromStdString(logoFileDatum->get()); } else if (auto logoFileDatumMods = (*modsTable)["logoFile"].as_string()) { logoFile = QString::fromStdString(logoFileDatumMods->get()); } details.icon_file = logoFile; return details; } // https://fabricmc.net/wiki/documentation:fabric_mod_json ModDetails ReadFabricModInfo(QByteArray contents) { QJsonParseError jsonError; QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); auto object = jsonDoc.object(); auto schemaVersion = object.contains("schemaVersion") ? object.value("schemaVersion").toInt(0) : 0; ModDetails details; details.mod_id = object.value("id").toString(); details.version = object.value("version").toString(); details.name = object.contains("name") ? object.value("name").toString() : details.mod_id; details.description = object.value("description").toString(); if (schemaVersion >= 1) { QJsonArray authors = object.value("authors").toArray(); for (auto author : authors) { if (author.isObject()) { details.authors.append(author.toObject().value("name").toString()); } else { details.authors.append(author.toString()); } } if (object.contains("contact")) { QJsonObject contact = object.value("contact").toObject(); if (contact.contains("homepage")) { details.homeurl = contact.value("homepage").toString(); } if (contact.contains("issues")) { details.issue_tracker = contact.value("issues").toString(); } } if (object.contains("license")) { auto license = object.value("license"); if (license.isArray()) { for (auto l : license.toArray()) { if (l.isString()) { details.licenses.append(ModLicense(l.toString())); } else if (l.isObject()) { auto obj = l.toObject(); details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), obj.value("description").toString())); } } } else if (license.isString()) { details.licenses.append(ModLicense(license.toString())); } else if (license.isObject()) { auto obj = license.toObject(); details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), obj.value("description").toString())); } } if (object.contains("icon")) { auto icon = object.value("icon"); if (icon.isObject()) { auto obj = icon.toObject(); // take the largest icon int largest = 0; for (auto key : obj.keys()) { auto size = key.split('x').first().toInt(); if (size > largest) { largest = size; } } if (largest > 0) { auto key = QString::number(largest) + "x" + QString::number(largest); details.icon_file = obj.value(key).toString(); } else { // parsing the sizes failed // take the first for (auto i : obj) { details.icon_file = i.toString(); break; } } } else if (icon.isString()) { details.icon_file = icon.toString(); } } } return details; } // https://github.com/QuiltMC/rfcs/blob/master/specification/0002-quilt.mod.json.md ModDetails ReadQuiltModInfo(QByteArray contents) { QJsonParseError jsonError; QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); auto object = Json::requireObject(jsonDoc, "quilt.mod.json"); auto schemaVersion = Json::ensureInteger(object.value("schema_version"), 0, "Quilt schema_version"); ModDetails details; // https://github.com/QuiltMC/rfcs/blob/be6ba280d785395fefa90a43db48e5bfc1d15eb4/specification/0002-quilt.mod.json.md if (schemaVersion == 1) { auto modInfo = Json::requireObject(object.value("quilt_loader"), "Quilt mod info"); details.mod_id = Json::requireString(modInfo.value("id"), "Mod ID"); details.version = Json::requireString(modInfo.value("version"), "Mod version"); auto modMetadata = Json::ensureObject(modInfo.value("metadata")); details.name = Json::ensureString(modMetadata.value("name"), details.mod_id); details.description = Json::ensureString(modMetadata.value("description")); auto modContributors = Json::ensureObject(modMetadata.value("contributors")); // We don't really care about the role of a contributor here details.authors += modContributors.keys(); auto modContact = Json::ensureObject(modMetadata.value("contact")); if (modContact.contains("homepage")) { details.homeurl = Json::requireString(modContact.value("homepage")); } if (modContact.contains("issues")) { details.issue_tracker = Json::requireString(modContact.value("issues")); } if (modMetadata.contains("license")) { auto license = modMetadata.value("license"); if (license.isArray()) { for (auto l : license.toArray()) { if (l.isString()) { details.licenses.append(ModLicense(l.toString())); } else if (l.isObject()) { auto obj = l.toObject(); details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), obj.value("description").toString())); } } } else if (license.isString()) { details.licenses.append(ModLicense(license.toString())); } else if (license.isObject()) { auto obj = license.toObject(); details.licenses.append(ModLicense(obj.value("name").toString(), obj.value("id").toString(), obj.value("url").toString(), obj.value("description").toString())); } } if (modMetadata.contains("icon")) { auto icon = modMetadata.value("icon"); if (icon.isObject()) { auto obj = icon.toObject(); // take the largest icon int largest = 0; for (auto key : obj.keys()) { auto size = key.split('x').first().toInt(); if (size > largest) { largest = size; } } if (largest > 0) { auto key = QString::number(largest) + "x" + QString::number(largest); details.icon_file = obj.value(key).toString(); } else { // parsing the sizes failed // take the first for (auto i : obj) { details.icon_file = i.toString(); break; } } } else if (icon.isString()) { details.icon_file = icon.toString(); } } } return details; } ModDetails ReadForgeInfo(QByteArray contents) { ModDetails details; // Read the data details.name = "Minecraft Forge"; details.mod_id = "Forge"; details.homeurl = "http://www.minecraftforge.net/forum/"; INIFile ini; if (!ini.loadFile(contents)) return details; QString major = ini.get("forge.major.number", "0").toString(); QString minor = ini.get("forge.minor.number", "0").toString(); QString revision = ini.get("forge.revision.number", "0").toString(); QString build = ini.get("forge.build.number", "0").toString(); details.version = major + "." + minor + "." + revision + "." + build; return details; } ModDetails ReadLiteModInfo(QByteArray contents) { ModDetails details; QJsonParseError jsonError; QJsonDocument jsonDoc = QJsonDocument::fromJson(contents, &jsonError); auto object = jsonDoc.object(); if (object.contains("name")) { details.mod_id = details.name = object.value("name").toString(); } if (object.contains("version")) { details.version = object.value("version").toString(""); } else { details.version = object.value("revision").toString(""); } details.mcversion = object.value("mcversion").toString(); auto author = object.value("author").toString(); if (!author.isEmpty()) { details.authors.append(author); } details.description = object.value("description").toString(); details.homeurl = object.value("url").toString(); return details; } // https://git.sleeping.town/unascribed/NilLoader/src/commit/d7fc87b255fc31019ff90f80d45894927fac6efc/src/main/java/nilloader/api/NilMetadata.java#L64 ModDetails ReadNilModInfo(QByteArray contents, QString fname) { ModDetails details; QDCSS cssData = QDCSS(contents); auto name = cssData.get("@nilmod.name"); auto desc = cssData.get("@nilmod.description"); auto authors = cssData.get("@nilmod.authors"); if (name->has_value()) { details.name = name->value(); } if (desc->has_value()) { details.description = desc->value(); } if (authors->has_value()) { details.authors.append(authors->value()); } details.version = cssData.get("@nilmod.version")->value_or("?"); details.mod_id = fname.remove(".nilmod.css"); return details; } bool process(Mod& mod, ProcessingLevel level) { switch (mod.type()) { case ResourceType::FOLDER: return processFolder(mod, level); case ResourceType::ZIPFILE: return processZIP(mod, level); case ResourceType::LITEMOD: return processLitemod(mod); default: qWarning() << "Invalid type for mod parse task!"; return false; } } bool processZIP(Mod& mod, [[maybe_unused]] ProcessingLevel level) { ModDetails details; QuaZip zip(mod.fileinfo().filePath()); if (!zip.open(QuaZip::mdUnzip)) return false; QuaZipFile file(&zip); if (zip.setCurrentFile("META-INF/mods.toml")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadMCModTOML(file.readAll()); file.close(); // to replace ${file.jarVersion} with the actual version, as needed if (details.version == "${file.jarVersion}") { if (zip.setCurrentFile("META-INF/MANIFEST.MF")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } // quick and dirty line-by-line parser auto manifestLines = file.readAll().split('\n'); QString manifestVersion = ""; for (auto& line : manifestLines) { if (QString(line).startsWith("Implementation-Version: ")) { manifestVersion = QString(line).remove("Implementation-Version: "); break; } } // some mods use ${projectversion} in their build.gradle, causing this mess to show up in MANIFEST.MF // also keep with forge's behavior of setting the version to "NONE" if none is found if (manifestVersion.contains("task ':jar' property 'archiveVersion'") || manifestVersion == "") { manifestVersion = "NONE"; } details.version = manifestVersion; file.close(); } } zip.close(); mod.setDetails(details); return true; } else if (zip.setCurrentFile("mcmod.info")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadMCModInfo(file.readAll()); file.close(); zip.close(); mod.setDetails(details); return true; } else if (zip.setCurrentFile("quilt.mod.json")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadQuiltModInfo(file.readAll()); file.close(); zip.close(); mod.setDetails(details); return true; } else if (zip.setCurrentFile("fabric.mod.json")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadFabricModInfo(file.readAll()); file.close(); zip.close(); mod.setDetails(details); return true; } else if (zip.setCurrentFile("forgeversion.properties")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadForgeInfo(file.readAll()); file.close(); zip.close(); mod.setDetails(details); return true; } else if (zip.setCurrentFile("META-INF/nil/mappings.json")) { // nilloader uses the filename of the metadata file for the modid, so we can't know the exact filename // thankfully, there is a good file to use as a canary so we don't look for nil meta all the time QString foundNilMeta; for (auto& fname : zip.getFileNameList()) { // nilmods can shade nilloader to be able to run as a standalone agent - which includes nilloader's own meta file if (fname.endsWith(".nilmod.css") && fname != "nilloader.nilmod.css") { foundNilMeta = fname; break; } } if (zip.setCurrentFile(foundNilMeta)) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadNilModInfo(file.readAll(), foundNilMeta); file.close(); zip.close(); mod.setDetails(details); return true; } } zip.close(); return false; // no valid mod found in archive } bool processFolder(Mod& mod, [[maybe_unused]] ProcessingLevel level) { ModDetails details; QFileInfo mcmod_info(FS::PathCombine(mod.fileinfo().filePath(), "mcmod.info")); if (mcmod_info.exists() && mcmod_info.isFile()) { QFile mcmod(mcmod_info.filePath()); if (!mcmod.open(QIODevice::ReadOnly)) return false; auto data = mcmod.readAll(); if (data.isEmpty() || data.isNull()) return false; details = ReadMCModInfo(data); mod.setDetails(details); return true; } return false; // no valid mcmod.info file found } bool processLitemod(Mod& mod, [[maybe_unused]] ProcessingLevel level) { ModDetails details; QuaZip zip(mod.fileinfo().filePath()); if (!zip.open(QuaZip::mdUnzip)) return false; QuaZipFile file(&zip); if (zip.setCurrentFile("litemod.json")) { if (!file.open(QIODevice::ReadOnly)) { zip.close(); return false; } details = ReadLiteModInfo(file.readAll()); file.close(); mod.setDetails(details); return true; } zip.close(); return false; // no valid litemod.json found in archive } /** Checks whether a file is valid as a mod or not. */ bool validate(QFileInfo file) { Mod mod{ file }; return ModUtils::process(mod, ProcessingLevel::BasicInfoOnly) && mod.valid(); } bool processIconPNG(const Mod& mod, QByteArray&& raw_data) { auto img = QImage::fromData(raw_data); if (!img.isNull()) { mod.setIcon(img); } else { qWarning() << "Failed to parse mod logo:" << mod.iconPath() << "from" << mod.name(); return false; } return true; } bool loadIconFile(const Mod& mod) { if (mod.iconPath().isEmpty()) { qWarning() << "No Iconfile set, be sure to parse the mod first"; return false; } auto png_invalid = [&mod]() { qWarning() << "Mod at" << mod.fileinfo().filePath() << "does not have a valid icon"; return false; }; switch (mod.type()) { case ResourceType::FOLDER: { QFileInfo icon_info(FS::PathCombine(mod.fileinfo().filePath(), mod.iconPath())); if (icon_info.exists() && icon_info.isFile()) { QFile icon(icon_info.filePath()); if (!icon.open(QIODevice::ReadOnly)) return false; auto data = icon.readAll(); bool icon_result = ModUtils::processIconPNG(mod, std::move(data)); icon.close(); if (!icon_result) { return png_invalid(); // icon invalid } } return false; } case ResourceType::ZIPFILE: { QuaZip zip(mod.fileinfo().filePath()); if (!zip.open(QuaZip::mdUnzip)) return false; QuaZipFile file(&zip); if (zip.setCurrentFile(mod.iconPath())) { if (!file.open(QIODevice::ReadOnly)) { qCritical() << "Failed to open file in zip."; zip.close(); return png_invalid(); } auto data = file.readAll(); bool icon_result = ModUtils::processIconPNG(mod, std::move(data)); file.close(); if (!icon_result) { return png_invalid(); // icon png invalid } } else { return png_invalid(); // could not set icon as current file. } return false; } case ResourceType::LITEMOD: { return false; // can lightmods even have icons? } default: qWarning() << "Invalid type for mod, can not load icon."; return false; } } } // namespace ModUtils LocalModParseTask::LocalModParseTask(int token, ResourceType type, const QFileInfo& modFile) : Task(nullptr, false), m_token(token), m_type(type), m_modFile(modFile), m_result(new Result()) {} bool LocalModParseTask::abort() { m_aborted.store(true); return true; } void LocalModParseTask::executeTask() { Mod mod{ m_modFile }; ModUtils::process(mod, ModUtils::ProcessingLevel::Full); m_result->details = mod.details(); if (m_aborted) emit finished(); else emitSucceeded(); }