summaryrefslogtreecommitdiff
path: root/meshmc/launcher/minecraft/mod
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:45:07 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-02 18:45:07 +0300
commit31b9a8949ed0a288143e23bf739f2eb64fdc63be (patch)
tree8a984fa143c38fccad461a77792d6864f3e82cd3 /meshmc/launcher/minecraft/mod
parent934382c8a1ce738589dee9ee0f14e1cec812770e (diff)
parentfad6a1066616b69d7f5fef01178efdf014c59537 (diff)
downloadProject-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.tar.gz
Project-Tick-31b9a8949ed0a288143e23bf739f2eb64fdc63be.zip
Add 'meshmc/' from commit 'fad6a1066616b69d7f5fef01178efdf014c59537'
git-subtree-dir: meshmc git-subtree-mainline: 934382c8a1ce738589dee9ee0f14e1cec812770e git-subtree-split: fad6a1066616b69d7f5fef01178efdf014c59537
Diffstat (limited to 'meshmc/launcher/minecraft/mod')
-rw-r--r--meshmc/launcher/minecraft/mod/LocalModParseTask.cpp423
-rw-r--r--meshmc/launcher/minecraft/mod/LocalModParseTask.h59
-rw-r--r--meshmc/launcher/minecraft/mod/Mod.cpp158
-rw-r--r--meshmc/launcher/minecraft/mod/Mod.h140
-rw-r--r--meshmc/launcher/minecraft/mod/ModDetails.h37
-rw-r--r--meshmc/launcher/minecraft/mod/ModFolderLoadTask.cpp38
-rw-r--r--meshmc/launcher/minecraft/mod/ModFolderLoadTask.h52
-rw-r--r--meshmc/launcher/minecraft/mod/ModFolderModel.cpp573
-rw-r--r--meshmc/launcher/minecraft/mod/ModFolderModel.h169
-rw-r--r--meshmc/launcher/minecraft/mod/ModFolderModel_test.cpp71
-rw-r--r--meshmc/launcher/minecraft/mod/ResourcePackFolderModel.cpp50
-rw-r--r--meshmc/launcher/minecraft/mod/ResourcePackFolderModel.h35
-rw-r--r--meshmc/launcher/minecraft/mod/TexturePackFolderModel.cpp50
-rw-r--r--meshmc/launcher/minecraft/mod/TexturePackFolderModel.h35
14 files changed, 1890 insertions, 0 deletions
diff --git a/meshmc/launcher/minecraft/mod/LocalModParseTask.cpp b/meshmc/launcher/minecraft/mod/LocalModParseTask.cpp
new file mode 100644
index 0000000000..09ff5f20ae
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/LocalModParseTask.cpp
@@ -0,0 +1,423 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "LocalModParseTask.h"
+
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QJsonValue>
+#include <toml.h>
+
+#include "MMCZip.h"
+
+#include "settings/INIFile.h"
+#include "FileSystem.h"
+
+namespace
+{
+
+ // NEW format
+ // https://github.com/MinecraftForge/FML/wiki/FML-mod-information-file/6f62b37cea040daf350dc253eae6326dd9c822c3
+
+ // OLD format:
+ // https://github.com/MinecraftForge/FML/wiki/FML-mod-information-file/5bf6a2d05145ec79387acc0d45c958642fb049fc
+ std::shared_ptr<ModDetails> ReadMCModInfo(QByteArray contents)
+ {
+ auto getInfoFromArray =
+ [&](QJsonArray arr) -> std::shared_ptr<ModDetails> {
+ if (!arr.at(0).isObject()) {
+ return nullptr;
+ }
+ std::shared_ptr<ModDetails> details =
+ std::make_shared<ModDetails>();
+ 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();
+ details->updateurl = firstObj.value("updateUrl").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();
+ }
+
+ for (auto author : authors) {
+ details->authors.append(author.toString());
+ }
+ details->credits = firstObj.value("credits").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 = val.toDouble();
+ if (version != 2) {
+ qCritical() << "BAD stuff happened to mod json:";
+ qCritical() << contents;
+ return nullptr;
+ }
+ auto arrVal = jsonDoc.object().value("modlist");
+ if (arrVal.isUndefined()) {
+ arrVal = jsonDoc.object().value("modList");
+ }
+ if (arrVal.isArray()) {
+ return getInfoFromArray(arrVal.toArray());
+ }
+ }
+ return nullptr;
+ }
+
+ // https://github.com/MinecraftForge/Documentation/blob/5ab4ba6cf9abc0ac4c0abd96ad187461aefd72af/docs/gettingstarted/structuring.md
+ std::shared_ptr<ModDetails> ReadMCModTOML(QByteArray contents)
+ {
+ std::shared_ptr<ModDetails> details = std::make_shared<ModDetails>();
+
+ char errbuf[200];
+ // top-level table
+ toml_table_t* tomlData =
+ toml_parse(contents.data(), errbuf, sizeof(errbuf));
+
+ if (!tomlData) {
+ return nullptr;
+ }
+
+ // array defined by [[mods]]
+ toml_array_t* tomlModsArr = toml_array_in(tomlData, "mods");
+ if (!tomlModsArr) {
+ qWarning() << "Corrupted mods.toml? Couldn't find [[mods]] array!";
+ return nullptr;
+ }
+
+ // we only really care about the first element, since multiple mods in
+ // one file is not supported by us at the moment
+ toml_table_t* tomlModsTable0 = toml_table_at(tomlModsArr, 0);
+ if (!tomlModsTable0) {
+ qWarning() << "Corrupted mods.toml? [[mods]] didn't have an "
+ "element at index 0!";
+ return nullptr;
+ }
+
+ // mandatory properties - always in [[mods]]
+ toml_datum_t modIdDatum = toml_string_in(tomlModsTable0, "modId");
+ if (modIdDatum.ok) {
+ details->mod_id = modIdDatum.u.s;
+ // library says this is required for strings
+ free(modIdDatum.u.s);
+ }
+ toml_datum_t versionDatum = toml_string_in(tomlModsTable0, "version");
+ if (versionDatum.ok) {
+ details->version = versionDatum.u.s;
+ free(versionDatum.u.s);
+ }
+ toml_datum_t displayNameDatum =
+ toml_string_in(tomlModsTable0, "displayName");
+ if (displayNameDatum.ok) {
+ details->name = displayNameDatum.u.s;
+ free(displayNameDatum.u.s);
+ }
+ toml_datum_t descriptionDatum =
+ toml_string_in(tomlModsTable0, "description");
+ if (descriptionDatum.ok) {
+ details->description = descriptionDatum.u.s;
+ free(descriptionDatum.u.s);
+ }
+
+ // optional properties - can be in the root table or [[mods]]
+ toml_datum_t authorsDatum = toml_string_in(tomlData, "authors");
+ QString authors = "";
+ if (authorsDatum.ok) {
+ authors = authorsDatum.u.s;
+ free(authorsDatum.u.s);
+ } else {
+ authorsDatum = toml_string_in(tomlModsTable0, "authors");
+ if (authorsDatum.ok) {
+ authors = authorsDatum.u.s;
+ free(authorsDatum.u.s);
+ }
+ }
+ if (!authors.isEmpty()) {
+ // author information is stored as a string now, not a list
+ details->authors.append(authors);
+ }
+ // is credits even used anywhere? including this for completion/parity
+ // with old data version
+ toml_datum_t creditsDatum = toml_string_in(tomlData, "credits");
+ QString credits = "";
+ if (creditsDatum.ok) {
+ authors = creditsDatum.u.s;
+ free(creditsDatum.u.s);
+ } else {
+ creditsDatum = toml_string_in(tomlModsTable0, "credits");
+ if (creditsDatum.ok) {
+ credits = creditsDatum.u.s;
+ free(creditsDatum.u.s);
+ }
+ }
+ details->credits = credits;
+ toml_datum_t homeurlDatum = toml_string_in(tomlData, "displayURL");
+ QString homeurl = "";
+ if (homeurlDatum.ok) {
+ homeurl = homeurlDatum.u.s;
+ free(homeurlDatum.u.s);
+ } else {
+ homeurlDatum = toml_string_in(tomlModsTable0, "displayURL");
+ if (homeurlDatum.ok) {
+ homeurl = homeurlDatum.u.s;
+ free(homeurlDatum.u.s);
+ }
+ }
+ if (!homeurl.isEmpty()) {
+ // fix up url.
+ if (!homeurl.startsWith("http://") &&
+ !homeurl.startsWith("https://") &&
+ !homeurl.startsWith("ftp://")) {
+ homeurl.prepend("http://");
+ }
+ }
+ details->homeurl = homeurl;
+
+ // this seems to be recursive, so it should free everything
+ toml_free(tomlData);
+
+ return details;
+ }
+
+ // https://fabricmc.net/wiki/documentation:fabric_mod_json
+ std::shared_ptr<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;
+
+ std::shared_ptr<ModDetails> details = std::make_shared<ModDetails>();
+
+ 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();
+ }
+ }
+ }
+ return details;
+ }
+
+ std::shared_ptr<ModDetails> ReadForgeInfo(QByteArray contents)
+ {
+ std::shared_ptr<ModDetails> details = std::make_shared<ModDetails>();
+ // 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;
+ }
+
+ std::shared_ptr<ModDetails> ReadLiteModInfo(QByteArray contents)
+ {
+ std::shared_ptr<ModDetails> details = std::make_shared<ModDetails>();
+ 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;
+ }
+
+} // namespace
+
+LocalModParseTask::LocalModParseTask(int token, Mod::ModType type,
+ const QFileInfo& modFile)
+ : m_token(token), m_type(type), m_modFile(modFile), m_result(new Result())
+{
+}
+
+void LocalModParseTask::processAsZip()
+{
+ QString zipPath = m_modFile.filePath();
+
+ QByteArray modsToml =
+ MMCZip::readFileFromZip(zipPath, "META-INF/mods.toml");
+ if (!modsToml.isEmpty()) {
+ m_result->details = ReadMCModTOML(modsToml);
+
+ // to replace ${file.jarVersion} with the actual version, as needed
+ if (m_result->details &&
+ m_result->details->version == "${file.jarVersion}") {
+ QByteArray manifestData =
+ MMCZip::readFileFromZip(zipPath, "META-INF/MANIFEST.MF");
+ if (!manifestData.isEmpty()) {
+ // quick and dirty line-by-line parser
+ auto manifestLines = manifestData.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";
+ }
+
+ m_result->details->version = manifestVersion;
+ }
+ }
+ return;
+ }
+
+ QByteArray mcmodInfo = MMCZip::readFileFromZip(zipPath, "mcmod.info");
+ if (!mcmodInfo.isEmpty()) {
+ m_result->details = ReadMCModInfo(mcmodInfo);
+ return;
+ }
+
+ QByteArray fabricModJson =
+ MMCZip::readFileFromZip(zipPath, "fabric.mod.json");
+ if (!fabricModJson.isEmpty()) {
+ m_result->details = ReadFabricModInfo(fabricModJson);
+ return;
+ }
+
+ QByteArray forgeVersionProps =
+ MMCZip::readFileFromZip(zipPath, "forgeversion.properties");
+ if (!forgeVersionProps.isEmpty()) {
+ m_result->details = ReadForgeInfo(forgeVersionProps);
+ return;
+ }
+}
+
+void LocalModParseTask::processAsFolder()
+{
+ QFileInfo mcmod_info(FS::PathCombine(m_modFile.filePath(), "mcmod.info"));
+ if (mcmod_info.isFile()) {
+ QFile mcmod(mcmod_info.filePath());
+ if (!mcmod.open(QIODevice::ReadOnly))
+ return;
+ auto data = mcmod.readAll();
+ if (data.isEmpty() || data.isNull())
+ return;
+ m_result->details = ReadMCModInfo(data);
+ }
+}
+
+void LocalModParseTask::processAsLitemod()
+{
+ QByteArray litemodJson =
+ MMCZip::readFileFromZip(m_modFile.filePath(), "litemod.json");
+ if (!litemodJson.isEmpty()) {
+ m_result->details = ReadLiteModInfo(litemodJson);
+ }
+}
+
+void LocalModParseTask::run()
+{
+ switch (m_type) {
+ case Mod::MOD_ZIPFILE:
+ processAsZip();
+ break;
+ case Mod::MOD_FOLDER:
+ processAsFolder();
+ break;
+ case Mod::MOD_LITEMOD:
+ processAsLitemod();
+ break;
+ default:
+ break;
+ }
+ emit finished(m_token);
+}
diff --git a/meshmc/launcher/minecraft/mod/LocalModParseTask.h b/meshmc/launcher/minecraft/mod/LocalModParseTask.h
new file mode 100644
index 0000000000..bd85bbc27b
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/LocalModParseTask.h
@@ -0,0 +1,59 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QRunnable>
+#include <QDebug>
+#include <QObject>
+#include "Mod.h"
+#include "ModDetails.h"
+
+class LocalModParseTask : public QObject, public QRunnable
+{
+ Q_OBJECT
+ public:
+ struct Result {
+ QString id;
+ std::shared_ptr<ModDetails> details;
+ };
+ using ResultPtr = std::shared_ptr<Result>;
+ ResultPtr result() const
+ {
+ return m_result;
+ }
+
+ LocalModParseTask(int token, Mod::ModType type, const QFileInfo& modFile);
+ void run();
+
+ signals:
+ void finished(int token);
+
+ private:
+ void processAsZip();
+ void processAsFolder();
+ void processAsLitemod();
+
+ private:
+ int m_token;
+ Mod::ModType m_type;
+ QFileInfo m_modFile;
+ ResultPtr m_result;
+};
diff --git a/meshmc/launcher/minecraft/mod/Mod.cpp b/meshmc/launcher/minecraft/mod/Mod.cpp
new file mode 100644
index 0000000000..dae36f968e
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/Mod.cpp
@@ -0,0 +1,158 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ *
+ * 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 <QDir>
+#include <QString>
+
+#include "Mod.h"
+#include <QDebug>
+#include <FileSystem.h>
+
+namespace
+{
+
+ ModDetails invalidDetails;
+
+}
+
+Mod::Mod(const QFileInfo& file)
+{
+ repath(file);
+ m_changedDateTime = file.lastModified();
+}
+
+void Mod::repath(const QFileInfo& file)
+{
+ m_file = file;
+ QString name_base = file.fileName();
+
+ m_type = Mod::MOD_UNKNOWN;
+
+ m_mmc_id = name_base;
+
+ if (m_file.isDir()) {
+ m_type = MOD_FOLDER;
+ m_name = name_base;
+ } else if (m_file.isFile()) {
+ if (name_base.endsWith(".disabled")) {
+ m_enabled = false;
+ name_base.chop(9);
+ } else {
+ m_enabled = true;
+ }
+ if (name_base.endsWith(".zip") || name_base.endsWith(".jar")) {
+ m_type = MOD_ZIPFILE;
+ name_base.chop(4);
+ } else if (name_base.endsWith(".litemod")) {
+ m_type = MOD_LITEMOD;
+ name_base.chop(8);
+ } else {
+ m_type = MOD_SINGLEFILE;
+ }
+ m_name = name_base;
+ }
+}
+
+bool Mod::enable(bool value)
+{
+ if (m_type == Mod::MOD_UNKNOWN || m_type == Mod::MOD_FOLDER)
+ return false;
+
+ if (m_enabled == value)
+ return false;
+
+ QString path = m_file.absoluteFilePath();
+ if (value) {
+ QFile foo(path);
+ if (!path.endsWith(".disabled"))
+ return false;
+ path.chop(9);
+ if (!foo.rename(path))
+ return false;
+ } else {
+ QFile foo(path);
+ path += ".disabled";
+ if (!foo.rename(path))
+ return false;
+ }
+ repath(QFileInfo(path));
+ m_enabled = value;
+ return true;
+}
+
+bool Mod::destroy()
+{
+ m_type = MOD_UNKNOWN;
+ return FS::deletePath(m_file.filePath());
+}
+
+const ModDetails& Mod::details() const
+{
+ if (!m_localDetails)
+ return invalidDetails;
+ return *m_localDetails;
+}
+
+QString Mod::version() const
+{
+ return details().version;
+}
+
+QString Mod::name() const
+{
+ auto& d = details();
+ if (!d.name.isEmpty()) {
+ return d.name;
+ }
+ return m_name;
+}
+
+QString Mod::homeurl() const
+{
+ return details().homeurl;
+}
+
+QString Mod::description() const
+{
+ return details().description;
+}
+
+QStringList Mod::authors() const
+{
+ return details().authors;
+}
diff --git a/meshmc/launcher/minecraft/mod/Mod.h b/meshmc/launcher/minecraft/mod/Mod.h
new file mode 100644
index 0000000000..430ae9519d
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/Mod.h
@@ -0,0 +1,140 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ *
+ * 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.
+ */
+
+#pragma once
+#include <QFileInfo>
+#include <QDateTime>
+#include <QList>
+#include <memory>
+
+#include "ModDetails.h"
+
+class Mod
+{
+ public:
+ enum ModType {
+ MOD_UNKNOWN, //!< Indicates an unspecified mod type.
+ MOD_ZIPFILE, //!< The mod is a zip file containing the mod's class
+ //!< files.
+ MOD_SINGLEFILE, //!< The mod is a single file (not a zip file).
+ MOD_FOLDER, //!< The mod is in a folder on the filesystem.
+ MOD_LITEMOD, //!< The mod is a litemod
+ };
+
+ Mod() = default;
+ Mod(const QFileInfo& file);
+
+ QFileInfo filename() const
+ {
+ return m_file;
+ }
+ QString mmc_id() const
+ {
+ return m_mmc_id;
+ }
+ ModType type() const
+ {
+ return m_type;
+ }
+ bool valid()
+ {
+ return m_type != MOD_UNKNOWN;
+ }
+
+ QDateTime dateTimeChanged() const
+ {
+ return m_changedDateTime;
+ }
+
+ bool enabled() const
+ {
+ return m_enabled;
+ }
+
+ const ModDetails& details() const;
+
+ QString name() const;
+ QString version() const;
+ QString homeurl() const;
+ QString description() const;
+ QStringList authors() const;
+
+ bool enable(bool value);
+
+ // delete all the files of this mod
+ bool destroy();
+
+ // change the mod's filesystem path (used by mod lists for *MAGIC* purposes)
+ void repath(const QFileInfo& file);
+
+ bool shouldResolve()
+ {
+ return !m_resolving && !m_resolved;
+ }
+ bool isResolving()
+ {
+ return m_resolving;
+ }
+ int resolutionTicket()
+ {
+ return m_resolutionTicket;
+ }
+ void setResolving(bool resolving, int resolutionTicket)
+ {
+ m_resolving = resolving;
+ m_resolutionTicket = resolutionTicket;
+ }
+ void finishResolvingWithDetails(std::shared_ptr<ModDetails> details)
+ {
+ m_resolving = false;
+ m_resolved = true;
+ m_localDetails = details;
+ }
+
+ protected:
+ QFileInfo m_file;
+ QDateTime m_changedDateTime;
+ QString m_mmc_id;
+ QString m_name;
+ bool m_enabled = true;
+ bool m_resolving = false;
+ bool m_resolved = false;
+ int m_resolutionTicket = 0;
+ ModType m_type = MOD_UNKNOWN;
+ std::shared_ptr<ModDetails> m_localDetails;
+};
diff --git a/meshmc/launcher/minecraft/mod/ModDetails.h b/meshmc/launcher/minecraft/mod/ModDetails.h
new file mode 100644
index 0000000000..37aa78b7cf
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ModDetails.h
@@ -0,0 +1,37 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QString>
+#include <QStringList>
+
+struct ModDetails {
+ QString mod_id;
+ QString name;
+ QString version;
+ QString mcversion;
+ QString homeurl;
+ QString updateurl;
+ QString description;
+ QStringList authors;
+ QString credits;
+};
diff --git a/meshmc/launcher/minecraft/mod/ModFolderLoadTask.cpp b/meshmc/launcher/minecraft/mod/ModFolderLoadTask.cpp
new file mode 100644
index 0000000000..9272009539
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ModFolderLoadTask.cpp
@@ -0,0 +1,38 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "ModFolderLoadTask.h"
+#include <QDebug>
+
+ModFolderLoadTask::ModFolderLoadTask(QDir dir)
+ : m_dir(dir), m_result(new Result())
+{
+}
+
+void ModFolderLoadTask::run()
+{
+ m_dir.refresh();
+ for (auto entry : m_dir.entryInfoList()) {
+ Mod m(entry);
+ m_result->mods[m.mmc_id()] = m;
+ }
+ emit succeeded();
+}
diff --git a/meshmc/launcher/minecraft/mod/ModFolderLoadTask.h b/meshmc/launcher/minecraft/mod/ModFolderLoadTask.h
new file mode 100644
index 0000000000..1aaafa6bbb
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ModFolderLoadTask.h
@@ -0,0 +1,52 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+#include <QRunnable>
+#include <QObject>
+#include <QDir>
+#include <QMap>
+#include "Mod.h"
+#include <memory>
+
+class ModFolderLoadTask : public QObject, public QRunnable
+{
+ Q_OBJECT
+ public:
+ struct Result {
+ QMap<QString, Mod> mods;
+ };
+ using ResultPtr = std::shared_ptr<Result>;
+ ResultPtr result() const
+ {
+ return m_result;
+ }
+
+ public:
+ ModFolderLoadTask(QDir dir);
+ void run();
+ signals:
+ void succeeded();
+
+ private:
+ QDir m_dir;
+ ResultPtr m_result;
+};
diff --git a/meshmc/launcher/minecraft/mod/ModFolderModel.cpp b/meshmc/launcher/minecraft/mod/ModFolderModel.cpp
new file mode 100644
index 0000000000..faaa814590
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ModFolderModel.cpp
@@ -0,0 +1,573 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ *
+ * 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 "ModFolderModel.h"
+#include <FileSystem.h>
+#include <QMimeData>
+#include <QUrl>
+#include <QUuid>
+#include <QString>
+#include <QFileSystemWatcher>
+#include <QDebug>
+#include "ModFolderLoadTask.h"
+#include <QThreadPool>
+#include <algorithm>
+#include "LocalModParseTask.h"
+
+ModFolderModel::ModFolderModel(const QString& dir)
+ : QAbstractListModel(), m_dir(dir)
+{
+ FS::ensureFolderPathExists(m_dir.absolutePath());
+ m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files |
+ QDir::Dirs);
+ m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware);
+ m_watcher = new QFileSystemWatcher(this);
+ connect(m_watcher, SIGNAL(directoryChanged(QString)), this,
+ SLOT(directoryChanged(QString)));
+}
+
+void ModFolderModel::startWatching()
+{
+ if (is_watching)
+ return;
+
+ update();
+
+ is_watching = m_watcher->addPath(m_dir.absolutePath());
+ if (is_watching) {
+ qDebug() << "Started watching " << m_dir.absolutePath();
+ } else {
+ qDebug() << "Failed to start watching " << m_dir.absolutePath();
+ }
+}
+
+void ModFolderModel::stopWatching()
+{
+ if (!is_watching)
+ return;
+
+ is_watching = !m_watcher->removePath(m_dir.absolutePath());
+ if (!is_watching) {
+ qDebug() << "Stopped watching " << m_dir.absolutePath();
+ } else {
+ qDebug() << "Failed to stop watching " << m_dir.absolutePath();
+ }
+}
+
+bool ModFolderModel::update()
+{
+ if (!isValid()) {
+ return false;
+ }
+ if (m_update) {
+ scheduled_update = true;
+ return true;
+ }
+
+ auto task = new ModFolderLoadTask(m_dir);
+ m_update = task->result();
+ QThreadPool* threadPool = QThreadPool::globalInstance();
+ connect(task, &ModFolderLoadTask::succeeded, this,
+ &ModFolderModel::finishUpdate);
+ threadPool->start(task);
+ return true;
+}
+
+void ModFolderModel::finishUpdate()
+{
+ auto keys1 = modsIndex.keys();
+ QSet<QString> currentSet(keys1.begin(), keys1.end());
+ auto& newMods = m_update->mods;
+ auto keys2 = newMods.keys();
+ QSet<QString> newSet(keys2.begin(), keys2.end());
+
+ // see if the kept mods changed in some way
+ {
+ QSet<QString> kept = currentSet;
+ kept.intersect(newSet);
+ for (auto& keptMod : kept) {
+ auto& newMod = newMods[keptMod];
+ auto row = modsIndex[keptMod];
+ auto& currentMod = mods[row];
+ if (newMod.dateTimeChanged() == currentMod.dateTimeChanged()) {
+ // no significant change, ignore...
+ continue;
+ }
+ auto& oldMod = mods[row];
+ if (oldMod.isResolving()) {
+ activeTickets.remove(oldMod.resolutionTicket());
+ }
+ oldMod = newMod;
+ resolveMod(mods[row]);
+ emit dataChanged(index(row, 0),
+ index(row, columnCount(QModelIndex()) - 1));
+ }
+ }
+
+ // remove mods no longer present
+ {
+ QSet<QString> removed = currentSet;
+ QList<int> removedRows;
+ removed.subtract(newSet);
+ for (auto& removedMod : removed) {
+ removedRows.append(modsIndex[removedMod]);
+ }
+ std::sort(removedRows.begin(), removedRows.end(), std::greater<int>());
+ for (auto iter = removedRows.begin(); iter != removedRows.end();
+ iter++) {
+ int removedIndex = *iter;
+ beginRemoveRows(QModelIndex(), removedIndex, removedIndex);
+ auto removedIter = mods.begin() + removedIndex;
+ if (removedIter->isResolving()) {
+ activeTickets.remove(removedIter->resolutionTicket());
+ }
+ mods.erase(removedIter);
+ endRemoveRows();
+ }
+ }
+
+ // add new mods to the end
+ {
+ QSet<QString> added = newSet;
+ added.subtract(currentSet);
+ beginInsertRows(QModelIndex(), mods.size(),
+ mods.size() + added.size() - 1);
+ for (auto& addedMod : added) {
+ mods.append(newMods[addedMod]);
+ resolveMod(mods.last());
+ }
+ endInsertRows();
+ }
+
+ // update index
+ {
+ modsIndex.clear();
+ int idx = 0;
+ for (auto& mod : mods) {
+ modsIndex[mod.mmc_id()] = idx;
+ idx++;
+ }
+ }
+
+ m_update.reset();
+
+ emit updateFinished();
+
+ if (scheduled_update) {
+ scheduled_update = false;
+ update();
+ }
+}
+
+void ModFolderModel::resolveMod(Mod& m)
+{
+ if (!m.shouldResolve()) {
+ return;
+ }
+
+ auto task =
+ new LocalModParseTask(nextResolutionTicket, m.type(), m.filename());
+ auto result = task->result();
+ result->id = m.mmc_id();
+ activeTickets.insert(nextResolutionTicket, result);
+ m.setResolving(true, nextResolutionTicket);
+ nextResolutionTicket++;
+ QThreadPool* threadPool = QThreadPool::globalInstance();
+ connect(task, &LocalModParseTask::finished, this,
+ &ModFolderModel::finishModParse);
+ threadPool->start(task);
+}
+
+void ModFolderModel::finishModParse(int token)
+{
+ auto iter = activeTickets.find(token);
+ if (iter == activeTickets.end()) {
+ return;
+ }
+ auto result = *iter;
+ activeTickets.remove(token);
+ int row = modsIndex[result->id];
+ auto& mod = mods[row];
+ mod.finishResolvingWithDetails(result->details);
+ emit dataChanged(index(row), index(row, columnCount(QModelIndex()) - 1));
+}
+
+void ModFolderModel::disableInteraction(bool disabled)
+{
+ if (interaction_disabled == disabled) {
+ return;
+ }
+ interaction_disabled = disabled;
+ if (size()) {
+ emit dataChanged(index(0), index(size() - 1));
+ }
+}
+
+void ModFolderModel::directoryChanged(QString path)
+{
+ update();
+}
+
+bool ModFolderModel::isValid()
+{
+ return m_dir.exists() && m_dir.isReadable();
+}
+
+// FIXME: this does not take disabled mod (with extra .disable extension) into
+// account...
+bool ModFolderModel::installMod(const QString& filename)
+{
+ if (interaction_disabled) {
+ return false;
+ }
+
+ // NOTE: fix for GH-1178: remove trailing slash to avoid issues with using
+ // the empty result of QFileInfo::fileName
+ auto originalPath = FS::NormalizePath(filename);
+ QFileInfo fileinfo(originalPath);
+
+ if (!fileinfo.exists() || !fileinfo.isReadable()) {
+ qWarning() << "Caught attempt to install non-existing file or "
+ "file-like object:"
+ << originalPath;
+ return false;
+ }
+ qDebug() << "installing: " << fileinfo.absoluteFilePath();
+
+ Mod installedMod(fileinfo);
+ if (!installedMod.valid()) {
+ qDebug() << originalPath << "is not a valid mod. Ignoring it.";
+ return false;
+ }
+
+ auto type = installedMod.type();
+ if (type == Mod::MOD_UNKNOWN) {
+ qDebug() << "Cannot recognize mod type of" << originalPath
+ << ", ignoring it.";
+ return false;
+ }
+
+ auto newpath =
+ FS::NormalizePath(FS::PathCombine(m_dir.path(), fileinfo.fileName()));
+ if (originalPath == newpath) {
+ qDebug() << "Overwriting the mod (" << originalPath
+ << ") with itself makes no sense...";
+ return false;
+ }
+
+ if (type == Mod::MOD_SINGLEFILE || type == Mod::MOD_ZIPFILE ||
+ type == Mod::MOD_LITEMOD) {
+ if (QFile::exists(newpath) ||
+ QFile::exists(newpath + QString(".disabled"))) {
+ if (!QFile::remove(newpath)) {
+ // FIXME: report error in a user-visible way
+ qWarning() << "Copy from" << originalPath << "to" << newpath
+ << "has failed.";
+ return false;
+ }
+ qDebug() << newpath << "has been deleted.";
+ }
+ if (!QFile::copy(fileinfo.filePath(), newpath)) {
+ qWarning() << "Copy from" << originalPath << "to" << newpath
+ << "has failed.";
+ // FIXME: report error in a user-visible way
+ return false;
+ }
+ FS::updateTimestamp(newpath);
+ installedMod.repath(QFileInfo(newpath));
+ update();
+ return true;
+ } else if (type == Mod::MOD_FOLDER) {
+ QString from = fileinfo.filePath();
+ if (QFile::exists(newpath)) {
+ qDebug() << "Ignoring folder " << from << ", it would merge with "
+ << newpath;
+ return false;
+ }
+
+ if (!FS::copy(from, newpath)()) {
+ qWarning() << "Copy of folder from" << originalPath << "to"
+ << newpath << "has (potentially partially) failed.";
+ return false;
+ }
+ installedMod.repath(QFileInfo(newpath));
+ update();
+ return true;
+ }
+ return false;
+}
+
+bool ModFolderModel::setModStatus(const QModelIndexList& indexes,
+ ModStatusAction enable)
+{
+ if (interaction_disabled) {
+ return false;
+ }
+
+ if (indexes.isEmpty())
+ return true;
+
+ for (auto index : indexes) {
+ if (index.column() != 0) {
+ continue;
+ }
+ setModStatus(index.row(), enable);
+ }
+ return true;
+}
+
+bool ModFolderModel::deleteMods(const QModelIndexList& indexes)
+{
+ if (interaction_disabled) {
+ return false;
+ }
+
+ if (indexes.isEmpty())
+ return true;
+
+ for (auto i : indexes) {
+ Mod& m = mods[i.row()];
+ m.destroy();
+ }
+ return true;
+}
+
+int ModFolderModel::columnCount(const QModelIndex& parent) const
+{
+ return NUM_COLUMNS;
+}
+
+QVariant ModFolderModel::data(const QModelIndex& index, int role) const
+{
+ if (!index.isValid())
+ return QVariant();
+
+ int row = index.row();
+ int column = index.column();
+
+ if (row < 0 || row >= mods.size())
+ return QVariant();
+
+ switch (role) {
+ case Qt::DisplayRole:
+ switch (column) {
+ case NameColumn:
+ return mods[row].name();
+ case VersionColumn: {
+ switch (mods[row].type()) {
+ case Mod::MOD_FOLDER:
+ return tr("Folder");
+ case Mod::MOD_SINGLEFILE:
+ return tr("File");
+ default:
+ break;
+ }
+ return mods[row].version();
+ }
+ case DateColumn:
+ return mods[row].dateTimeChanged();
+
+ default:
+ return QVariant();
+ }
+
+ case Qt::ToolTipRole:
+ return mods[row].mmc_id();
+
+ case Qt::CheckStateRole:
+ switch (column) {
+ case ActiveColumn:
+ return mods[row].enabled() ? Qt::Checked : Qt::Unchecked;
+ default:
+ return QVariant();
+ }
+ default:
+ return QVariant();
+ }
+}
+
+bool ModFolderModel::setData(const QModelIndex& index, const QVariant& value,
+ int role)
+{
+ if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) {
+ return false;
+ }
+
+ if (role == Qt::CheckStateRole) {
+ return setModStatus(index.row(), Toggle);
+ }
+ return false;
+}
+
+bool ModFolderModel::setModStatus(int row,
+ ModFolderModel::ModStatusAction action)
+{
+ if (row < 0 || row >= mods.size()) {
+ return false;
+ }
+
+ auto& mod = mods[row];
+ bool desiredStatus;
+ switch (action) {
+ case Enable:
+ desiredStatus = true;
+ break;
+ case Disable:
+ desiredStatus = false;
+ break;
+ case Toggle:
+ default:
+ desiredStatus = !mod.enabled();
+ break;
+ }
+
+ if (desiredStatus == mod.enabled()) {
+ return true;
+ }
+
+ // preserve the row, but change its ID
+ auto oldId = mod.mmc_id();
+ if (!mod.enable(!mod.enabled())) {
+ return false;
+ }
+ auto newId = mod.mmc_id();
+ if (modsIndex.contains(newId)) {
+ // NOTE: this could handle a corner case, where we are overwriting a
+ // file, because the same 'mod' exists both enabled and disabled But is
+ // it necessary?
+ }
+ modsIndex.remove(oldId);
+ modsIndex[newId] = row;
+ emit dataChanged(index(row, 0), index(row, columnCount(QModelIndex()) - 1));
+ return true;
+}
+
+QVariant ModFolderModel::headerData(int section, Qt::Orientation orientation,
+ int role) const
+{
+ switch (role) {
+ case Qt::DisplayRole:
+ switch (section) {
+ case ActiveColumn:
+ return QString();
+ case NameColumn:
+ return tr("Name");
+ case VersionColumn:
+ return tr("Version");
+ case DateColumn:
+ return tr("Last changed");
+ default:
+ return QVariant();
+ }
+
+ case Qt::ToolTipRole:
+ switch (section) {
+ case ActiveColumn:
+ return tr("Is the mod enabled?");
+ case NameColumn:
+ return tr("The name of the mod.");
+ case VersionColumn:
+ return tr("The version of the mod.");
+ case DateColumn:
+ return tr("The date and time this mod was last changed (or "
+ "added).");
+ default:
+ return QVariant();
+ }
+ default:
+ return QVariant();
+ }
+ return QVariant();
+}
+
+Qt::ItemFlags ModFolderModel::flags(const QModelIndex& index) const
+{
+ Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index);
+ auto flags = defaultFlags;
+ if (interaction_disabled) {
+ flags &= ~Qt::ItemIsDropEnabled;
+ } else {
+ flags |= Qt::ItemIsDropEnabled;
+ if (index.isValid()) {
+ flags |= Qt::ItemIsUserCheckable;
+ }
+ }
+ return flags;
+}
+
+Qt::DropActions ModFolderModel::supportedDropActions() const
+{
+ // copy from outside, move from within and other mod lists
+ return Qt::CopyAction | Qt::MoveAction;
+}
+
+QStringList ModFolderModel::mimeTypes() const
+{
+ QStringList types;
+ types << "text/uri-list";
+ return types;
+}
+
+bool ModFolderModel::dropMimeData(const QMimeData* data, Qt::DropAction action,
+ int, int, const QModelIndex&)
+{
+ if (action == Qt::IgnoreAction) {
+ return true;
+ }
+
+ // check if the action is supported
+ if (!data || !(action & supportedDropActions())) {
+ return false;
+ }
+
+ // files dropped from outside?
+ if (data->hasUrls()) {
+ auto urls = data->urls();
+ for (auto url : urls) {
+ // only local files may be dropped...
+ if (!url.isLocalFile()) {
+ continue;
+ }
+ // TODO: implement not only copy, but also move
+ // FIXME: handle errors here
+ installMod(url.toLocalFile());
+ }
+ return true;
+ }
+ return false;
+}
diff --git a/meshmc/launcher/minecraft/mod/ModFolderModel.h b/meshmc/launcher/minecraft/mod/ModFolderModel.h
new file mode 100644
index 0000000000..845c6d6e4a
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ModFolderModel.h
@@ -0,0 +1,169 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ *
+ * 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.
+ */
+
+#pragma once
+
+#include <QList>
+#include <QMap>
+#include <QSet>
+#include <QString>
+#include <QDir>
+#include <QAbstractListModel>
+
+#include "Mod.h"
+
+#include "ModFolderLoadTask.h"
+#include "LocalModParseTask.h"
+
+class LegacyInstance;
+class BaseInstance;
+class QFileSystemWatcher;
+
+/**
+ * A legacy mod list.
+ * Backed by a folder.
+ */
+class ModFolderModel : public QAbstractListModel
+{
+ Q_OBJECT
+ public:
+ enum Columns {
+ ActiveColumn = 0,
+ NameColumn,
+ VersionColumn,
+ DateColumn,
+ NUM_COLUMNS
+ };
+ enum ModStatusAction { Disable, Enable, Toggle };
+ ModFolderModel(const QString& dir);
+
+ virtual QVariant data(const QModelIndex& index,
+ int role = Qt::DisplayRole) const override;
+ virtual bool setData(const QModelIndex& index, const QVariant& value,
+ int role = Qt::EditRole) override;
+ Qt::DropActions supportedDropActions() const override;
+
+ /// flags, mostly to support drag&drop
+ virtual Qt::ItemFlags flags(const QModelIndex& index) const override;
+ QStringList mimeTypes() const override;
+ bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row,
+ int column, const QModelIndex& parent) override;
+
+ virtual int rowCount(const QModelIndex&) const override
+ {
+ return size();
+ }
+
+ virtual QVariant headerData(int section, Qt::Orientation orientation,
+ int role = Qt::DisplayRole) const override;
+ virtual int columnCount(const QModelIndex& parent) const override;
+
+ size_t size() const
+ {
+ return mods.size();
+ };
+ bool empty() const
+ {
+ return size() == 0;
+ }
+ Mod& operator[](size_t index)
+ {
+ return mods[index];
+ }
+ const Mod& at(size_t index) const
+ {
+ return mods.at(index);
+ }
+
+ /// Reloads the mod list and returns true if the list changed.
+ bool update();
+
+ /**
+ * Adds the given mod to the list at the given index - if the list supports
+ * custom ordering
+ */
+ bool installMod(const QString& filename);
+
+ /// Deletes all the selected mods
+ bool deleteMods(const QModelIndexList& indexes);
+
+ /// Enable or disable listed mods
+ bool setModStatus(const QModelIndexList& indexes, ModStatusAction action);
+
+ void startWatching();
+ void stopWatching();
+
+ bool isValid();
+
+ QDir dir()
+ {
+ return m_dir;
+ }
+
+ const QList<Mod>& allMods()
+ {
+ return mods;
+ }
+
+ public slots:
+ void disableInteraction(bool disabled);
+
+ private slots:
+ void directoryChanged(QString path);
+ void finishUpdate();
+ void finishModParse(int token);
+
+ signals:
+ void updateFinished();
+
+ private:
+ void resolveMod(Mod& m);
+ bool setModStatus(int index, ModStatusAction action);
+
+ protected:
+ QFileSystemWatcher* m_watcher;
+ bool is_watching = false;
+ ModFolderLoadTask::ResultPtr m_update;
+ bool scheduled_update = false;
+ bool interaction_disabled = false;
+ QDir m_dir;
+ QMap<QString, int> modsIndex;
+ QMap<int, LocalModParseTask::ResultPtr> activeTickets;
+ int nextResolutionTicket = 0;
+ QList<Mod> mods;
+};
diff --git a/meshmc/launcher/minecraft/mod/ModFolderModel_test.cpp b/meshmc/launcher/minecraft/mod/ModFolderModel_test.cpp
new file mode 100644
index 0000000000..12b0d44478
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ModFolderModel_test.cpp
@@ -0,0 +1,71 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#include <QTest>
+#include <QTemporaryDir>
+#include "TestUtil.h"
+
+#include "FileSystem.h"
+#include "minecraft/mod/ModFolderModel.h"
+
+class ModFolderModelTest : public QObject
+{
+ Q_OBJECT
+
+ private slots:
+ // test for GH-1178 - install a folder with files to a mod list
+ void test_1178()
+ {
+ // source
+ QString source = QFINDTESTDATA("data/test_folder");
+
+ // sanity check
+ QVERIFY(!source.endsWith('/'));
+
+ auto verify = [](QString path) {
+ QDir target_dir(FS::PathCombine(path, "test_folder"));
+ QVERIFY(target_dir.entryList().contains("pack.mcmeta"));
+ QVERIFY(target_dir.entryList().contains("assets"));
+ };
+
+ // 1. test with no trailing /
+ {
+ QString folder = source;
+ QTemporaryDir tempDir;
+ ModFolderModel m(tempDir.path());
+ m.installMod(folder);
+ verify(tempDir.path());
+ }
+
+ // 2. test with trailing /
+ {
+ QString folder = source + '/';
+ QTemporaryDir tempDir;
+ ModFolderModel m(tempDir.path());
+ m.installMod(folder);
+ verify(tempDir.path());
+ }
+ }
+};
+
+QTEST_GUILESS_MAIN(ModFolderModelTest)
+
+#include "ModFolderModel_test.moc"
diff --git a/meshmc/launcher/minecraft/mod/ResourcePackFolderModel.cpp b/meshmc/launcher/minecraft/mod/ResourcePackFolderModel.cpp
new file mode 100644
index 0000000000..2f43cb2ff1
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ResourcePackFolderModel.cpp
@@ -0,0 +1,50 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "ResourcePackFolderModel.h"
+
+ResourcePackFolderModel::ResourcePackFolderModel(const QString& dir)
+ : ModFolderModel(dir)
+{
+}
+
+QVariant ResourcePackFolderModel::headerData(int section,
+ Qt::Orientation orientation,
+ int role) const
+{
+ if (role == Qt::ToolTipRole) {
+ switch (section) {
+ case ActiveColumn:
+ return tr("Is the resource pack enabled?");
+ case NameColumn:
+ return tr("The name of the resource pack.");
+ case VersionColumn:
+ return tr("The version of the resource pack.");
+ case DateColumn:
+ return tr("The date and time this resource pack was last "
+ "changed (or added).");
+ default:
+ return QVariant();
+ }
+ }
+
+ return ModFolderModel::headerData(section, orientation, role);
+}
diff --git a/meshmc/launcher/minecraft/mod/ResourcePackFolderModel.h b/meshmc/launcher/minecraft/mod/ResourcePackFolderModel.h
new file mode 100644
index 0000000000..7c3008a432
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/ResourcePackFolderModel.h
@@ -0,0 +1,35 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderModel.h"
+
+class ResourcePackFolderModel : public ModFolderModel
+{
+ Q_OBJECT
+
+ public:
+ explicit ResourcePackFolderModel(const QString& dir);
+
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override;
+};
diff --git a/meshmc/launcher/minecraft/mod/TexturePackFolderModel.cpp b/meshmc/launcher/minecraft/mod/TexturePackFolderModel.cpp
new file mode 100644
index 0000000000..af8510d643
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/TexturePackFolderModel.cpp
@@ -0,0 +1,50 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#include "TexturePackFolderModel.h"
+
+TexturePackFolderModel::TexturePackFolderModel(const QString& dir)
+ : ModFolderModel(dir)
+{
+}
+
+QVariant TexturePackFolderModel::headerData(int section,
+ Qt::Orientation orientation,
+ int role) const
+{
+ if (role == Qt::ToolTipRole) {
+ switch (section) {
+ case ActiveColumn:
+ return tr("Is the texture pack enabled?");
+ case NameColumn:
+ return tr("The name of the texture pack.");
+ case VersionColumn:
+ return tr("The version of the texture pack.");
+ case DateColumn:
+ return tr("The date and time this texture pack was last "
+ "changed (or added).");
+ default:
+ return QVariant();
+ }
+ }
+
+ return ModFolderModel::headerData(section, orientation, role);
+}
diff --git a/meshmc/launcher/minecraft/mod/TexturePackFolderModel.h b/meshmc/launcher/minecraft/mod/TexturePackFolderModel.h
new file mode 100644
index 0000000000..d7a49d0ffe
--- /dev/null
+++ b/meshmc/launcher/minecraft/mod/TexturePackFolderModel.h
@@ -0,0 +1,35 @@
+/* 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 <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include "ModFolderModel.h"
+
+class TexturePackFolderModel : public ModFolderModel
+{
+ Q_OBJECT
+
+ public:
+ explicit TexturePackFolderModel(const QString& dir);
+
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override;
+};