/* SPDX-FileCopyrightText: 2026 Project Tick * SPDX-FileContributor: Project Tick * SPDX-License-Identifier: GPL-3.0-or-later * * MeshMC - A Custom Launcher for Minecraft * Copyright (C) 2026 Project Tick * * 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, either version 3 of the License, or * (at your option) any later version. * * 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 . * * This file incorporates work covered by the following copyright and * permission notice: * * Copyright 2013-2021 MultiMC Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #include "InstanceImportTask.h" #include "BaseInstance.h" #include "FileSystem.h" #include "Application.h" #include "MMCZip.h" #include "NullInstance.h" #include "settings/INISettingsObject.h" #include "icons/IconUtils.h" #include #include // FIXME: this does not belong here, it's Minecraft/Flame specific #include "minecraft/MinecraftInstance.h" #include "minecraft/PackProfile.h" #include "modplatform/flame/FileResolvingTask.h" #include "modplatform/flame/PackManifest.h" #include "modplatform/modrinth/ModrinthPackManifest.h" #include "Json.h" #include "modplatform/technic/TechnicPackProcessor.h" #include "icons/IconList.h" #include "Application.h" #include "ui/dialogs/BlockedModsDialog.h" #include #include InstanceImportTask::InstanceImportTask(const QUrl sourceUrl) { m_sourceUrl = sourceUrl; } void InstanceImportTask::executeTask() { if (m_sourceUrl.isLocalFile()) { m_archivePath = m_sourceUrl.toLocalFile(); processZipPack(); } else { setStatus(tr("Downloading modpack:\n%1").arg(m_sourceUrl.toString())); m_downloadRequired = true; const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path(); auto entry = APPLICATION->metacache()->resolveEntry("general", path); entry->setStale(true); m_filesNetJob = new NetJob(tr("Modpack download"), APPLICATION->network()); m_filesNetJob->addNetAction( Net::Download::makeCached(m_sourceUrl, entry)); m_archivePath = entry->getFullPath(); auto job = m_filesNetJob.get(); connect(job, &NetJob::succeeded, this, &InstanceImportTask::downloadSucceeded); connect(job, &NetJob::progress, this, &InstanceImportTask::downloadProgressChanged); connect(job, &NetJob::failed, this, &InstanceImportTask::downloadFailed); m_filesNetJob->start(); } } void InstanceImportTask::downloadSucceeded() { processZipPack(); m_filesNetJob.reset(); } void InstanceImportTask::downloadFailed(QString reason) { emitFailed(reason); m_filesNetJob.reset(); } void InstanceImportTask::downloadProgressChanged(qint64 current, qint64 total) { setProgress(current / 2, total); } void InstanceImportTask::processZipPack() { setStatus(tr("Extracting modpack")); QDir extractDir(m_stagingPath); qDebug() << "Attempting to create instance from" << m_archivePath; // find relevant files in the zip QStringList blacklist = {"instance.cfg", "manifest.json", "modrinth.index.json"}; QString mmcFound = MMCZip::findFolderOfFileInZip(m_archivePath, "instance.cfg"); bool technicFound = MMCZip::entryExists(m_archivePath, "bin/modpack.jar") || MMCZip::entryExists(m_archivePath, "bin/version.json"); QString flameFound = MMCZip::findFolderOfFileInZip(m_archivePath, "manifest.json"); QString modrinthFound = MMCZip::findFolderOfFileInZip(m_archivePath, "modrinth.index.json"); QString root; if (!mmcFound.isNull()) { // process as MeshMC instance/pack qDebug() << "MeshMC:" << mmcFound; root = mmcFound; m_modpackType = ModpackType::MeshMC; } else if (!modrinthFound.isNull()) { // process as Modrinth pack qDebug() << "Modrinth:" << modrinthFound; root = modrinthFound; m_modpackType = ModpackType::Modrinth; } else if (technicFound) { // process as Technic pack qDebug() << "Technic:" << technicFound; extractDir.mkpath(".minecraft"); extractDir.cd(".minecraft"); m_modpackType = ModpackType::Technic; } else if (!flameFound.isNull()) { // process as Flame pack qDebug() << "Flame:" << flameFound; root = flameFound; m_modpackType = ModpackType::Flame; } if (m_modpackType == ModpackType::Unknown) { emitFailed(tr("Archive does not contain a recognized modpack type.")); return; } // make sure we extract just the pack QString archivePath = m_archivePath; m_extractFuture = QtConcurrent::run(QThreadPool::globalInstance(), MMCZip::extractSubDir, archivePath, root, extractDir.absolutePath()); connect(&m_extractFutureWatcher, &QFutureWatcher::finished, this, &InstanceImportTask::extractFinished); connect(&m_extractFutureWatcher, &QFutureWatcher::canceled, this, &InstanceImportTask::extractAborted); m_extractFutureWatcher.setFuture(m_extractFuture); } void InstanceImportTask::extractFinished() { if (!m_extractFuture.result()) { emitFailed(tr("Failed to extract modpack")); return; } QDir extractDir(m_stagingPath); qDebug() << "Fixing permissions for extracted pack files..."; QDirIterator it(extractDir, QDirIterator::Subdirectories); while (it.hasNext()) { auto filepath = it.next(); QFileInfo file(filepath); auto permissions = QFile::permissions(filepath); auto origPermissions = permissions; if (file.isDir()) { // Folder +rwx for current user permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser | QFileDevice::Permission::ExeUser; } else { // File +rw for current user permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser; } if (origPermissions != permissions) { if (!QFile::setPermissions(filepath, permissions)) { logWarning( tr("Could not fix permissions for %1").arg(filepath)); } else { qDebug() << "Fixed" << filepath; } } } switch (m_modpackType) { case ModpackType::Flame: processFlame(); return; case ModpackType::Modrinth: processModrinth(); return; case ModpackType::MeshMC: processMeshMC(); return; case ModpackType::Technic: processTechnic(); return; case ModpackType::Unknown: emitFailed( tr("Archive does not contain a recognized modpack type.")); return; } } void InstanceImportTask::extractAborted() { emitFailed(tr("Instance import has been aborted.")); return; } void InstanceImportTask::processFlame() { Flame::Manifest pack; try { QString configPath = FS::PathCombine(m_stagingPath, "manifest.json"); Flame::loadManifest(pack, configPath); if (!QFile::remove(configPath)) { qWarning() << "Could not remove manifest.json from staging"; } } catch (const JSONValidationError& e) { emitFailed(tr("Could not understand pack manifest:\n") + e.cause()); return; } if (!pack.overrides.isEmpty()) { QString overridePath = FS::PathCombine(m_stagingPath, pack.overrides); if (QFile::exists(overridePath)) { QString mcPath = FS::PathCombine(m_stagingPath, "minecraft"); if (!QFile::rename(overridePath, mcPath)) { emitFailed(tr("Could not rename the overrides folder:\n") + pack.overrides); return; } } else { logWarning(tr("The specified overrides folder (%1) is missing. " "Maybe the modpack was already used before?") .arg(pack.overrides)); } } configureFlameInstance(pack); m_modIdResolver = new Flame::FileResolvingTask(APPLICATION->network(), pack); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, this, &InstanceImportTask::onFlameFileResolutionSucceeded); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, [&](QString reason) { m_modIdResolver.reset(); emitFailed(tr("Unable to resolve mod IDs:\n") + reason); }); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::progress, [&](qint64 current, qint64 total) { setProgress(current, total); }); connect(m_modIdResolver.get(), &Flame::FileResolvingTask::status, [&](QString status) { setStatus(status); }); m_modIdResolver->start(); } static QString selectFlameIcon(const QString& instIcon, const Flame::Manifest& pack) { if (instIcon != "default") return instIcon; if (pack.name.contains("Direwolf20")) return "steve"; if (pack.name.contains("FTB") || pack.name.contains("Feed The Beast")) return "ftb_logo"; // default to something other than the MeshMC default to distinguish these return "flame"; } void InstanceImportTask::configureFlameInstance(Flame::Manifest& pack) { const static QMap forgemap = {{"1.2.5", "3.4.9.171"}, {"1.4.2", "6.0.1.355"}, {"1.4.7", "6.6.2.534"}, {"1.5.2", "7.8.1.737"}}; struct FlameLoaderMapping { const char* prefix; QString version; const char* componentId; }; FlameLoaderMapping loaderMappings[] = { {"forge-", {}, "net.minecraftforge"}, {"fabric-", {}, "net.fabricmc.fabric-loader"}, {"neoforge-", {}, "net.neoforged"}, {"quilt-", {}, "org.quiltmc.quilt-loader"}, }; for (auto& loader : pack.minecraft.modLoaders) { auto id = loader.id; bool matched = false; for (auto& mapping : loaderMappings) { if (id.startsWith(mapping.prefix)) { id.remove(mapping.prefix); mapping.version = id; matched = true; break; } } if (!matched) { logWarning(tr("Unknown mod loader in manifest: %1").arg(id)); } } QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); auto instanceSettings = std::make_shared(configPath); instanceSettings->registerSetting("InstanceType", "Legacy"); instanceSettings->set("InstanceType", "OneSix"); MinecraftInstance instance(m_globalSettings, instanceSettings, m_stagingPath); auto mcVersion = pack.minecraft.version; // Hack to correct some 'special sauce'... if (mcVersion.endsWith('.')) { mcVersion.remove(QRegularExpression("[.]+$")); logWarning(tr("Mysterious trailing dots removed from Minecraft version " "while importing pack.")); } auto components = instance.getPackProfile(); components->buildingFromScratch(); components->setComponentVersion("net.minecraft", mcVersion, true); // Handle Forge "recommended" version mapping auto& forgeMapping = loaderMappings[0]; if (forgeMapping.version == "recommended") { if (forgemap.contains(mcVersion)) { forgeMapping.version = forgemap[mcVersion]; } else { logWarning( tr("Could not map recommended forge version for Minecraft %1") .arg(mcVersion)); } } for (const auto& mapping : loaderMappings) { if (!mapping.version.isEmpty()) { components->setComponentVersion(mapping.componentId, mapping.version); } } instance.setIconKey(selectFlameIcon(m_instIcon, pack)); QString jarmodsPath = FS::PathCombine(m_stagingPath, "minecraft", "jarmods"); QFileInfo jarmodsInfo(jarmodsPath); if (jarmodsInfo.isDir()) { // install all the jar mods qDebug() << "Found jarmods:"; QDir jarmodsDir(jarmodsPath); QStringList jarMods; for (auto info : jarmodsDir.entryInfoList(QDir::NoDotAndDotDot | QDir::Files)) { qDebug() << info.fileName(); jarMods.push_back(info.absoluteFilePath()); } auto profile = instance.getPackProfile(); profile->installJarMods(jarMods); // nuke the original files FS::deletePath(jarmodsPath); } instance.setName(m_instName); } void InstanceImportTask::onFlameFileResolutionSucceeded() { auto results = m_modIdResolver->getResults(); m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); // Collect restricted mods that need browser download QList blockedMods; for (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)); [[fallthrough]]; } case Flame::File::Type::SingleFile: [[fallthrough]]; case Flame::File::Type::Mod: { bool isBlocked = !result.resolved || !result.url.isValid() || result.url.isEmpty(); if (isBlocked && !result.fileName.isEmpty()) { blockedMods.append({result.projectId, result.fileId, result.fileName, path, false}); break; } if (isBlocked) { logWarning(tr("Skipping mod %1 (project %2) - no download " "URL and no filename available") .arg(result.fileId) .arg(result.projectId)); break; } 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: [[fallthrough]]; case Flame::File::Type::Ctoc: [[fallthrough]]; case Flame::File::Type::Unknown: logWarning(tr("Unrecognized/unhandled PackageType for: %1") .arg(relpath)); break; } } // Handle restricted mods via dialog if (!blockedMods.isEmpty()) { BlockedModsDialog dlg(nullptr, tr("Restricted Mods"), tr("The following mods have restricted downloads " "and are not available through the API.\n" "Click the Download button next to each mod " "to open its download page in your browser.\n" "Once all files appear in your Downloads " "folder, click Continue."), blockedMods); if (dlg.exec() == QDialog::Accepted) { QString downloadDir = QStandardPaths::writableLocation( QStandardPaths::DownloadLocation); for (const auto& mod : blockedMods) { if (mod.found) { QString srcPath = FS::PathCombine(downloadDir, mod.fileName); QFileInfo targetInfo(mod.targetPath); QDir().mkpath(targetInfo.absolutePath()); if (QFile::copy(srcPath, mod.targetPath)) { qDebug() << "Copied restricted mod:" << mod.fileName; } else { logWarning(tr("Failed to copy %1 from downloads folder") .arg(mod.fileName)); } } } } else { logWarning(tr("User cancelled restricted mod downloads - %1 mod(s) " "will be missing") .arg(blockedMods.size())); } } m_modIdResolver.reset(); connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() { emitSucceeded(); m_filesNetJob.reset(); }); connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) { emitFailed(reason); m_filesNetJob.reset(); }); connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) { setProgress(current, total); }); setStatus(tr("Downloading mods...")); m_filesNetJob->start(); } static void applyModrinthOverrides(const QString& stagingPath, const QString& mcPath) { QString overridePath = FS::PathCombine(stagingPath, "overrides"); QString clientOverridePath = FS::PathCombine(stagingPath, "client-overrides"); if (QFile::exists(overridePath)) { if (!FS::copy(overridePath, mcPath)()) { qWarning() << "Could not apply overrides from the modpack."; } FS::deletePath(overridePath); } if (QFile::exists(clientOverridePath)) { if (!FS::copy(clientOverridePath, mcPath)()) { qWarning() << "Could not apply client-overrides from the modpack."; } FS::deletePath(clientOverridePath); } } void InstanceImportTask::processModrinth() { Modrinth::Manifest pack; try { QString configPath = FS::PathCombine(m_stagingPath, "modrinth.index.json"); Modrinth::loadManifest(pack, configPath); if (!QFile::remove(configPath)) { qWarning() << "Could not remove modrinth.index.json from staging"; } } catch (const JSONValidationError& e) { emitFailed(tr("Could not understand Modrinth modpack manifest:\n") + e.cause()); return; } // Move overrides folder contents to minecraft directory QString mcPath = FS::PathCombine(m_stagingPath, "minecraft"); QDir mcDir(mcPath); if (!mcDir.exists()) { mcDir.mkpath("."); } applyModrinthOverrides(m_stagingPath, mcPath); // Create instance config QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); auto instanceSettings = std::make_shared(configPath); instanceSettings->registerSetting("InstanceType", "Legacy"); instanceSettings->set("InstanceType", "OneSix"); MinecraftInstance instance(m_globalSettings, instanceSettings, m_stagingPath); auto components = instance.getPackProfile(); components->buildingFromScratch(); struct ModLoaderMapping { const QString& version; const char* componentId; }; const ModLoaderMapping loaders[] = { {pack.forgeVersion, "net.minecraftforge"}, {pack.fabricVersion, "net.fabricmc.fabric-loader"}, {pack.quiltVersion, "org.quiltmc.quilt-loader"}, {pack.neoForgeVersion, "net.neoforged"}, }; if (!pack.minecraftVersion.isEmpty()) { components->setComponentVersion("net.minecraft", pack.minecraftVersion, true); } for (const auto& loader : loaders) { if (!loader.version.isEmpty()) { components->setComponentVersion(loader.componentId, loader.version); } } if (m_instIcon != "default") { instance.setIconKey(m_instIcon); } else { instance.setIconKey("modrinth"); } instance.setName(m_instName); // Download all mod files m_filesNetJob = new NetJob(tr("Modrinth mod download"), APPLICATION->network()); auto minecraftDir = FS::PathCombine(m_stagingPath, "minecraft"); auto canonicalBase = QDir(minecraftDir).canonicalPath(); for (auto& file : pack.files) { if (file.path.contains("..") || QDir::isAbsolutePath(file.path)) { qWarning() << "Skipping potentially malicious file path:" << file.path; continue; } auto path = FS::PathCombine(minecraftDir, file.path); auto canonicalDir = QFileInfo(path).absolutePath(); if (!canonicalDir.startsWith(canonicalBase)) { qWarning() << "Skipping file path that escapes staging directory:" << file.path; continue; } if (!file.downloadUrl.isValid() || file.downloadUrl.isEmpty()) { logWarning( tr("Skipping file with no download URL: %1").arg(file.path)); continue; } qDebug() << "Will download" << file.downloadUrl << "to" << path; auto dl = Net::Download::makeFile(file.downloadUrl, path); m_filesNetJob->addNetAction(dl); } connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() { emitSucceeded(); m_filesNetJob.reset(); }); connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) { emitFailed(reason); m_filesNetJob.reset(); }); connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) { setProgress(current, total); }); setStatus(tr("Downloading mods...")); m_filesNetJob->start(); } void InstanceImportTask::processTechnic() { shared_qobject_ptr packProcessor = new Technic::TechnicPackProcessor(); connect(packProcessor.get(), &Technic::TechnicPackProcessor::succeeded, this, &InstanceImportTask::emitSucceeded); connect(packProcessor.get(), &Technic::TechnicPackProcessor::failed, this, &InstanceImportTask::emitFailed); packProcessor->run(m_globalSettings, m_instName, m_instIcon, m_stagingPath); } void InstanceImportTask::processMeshMC() { QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); auto instanceSettings = std::make_shared(configPath); instanceSettings->registerSetting("InstanceType", "Legacy"); NullInstance instance(m_globalSettings, instanceSettings, m_stagingPath); // reset time played on import... because packs. instance.resetTimePlayed(); // Set a new name for the imported instance instance.setName(m_instName); // Use user-specified icon if available, otherwise import from the pack if (m_instIcon != "default") { instance.setIconKey(m_instIcon); } else { m_instIcon = instance.iconKey(); auto importIconPath = IconUtils::findBestIconIn(instance.instanceRoot(), m_instIcon); if (!importIconPath.isNull() && QFile::exists(importIconPath)) { // import icon auto iconList = APPLICATION->icons(); if (iconList->iconFileExists(m_instIcon)) { iconList->deleteIcon(m_instIcon); } iconList->installIcons({importIconPath}); } } emitSucceeded(); }