diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-02 18:45:07 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-02 18:45:07 +0300 |
| commit | 31b9a8949ed0a288143e23bf739f2eb64fdc63be (patch) | |
| tree | 8a984fa143c38fccad461a77792d6864f3e82cd3 /meshmc/launcher/minecraft | |
| parent | 934382c8a1ce738589dee9ee0f14e1cec812770e (diff) | |
| parent | fad6a1066616b69d7f5fef01178efdf014c59537 (diff) | |
| download | Project-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')
147 files changed, 21295 insertions, 0 deletions
diff --git a/meshmc/launcher/minecraft/AssetsUtils.cpp b/meshmc/launcher/minecraft/AssetsUtils.cpp new file mode 100644 index 0000000000..9781a18648 --- /dev/null +++ b/meshmc/launcher/minecraft/AssetsUtils.cpp @@ -0,0 +1,344 @@ +/* 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 <QFileInfo> +#include <QDir> +#include <QDirIterator> +#include <QCryptographicHash> +#include <QJsonParseError> +#include <QJsonDocument> +#include <QJsonObject> +#include <QVariant> +#include <QDebug> + +#include "AssetsUtils.h" +#include "FileSystem.h" +#include "net/Download.h" +#include "net/ChecksumValidator.h" +#include "BuildConfig.h" + +#include "Application.h" + +namespace +{ + QSet<QString> collectPathsFromDir(QString dirPath) + { + QFileInfo dirInfo(dirPath); + + if (!dirInfo.exists()) { + return {}; + } + + QSet<QString> out; + + QDirIterator iter(dirPath, QDirIterator::Subdirectories); + while (iter.hasNext()) { + QString value = iter.next(); + QFileInfo info(value); + if (info.isFile()) { + out.insert(value); + qDebug() << value; + } + } + return out; + } +} // namespace + +namespace AssetsUtils +{ + + /* + * Returns true on success, with index populated + * index is undefined otherwise + */ + bool loadAssetsIndexJson(const QString& assetsId, const QString& path, + AssetsIndex& index) + { + /* + { + "objects": { + "icons/icon_16x16.png": { + "hash": "bdf48ef6b5d0d23bbb02e17d04865216179f510a", + "size": 3665 + }, + ... + } + } + } + */ + + QFile file(path); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << "Failed to read assets index file" << path; + return false; + } + index.id = assetsId; + + // Read the file and close it. + QByteArray jsonData = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); + + // Fail if the JSON is invalid. + if (parseError.error != QJsonParseError::NoError) { + qCritical() << "Failed to parse assets index file:" + << parseError.errorString() << "at offset " + << QString::number(parseError.offset); + return false; + } + + // Make sure the root is an object. + if (!jsonDoc.isObject()) { + qCritical() + << "Invalid assets index JSON: Root should be an array."; + return false; + } + + QJsonObject root = jsonDoc.object(); + + QJsonValue isVirtual = root.value("virtual"); + if (!isVirtual.isUndefined()) { + index.isVirtual = isVirtual.toBool(false); + } + + QJsonValue mapToResources = root.value("map_to_resources"); + if (!mapToResources.isUndefined()) { + index.mapToResources = mapToResources.toBool(false); + } + + QJsonValue objects = root.value("objects"); + QVariantMap map = objects.toVariant().toMap(); + + for (QVariantMap::const_iterator iter = map.begin(); iter != map.end(); + ++iter) { + // qDebug() << iter.key(); + + QVariant variant = iter.value(); + QVariantMap nested_objects = variant.toMap(); + + AssetObject object; + + for (QVariantMap::const_iterator nested_iter = + nested_objects.begin(); + nested_iter != nested_objects.end(); ++nested_iter) { + // qDebug() << nested_iter.key() << + // nested_iter.value().toString(); + QString key = nested_iter.key(); + QVariant value = nested_iter.value(); + + if (key == "hash") { + object.hash = value.toString(); + } else if (key == "size") { + object.size = value.toDouble(); + } + } + + index.objects.insert(iter.key(), object); + } + + return true; + } + + // FIXME: ugly code duplication + QDir getAssetsDir(const QString& assetsId, const QString& resourcesFolder) + { + QDir assetsDir = QDir("assets/"); + QDir indexDir = QDir(FS::PathCombine(assetsDir.path(), "indexes")); + QDir objectDir = QDir(FS::PathCombine(assetsDir.path(), "objects")); + QDir virtualDir = QDir(FS::PathCombine(assetsDir.path(), "virtual")); + + QString indexPath = + FS::PathCombine(indexDir.path(), assetsId + ".json"); + QFile indexFile(indexPath); + QDir virtualRoot(FS::PathCombine(virtualDir.path(), assetsId)); + + if (!indexFile.exists()) { + qCritical() << "No assets index file" << indexPath + << "; can't determine assets path!"; + return virtualRoot; + } + + AssetsIndex index; + if (!AssetsUtils::loadAssetsIndexJson(assetsId, indexPath, index)) { + qCritical() << "Failed to load asset index file" << indexPath + << "; can't determine assets path!"; + return virtualRoot; + } + + QString targetPath; + if (index.isVirtual) { + return virtualRoot; + } else if (index.mapToResources) { + return QDir(resourcesFolder); + } + return virtualRoot; + } + + // FIXME: ugly code duplication + bool reconstructAssets(QString assetsId, QString resourcesFolder) + { + QDir assetsDir = QDir("assets/"); + QDir indexDir = QDir(FS::PathCombine(assetsDir.path(), "indexes")); + QDir objectDir = QDir(FS::PathCombine(assetsDir.path(), "objects")); + QDir virtualDir = QDir(FS::PathCombine(assetsDir.path(), "virtual")); + + QString indexPath = + FS::PathCombine(indexDir.path(), assetsId + ".json"); + QFile indexFile(indexPath); + QDir virtualRoot(FS::PathCombine(virtualDir.path(), assetsId)); + + if (!indexFile.exists()) { + qCritical() << "No assets index file" << indexPath + << "; can't reconstruct assets!"; + return false; + } + + qDebug() << "reconstructAssets" << assetsDir.path() << indexDir.path() + << objectDir.path() << virtualDir.path() << virtualRoot.path(); + + AssetsIndex index; + if (!AssetsUtils::loadAssetsIndexJson(assetsId, indexPath, index)) { + qCritical() << "Failed to load asset index file" << indexPath + << "; can't reconstruct assets!"; + return false; + } + + QString targetPath; + bool removeLeftovers = false; + if (index.isVirtual) { + targetPath = virtualRoot.path(); + removeLeftovers = true; + qDebug() << "Reconstructing virtual assets folder at" << targetPath; + } else if (index.mapToResources) { + targetPath = resourcesFolder; + qDebug() << "Reconstructing resources folder at" << targetPath; + } + + if (!targetPath.isNull()) { + auto presentFiles = collectPathsFromDir(targetPath); + for (QString map : index.objects.keys()) { + AssetObject asset_object = index.objects.value(map); + QString target_path = FS::PathCombine(targetPath, map); + QFile target(target_path); + + QString tlk = asset_object.hash.left(2); + + QString original_path = + FS::PathCombine(objectDir.path(), tlk, asset_object.hash); + QFile original(original_path); + if (!original.exists()) + continue; + + presentFiles.remove(target_path); + + if (!target.exists()) { + QFileInfo info(target_path); + QDir target_dir = info.dir(); + + qDebug() << target_dir.path(); + FS::ensureFolderPathExists(target_dir.path()); + + bool couldCopy = original.copy(target_path); + qDebug() << " Copying" << original_path << "to" + << target_path << QString::number(couldCopy); + } + } + + // TODO: Write last used time to virtualRoot/.lastused + if (removeLeftovers) { + for (auto& file : presentFiles) { + qDebug() << "Would remove" << file; + } + } + } + return true; + } + +} // namespace AssetsUtils + +NetAction::Ptr AssetObject::getDownloadAction() +{ + QFileInfo objectFile(getLocalPath()); + if ((!objectFile.isFile()) || (objectFile.size() != size)) { + auto objectDL = + Net::Download::makeFile(getUrl(), objectFile.filePath()); + if (hash.size()) { + auto rawHash = QByteArray::fromHex(hash.toLatin1()); + objectDL->addValidator( + new Net::ChecksumValidator(QCryptographicHash::Sha1, rawHash)); + } + objectDL->m_total_progress = size; + return objectDL; + } + return nullptr; +} + +QString AssetObject::getLocalPath() +{ + return "assets/objects/" + getRelPath(); +} + +QUrl AssetObject::getUrl() +{ + return BuildConfig.RESOURCE_BASE + getRelPath(); +} + +QString AssetObject::getRelPath() +{ + return hash.left(2) + "/" + hash; +} + +NetJob::Ptr AssetsIndex::getDownloadJob() +{ + auto job = new NetJob(QObject::tr("Assets for %1").arg(id), + APPLICATION->network()); + for (auto& object : objects.values()) { + auto dl = object.getDownloadAction(); + if (dl) { + job->addNetAction(dl); + } + } + if (job->size()) + return job; + return nullptr; +} diff --git a/meshmc/launcher/minecraft/AssetsUtils.h b/meshmc/launcher/minecraft/AssetsUtils.h new file mode 100644 index 0000000000..68e88a5e3f --- /dev/null +++ b/meshmc/launcher/minecraft/AssetsUtils.h @@ -0,0 +1,76 @@ +/* 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 <QString> +#include <QMap> +#include "net/NetAction.h" +#include "net/NetJob.h" + +struct AssetObject { + QString getRelPath(); + QUrl getUrl(); + QString getLocalPath(); + NetAction::Ptr getDownloadAction(); + + QString hash; + qint64 size; +}; + +struct AssetsIndex { + NetJob::Ptr getDownloadJob(); + + QString id; + QMap<QString, AssetObject> objects; + bool isVirtual = false; + bool mapToResources = false; +}; + +/// FIXME: this is absolutely horrendous. REDO!!!! +namespace AssetsUtils +{ + bool loadAssetsIndexJson(const QString& id, const QString& file, + AssetsIndex& index); + + QDir getAssetsDir(const QString& assetsId, const QString& resourcesFolder); + + /// Reconstruct a virtual assets folder for the given assets ID and return + /// the folder + bool reconstructAssets(QString assetsId, QString resourcesFolder); +} // namespace AssetsUtils diff --git a/meshmc/launcher/minecraft/Component.cpp b/meshmc/launcher/minecraft/Component.cpp new file mode 100644 index 0000000000..e11c0fa492 --- /dev/null +++ b/meshmc/launcher/minecraft/Component.cpp @@ -0,0 +1,408 @@ +/* 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 <meta/VersionList.h> +#include <meta/Index.h> +#include "Component.h" + +#include <QSaveFile> + +#include "meta/Version.h" +#include "VersionFile.h" +#include "minecraft/PackProfile.h" +#include "FileSystem.h" +#include "OneSixVersionFormat.h" +#include "Application.h" + +#include <assert.h> + +Component::Component(PackProfile* parent, const QString& uid) +{ + assert(parent); + m_parent = parent; + + m_uid = uid; +} + +Component::Component(PackProfile* parent, + std::shared_ptr<Meta::Version> version) +{ + assert(parent); + m_parent = parent; + + m_metaVersion = version; + m_uid = version->uid(); + m_version = m_cachedVersion = version->version(); + m_cachedName = version->name(); + m_loaded = version->isLoaded(); +} + +Component::Component(PackProfile* parent, const QString& uid, + std::shared_ptr<VersionFile> file) +{ + assert(parent); + m_parent = parent; + + m_file = file; + m_uid = uid; + m_cachedVersion = m_file->version; + m_cachedName = m_file->name; + m_loaded = true; +} + +std::shared_ptr<Meta::Version> Component::getMeta() +{ + return m_metaVersion; +} + +void Component::applyTo(LaunchProfile* profile) +{ + // do not apply disabled components + if (!isEnabled()) { + return; + } + auto vfile = getVersionFile(); + if (vfile) { + vfile->applyTo(profile); + } else { + profile->applyProblemSeverity(getProblemSeverity()); + } +} + +std::shared_ptr<class VersionFile> Component::getVersionFile() const +{ + if (m_metaVersion) { + if (!m_metaVersion->isLoaded()) { + m_metaVersion->load(Net::Mode::Online); + } + return m_metaVersion->data(); + } else { + return m_file; + } +} + +std::shared_ptr<class Meta::VersionList> Component::getVersionList() const +{ + // FIXME: what if the metadata index isn't loaded yet? + if (APPLICATION->metadataIndex()->hasUid(m_uid)) { + return APPLICATION->metadataIndex()->get(m_uid); + } + return nullptr; +} + +int Component::getOrder() +{ + if (m_orderOverride) + return m_order; + + auto vfile = getVersionFile(); + if (vfile) { + return vfile->order; + } + return 0; +} +void Component::setOrder(int order) +{ + m_orderOverride = true; + m_order = order; +} +QString Component::getID() +{ + return m_uid; +} +QString Component::getName() +{ + if (!m_cachedName.isEmpty()) + return m_cachedName; + return m_uid; +} +QString Component::getVersion() +{ + return m_cachedVersion; +} +QString Component::getFilename() +{ + return m_parent->patchFilePathForUid(m_uid); +} +QDateTime Component::getReleaseDateTime() +{ + if (m_metaVersion) { + return m_metaVersion->time(); + } + auto vfile = getVersionFile(); + if (vfile) { + return vfile->releaseTime; + } + // FIXME: fake + return QDateTime::currentDateTime(); +} + +bool Component::isEnabled() +{ + return !canBeDisabled() || !m_disabled; +} + +bool Component::canBeDisabled() +{ + return isRemovable() && !m_dependencyOnly; +} + +bool Component::setEnabled(bool state) +{ + bool intendedDisabled = !state; + if (!canBeDisabled()) { + intendedDisabled = false; + } + if (intendedDisabled != m_disabled) { + m_disabled = intendedDisabled; + emit dataChanged(); + return true; + } + return false; +} + +bool Component::isCustom() +{ + return m_file != nullptr; +} + +bool Component::isCustomizable() +{ + if (m_metaVersion) { + if (getVersionFile()) { + return true; + } + } + return false; +} +bool Component::isRemovable() +{ + return !m_important; +} +bool Component::isRevertible() +{ + if (isCustom()) { + if (APPLICATION->metadataIndex()->hasUid(m_uid)) { + return true; + } + } + return false; +} +bool Component::isMoveable() +{ + // HACK, FIXME: this was too dumb and wouldn't follow dependency constraints + // anyway. For now hardcoded to 'true'. + return true; +} +bool Component::isVersionChangeable() +{ + auto list = getVersionList(); + if (list) { + if (!list->isLoaded()) { + list->load(Net::Mode::Online); + } + return list->count() != 0; + } + return false; +} + +void Component::setImportant(bool state) +{ + if (m_important != state) { + m_important = state; + emit dataChanged(); + } +} + +ProblemSeverity Component::getProblemSeverity() const +{ + auto file = getVersionFile(); + if (file) { + return file->getProblemSeverity(); + } + return ProblemSeverity::Error; +} + +const QList<PatchProblem> Component::getProblems() const +{ + auto file = getVersionFile(); + if (file) { + return file->getProblems(); + } + return {{ProblemSeverity::Error, QObject::tr("Patch is not loaded yet.")}}; +} + +void Component::setVersion(const QString& version) +{ + if (version == m_version) { + return; + } + m_version = version; + if (m_loaded) { + // we are loaded and potentially have state to invalidate + if (m_file) { + // we have a file... explicit version has been changed and there is + // nothing else to do. + } else { + // we don't have a file, therefore we are loaded with metadata + m_cachedVersion = version; + // see if the meta version is loaded + auto metaVersion = + APPLICATION->metadataIndex()->get(m_uid, version); + if (metaVersion->isLoaded()) { + // if yes, we can continue with that. + m_metaVersion = metaVersion; + } else { + // if not, we need loading + m_metaVersion.reset(); + m_loaded = false; + } + updateCachedData(); + } + } else { + // not loaded... assume it will be sorted out later by the update task + } + emit dataChanged(); +} + +bool Component::customize() +{ + if (isCustom()) { + return false; + } + + auto filename = getFilename(); + if (!FS::ensureFilePathExists(filename)) { + return false; + } + // FIXME: get rid of this try-catch. + try { + QSaveFile jsonFile(filename); + if (!jsonFile.open(QIODevice::WriteOnly)) { + return false; + } + auto vfile = getVersionFile(); + if (!vfile) { + return false; + } + auto document = OneSixVersionFormat::versionFileToJson(vfile); + jsonFile.write(document.toJson()); + if (!jsonFile.commit()) { + return false; + } + m_file = vfile; + m_metaVersion.reset(); + emit dataChanged(); + } catch (const Exception& error) { + qWarning() << "Version could not be loaded:" << error.cause(); + } + return true; +} + +bool Component::revert() +{ + if (!isCustom()) { + // already not custom + return true; + } + auto filename = getFilename(); + bool result = true; + // just kill the file and reload + if (QFile::exists(filename)) { + result = QFile::remove(filename); + } + if (result) { + // file gone... + m_file.reset(); + + // check local cache for metadata... + auto version = APPLICATION->metadataIndex()->get(m_uid, m_version); + if (version->isLoaded()) { + m_metaVersion = version; + } else { + m_metaVersion.reset(); + m_loaded = false; + } + emit dataChanged(); + } + return result; +} + +/** + * deep inspecting compare for requirement sets + * By default, only uids are compared for set operations. + * This compares all fields of the Require structs in the sets. + */ +static bool deepCompare(const std::set<Meta::Require>& a, + const std::set<Meta::Require>& b) +{ + // NOTE: this needs to be rewritten if the type of Meta::RequireSet changes + if (a.size() != b.size()) { + return false; + } + for (const auto& reqA : a) { + const auto& iter2 = b.find(reqA); + if (iter2 == b.cend()) { + return false; + } + const auto& reqB = *iter2; + if (!reqA.deepEquals(reqB)) { + return false; + } + } + return true; +} + +void Component::updateCachedData() +{ + auto file = getVersionFile(); + if (file) { + bool changed = false; + if (m_cachedName != file->name) { + m_cachedName = file->name; + changed = true; + } + if (m_cachedVersion != file->version) { + m_cachedVersion = file->version; + changed = true; + } + if (m_cachedVolatile != file->m_volatile) { + m_cachedVolatile = file->m_volatile; + changed = true; + } + if (!deepCompare(m_cachedRequires, file->requirements)) { + m_cachedRequires = file->requirements; + changed = true; + } + if (!deepCompare(m_cachedConflicts, file->conflicts)) { + m_cachedConflicts = file->conflicts; + changed = true; + } + if (changed) { + emit dataChanged(); + } + } else { + // in case we removed all the metadata + m_cachedRequires.clear(); + m_cachedConflicts.clear(); + emit dataChanged(); + } +} diff --git a/meshmc/launcher/minecraft/Component.h b/meshmc/launcher/minecraft/Component.h new file mode 100644 index 0000000000..6214fb623b --- /dev/null +++ b/meshmc/launcher/minecraft/Component.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/>. + */ + +#pragma once + +#include <memory> +#include <QList> +#include <QJsonDocument> +#include <QDateTime> +#include "meta/JsonFormat.h" +#include "ProblemProvider.h" +#include "QObjectPtr.h" + +class PackProfile; +class LaunchProfile; +namespace Meta +{ + class Version; + class VersionList; +} // namespace Meta +class VersionFile; + +class Component : public QObject, public ProblemProvider +{ + Q_OBJECT + public: + Component(PackProfile* parent, const QString& uid); + + // DEPRECATED: remove these constructors? + Component(PackProfile* parent, std::shared_ptr<Meta::Version> version); + Component(PackProfile* parent, const QString& uid, + std::shared_ptr<VersionFile> file); + + virtual ~Component() {}; + void applyTo(LaunchProfile* profile); + + bool isEnabled(); + bool setEnabled(bool state); + bool canBeDisabled(); + + bool isMoveable(); + bool isCustomizable(); + bool isRevertible(); + bool isRemovable(); + bool isCustom(); + bool isVersionChangeable(); + + // DEPRECATED: explicit numeric order values, used for loading old + // non-component config. TODO: refactor and move to migration code + void setOrder(int order); + int getOrder(); + + QString getID(); + QString getName(); + QString getVersion(); + std::shared_ptr<Meta::Version> getMeta(); + QDateTime getReleaseDateTime(); + + QString getFilename(); + + std::shared_ptr<class VersionFile> getVersionFile() const; + std::shared_ptr<class Meta::VersionList> getVersionList() const; + + void setImportant(bool state); + + const QList<PatchProblem> getProblems() const override; + ProblemSeverity getProblemSeverity() const override; + + void setVersion(const QString& version); + bool customize(); + bool revert(); + + void updateCachedData(); + + signals: + void dataChanged(); + + public: /* data */ + PackProfile* m_parent; + + // BEGIN: persistent component list properties + /// ID of the component + QString m_uid; + /// version of the component - when there's a custom json override, this is + /// also the version the component reverts to + QString m_version; + /// if true, this has been added automatically to satisfy dependencies and + /// may be automatically removed + bool m_dependencyOnly = false; + /// if true, the component is either the main component of the instance, or + /// otherwise important and cannot be removed. + bool m_important = false; + /// if true, the component is disabled + bool m_disabled = false; + + /// cached name for display purposes, taken from the version file (meta or + /// local override) + QString m_cachedName; + /// cached version for display AND other purposes, taken from the version + /// file (meta or local override) + QString m_cachedVersion; + /// cached set of requirements, taken from the version file (meta or local + /// override) + Meta::RequireSet m_cachedRequires; + Meta::RequireSet m_cachedConflicts; + /// if true, the component is volatile and may be automatically removed when + /// no longer needed + bool m_cachedVolatile = false; + // END: persistent component list properties + + // DEPRECATED: explicit numeric order values, used for loading old + // non-component config. TODO: refactor and move to migration code + bool m_orderOverride = false; + int m_order = 0; + + // load state + std::shared_ptr<Meta::Version> m_metaVersion; + std::shared_ptr<VersionFile> m_file; + bool m_loaded = false; +}; + +typedef shared_qobject_ptr<Component> ComponentPtr; diff --git a/meshmc/launcher/minecraft/ComponentUpdateTask.cpp b/meshmc/launcher/minecraft/ComponentUpdateTask.cpp new file mode 100644 index 0000000000..33d549cfd6 --- /dev/null +++ b/meshmc/launcher/minecraft/ComponentUpdateTask.cpp @@ -0,0 +1,667 @@ +/* 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 "ComponentUpdateTask.h" + +#include "PackProfile_p.h" +#include "PackProfile.h" +#include "Component.h" +#include "meta/Index.h" +#include "meta/VersionList.h" +#include "meta/Version.h" +#include "ComponentUpdateTask_p.h" +#include "cassert" +#include "Version.h" +#include "net/Mode.h" +#include "OneSixVersionFormat.h" + +#include "Application.h" + +/* + * This is responsible for loading the components of a component list AND + * resolving dependency issues between them + */ + +/* + * FIXME: the 'one shot async task' nature of this does not fit the intended + * usage Really, it should be a reactor/state machine that receives input from + * the application and dynamically adapts to changing requirements... + * + * The reactor should be the only entry into manipulating the PackProfile. + * See: https://en.wikipedia.org/wiki/Reactor_pattern + */ + +/* + * Or make this operate on a snapshot of the PackProfile state, then merge + * results in as long as the snapshot and PackProfile didn't change? If the + * component list changes, start over. + */ + +ComponentUpdateTask::ComponentUpdateTask(Mode mode, Net::Mode netmode, + PackProfile* list, QObject* parent) + : Task(parent) +{ + d.reset(new ComponentUpdateTaskData); + d->m_list = list; + d->mode = mode; + d->netmode = netmode; +} + +ComponentUpdateTask::~ComponentUpdateTask() {} + +void ComponentUpdateTask::executeTask() +{ + qDebug() << "Loading components"; + loadComponents(); +} + +namespace +{ + enum class LoadResult { LoadedLocal, RequiresRemote, Failed }; + + LoadResult composeLoadResult(LoadResult a, LoadResult b) + { + if (a < b) { + return b; + } + return a; + } + + static LoadResult loadComponent(ComponentPtr component, Task::Ptr& loadTask, + Net::Mode netmode) + { + if (component->m_loaded) { + qDebug() << component->getName() << "is already loaded"; + return LoadResult::LoadedLocal; + } + + LoadResult result = LoadResult::Failed; + auto customPatchFilename = component->getFilename(); + if (QFile::exists(customPatchFilename)) { + // if local file exists... + + // check for uid problems inside... + bool fileChanged = false; + auto file = ProfileUtils::parseJsonFile( + QFileInfo(customPatchFilename), false); + if (file->uid != component->m_uid) { + file->uid = component->m_uid; + fileChanged = true; + } + if (fileChanged) { + // FIXME: @QUALITY do not ignore return value + ProfileUtils::saveJsonFile( + OneSixVersionFormat::versionFileToJson(file), + customPatchFilename); + } + + component->m_file = file; + component->m_loaded = true; + result = LoadResult::LoadedLocal; + } else { + auto metaVersion = APPLICATION->metadataIndex()->get( + component->m_uid, component->m_version); + component->m_metaVersion = metaVersion; + if (metaVersion->isLoaded()) { + component->m_loaded = true; + result = LoadResult::LoadedLocal; + } else { + metaVersion->load(netmode); + loadTask = metaVersion->getCurrentTask(); + if (loadTask) + result = LoadResult::RequiresRemote; + else if (metaVersion->isLoaded()) + result = LoadResult::LoadedLocal; + else + result = LoadResult::Failed; + } + } + return result; + } + + // FIXME: dead code. determine if this can still be useful? + /* + static LoadResult loadPackProfile(ComponentPtr component, Task::Ptr& + loadTask, Net::Mode netmode) + { + if(component->m_loaded) + { + qDebug() << component->getName() << "is already loaded"; + return LoadResult::LoadedLocal; + } + + LoadResult result = LoadResult::Failed; + auto metaList = APPLICATION->metadataIndex()->get(component->m_uid); + if(metaList->isLoaded()) + { + component->m_loaded = true; + result = LoadResult::LoadedLocal; + } + else + { + metaList->load(netmode); + loadTask = metaList->getCurrentTask(); + result = LoadResult::RequiresRemote; + } + return result; + } + */ + + static LoadResult loadIndex(Task::Ptr& loadTask, Net::Mode netmode) + { + // FIXME: DECIDE. do we want to run the update task anyway? + if (APPLICATION->metadataIndex()->isLoaded()) { + qDebug() << "Index is already loaded"; + return LoadResult::LoadedLocal; + } + APPLICATION->metadataIndex()->load(netmode); + loadTask = APPLICATION->metadataIndex()->getCurrentTask(); + if (loadTask) { + return LoadResult::RequiresRemote; + } + // FIXME: this is assuming the load succeeded... did it really? + return LoadResult::LoadedLocal; + } +} // namespace + +void ComponentUpdateTask::loadComponents() +{ + LoadResult result = LoadResult::LoadedLocal; + size_t taskIndex = 0; + size_t componentIndex = 0; + d->remoteLoadSuccessful = true; + // load the main index (it is needed to determine if components can revert) + { + // FIXME: tear out as a method? or lambda? + Task::Ptr indexLoadTask; + auto singleResult = loadIndex(indexLoadTask, d->netmode); + result = composeLoadResult(result, singleResult); + if (indexLoadTask) { + qDebug() << "Remote loading is being run for metadata index"; + RemoteLoadStatus status; + status.type = RemoteLoadStatus::Type::Index; + d->remoteLoadStatusList.append(status); + connect(indexLoadTask.get(), &Task::succeeded, + [this, taskIndex]() { remoteLoadSucceeded(taskIndex); }); + connect(indexLoadTask.get(), &Task::failed, + [this, taskIndex](const QString& error) { + remoteLoadFailed(taskIndex, error); + }); + taskIndex++; + } + } + // load all the components OR their lists... + for (auto component : d->m_list->d->components) { + Task::Ptr loadTask; + LoadResult singleResult; + RemoteLoadStatus::Type loadType; + // FIXME: to do this right, we need to load the lists and decide on + // which versions to use during dependency resolution. For now, ignore + // all that... +#if 0 + switch(d->mode) + { + case Mode::Launch: + { + singleResult = loadComponent(component, loadTask, d->netmode); + loadType = RemoteLoadStatus::Type::Version; + break; + } + case Mode::Resolution: + { + singleResult = loadPackProfile(component, loadTask, d->netmode); + loadType = RemoteLoadStatus::Type::List; + break; + } + } +#else + singleResult = loadComponent(component, loadTask, d->netmode); + loadType = RemoteLoadStatus::Type::Version; +#endif + if (singleResult == LoadResult::LoadedLocal) { + component->updateCachedData(); + } + result = composeLoadResult(result, singleResult); + if (loadTask) { + qDebug() << "Remote loading is being run for" + << component->getName(); + connect(loadTask.get(), &Task::succeeded, + [this, taskIndex]() { remoteLoadSucceeded(taskIndex); }); + connect(loadTask.get(), &Task::failed, + [this, taskIndex](const QString& error) { + remoteLoadFailed(taskIndex, error); + }); + RemoteLoadStatus status; + status.type = loadType; + status.PackProfileIndex = componentIndex; + d->remoteLoadStatusList.append(status); + taskIndex++; + } + componentIndex++; + } + d->remoteTasksInProgress = taskIndex; + switch (result) { + case LoadResult::LoadedLocal: { + // Everything got loaded. Advance to dependency resolution. + resolveDependencies(d->mode == Mode::Launch || + d->netmode == Net::Mode::Offline); + break; + } + case LoadResult::RequiresRemote: { + // we wait for signals. + break; + } + case LoadResult::Failed: { + emitFailed(tr("Some component metadata load tasks failed.")); + break; + } + } +} + +namespace +{ + struct RequireEx : public Meta::Require { + size_t indexOfFirstDependee = 0; + }; + struct RequireCompositionResult { + bool ok; + RequireEx outcome; + }; + using RequireExSet = std::set<RequireEx>; +} // namespace + +static RequireCompositionResult composeRequirement(const RequireEx& a, + const RequireEx& b) +{ + assert(a.uid == b.uid); + RequireEx out; + out.uid = a.uid; + out.indexOfFirstDependee = + std::min(a.indexOfFirstDependee, b.indexOfFirstDependee); + if (a.equalsVersion.isEmpty()) { + out.equalsVersion = b.equalsVersion; + } else if (b.equalsVersion.isEmpty()) { + out.equalsVersion = a.equalsVersion; + } else if (a.equalsVersion == b.equalsVersion) { + out.equalsVersion = a.equalsVersion; + } else { + // FIXME: mark error as explicit version conflict + return {false, out}; + } + + if (a.suggests.isEmpty()) { + out.suggests = b.suggests; + } else if (b.suggests.isEmpty()) { + out.suggests = a.suggests; + } else { + Version aVer(a.suggests); + Version bVer(b.suggests); + out.suggests = (aVer < bVer ? b.suggests : a.suggests); + } + return {true, out}; +} + +// gather the requirements from all components, finding any obvious conflicts +static bool gatherRequirementsFromComponents(const ComponentContainer& input, + RequireExSet& output) +{ + bool succeeded = true; + size_t componentNum = 0; + for (auto component : input) { + auto& componentRequires = component->m_cachedRequires; + for (const auto& componentRequire : componentRequires) { + auto found = + std::find_if(output.cbegin(), output.cend(), + [componentRequire](const Meta::Require& req) { + return req.uid == componentRequire.uid; + }); + + RequireEx componenRequireEx; + componenRequireEx.uid = componentRequire.uid; + componenRequireEx.suggests = componentRequire.suggests; + componenRequireEx.equalsVersion = componentRequire.equalsVersion; + componenRequireEx.indexOfFirstDependee = componentNum; + + if (found != output.cend()) { + // found... process it further + auto result = composeRequirement(componenRequireEx, *found); + if (result.ok) { + output.erase(componenRequireEx); + output.insert(result.outcome); + } else { + qCritical() + << "Conflicting requirements:" << componentRequire.uid + << "versions:" << componentRequire.equalsVersion << ";" + << (*found).equalsVersion; + } + succeeded &= result.ok; + } else { + // not found, accumulate + output.insert(componenRequireEx); + } + } + componentNum++; + } + return succeeded; +} + +/// Get list of uids that can be trivially removed because nothing is depending +/// on them anymore (and they are installed as deps) +static void getTrivialRemovals(const ComponentContainer& components, + const RequireExSet& reqs, QStringList& toRemove) +{ + for (const auto& component : components) { + if (!component->m_dependencyOnly) + continue; + if (!component->m_cachedVolatile) + continue; + RequireEx reqNeedle; + reqNeedle.uid = component->m_uid; + const auto iter = reqs.find(reqNeedle); + if (iter == reqs.cend()) { + toRemove.append(component->m_uid); + } + } +} + +/** + * handles: + * - trivial addition (there is an unmet requirement and it can be trivially met + * by adding something) + * - trivial version conflict of dependencies == explicit version required and + * installed is different + * + * toAdd - set of requirements than mean adding a new component + * toChange - set of requirements that mean changing version of an existing + * component + */ +static bool getTrivialComponentChanges(const ComponentIndex& index, + const RequireExSet& input, + RequireExSet& toAdd, + RequireExSet& toChange) +{ + enum class Decision { + Undetermined, + Met, + Missing, + VersionNotSame, + LockedVersionNotSame + } decision = Decision::Undetermined; + + QString reqStr; + bool succeeded = true; + // list the composed requirements and say if they are met or unmet + for (auto& req : input) { + do { + if (req.equalsVersion.isEmpty()) { + reqStr = QString("Req: %1").arg(req.uid); + if (index.contains(req.uid)) { + decision = Decision::Met; + } else { + toAdd.insert(req); + decision = Decision::Missing; + } + break; + } else { + reqStr = + QString("Req: %1 == %2").arg(req.uid, req.equalsVersion); + const auto& compIter = index.find(req.uid); + if (compIter == index.cend()) { + toAdd.insert(req); + decision = Decision::Missing; + break; + } + auto& comp = (*compIter); + if (comp->getVersion() != req.equalsVersion) { + if (comp->isCustom()) { + decision = Decision::LockedVersionNotSame; + } else { + if (comp->m_dependencyOnly) { + decision = Decision::VersionNotSame; + } else { + decision = Decision::LockedVersionNotSame; + } + } + break; + } + decision = Decision::Met; + } + } while (false); + switch (decision) { + case Decision::Undetermined: + qCritical() << "No decision for" << reqStr; + succeeded = false; + break; + case Decision::Met: + qDebug() << reqStr << "Is met."; + break; + case Decision::Missing: + qDebug() << reqStr << "Is missing and should be added at" + << req.indexOfFirstDependee; + toAdd.insert(req); + break; + case Decision::VersionNotSame: + qDebug() + << reqStr + << "already has different version that can be changed."; + toChange.insert(req); + break; + case Decision::LockedVersionNotSame: + qDebug() + << reqStr + << "already has different version that cannot be changed."; + succeeded = false; + break; + } + } + return succeeded; +} + +// FIXME, TODO: decouple dependency resolution from loading +// FIXME: This works directly with the PackProfile internals. It shouldn't! It +// needs richer data types than PackProfile uses. +// FIXME: throw all this away and use a graph +void ComponentUpdateTask::resolveDependencies(bool checkOnly) +{ + qDebug() << "Resolving dependencies"; + /* + * this is a naive dependency resolving algorithm. all it does is check for + * following conditions and react in simple ways: + * 1. There are conflicting dependencies on the same uid with different + * exact version numbers + * -> hard error + * 2. A dependency has non-matching exact version number + * -> hard error + * 3. A dependency is entirely missing and needs to be injected before the + * dependee(s) + * -> requirements are injected + * + * NOTE: this is a placeholder and should eventually be replaced with + * something 'serious' + */ + auto& components = d->m_list->d->components; + auto& componentIndex = d->m_list->d->componentIndex; + + RequireExSet allRequires; + QStringList toRemove; + do { + allRequires.clear(); + toRemove.clear(); + if (!gatherRequirementsFromComponents(components, allRequires)) { + emitFailed(tr("Conflicting requirements detected during dependency " + "checking!")); + return; + } + getTrivialRemovals(components, allRequires, toRemove); + if (!toRemove.isEmpty()) { + qDebug() << "Removing obsolete components..."; + for (auto& remove : toRemove) { + qDebug() << "Removing" << remove; + d->m_list->remove(remove); + } + } + } while (!toRemove.isEmpty()); + RequireExSet toAdd; + RequireExSet toChange; + bool succeeded = getTrivialComponentChanges(componentIndex, allRequires, + toAdd, toChange); + if (!succeeded) { + emitFailed(tr("Instance has conflicting dependencies.")); + return; + } + if (checkOnly) { + if (toAdd.size() || toChange.size()) { + emitFailed(tr("Instance has unresolved dependencies while " + "loading/checking for launch.")); + } else { + emitSucceeded(); + } + return; + } + + bool recursionNeeded = false; + if (toAdd.size()) { + // add stuff... + for (auto& add : toAdd) { + ComponentPtr component = new Component(d->m_list, add.uid); + if (!add.equalsVersion.isEmpty()) { + // exact version + qDebug() << "Adding" << add.uid << "version" + << add.equalsVersion << "at position" + << add.indexOfFirstDependee; + component->m_version = add.equalsVersion; + } else { + // version needs to be decided + qDebug() << "Adding" << add.uid << "at position" + << add.indexOfFirstDependee; + // ############################################################################################################ + // HACK HACK HACK HACK FIXME: this is a placeholder for deciding + // what version to use. For now, it is hardcoded. + if (!add.suggests.isEmpty()) { + component->m_version = add.suggests; + } else { + if (add.uid == "org.lwjgl") { + component->m_version = "2.9.1"; + } else if (add.uid == "org.lwjgl3") { + component->m_version = "3.1.2"; + } else if (add.uid == "net.fabricmc.intermediary") { + auto minecraft = std::find_if( + components.begin(), components.end(), + [](ComponentPtr& cmp) { + return cmp->getID() == "net.minecraft"; + }); + if (minecraft != components.end()) { + component->m_version = (*minecraft)->getVersion(); + } + } + } + // HACK HACK HACK HACK FIXME: this is a placeholder for deciding + // what version to use. For now, it is hardcoded. + // ############################################################################################################ + } + component->m_dependencyOnly = true; + // FIXME: this should not work directly with the component list + d->m_list->insertComponent(add.indexOfFirstDependee, component); + componentIndex[add.uid] = component; + } + recursionNeeded = true; + } + if (toChange.size()) { + // change a version of something that exists + for (auto& change : toChange) { + // FIXME: this should not work directly with the component list + qDebug() << "Setting version of " << change.uid << "to" + << change.equalsVersion; + auto component = componentIndex[change.uid]; + component->setVersion(change.equalsVersion); + } + recursionNeeded = true; + } + + if (recursionNeeded) { + loadComponents(); + } else { + emitSucceeded(); + } +} + +void ComponentUpdateTask::remoteLoadSucceeded(size_t taskIndex) +{ + auto& taskSlot = d->remoteLoadStatusList[taskIndex]; + if (taskSlot.finished) { + qWarning() << "Got multiple results from remote load task" << taskIndex; + return; + } + qDebug() << "Remote task" << taskIndex << "succeeded"; + taskSlot.succeeded = false; + taskSlot.finished = true; + d->remoteTasksInProgress--; + // update the cached data of the component from the downloaded version file. + if (taskSlot.type == RemoteLoadStatus::Type::Version) { + auto component = d->m_list->getComponent(taskSlot.PackProfileIndex); + component->m_loaded = true; + component->updateCachedData(); + } + checkIfAllFinished(); +} + +void ComponentUpdateTask::remoteLoadFailed(size_t taskIndex, const QString& msg) +{ + auto& taskSlot = d->remoteLoadStatusList[taskIndex]; + if (taskSlot.finished) { + qWarning() << "Got multiple results from remote load task" << taskIndex; + return; + } + qDebug() << "Remote task" << taskIndex << "failed: " << msg; + d->remoteLoadSuccessful = false; + taskSlot.succeeded = false; + taskSlot.finished = true; + taskSlot.error = msg; + d->remoteTasksInProgress--; + checkIfAllFinished(); +} + +void ComponentUpdateTask::checkIfAllFinished() +{ + if (d->remoteTasksInProgress) { + // not yet... + return; + } + if (d->remoteLoadSuccessful) { + // nothing bad happened... clear the temp load status and proceed with + // looking at dependencies + d->remoteLoadStatusList.clear(); + resolveDependencies(d->mode == Mode::Launch); + } else { + // remote load failed... report error and bail + QStringList allErrorsList; + for (auto& item : d->remoteLoadStatusList) { + if (!item.succeeded) { + allErrorsList.append(item.error); + } + } + auto allErrors = allErrorsList.join("\n"); + emitFailed(tr("Component metadata update task failed while downloading " + "from remote server:\n%1") + .arg(allErrors)); + d->remoteLoadStatusList.clear(); + } +} diff --git a/meshmc/launcher/minecraft/ComponentUpdateTask.h b/meshmc/launcher/minecraft/ComponentUpdateTask.h new file mode 100644 index 0000000000..b50b2b65f2 --- /dev/null +++ b/meshmc/launcher/minecraft/ComponentUpdateTask.h @@ -0,0 +1,55 @@ +/* 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 "tasks/Task.h" +#include "net/Mode.h" + +#include <memory> +class PackProfile; +struct ComponentUpdateTaskData; + +class ComponentUpdateTask : public Task +{ + Q_OBJECT + public: + enum class Mode { Launch, Resolution }; + + public: + explicit ComponentUpdateTask(Mode mode, Net::Mode netmode, + PackProfile* list, QObject* parent = 0); + virtual ~ComponentUpdateTask(); + + protected: + void executeTask(); + + private: + void loadComponents(); + void resolveDependencies(bool checkOnly); + + void remoteLoadSucceeded(size_t index); + void remoteLoadFailed(size_t index, const QString& msg); + void checkIfAllFinished(); + + private: + std::unique_ptr<ComponentUpdateTaskData> d; +}; diff --git a/meshmc/launcher/minecraft/ComponentUpdateTask_p.h b/meshmc/launcher/minecraft/ComponentUpdateTask_p.h new file mode 100644 index 0000000000..a3e6b62b7f --- /dev/null +++ b/meshmc/launcher/minecraft/ComponentUpdateTask_p.h @@ -0,0 +1,46 @@ +/* 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 <cstddef> +#include <QString> +#include <QList> +#include "net/Mode.h" + +class PackProfile; + +struct RemoteLoadStatus { + enum class Type { Index, List, Version } type = Type::Version; + size_t PackProfileIndex = 0; + bool finished = false; + bool succeeded = false; + QString error; +}; + +struct ComponentUpdateTaskData { + PackProfile* m_list = nullptr; + QList<RemoteLoadStatus> remoteLoadStatusList; + bool remoteLoadSuccessful = true; + size_t remoteTasksInProgress = 0; + ComponentUpdateTask::Mode mode; + Net::Mode netmode; +}; diff --git a/meshmc/launcher/minecraft/GradleSpecifier.h b/meshmc/launcher/minecraft/GradleSpecifier.h new file mode 100644 index 0000000000..cc0f8ebd42 --- /dev/null +++ b/meshmc/launcher/minecraft/GradleSpecifier.h @@ -0,0 +1,175 @@ +/* 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> +#include <QRegularExpression> +#include "DefaultVariable.h" + +struct GradleSpecifier { + GradleSpecifier() + { + m_valid = false; + } + GradleSpecifier(QString value) + { + operator=(value); + } + GradleSpecifier& operator=(const QString& value) + { + /* + org.gradle.test.classifiers : service : 1.0 : jdk15 @ jar + 0 "org.gradle.test.classifiers:service:1.0:jdk15@jar" + 1 "org.gradle.test.classifiers" + 2 "service" + 3 "1.0" + 4 "jdk15" + 5 "jar" + */ + QRegularExpression matcher("^([^:@]+):([^:@]+):([^:@]+)" + "(?::([^:@]+))?" + "(?:@([^:@]+))?$"); + auto match = matcher.match(value); + m_valid = match.hasMatch(); + if (!m_valid) { + m_invalidValue = value; + return *this; + } + m_groupId = match.captured(1); + m_artifactId = match.captured(2); + m_version = match.captured(3); + m_classifier = match.captured(4); + auto ext = match.captured(5); + if (!ext.isEmpty()) { + m_extension = ext; + } + return *this; + } + QString serialize() const + { + if (!m_valid) { + return m_invalidValue; + } + QString retval = m_groupId + ":" + m_artifactId + ":" + m_version; + if (!m_classifier.isEmpty()) { + retval += ":" + m_classifier; + } + if (m_extension.isExplicit()) { + retval += "@" + m_extension; + } + return retval; + } + QString getFileName() const + { + if (!m_valid) { + return QString(); + } + QString filename = m_artifactId + '-' + m_version; + if (!m_classifier.isEmpty()) { + filename += "-" + m_classifier; + } + filename += "." + m_extension; + return filename; + } + QString toPath(const QString& filenameOverride = QString()) const + { + if (!m_valid) { + return QString(); + } + QString filename; + if (filenameOverride.isEmpty()) { + filename = getFileName(); + } else { + filename = filenameOverride; + } + QString path = m_groupId; + path.replace('.', '/'); + path += '/' + m_artifactId + '/' + m_version + '/' + filename; + return path; + } + inline bool valid() const + { + return m_valid; + } + inline QString version() const + { + return m_version; + } + inline QString groupId() const + { + return m_groupId; + } + inline QString artifactId() const + { + return m_artifactId; + } + inline void setClassifier(const QString& classifier) + { + m_classifier = classifier; + } + inline QString classifier() const + { + return m_classifier; + } + inline QString extension() const + { + return m_extension; + } + inline QString artifactPrefix() const + { + return m_groupId + ":" + m_artifactId; + } + bool matchName(const GradleSpecifier& other) const + { + // Classifiers differentiate otherwise identical coordinates (e.g. the + // base lwjgl-glfw:3.3.2 jar vs lwjgl-glfw:3.3.2:natives-linux). Two + // entries with different classifiers must be treated as distinct + // library entries. + return other.artifactId() == artifactId() && + other.groupId() == groupId() && + other.m_classifier == m_classifier; + } + bool operator==(const GradleSpecifier& other) const + { + if (m_groupId != other.m_groupId) + return false; + if (m_artifactId != other.m_artifactId) + return false; + if (m_version != other.m_version) + return false; + if (m_classifier != other.m_classifier) + return false; + if (m_extension != other.m_extension) + return false; + return true; + } + + private: + QString m_invalidValue; + QString m_groupId; + QString m_artifactId; + QString m_version; + QString m_classifier; + DefaultVariable<QString> m_extension = DefaultVariable<QString>("jar"); + bool m_valid = false; +}; diff --git a/meshmc/launcher/minecraft/GradleSpecifier_test.cpp b/meshmc/launcher/minecraft/GradleSpecifier_test.cpp new file mode 100644 index 0000000000..bea37b69db --- /dev/null +++ b/meshmc/launcher/minecraft/GradleSpecifier_test.cpp @@ -0,0 +1,99 @@ +/* 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 "TestUtil.h" + +#include "minecraft/GradleSpecifier.h" + +class GradleSpecifierTest : public QObject +{ + Q_OBJECT + private slots: + void initTestCase() {} + void cleanupTestCase() {} + + void test_Positive_data() + { + QTest::addColumn<QString>("through"); + + QTest::newRow("3 parter") << "org.gradle.test.classifiers:service:1.0"; + QTest::newRow("classifier") + << "org.gradle.test.classifiers:service:1.0:jdk15"; + QTest::newRow("jarextension") + << "org.gradle.test.classifiers:service:1.0@jar"; + QTest::newRow("jarboth") + << "org.gradle.test.classifiers:service:1.0:jdk15@jar"; + QTest::newRow("packxz") + << "org.gradle.test.classifiers:service:1.0:jdk15@jar.pack.xz"; + } + void test_Positive() + { + QFETCH(QString, through); + + QString converted = GradleSpecifier(through).serialize(); + + QCOMPARE(converted, through); + } + + void test_Path_data() + { + QTest::addColumn<QString>("spec"); + QTest::addColumn<QString>("expected"); + + QTest::newRow("3 parter") << "group.id:artifact:1.0" + << "group/id/artifact/1.0/artifact-1.0.jar"; + QTest::newRow("doom") << "id.software:doom:1.666:demons@wad" + << "id/software/doom/1.666/doom-1.666-demons.wad"; + } + void test_Path() + { + QFETCH(QString, spec); + QFETCH(QString, expected); + + QString converted = GradleSpecifier(spec).toPath(); + + QCOMPARE(converted, expected); + } + void test_Negative_data() + { + QTest::addColumn<QString>("input"); + + QTest::newRow("too many :") + << "org:gradle.test:class:::ifiers:service:1.0::"; + QTest::newRow("nonsense") << "I like turtles"; + QTest::newRow("empty string") << ""; + QTest::newRow("missing version") << "herp.derp:artifact"; + } + void test_Negative() + { + QFETCH(QString, input); + + GradleSpecifier spec(input); + QVERIFY(!spec.valid()); + QCOMPARE(spec.serialize(), input); + QCOMPARE(spec.toPath(), QString()); + } +}; + +QTEST_GUILESS_MAIN(GradleSpecifierTest) + +#include "GradleSpecifier_test.moc" diff --git a/meshmc/launcher/minecraft/LaunchProfile.cpp b/meshmc/launcher/minecraft/LaunchProfile.cpp new file mode 100644 index 0000000000..a94a6d83a6 --- /dev/null +++ b/meshmc/launcher/minecraft/LaunchProfile.cpp @@ -0,0 +1,316 @@ +/* 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 "LaunchProfile.h" +#include <Version.h> + +void LaunchProfile::clear() +{ + m_minecraftVersion.clear(); + m_minecraftVersionType.clear(); + m_minecraftAssets.reset(); + m_minecraftArguments.clear(); + m_tweakers.clear(); + m_mainClass.clear(); + m_appletClass.clear(); + m_libraries.clear(); + m_mavenFiles.clear(); + m_traits.clear(); + m_jarMods.clear(); + m_mainJar.reset(); + m_problemSeverity = ProblemSeverity::None; +} + +static void applyString(const QString& from, QString& to) +{ + if (from.isEmpty()) + return; + to = from; +} + +void LaunchProfile::applyMinecraftVersion(const QString& id) +{ + applyString(id, this->m_minecraftVersion); +} + +void LaunchProfile::applyAppletClass(const QString& appletClass) +{ + applyString(appletClass, this->m_appletClass); +} + +void LaunchProfile::applyMainClass(const QString& mainClass) +{ + applyString(mainClass, this->m_mainClass); +} + +void LaunchProfile::applyMinecraftArguments(const QString& minecraftArguments) +{ + applyString(minecraftArguments, this->m_minecraftArguments); +} + +void LaunchProfile::applyMinecraftVersionType(const QString& type) +{ + applyString(type, this->m_minecraftVersionType); +} + +void LaunchProfile::applyMinecraftAssets(MojangAssetIndexInfo::Ptr assets) +{ + if (assets) { + m_minecraftAssets = assets; + } +} + +void LaunchProfile::applyTraits(const QSet<QString>& traits) +{ + this->m_traits.unite(traits); +} + +void LaunchProfile::applyTweakers(const QStringList& tweakers) +{ + // if the applied tweakers override an existing one, skip it. this + // effectively moves it later in the sequence + QStringList newTweakers; + for (auto& tweaker : m_tweakers) { + if (tweakers.contains(tweaker)) { + continue; + } + newTweakers.append(tweaker); + } + // then just append the new tweakers (or moved original ones) + newTweakers += tweakers; + m_tweakers = newTweakers; +} + +void LaunchProfile::applyJarMods(const QList<LibraryPtr>& jarMods) +{ + this->m_jarMods.append(jarMods); +} + +static int findLibraryByName(QList<LibraryPtr>* haystack, + const GradleSpecifier& needle) +{ + int retval = -1; + for (int i = 0; i < haystack->size(); ++i) { + if (haystack->at(i)->rawName().matchName(needle)) { + // only one is allowed. + if (retval != -1) + return -1; + retval = i; + } + } + return retval; +} + +void LaunchProfile::applyMods(const QList<LibraryPtr>& mods) +{ + QList<LibraryPtr>* list = &m_mods; + for (auto& mod : mods) { + auto modCopy = Library::limitedCopy(mod); + + // find the mod by name. + const int index = findLibraryByName(list, mod->rawName()); + // mod not found? just add it. + if (index < 0) { + list->append(modCopy); + return; + } + + auto existingLibrary = list->at(index); + // if we are higher it means we should update + if (Version(mod->version()) > Version(existingLibrary->version())) { + list->replace(index, modCopy); + } + } +} + +void LaunchProfile::applyLibrary(LibraryPtr library) +{ + if (!library->isActive()) { + return; + } + + QList<LibraryPtr>* list = &m_libraries; + if (library->isNative()) { + list = &m_nativeLibraries; + } + + auto libraryCopy = Library::limitedCopy(library); + + // find the library by name. + const int index = findLibraryByName(list, library->rawName()); + // library not found? just add it. + if (index < 0) { + list->append(libraryCopy); + return; + } + + auto existingLibrary = list->at(index); + // if we are higher it means we should update + if (Version(library->version()) > Version(existingLibrary->version())) { + list->replace(index, libraryCopy); + } +} + +void LaunchProfile::applyMavenFile(LibraryPtr mavenFile) +{ + if (!mavenFile->isActive()) { + return; + } + + if (mavenFile->isNative()) { + return; + } + + // unlike libraries, we do not keep only one version or try to dedupe them + m_mavenFiles.append(Library::limitedCopy(mavenFile)); +} + +const LibraryPtr LaunchProfile::getMainJar() const +{ + return m_mainJar; +} + +void LaunchProfile::applyMainJar(LibraryPtr jar) +{ + if (jar) { + m_mainJar = jar; + } +} + +void LaunchProfile::applyProblemSeverity(ProblemSeverity severity) +{ + if (m_problemSeverity < severity) { + m_problemSeverity = severity; + } +} + +const QList<PatchProblem> LaunchProfile::getProblems() const +{ + // FIXME: implement something that actually makes sense here + return {}; +} + +QString LaunchProfile::getMinecraftVersion() const +{ + return m_minecraftVersion; +} + +QString LaunchProfile::getAppletClass() const +{ + return m_appletClass; +} + +QString LaunchProfile::getMainClass() const +{ + return m_mainClass; +} + +const QSet<QString>& LaunchProfile::getTraits() const +{ + return m_traits; +} + +const QStringList& LaunchProfile::getTweakers() const +{ + return m_tweakers; +} + +bool LaunchProfile::hasTrait(const QString& trait) const +{ + return m_traits.contains(trait); +} + +ProblemSeverity LaunchProfile::getProblemSeverity() const +{ + return m_problemSeverity; +} + +QString LaunchProfile::getMinecraftVersionType() const +{ + return m_minecraftVersionType; +} + +std::shared_ptr<MojangAssetIndexInfo> LaunchProfile::getMinecraftAssets() const +{ + if (!m_minecraftAssets) { + return std::make_shared<MojangAssetIndexInfo>("legacy"); + } + return m_minecraftAssets; +} + +QString LaunchProfile::getMinecraftArguments() const +{ + return m_minecraftArguments; +} + +const QList<LibraryPtr>& LaunchProfile::getJarMods() const +{ + return m_jarMods; +} + +const QList<LibraryPtr>& LaunchProfile::getLibraries() const +{ + return m_libraries; +} + +const QList<LibraryPtr>& LaunchProfile::getNativeLibraries() const +{ + return m_nativeLibraries; +} + +const QList<LibraryPtr>& LaunchProfile::getMavenFiles() const +{ + return m_mavenFiles; +} + +void LaunchProfile::getLibraryFiles(const QString& architecture, + QStringList& jars, QStringList& nativeJars, + const QString& overridePath, + const QString& tempPath) const +{ + QStringList native32, native64; + jars.clear(); + nativeJars.clear(); + for (auto lib : getLibraries()) { + lib->getApplicableFiles(currentSystem, jars, nativeJars, native32, + native64, overridePath); + } + // NOTE: order is important here, add main jar last to the lists + if (m_mainJar) { + // FIXME: HACK!! jar modding is weird and unsystematic! + if (m_jarMods.size()) { + QDir tempDir(tempPath); + jars.append(tempDir.absoluteFilePath("minecraft.jar")); + } else { + m_mainJar->getApplicableFiles(currentSystem, jars, nativeJars, + native32, native64, overridePath); + } + } + for (auto lib : getNativeLibraries()) { + lib->getApplicableFiles(currentSystem, jars, nativeJars, native32, + native64, overridePath); + } + if (architecture == "32") { + nativeJars.append(native32); + } else if (architecture == "64") { + nativeJars.append(native64); + } +} diff --git a/meshmc/launcher/minecraft/LaunchProfile.h b/meshmc/launcher/minecraft/LaunchProfile.h new file mode 100644 index 0000000000..b508c23d14 --- /dev/null +++ b/meshmc/launcher/minecraft/LaunchProfile.h @@ -0,0 +1,123 @@ +/* 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 "Library.h" +#include <ProblemProvider.h> + +class LaunchProfile : public ProblemProvider +{ + public: + virtual ~LaunchProfile() {}; + + public: /* application of profile variables from patches */ + void applyMinecraftVersion(const QString& id); + void applyMainClass(const QString& mainClass); + void applyAppletClass(const QString& appletClass); + void applyMinecraftArguments(const QString& minecraftArguments); + void applyMinecraftVersionType(const QString& type); + void applyMinecraftAssets(MojangAssetIndexInfo::Ptr assets); + void applyTraits(const QSet<QString>& traits); + void applyTweakers(const QStringList& tweakers); + void applyJarMods(const QList<LibraryPtr>& jarMods); + void applyMods(const QList<LibraryPtr>& jarMods); + void applyLibrary(LibraryPtr library); + void applyMavenFile(LibraryPtr library); + void applyMainJar(LibraryPtr jar); + void applyProblemSeverity(ProblemSeverity severity); + /// clear the profile + void clear(); + + public: /* getters for profile variables */ + QString getMinecraftVersion() const; + QString getMainClass() const; + QString getAppletClass() const; + QString getMinecraftVersionType() const; + MojangAssetIndexInfo::Ptr getMinecraftAssets() const; + QString getMinecraftArguments() const; + const QSet<QString>& getTraits() const; + const QStringList& getTweakers() const; + const QList<LibraryPtr>& getJarMods() const; + const QList<LibraryPtr>& getLibraries() const; + const QList<LibraryPtr>& getNativeLibraries() const; + const QList<LibraryPtr>& getMavenFiles() const; + const LibraryPtr getMainJar() const; + void getLibraryFiles(const QString& architecture, QStringList& jars, + QStringList& nativeJars, const QString& overridePath, + const QString& tempPath) const; + bool hasTrait(const QString& trait) const; + ProblemSeverity getProblemSeverity() const override; + const QList<PatchProblem> getProblems() const override; + + private: + /// the version of Minecraft - jar to use + QString m_minecraftVersion; + + /// Release type - "release" or "snapshot" + QString m_minecraftVersionType; + + /// Assets type - "legacy" or a version ID + MojangAssetIndexInfo::Ptr m_minecraftAssets; + + /** + * arguments that should be used for launching minecraft + * + * ex: "--username ${auth_player_name} --session ${auth_session} + * --version ${version_name} --gameDir ${game_directory} --assetsDir + * ${game_assets}" + */ + QString m_minecraftArguments; + + /// A list of all tweaker classes + QStringList m_tweakers; + + /// The main class to load first + QString m_mainClass; + + /// The applet class, for some very old minecraft releases + QString m_appletClass; + + /// the list of libraries + QList<LibraryPtr> m_libraries; + + /// the list of maven files to be placed in the libraries folder, but not + /// acted upon + QList<LibraryPtr> m_mavenFiles; + + /// the main jar + LibraryPtr m_mainJar; + + /// the list of native libraries + QList<LibraryPtr> m_nativeLibraries; + + /// traits, collected from all the version files (version files can only + /// add) + QSet<QString> m_traits; + + /// A list of jar mods. version files can add those. + QList<LibraryPtr> m_jarMods; + + /// the list of mods + QList<LibraryPtr> m_mods; + + ProblemSeverity m_problemSeverity = ProblemSeverity::None; +}; diff --git a/meshmc/launcher/minecraft/Library.cpp b/meshmc/launcher/minecraft/Library.cpp new file mode 100644 index 0000000000..8ecc32d200 --- /dev/null +++ b/meshmc/launcher/minecraft/Library.cpp @@ -0,0 +1,282 @@ +/* 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 "Library.h" +#include "MinecraftInstance.h" + +#include <net/Download.h> +#include <net/ChecksumValidator.h> +#include <FileSystem.h> +#include <BuildConfig.h> + +void Library::getApplicableFiles(OpSys system, QStringList& jar, + QStringList& native, QStringList& native32, + QStringList& native64, + const QString& overridePath) const +{ + bool local = isLocal(); + auto actualPath = [&](QString relPath) { + QFileInfo out(FS::PathCombine(storagePrefix(), relPath)); + if (local && !overridePath.isEmpty()) { + QString fileName = out.fileName(); + return QFileInfo(FS::PathCombine(overridePath, fileName)) + .absoluteFilePath(); + } + return out.absoluteFilePath(); + }; + QString raw_storage = storageSuffix(system); + if (isNative()) { + if (raw_storage.contains("${arch}")) { + auto nat32Storage = raw_storage; + nat32Storage.replace("${arch}", "32"); + auto nat64Storage = raw_storage; + nat64Storage.replace("${arch}", "64"); + native32 += actualPath(nat32Storage); + native64 += actualPath(nat64Storage); + } else { + native += actualPath(raw_storage); + } + } else { + jar += actualPath(raw_storage); + } +} + +QList<NetAction::Ptr> Library::getDownloads(OpSys system, + class HttpMetaCache* cache, + QStringList& failedLocalFiles, + const QString& overridePath) const +{ + QList<NetAction::Ptr> out; + bool stale = isAlwaysStale(); + bool local = isLocal(); + + auto check_local_file = [&](QString storage) { + QFileInfo fileinfo(storage); + QString fileName = fileinfo.fileName(); + auto fullPath = FS::PathCombine(overridePath, fileName); + QFileInfo localFileInfo(fullPath); + if (!localFileInfo.exists()) { + failedLocalFiles.append(localFileInfo.filePath()); + return false; + } + return true; + }; + + auto add_download = [&](QString storage, QString url, QString sha1) { + if (local) { + return check_local_file(storage); + } + auto entry = cache->resolveEntry("libraries", storage); + if (stale) { + entry->setStale(true); + } + if (!entry->isStale()) + return true; + Net::Download::Options options; + if (stale) { + options |= Net::Download::Option::AcceptLocalFiles; + } + + if (sha1.size()) { + auto rawSha1 = QByteArray::fromHex(sha1.toLatin1()); + auto dl = Net::Download::makeCached(url, entry, options); + dl->addValidator( + new Net::ChecksumValidator(QCryptographicHash::Sha1, rawSha1)); + qDebug() << "Checksummed Download for:" << rawName().serialize() + << "storage:" << storage << "url:" << url; + out.append(dl); + } else { + out.append(Net::Download::makeCached(url, entry, options)); + qDebug() << "Download for:" << rawName().serialize() + << "storage:" << storage << "url:" << url; + } + return true; + }; + + QString raw_storage = storageSuffix(system); + if (m_mojangDownloads) { + if (isNative()) { + if (m_nativeClassifiers.contains(system)) { + auto nativeClassifier = m_nativeClassifiers[system]; + if (nativeClassifier.contains("${arch}")) { + auto nat32Classifier = nativeClassifier; + nat32Classifier.replace("${arch}", "32"); + auto nat64Classifier = nativeClassifier; + nat64Classifier.replace("${arch}", "64"); + auto nat32info = + m_mojangDownloads->getDownloadInfo(nat32Classifier); + if (nat32info) { + auto cooked_storage = raw_storage; + cooked_storage.replace("${arch}", "32"); + add_download(cooked_storage, nat32info->url, + nat32info->sha1); + } + auto nat64info = + m_mojangDownloads->getDownloadInfo(nat64Classifier); + if (nat64info) { + auto cooked_storage = raw_storage; + cooked_storage.replace("${arch}", "64"); + add_download(cooked_storage, nat64info->url, + nat64info->sha1); + } + } else { + auto info = + m_mojangDownloads->getDownloadInfo(nativeClassifier); + if (info) { + add_download(raw_storage, info->url, info->sha1); + } + } + } else { + qDebug() << "Ignoring native library" << m_name.serialize() + << "because it has no classifier for current OS"; + } + } else { + if (m_mojangDownloads->artifact) { + auto artifact = m_mojangDownloads->artifact; + add_download(raw_storage, artifact->url, artifact->sha1); + } else { + qDebug() << "Ignoring java library" << m_name.serialize() + << "because it has no artifact"; + } + } + } else { + auto raw_dl = [&]() { + if (!m_absoluteURL.isEmpty()) { + return m_absoluteURL; + } + + if (m_repositoryURL.isEmpty()) { + return BuildConfig.LIBRARY_BASE + raw_storage; + } + + if (m_repositoryURL.endsWith('/')) { + return m_repositoryURL + raw_storage; + } else { + return m_repositoryURL + QChar('/') + raw_storage; + } + }(); + if (raw_storage.contains("${arch}")) { + QString cooked_storage = raw_storage; + QString cooked_dl = raw_dl; + add_download(cooked_storage.replace("${arch}", "32"), + cooked_dl.replace("${arch}", "32"), QString()); + cooked_storage = raw_storage; + cooked_dl = raw_dl; + add_download(cooked_storage.replace("${arch}", "64"), + cooked_dl.replace("${arch}", "64"), QString()); + } else { + add_download(raw_storage, raw_dl, QString()); + } + } + return out; +} + +bool Library::isActive() const +{ + bool result = true; + if (m_rules.empty()) { + result = true; + } else { + RuleAction ruleResult = Disallow; + for (auto rule : m_rules) { + RuleAction temp = rule->apply(this); + if (temp != Defer) + ruleResult = temp; + } + result = result && (ruleResult == Allow); + } + if (isNative()) { + result = result && m_nativeClassifiers.contains(currentSystem); + } + return result; +} + +bool Library::isLocal() const +{ + return m_hint == "local"; +} + +bool Library::isAlwaysStale() const +{ + return m_hint == "always-stale"; +} + +void Library::setStoragePrefix(QString prefix) +{ + m_storagePrefix = prefix; +} + +QString Library::defaultStoragePrefix() +{ + return "libraries/"; +} + +QString Library::storagePrefix() const +{ + if (m_storagePrefix.isEmpty()) { + return defaultStoragePrefix(); + } + return m_storagePrefix; +} + +QString Library::filename(OpSys system) const +{ + if (!m_filename.isEmpty()) { + return m_filename; + } + // non-native? use only the gradle specifier + if (!isNative()) { + return m_name.getFileName(); + } + + // otherwise native, override classifiers. Mojang HACK! + GradleSpecifier nativeSpec = m_name; + if (m_nativeClassifiers.contains(system)) { + nativeSpec.setClassifier(m_nativeClassifiers[system]); + } else { + nativeSpec.setClassifier("INVALID"); + } + return nativeSpec.getFileName(); +} + +QString Library::displayName(OpSys system) const +{ + if (!m_displayname.isEmpty()) + return m_displayname; + return filename(system); +} + +QString Library::storageSuffix(OpSys system) const +{ + // non-native? use only the gradle specifier + if (!isNative()) { + return m_name.toPath(m_filename); + } + + // otherwise native, override classifiers. Mojang HACK! + GradleSpecifier nativeSpec = m_name; + if (m_nativeClassifiers.contains(system)) { + nativeSpec.setClassifier(m_nativeClassifiers[system]); + } else { + nativeSpec.setClassifier("INVALID"); + } + return nativeSpec.toPath(m_filename); +} diff --git a/meshmc/launcher/minecraft/Library.h b/meshmc/launcher/minecraft/Library.h new file mode 100644 index 0000000000..60e59f9df3 --- /dev/null +++ b/meshmc/launcher/minecraft/Library.h @@ -0,0 +1,242 @@ +/* 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 <net/NetAction.h> +#include <QPair> +#include <QList> +#include <QStringList> +#include <QMap> +#include <QDir> +#include <QUrl> +#include <memory> + +#include "Rule.h" +#include "minecraft/OpSys.h" +#include "GradleSpecifier.h" +#include "MojangDownloadInfo.h" + +class Library; +class MinecraftInstance; + +typedef std::shared_ptr<Library> LibraryPtr; + +class Library +{ + friend class OneSixVersionFormat; + friend class MojangVersionFormat; + friend class LibraryTest; + + public: + Library() {} + Library(const QString& name) + { + m_name = name; + } + /// limited copy without some data. TODO: why? + static LibraryPtr limitedCopy(LibraryPtr base) + { + auto newlib = std::make_shared<Library>(); + newlib->m_name = base->m_name; + newlib->m_repositoryURL = base->m_repositoryURL; + newlib->m_hint = base->m_hint; + newlib->m_absoluteURL = base->m_absoluteURL; + newlib->m_extractExcludes = base->m_extractExcludes; + newlib->m_nativeClassifiers = base->m_nativeClassifiers; + newlib->m_rules = base->m_rules; + newlib->m_storagePrefix = base->m_storagePrefix; + newlib->m_mojangDownloads = base->m_mojangDownloads; + newlib->m_filename = base->m_filename; + return newlib; + } + + public: /* methods */ + /// Returns the raw name field + const GradleSpecifier& rawName() const + { + return m_name; + } + + void setRawName(const GradleSpecifier& spec) + { + m_name = spec; + } + + void setClassifier(const QString& spec) + { + m_name.setClassifier(spec); + } + + /// returns the full group and artifact prefix + QString artifactPrefix() const + { + return m_name.artifactPrefix(); + } + + /// get the artifact ID + QString artifactId() const + { + return m_name.artifactId(); + } + + /// get the artifact version + QString version() const + { + return m_name.version(); + } + + /// Returns true if the library is native + bool isNative() const + { + return m_nativeClassifiers.size() != 0; + } + + void setStoragePrefix(QString prefix = QString()); + + /// Set the url base for downloads + void setRepositoryURL(const QString& base_url) + { + m_repositoryURL = base_url; + } + + void getApplicableFiles(OpSys system, QStringList& jar, QStringList& native, + QStringList& native32, QStringList& native64, + const QString& overridePath) const; + + void setAbsoluteUrl(const QString& absolute_url) + { + m_absoluteURL = absolute_url; + } + + void setFilename(const QString& filename) + { + m_filename = filename; + } + + /// Get the file name of the library + QString filename(OpSys system) const; + + // DEPRECATED: set a display name, used by jar mods only + void setDisplayName(const QString& displayName) + { + m_displayname = displayName; + } + + /// Get the file name of the library + QString displayName(OpSys system) const; + + void setMojangDownloadInfo(MojangLibraryDownloadInfo::Ptr info) + { + m_mojangDownloads = info; + } + + void setHint(const QString& hint) + { + m_hint = hint; + } + + /// Set the load rules + void setRules(QList<std::shared_ptr<Rule>> rules) + { + m_rules = rules; + } + + /// Returns true if the library should be loaded (or extracted, in case of + /// natives) + bool isActive() const; + + /// Returns true if the library is contained in an instance and false if it + /// is shared + bool isLocal() const; + + /// Returns true if the library is to always be checked for updates + bool isAlwaysStale() const; + + /// Return true if the library requires forge XZ hacks + bool isForge() const; + + // Get a list of downloads for this library + QList<NetAction::Ptr> getDownloads(OpSys system, class HttpMetaCache* cache, + QStringList& failedLocalFiles, + const QString& overridePath) const; + + private: /* methods */ + /// the default storage prefix used by MeshMC + static QString defaultStoragePrefix(); + + /// Get the prefix - root of the storage to be used + QString storagePrefix() const; + + /// Get the relative file path where the library should be saved + QString storageSuffix(OpSys system) const; + + QString hint() const + { + return m_hint; + } + + protected: /* data */ + /// the basic gradle dependency specifier. + GradleSpecifier m_name; + + /// DEPRECATED URL prefix of the maven repo where the file can be downloaded + QString m_repositoryURL; + + /// DEPRECATED: MeshMC-specific absolute URL. takes precedence over the + /// implicit maven repo URL, if defined + QString m_absoluteURL; + + /// MeshMC extension - filename override + QString m_filename; + + /// DEPRECATED MeshMC extension - display name + QString m_displayname; + + /** + * MeshMC-specific type hint - modifies how the library is treated + */ + QString m_hint; + + /** + * storage - by default the local libraries folder in meshmc, but could be + * elsewhere MeshMC specific, because of FTB. + */ + QString m_storagePrefix; + + /// true if the library had an extract/excludes section (even empty) + bool m_hasExcludes = false; + + /// a list of files that shouldn't be extracted from the library + QStringList m_extractExcludes; + + /// native suffixes per OS + QMap<OpSys, QString> m_nativeClassifiers; + + /// true if the library had a rules section (even empty) + bool applyRules = false; + + /// rules associated with the library + QList<std::shared_ptr<Rule>> m_rules; + + /// MOJANG: container with Mojang style download info + MojangLibraryDownloadInfo::Ptr m_mojangDownloads; +}; diff --git a/meshmc/launcher/minecraft/Library_test.cpp b/meshmc/launcher/minecraft/Library_test.cpp new file mode 100644 index 0000000000..ad6acc32bd --- /dev/null +++ b/meshmc/launcher/minecraft/Library_test.cpp @@ -0,0 +1,377 @@ +/* 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 "TestUtil.h" + +#include "minecraft/MojangVersionFormat.h" +#include "minecraft/OneSixVersionFormat.h" +#include "minecraft/Library.h" +#include "net/HttpMetaCache.h" +#include "FileSystem.h" + +class LibraryTest : public QObject +{ + Q_OBJECT + private: + LibraryPtr readMojangJson(const char* file) + { + auto path = QFINDTESTDATA(file); + QFile jsonFile(path); + if (!jsonFile.open(QIODevice::ReadOnly)) + return nullptr; + auto data = jsonFile.readAll(); + jsonFile.close(); + ProblemContainer problems; + return MojangVersionFormat::libraryFromJson( + problems, QJsonDocument::fromJson(data).object(), file); + } + // get absolute path to expected storage, assuming default cache prefix + QStringList getStorage(QString relative) + { + return {FS::PathCombine(cache->getBasePath("libraries"), relative)}; + } + private slots: + void initTestCase() + { + cache.reset(new HttpMetaCache()); + cache->addBase("libraries", QDir("libraries").absolutePath()); + dataDir = QDir("data").absolutePath(); + } + void test_legacy() + { + Library test("test.package:testname:testversion"); + QCOMPARE(test.artifactPrefix(), QString("test.package:testname")); + QCOMPARE(test.isNative(), false); + + QStringList jar, native, native32, native64; + test.getApplicableFiles(currentSystem, jar, native, native32, native64, + QString()); + QCOMPARE( + jar, + getStorage( + "test/package/testname/testversion/testname-testversion.jar")); + QCOMPARE(native, {}); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + } + void test_legacy_url() + { + QStringList failedFiles; + Library test("test.package:testname:testversion"); + test.setRepositoryURL("file://foo/bar"); + auto downloads = test.getDownloads(currentSystem, cache.get(), + failedFiles, QString()); + QCOMPARE(downloads.size(), 1); + QCOMPARE(failedFiles, {}); + NetAction::Ptr dl = downloads[0]; + QCOMPARE(dl->m_url, QUrl("file://foo/bar/test/package/testname/" + "testversion/testname-testversion.jar")); + } + void test_legacy_url_local_broken() + { + Library test("test.package:testname:testversion"); + QCOMPARE(test.isNative(), false); + QStringList failedFiles; + test.setHint("local"); + auto downloads = test.getDownloads(currentSystem, cache.get(), + failedFiles, QString()); + QCOMPARE(downloads.size(), 0); + QCOMPARE(failedFiles, {"testname-testversion.jar"}); + } + void test_legacy_url_local_override() + { + Library test("com.paulscode:codecwav:20101023"); + QCOMPARE(test.isNative(), false); + QStringList failedFiles; + test.setHint("local"); + auto downloads = test.getDownloads(currentSystem, cache.get(), + failedFiles, QString("data")); + QCOMPARE(downloads.size(), 0); + qDebug() << failedFiles; + QCOMPARE(failedFiles.size(), 0); + + QStringList jar, native, native32, native64; + test.getApplicableFiles(currentSystem, jar, native, native32, native64, + QString("data")); + QCOMPARE(jar, + {QFileInfo("data/codecwav-20101023.jar").absoluteFilePath()}); + QCOMPARE(native, {}); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + } + void test_legacy_native() + { + Library test("test.package:testname:testversion"); + test.m_nativeClassifiers[OpSys::Os_Linux] = "linux"; + QCOMPARE(test.isNative(), true); + test.setRepositoryURL("file://foo/bar"); + { + QStringList jar, native, native32, native64; + test.getApplicableFiles(Os_Linux, jar, native, native32, native64, + QString()); + QCOMPARE(jar, {}); + QCOMPARE(native, getStorage("test/package/testname/testversion/" + "testname-testversion-linux.jar")); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + QStringList failedFiles; + auto dls = test.getDownloads(Os_Linux, cache.get(), failedFiles, + QString()); + QCOMPARE(dls.size(), 1); + QCOMPARE(failedFiles, {}); + auto dl = dls[0]; + QCOMPARE(dl->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-linux.jar")); + } + } + void test_legacy_native_arch() + { + Library test("test.package:testname:testversion"); + test.m_nativeClassifiers[OpSys::Os_Linux] = "linux-${arch}"; + test.m_nativeClassifiers[OpSys::Os_OSX] = "osx-${arch}"; + test.m_nativeClassifiers[OpSys::Os_Windows] = "windows-${arch}"; + QCOMPARE(test.isNative(), true); + test.setRepositoryURL("file://foo/bar"); + { + QStringList jar, native, native32, native64; + test.getApplicableFiles(Os_Linux, jar, native, native32, native64, + QString()); + QCOMPARE(jar, {}); + QCOMPARE(native, {}); + QCOMPARE(native32, getStorage("test/package/testname/testversion/" + "testname-testversion-linux-32.jar")); + QCOMPARE(native64, getStorage("test/package/testname/testversion/" + "testname-testversion-linux-64.jar")); + QStringList failedFiles; + auto dls = test.getDownloads(Os_Linux, cache.get(), failedFiles, + QString()); + QCOMPARE(dls.size(), 2); + QCOMPARE(failedFiles, {}); + QCOMPARE(dls[0]->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-linux-32.jar")); + QCOMPARE(dls[1]->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-linux-64.jar")); + } + { + QStringList jar, native, native32, native64; + test.getApplicableFiles(Os_Windows, jar, native, native32, native64, + QString()); + QCOMPARE(jar, {}); + QCOMPARE(native, {}); + QCOMPARE(native32, + getStorage("test/package/testname/testversion/" + "testname-testversion-windows-32.jar")); + QCOMPARE(native64, + getStorage("test/package/testname/testversion/" + "testname-testversion-windows-64.jar")); + QStringList failedFiles; + auto dls = test.getDownloads(Os_Windows, cache.get(), failedFiles, + QString()); + QCOMPARE(dls.size(), 2); + QCOMPARE(failedFiles, {}); + QCOMPARE(dls[0]->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-windows-32.jar")); + QCOMPARE(dls[1]->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-windows-64.jar")); + } + { + QStringList jar, native, native32, native64; + test.getApplicableFiles(Os_OSX, jar, native, native32, native64, + QString()); + QCOMPARE(jar, {}); + QCOMPARE(native, {}); + QCOMPARE(native32, getStorage("test/package/testname/testversion/" + "testname-testversion-osx-32.jar")); + QCOMPARE(native64, getStorage("test/package/testname/testversion/" + "testname-testversion-osx-64.jar")); + QStringList failedFiles; + auto dls = + test.getDownloads(Os_OSX, cache.get(), failedFiles, QString()); + QCOMPARE(dls.size(), 2); + QCOMPARE(failedFiles, {}); + QCOMPARE(dls[0]->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-osx-32.jar")); + QCOMPARE(dls[1]->m_url, + QUrl("file://foo/bar/test/package/testname/testversion/" + "testname-testversion-osx-64.jar")); + } + } + void test_legacy_native_arch_local_override() + { + Library test("test.package:testname:testversion"); + test.m_nativeClassifiers[OpSys::Os_Linux] = "linux-${arch}"; + test.setHint("local"); + QCOMPARE(test.isNative(), true); + test.setRepositoryURL("file://foo/bar"); + { + QStringList jar, native, native32, native64; + test.getApplicableFiles(Os_Linux, jar, native, native32, native64, + QString("data")); + QCOMPARE(jar, {}); + QCOMPARE(native, {}); + QCOMPARE(native32, + {QFileInfo("data/testname-testversion-linux-32.jar") + .absoluteFilePath()}); + QCOMPARE(native64, + {QFileInfo("data/testname-testversion-linux-64.jar") + .absoluteFilePath()}); + QStringList failedFiles; + auto dls = test.getDownloads(Os_Linux, cache.get(), failedFiles, + QString("data")); + QCOMPARE(dls.size(), 0); + QCOMPARE(failedFiles, {"data/testname-testversion-linux-64.jar"}); + } + } + void test_onenine() + { + auto test = readMojangJson("data/lib-simple.json"); + { + QStringList jar, native, native32, native64; + test->getApplicableFiles(Os_OSX, jar, native, native32, native64, + QString()); + QCOMPARE( + jar, + getStorage( + "com/paulscode/codecwav/20101023/codecwav-20101023.jar")); + QCOMPARE(native, {}); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + } + { + QStringList failedFiles; + auto dls = test->getDownloads(Os_Linux, cache.get(), failedFiles, + QString()); + QCOMPARE(dls.size(), 1); + QCOMPARE(failedFiles, {}); + QCOMPARE(dls[0]->m_url, + QUrl("https://libraries.minecraft.net/com/paulscode/" + "codecwav/20101023/codecwav-20101023.jar")); + } + test->setHint("local"); + { + QStringList jar, native, native32, native64; + test->getApplicableFiles(Os_OSX, jar, native, native32, native64, + QString("data")); + QCOMPARE( + jar, + {QFileInfo("data/codecwav-20101023.jar").absoluteFilePath()}); + QCOMPARE(native, {}); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + } + { + QStringList failedFiles; + auto dls = test->getDownloads(Os_Linux, cache.get(), failedFiles, + QString("data")); + QCOMPARE(dls.size(), 0); + QCOMPARE(failedFiles, {}); + } + } + void test_onenine_local_override() + { + auto test = readMojangJson("data/lib-simple.json"); + test->setHint("local"); + { + QStringList jar, native, native32, native64; + test->getApplicableFiles(Os_OSX, jar, native, native32, native64, + QString("data")); + QCOMPARE( + jar, + {QFileInfo("data/codecwav-20101023.jar").absoluteFilePath()}); + QCOMPARE(native, {}); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + } + { + QStringList failedFiles; + auto dls = test->getDownloads(Os_Linux, cache.get(), failedFiles, + QString("data")); + QCOMPARE(dls.size(), 0); + QCOMPARE(failedFiles, {}); + } + } + void test_onenine_native() + { + auto test = readMojangJson("data/lib-native.json"); + QStringList jar, native, native32, native64; + test->getApplicableFiles(Os_OSX, jar, native, native32, native64, + QString()); + QCOMPARE(jar, QStringList()); + QCOMPARE(native, + getStorage( + "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/" + "lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar")); + QCOMPARE(native32, {}); + QCOMPARE(native64, {}); + QStringList failedFiles; + auto dls = + test->getDownloads(Os_OSX, cache.get(), failedFiles, QString()); + QCOMPARE(dls.size(), 1); + QCOMPARE(failedFiles, {}); + QCOMPARE(dls[0]->m_url, + QUrl("https://libraries.minecraft.net/org/lwjgl/lwjgl/" + "lwjgl-platform/2.9.4-nightly-20150209/" + "lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar")); + } + void test_onenine_native_arch() + { + auto test = readMojangJson("data/lib-native-arch.json"); + QStringList jar, native, native32, native64; + test->getApplicableFiles(Os_Windows, jar, native, native32, native64, + QString()); + QCOMPARE(jar, {}); + QCOMPARE(native, {}); + QCOMPARE(native32, + getStorage("tv/twitch/twitch-platform/5.16/" + "twitch-platform-5.16-natives-windows-32.jar")); + QCOMPARE(native64, + getStorage("tv/twitch/twitch-platform/5.16/" + "twitch-platform-5.16-natives-windows-64.jar")); + QStringList failedFiles; + auto dls = + test->getDownloads(Os_Windows, cache.get(), failedFiles, QString()); + QCOMPARE(dls.size(), 2); + QCOMPARE(failedFiles, {}); + QCOMPARE( + dls[0]->m_url, + QUrl("https://libraries.minecraft.net/tv/twitch/twitch-platform/" + "5.16/twitch-platform-5.16-natives-windows-32.jar")); + QCOMPARE( + dls[1]->m_url, + QUrl("https://libraries.minecraft.net/tv/twitch/twitch-platform/" + "5.16/twitch-platform-5.16-natives-windows-64.jar")); + } + + private: + std::unique_ptr<HttpMetaCache> cache; + QString dataDir; +}; + +QTEST_GUILESS_MAIN(LibraryTest) + +#include "Library_test.moc" diff --git a/meshmc/launcher/minecraft/MinecraftInstance.cpp b/meshmc/launcher/minecraft/MinecraftInstance.cpp new file mode 100644 index 0000000000..d0a7ab740f --- /dev/null +++ b/meshmc/launcher/minecraft/MinecraftInstance.cpp @@ -0,0 +1,1108 @@ +/* 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 "MinecraftInstance.h" +#include "minecraft/launch/CreateGameFolders.h" +#include "minecraft/launch/ExtractNatives.h" +#include "minecraft/launch/PrintInstanceInfo.h" +#include "settings/Setting.h" +#include "settings/SettingsObject.h" +#include "Application.h" +#include <QRegularExpression> + +#include "MMCStrings.h" +#include "pathmatcher/RegexpMatcher.h" +#include "pathmatcher/MultiMatcher.h" +#include "FileSystem.h" +#include "java/JavaVersion.h" +#include "MMCTime.h" + +#include "launch/LaunchTask.h" +#include "launch/steps/LookupServerAddress.h" +#include "launch/steps/PostLaunchCommand.h" +#include "launch/steps/Update.h" +#include "launch/steps/PreLaunchCommand.h" +#include "launch/steps/TextPrint.h" +#include "launch/steps/CheckJava.h" + +#include "minecraft/launch/MeshMCPartLaunch.h" +#include "minecraft/launch/DirectJavaLaunch.h" +#include "minecraft/launch/ModMinecraftJar.h" +#include "minecraft/launch/ClaimAccount.h" +#include "minecraft/launch/ReconstructAssets.h" +#include "minecraft/launch/ScanModFolders.h" +#include "minecraft/launch/VerifyJavaInstall.h" + +#include "java/JavaUtils.h" + +#include "meta/Index.h" +#include "meta/VersionList.h" + +#include "icons/IconList.h" + +#include "mod/ModFolderModel.h" +#include "mod/ResourcePackFolderModel.h" +#include "mod/TexturePackFolderModel.h" + +#include "WorldList.h" + +#include "PackProfile.h" +#include "AssetsUtils.h" +#include "MinecraftUpdate.h" +#include "MinecraftLoadAndCheck.h" +#include "minecraft/gameoptions/GameOptions.h" +#include "minecraft/update/FoldersTask.h" + +#define IBUS "@im=ibus" + +// all of this because keeping things compatible with deprecated old settings +// if either of the settings {a, b} is true, this also resolves to true +class OrSetting : public Setting +{ + Q_OBJECT + public: + OrSetting(QString id, std::shared_ptr<Setting> a, + std::shared_ptr<Setting> b) + : Setting({id}, false), m_a(a), m_b(b) + { + } + virtual QVariant get() const + { + bool a = m_a->get().toBool(); + bool b = m_b->get().toBool(); + return a || b; + } + virtual void reset() {} + virtual void set(QVariant value) {} + + private: + std::shared_ptr<Setting> m_a; + std::shared_ptr<Setting> m_b; +}; + +MinecraftInstance::MinecraftInstance(SettingsObjectPtr globalSettings, + SettingsObjectPtr settings, + const QString& rootDir) + : BaseInstance(globalSettings, settings, rootDir) +{ + // Java Settings + auto javaOverride = m_settings->registerSetting("OverrideJava", false); + auto locationOverride = + m_settings->registerSetting("OverrideJavaLocation", false); + auto argsOverride = m_settings->registerSetting("OverrideJavaArgs", false); + + // combinations + auto javaOrLocation = std::make_shared<OrSetting>( + "JavaOrLocationOverride", javaOverride, locationOverride); + auto javaOrArgs = std::make_shared<OrSetting>("JavaOrArgsOverride", + javaOverride, argsOverride); + + m_settings->registerOverride(globalSettings->getSetting("JavaPath"), + javaOrLocation); + m_settings->registerOverride(globalSettings->getSetting("JvmArgs"), + javaOrArgs); + + // special! + m_settings->registerPassthrough(globalSettings->getSetting("JavaTimestamp"), + javaOrLocation); + m_settings->registerPassthrough(globalSettings->getSetting("JavaVersion"), + javaOrLocation); + m_settings->registerPassthrough( + globalSettings->getSetting("JavaArchitecture"), javaOrLocation); + + // Window Size + auto windowSetting = m_settings->registerSetting("OverrideWindow", false); + m_settings->registerOverride(globalSettings->getSetting("LaunchMaximized"), + windowSetting); + m_settings->registerOverride( + globalSettings->getSetting("MinecraftWinWidth"), windowSetting); + m_settings->registerOverride( + globalSettings->getSetting("MinecraftWinHeight"), windowSetting); + + // Memory + auto memorySetting = m_settings->registerSetting("OverrideMemory", false); + m_settings->registerOverride(globalSettings->getSetting("MinMemAlloc"), + memorySetting); + m_settings->registerOverride(globalSettings->getSetting("MaxMemAlloc"), + memorySetting); + m_settings->registerOverride(globalSettings->getSetting("PermGen"), + memorySetting); + + // Minecraft launch method + auto launchMethodOverride = + m_settings->registerSetting("OverrideMCLaunchMethod", false); + m_settings->registerOverride(globalSettings->getSetting("MCLaunchMethod"), + launchMethodOverride); + + // Native library workarounds + auto nativeLibraryWorkaroundsOverride = + m_settings->registerSetting("OverrideNativeWorkarounds", false); + m_settings->registerOverride(globalSettings->getSetting("UseNativeOpenAL"), + nativeLibraryWorkaroundsOverride); + m_settings->registerOverride(globalSettings->getSetting("UseNativeGLFW"), + nativeLibraryWorkaroundsOverride); + + // Game time + auto gameTimeOverride = + m_settings->registerSetting("OverrideGameTime", false); + m_settings->registerOverride(globalSettings->getSetting("ShowGameTime"), + gameTimeOverride); + m_settings->registerOverride(globalSettings->getSetting("RecordGameTime"), + gameTimeOverride); + + // Join server on launch, this does not have a global override + m_settings->registerSetting("JoinServerOnLaunch", false); + m_settings->registerSetting("JoinServerOnLaunchAddress", ""); + + // DEPRECATED: Read what versions the user configuration thinks should be + // used + m_settings->registerSetting({"IntendedVersion", "MinecraftVersion"}, ""); + m_settings->registerSetting("LWJGLVersion", ""); + m_settings->registerSetting("ForgeVersion", ""); + m_settings->registerSetting("LiteloaderVersion", ""); + + m_components.reset(new PackProfile(this)); + m_components->setOldConfigVersion( + "net.minecraft", m_settings->get("IntendedVersion").toString()); + auto setting = m_settings->getSetting("LWJGLVersion"); + m_components->setOldConfigVersion( + "org.lwjgl", m_settings->get("LWJGLVersion").toString()); + m_components->setOldConfigVersion( + "net.minecraftforge", m_settings->get("ForgeVersion").toString()); + m_components->setOldConfigVersion( + "com.mumfrey.liteloader", + m_settings->get("LiteloaderVersion").toString()); +} + +void MinecraftInstance::saveNow() +{ + m_components->saveNow(); +} + +QString MinecraftInstance::typeName() const +{ + return "Minecraft"; +} + +std::shared_ptr<PackProfile> MinecraftInstance::getPackProfile() const +{ + return m_components; +} + +QSet<QString> MinecraftInstance::traits() const +{ + auto components = getPackProfile(); + if (!components) { + return {"version-incomplete"}; + } + auto profile = components->getProfile(); + if (!profile) { + return {"version-incomplete"}; + } + return profile->getTraits(); +} + +QString MinecraftInstance::gameRoot() const +{ + QFileInfo mcDir(FS::PathCombine(instanceRoot(), "minecraft")); + QFileInfo dotMCDir(FS::PathCombine(instanceRoot(), ".minecraft")); + + if (mcDir.exists() && !dotMCDir.exists()) + return mcDir.filePath(); + else + return dotMCDir.filePath(); +} + +QString MinecraftInstance::binRoot() const +{ + return FS::PathCombine(gameRoot(), "bin"); +} + +QString MinecraftInstance::getNativePath() const +{ + QDir natives_dir(FS::PathCombine(instanceRoot(), "natives/")); + return natives_dir.absolutePath(); +} + +QString MinecraftInstance::getLocalLibraryPath() const +{ + QDir libraries_dir(FS::PathCombine(instanceRoot(), "libraries/")); + return libraries_dir.absolutePath(); +} + +QString MinecraftInstance::jarModsDir() const +{ + QDir jarmods_dir(FS::PathCombine(instanceRoot(), "jarmods/")); + return jarmods_dir.absolutePath(); +} + +QString MinecraftInstance::modsRoot() const +{ + return FS::PathCombine(gameRoot(), "mods"); +} + +QString MinecraftInstance::modsCacheLocation() const +{ + return FS::PathCombine(instanceRoot(), "mods.cache"); +} + +QString MinecraftInstance::coreModsDir() const +{ + return FS::PathCombine(gameRoot(), "coremods"); +} + +QString MinecraftInstance::resourcePacksDir() const +{ + return FS::PathCombine(gameRoot(), "resourcepacks"); +} + +QString MinecraftInstance::texturePacksDir() const +{ + return FS::PathCombine(gameRoot(), "texturepacks"); +} + +QString MinecraftInstance::shaderPacksDir() const +{ + return FS::PathCombine(gameRoot(), "shaderpacks"); +} + +QString MinecraftInstance::instanceConfigFolder() const +{ + return FS::PathCombine(gameRoot(), "config"); +} + +QString MinecraftInstance::libDir() const +{ + return FS::PathCombine(gameRoot(), "lib"); +} + +QString MinecraftInstance::worldDir() const +{ + return FS::PathCombine(gameRoot(), "saves"); +} + +QString MinecraftInstance::resourcesDir() const +{ + return FS::PathCombine(gameRoot(), "resources"); +} + +QDir MinecraftInstance::librariesPath() const +{ + return QDir::current().absoluteFilePath("libraries"); +} + +QDir MinecraftInstance::jarmodsPath() const +{ + return QDir(jarModsDir()); +} + +QDir MinecraftInstance::versionsPath() const +{ + return QDir::current().absoluteFilePath("versions"); +} + +QStringList MinecraftInstance::getClassPath() const +{ + QStringList jars, nativeJars; + auto javaArchitecture = settings()->get("JavaArchitecture").toString(); + auto profile = m_components->getProfile(); + profile->getLibraryFiles(javaArchitecture, jars, nativeJars, + getLocalLibraryPath(), binRoot()); + return jars; +} + +QString MinecraftInstance::getMainClass() const +{ + auto profile = m_components->getProfile(); + return profile->getMainClass(); +} + +QStringList MinecraftInstance::getNativeJars() const +{ + QStringList jars, nativeJars; + auto javaArchitecture = settings()->get("JavaArchitecture").toString(); + auto profile = m_components->getProfile(); + profile->getLibraryFiles(javaArchitecture, jars, nativeJars, + getLocalLibraryPath(), binRoot()); + return nativeJars; +} + +QStringList MinecraftInstance::extraArguments() const +{ + auto list = BaseInstance::extraArguments(); + auto version = getPackProfile(); + if (!version) + return list; + auto jarMods = getJarMods(); + if (!jarMods.isEmpty()) { + list.append({"-Dfml.ignoreInvalidMinecraftCertificates=true", + "-Dfml.ignorePatchDiscrepancies=true"}); + } + return list; +} + +QStringList MinecraftInstance::javaArguments() const +{ + QStringList args; + + // custom args go first. we want to override them if we have our own here. + args.append(extraArguments()); + + // OSX dock icon and name +#ifdef Q_OS_MAC + args << "-Xdock:icon=icon.png"; + args << QString("-Xdock:name=\"%1\"").arg(windowTitle()); +#endif + auto traits_ = traits(); + // HACK: fix issues on macOS with 1.13 snapshots + // NOTE: Oracle Java option. if there are alternate jvm implementations, + // this would be the place to customize this for them +#ifdef Q_OS_MAC + if (traits_.contains("FirstThreadOnMacOS")) { + args << QString("-XstartOnFirstThread"); + } +#endif + + // HACK: Stupid hack for Intel drivers. See: + // https://mojang.atlassian.net/browse/MCL-767 +#ifdef Q_OS_WIN32 + args << QString( + "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_" + "minecraft.exe.heapdump"); +#endif + + int min = settings()->get("MinMemAlloc").toInt(); + int max = settings()->get("MaxMemAlloc").toInt(); + if (min < max) { + args << QString("-Xms%1m").arg(min); + args << QString("-Xmx%1m").arg(max); + } else { + args << QString("-Xms%1m").arg(max); + args << QString("-Xmx%1m").arg(min); + } + + // No PermGen in newer java. + JavaVersion javaVersion = getJavaVersion(); + if (javaVersion.requiresPermGen()) { + auto permgen = settings()->get("PermGen").toInt(); + if (permgen != 64) { + args << QString("-XX:PermSize=%1m").arg(permgen); + } + } + + args << "-Duser.language=en"; + + return args; +} + +QMap<QString, QString> MinecraftInstance::getVariables() const +{ + QMap<QString, QString> out; + out.insert("INST_NAME", name()); + out.insert("INST_ID", id()); + out.insert("INST_DIR", QDir(instanceRoot()).absolutePath()); + out.insert("INST_MC_DIR", QDir(gameRoot()).absolutePath()); + out.insert("INST_JAVA", settings()->get("JavaPath").toString()); + out.insert("INST_JAVA_ARGS", javaArguments().join(' ')); + return out; +} + +QProcessEnvironment MinecraftInstance::createEnvironment() +{ + // prepare the process environment + QProcessEnvironment env = CleanEnviroment(); + + // export some infos + auto variables = getVariables(); + for (auto it = variables.begin(); it != variables.end(); ++it) { + env.insert(it.key(), it.value()); + } + return env; +} + +static QString replaceTokensIn(QString text, QMap<QString, QString> with) +{ + QString result; + QRegularExpression token_regexp("\\$\\{(.+?)\\}"); + QRegularExpressionMatchIterator it = token_regexp.globalMatch(text); + int tail = 0; + while (it.hasNext()) { + QRegularExpressionMatch match = it.next(); + result.append(text.mid(tail, match.capturedStart() - tail)); + QString key = match.captured(1); + auto iter = with.find(key); + if (iter != with.end()) { + result.append(*iter); + } + tail = match.capturedEnd(); + } + result.append(text.mid(tail)); + return result; +} + +QStringList MinecraftInstance::processMinecraftArgs( + AuthSessionPtr session, MinecraftServerTargetPtr serverToJoin) const +{ + auto profile = m_components->getProfile(); + QString args_pattern = profile->getMinecraftArguments(); + for (auto tweaker : profile->getTweakers()) { + args_pattern += " --tweakClass " + tweaker; + } + + if (serverToJoin && !serverToJoin->address.isEmpty()) { + args_pattern += " --server " + serverToJoin->address; + args_pattern += " --port " + QString::number(serverToJoin->port); + } + + QMap<QString, QString> token_mapping; + // yggdrasil! + if (session) { + // token_mapping["auth_username"] = session->username; + token_mapping["auth_session"] = session->session; + token_mapping["auth_access_token"] = session->access_token; + token_mapping["auth_player_name"] = session->player_name; + token_mapping["auth_uuid"] = session->uuid; + token_mapping["user_properties"] = session->serializeUserProperties(); + token_mapping["user_type"] = session->user_type; + if (session->demo) { + args_pattern += " --demo"; + } + } + + // blatant self-promotion. + token_mapping["profile_name"] = token_mapping["version_name"] = "MeshMC"; + + token_mapping["version_type"] = profile->getMinecraftVersionType(); + + QString absRootDir = QDir(gameRoot()).absolutePath(); + token_mapping["game_directory"] = absRootDir; + QString absAssetsDir = QDir("assets/").absolutePath(); + auto assets = profile->getMinecraftAssets(); + token_mapping["game_assets"] = + AssetsUtils::getAssetsDir(assets->id, resourcesDir()).absolutePath(); + + // 1.7.3+ assets tokens + token_mapping["assets_root"] = absAssetsDir; + token_mapping["assets_index_name"] = assets->id; + + QStringList parts = args_pattern.split(' ', Qt::SkipEmptyParts); + for (int i = 0; i < parts.length(); i++) { + parts[i] = replaceTokensIn(parts[i], token_mapping); + } + return parts; +} + +QString +MinecraftInstance::createLaunchScript(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) +{ + QString launchScript; + + if (!m_components) + return QString(); + auto profile = m_components->getProfile(); + if (!profile) + return QString(); + + auto mainClass = getMainClass(); + if (!mainClass.isEmpty()) { + launchScript += "mainClass " + mainClass + "\n"; + } + auto appletClass = profile->getAppletClass(); + if (!appletClass.isEmpty()) { + launchScript += "appletClass " + appletClass + "\n"; + } + + if (serverToJoin && !serverToJoin->address.isEmpty()) { + launchScript += "serverAddress " + serverToJoin->address + "\n"; + launchScript += + "serverPort " + QString::number(serverToJoin->port) + "\n"; + } + + // generic minecraft params + for (auto param : processMinecraftArgs( + session, nullptr /* When using a launch script, the server + parameters are handled by it*/ + )) { + launchScript += "param " + param + "\n"; + } + + // window size, title and state, legacy + { + QString windowParams; + if (settings()->get("LaunchMaximized").toBool()) + windowParams = "max"; + else + windowParams = + QString("%1x%2") + .arg(settings()->get("MinecraftWinWidth").toInt()) + .arg(settings()->get("MinecraftWinHeight").toInt()); + launchScript += "windowTitle " + windowTitle() + "\n"; + launchScript += "windowParams " + windowParams + "\n"; + } + + // legacy auth + if (session) { + launchScript += "userName " + session->player_name + "\n"; + launchScript += "sessionId " + session->session + "\n"; + } + + // libraries and class path. + { + QStringList jars, nativeJars; + auto javaArchitecture = settings()->get("JavaArchitecture").toString(); + profile->getLibraryFiles(javaArchitecture, jars, nativeJars, + getLocalLibraryPath(), binRoot()); + for (auto file : jars) { + launchScript += "cp " + file + "\n"; + } + for (auto file : nativeJars) { + launchScript += "ext " + file + "\n"; + } + launchScript += "natives " + getNativePath() + "\n"; + } + + for (auto trait : profile->getTraits()) { + launchScript += "traits " + trait + "\n"; + } + + // Decide between legacy (in-process, applet-based) and modern (subprocess) + // launch. Instances with the "legacyLaunch" or "alphaLaunch" trait use the + // classic OneSix applet launcher path which runs everything in-process. All + // other instances use the ModernMeshMC which spawns the game as a child + // process with the configured Java binary, allowing Minecraft versions that + // require Java 21, 25 or newer to run even when MeshMC library JVM is an + // older version. + auto profileTraits = profile->getTraits(); + bool isLegacyApplet = profileTraits.contains("legacyLaunch") || + profileTraits.contains("alphaLaunch"); + + if (isLegacyApplet) { + launchScript += "launcher onesix\n"; + } else { + // Pass the configured Java binary path so ModernMeshMC can spawn the + // game process with the correct JVM. + launchScript += + "javaPath " + settings()->get("JavaPath").toString() + "\n"; + + // Forward all JVM arguments (memory settings, platform flags, extra + // args, …) so the child process inherits the same tuning as the current + // process. + for (auto arg : javaArguments()) { + launchScript += "jvmArg " + arg + "\n"; + } + + launchScript += "launcher modern\n"; + } + + // qDebug() << "Generated launch script:" << launchScript; + return launchScript; +} + +QStringList +MinecraftInstance::verboseDescription(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) +{ + QStringList out; + out << "Main Class:" << " " + getMainClass() << ""; + out << "Native path:" << " " + getNativePath() << ""; + + auto profile = m_components->getProfile(); + + auto alltraits = traits(); + if (alltraits.size()) { + out << "Traits:"; + for (auto trait : alltraits) { + out << "traits " + trait; + } + out << ""; + } + + auto settings = this->settings(); + bool nativeOpenAL = settings->get("UseNativeOpenAL").toBool(); + bool nativeGLFW = settings->get("UseNativeGLFW").toBool(); + if (nativeOpenAL || nativeGLFW) { + if (nativeOpenAL) + out << "Using system OpenAL."; + if (nativeGLFW) + out << "Using system GLFW."; + out << ""; + } + + // libraries and class path. + { + out << "Libraries:"; + QStringList jars, nativeJars; + auto javaArchitecture = settings->get("JavaArchitecture").toString(); + profile->getLibraryFiles(javaArchitecture, jars, nativeJars, + getLocalLibraryPath(), binRoot()); + auto printLibFile = [&](const QString& path) { + QFileInfo info(path); + if (info.exists()) { + out << " " + path; + } else { + out << " " + path + " (missing)"; + } + }; + for (auto file : jars) { + printLibFile(file); + } + out << ""; + out << "Native libraries:"; + for (auto file : nativeJars) { + printLibFile(file); + } + out << ""; + } + + auto printModList = [&](const QString& label, ModFolderModel& model) { + if (model.size()) { + out << QString("%1:").arg(label); + auto modList = model.allMods(); + std::sort(modList.begin(), modList.end(), [](Mod& a, Mod& b) { + auto aName = a.filename().completeBaseName(); + auto bName = b.filename().completeBaseName(); + return aName.localeAwareCompare(bName) < 0; + }); + for (auto& mod : modList) { + if (mod.type() == Mod::MOD_FOLDER) { + out << u8" [📁] " + mod.filename().completeBaseName() + + " (folder)"; + continue; + } + + if (mod.enabled()) { + out << u8" [✔️] " + mod.filename().completeBaseName(); + } else { + out << u8" [❌] " + mod.filename().completeBaseName() + + " (disabled)"; + } + } + out << ""; + } + }; + + printModList("Mods", *(loaderModList().get())); + printModList("Core Mods", *(coreModList().get())); + + auto& jarMods = profile->getJarMods(); + if (jarMods.size()) { + out << "Jar Mods:"; + for (auto& jarmod : jarMods) { + auto displayname = jarmod->displayName(currentSystem); + auto realname = jarmod->filename(currentSystem); + if (displayname != realname) { + out << " " + displayname + " (" + realname + ")"; + } else { + out << " " + realname; + } + } + out << ""; + } + + auto params = processMinecraftArgs(nullptr, serverToJoin); + out << "Params:"; + out << " " + params.join(' '); + out << ""; + + QString windowParams; + if (settings->get("LaunchMaximized").toBool()) { + out << "Window size: max (if available)"; + } else { + auto width = settings->get("MinecraftWinWidth").toInt(); + auto height = settings->get("MinecraftWinHeight").toInt(); + out << "Window size: " + QString::number(width) + " x " + + QString::number(height); + } + out << ""; + return out; +} + +QMap<QString, QString> +MinecraftInstance::createCensorFilterFromSession(AuthSessionPtr session) +{ + if (!session) { + return QMap<QString, QString>(); + } + auto& sessionRef = *session.get(); + QMap<QString, QString> filter; + auto addToFilter = [&filter](QString key, QString value) { + if (key.trimmed().size()) { + filter[key] = value; + } + }; + if (sessionRef.session != "-") { + addToFilter(sessionRef.session, tr("<SESSION ID>")); + } + addToFilter(sessionRef.access_token, tr("<ACCESS TOKEN>")); + if (sessionRef.client_token.size()) { + addToFilter(sessionRef.client_token, tr("<CLIENT TOKEN>")); + } + addToFilter(sessionRef.uuid, tr("<PROFILE ID>")); + + return filter; +} + +MessageLevel::Enum MinecraftInstance::guessLevel(const QString& line, + MessageLevel::Enum level) +{ + QRegularExpression re( + "\\[(?<timestamp>[0-9:]+)\\] \\[[^/]+/(?<level>[^\\]]+)\\]"); + auto match = re.match(line); + if (match.hasMatch()) { + // New style logs from log4j + QString timestamp = match.captured("timestamp"); + QString levelStr = match.captured("level"); + if (levelStr == "INFO") + level = MessageLevel::Message; + if (levelStr == "WARN") + level = MessageLevel::Warning; + if (levelStr == "ERROR") + level = MessageLevel::Error; + if (levelStr == "FATAL") + level = MessageLevel::Fatal; + if (levelStr == "TRACE" || levelStr == "DEBUG") + level = MessageLevel::Debug; + } else { + // Old style forge logs + if (line.contains("[INFO]") || line.contains("[CONFIG]") || + line.contains("[FINE]") || line.contains("[FINER]") || + line.contains("[FINEST]")) + level = MessageLevel::Message; + if (line.contains("[SEVERE]") || line.contains("[STDERR]")) + level = MessageLevel::Error; + if (line.contains("[WARNING]")) + level = MessageLevel::Warning; + if (line.contains("[DEBUG]")) + level = MessageLevel::Debug; + } + if (line.contains("overwriting existing")) + return MessageLevel::Fatal; + // NOTE: this diverges from the real regexp. no unicode, the first section + // is + instead of * + static const QString javaSymbol = + "([a-zA-Z_$][a-zA-Z\\d_$]*\\.)+[a-zA-Z_$][a-zA-Z\\d_$]*"; + if (line.contains("Exception in thread") || + line.contains(QRegularExpression("\\s+at " + javaSymbol)) || + line.contains(QRegularExpression("Caused by: " + javaSymbol)) || + line.contains( + QRegularExpression("([a-zA-Z_$][a-zA-Z\\d_$]*\\.)+[a-zA-Z_$]?[a-zA-" + "Z\\d_$]*(Exception|Error|Throwable)")) || + line.contains(QRegularExpression("... \\d+ more$"))) + return MessageLevel::Error; + return level; +} + +IPathMatcher::Ptr MinecraftInstance::getLogFileMatcher() +{ + auto combined = std::make_shared<MultiMatcher>(); + combined->add( + std::make_shared<RegexpMatcher>(".*\\.log(\\.[0-9]*)?(\\.gz)?$")); + combined->add(std::make_shared<RegexpMatcher>("crash-.*\\.txt")); + combined->add(std::make_shared<RegexpMatcher>("IDMap dump.*\\.txt$")); + combined->add(std::make_shared<RegexpMatcher>("ModLoader\\.txt(\\..*)?$")); + return combined; +} + +QString MinecraftInstance::getLogFileRoot() +{ + return gameRoot(); +} + +QString MinecraftInstance::getStatusbarDescription() +{ + QStringList traits; + if (hasVersionBroken()) { + traits.append(tr("broken")); + } + + QString description; + description.append( + tr("Minecraft %1 (%2)") + .arg(m_components->getComponentVersion("net.minecraft")) + .arg(typeName())); + if (m_settings->get("ShowGameTime").toBool()) { + if (lastTimePlayed() > 0) { + description.append( + tr(", last played for %1") + .arg(Time::prettifyDuration(lastTimePlayed()))); + } + + if (totalTimePlayed() > 0) { + description.append( + tr(", total played for %1") + .arg(Time::prettifyDuration(totalTimePlayed()))); + } + } + if (hasCrashed()) { + description.append(tr(", has crashed.")); + } + return description; +} + +Task::Ptr MinecraftInstance::createUpdateTask(Net::Mode mode) +{ + switch (mode) { + case Net::Mode::Offline: { + return Task::Ptr(new MinecraftLoadAndCheck(this)); + } + case Net::Mode::Online: { + return Task::Ptr(new MinecraftUpdate(this)); + } + } + return nullptr; +} + +shared_qobject_ptr<LaunchTask> +MinecraftInstance::createLaunchTask(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) +{ + // FIXME: get rid of shared_from_this ... + auto process = LaunchTask::create( + std::dynamic_pointer_cast<MinecraftInstance>(shared_from_this())); + auto pptr = process.get(); + + APPLICATION->icons()->saveIcon( + iconKey(), FS::PathCombine(gameRoot(), "icon.png"), "PNG"); + + // print a header + { + process->appendStep( + new TextPrint(pptr, "Minecraft folder is:\n" + gameRoot() + "\n\n", + MessageLevel::MeshMC)); + } + + // check java + { + process->appendStep(new CheckJava(pptr)); + } + + // check launch method + QStringList validMethods = {"MeshMCPart", "DirectJava"}; + QString method = launchMethod(); + if (!validMethods.contains(method)) { + process->appendStep(new TextPrint( + pptr, "Selected launch method \"" + method + "\" is not valid.\n", + MessageLevel::Fatal)); + return process; + } + + // create the .minecraft folder and server-resource-packs (workaround for + // Minecraft bug MCL-3732) + { + process->appendStep(new CreateGameFolders(pptr)); + } + + if (!serverToJoin && m_settings->get("JoinServerOnLaunch").toBool()) { + QString fullAddress = + m_settings->get("JoinServerOnLaunchAddress").toString(); + serverToJoin.reset(new MinecraftServerTarget( + MinecraftServerTarget::parse(fullAddress))); + } + + if (serverToJoin && serverToJoin->port == 25565) { + // Resolve server address to join on launch + auto* step = new LookupServerAddress(pptr); + step->setLookupAddress(serverToJoin->address); + step->setOutputAddressPtr(serverToJoin); + process->appendStep(step); + } + + // run pre-launch command if that's needed + if (getPreLaunchCommand().size()) { + auto step = new PreLaunchCommand(pptr); + step->setWorkingDirectory(gameRoot()); + process->appendStep(step); + } + + // if we aren't in offline mode,. + if (session->status != AuthSession::PlayableOffline) { + if (!session->demo) { + process->appendStep(new ClaimAccount(pptr, session)); + } + process->appendStep(new Update(pptr, Net::Mode::Online)); + } else { + process->appendStep(new Update(pptr, Net::Mode::Offline)); + } + + // if there are any jar mods + { + process->appendStep(new ModMinecraftJar(pptr)); + } + + // Scan mods folders for mods + { + process->appendStep(new ScanModFolders(pptr)); + } + + // print some instance info here... + { + process->appendStep(new PrintInstanceInfo(pptr, session, serverToJoin)); + } + + // extract native jars if needed + { + process->appendStep(new ExtractNatives(pptr)); + } + + // reconstruct assets if needed + { + process->appendStep(new ReconstructAssets(pptr)); + } + + // verify that minimum Java requirements are met + { + process->appendStep(new VerifyJavaInstall(pptr)); + } + + { + // actually launch the game + auto method = launchMethod(); + if (method == "MeshMCPart") { + auto step = new MeshMCPartLaunch(pptr); + step->setWorkingDirectory(gameRoot()); + step->setAuthSession(session); + step->setServerToJoin(serverToJoin); + process->appendStep(step); + } else if (method == "DirectJava") { + auto step = new DirectJavaLaunch(pptr); + step->setWorkingDirectory(gameRoot()); + step->setAuthSession(session); + step->setServerToJoin(serverToJoin); + process->appendStep(step); + } + } + + // run post-exit command if that's needed + if (getPostExitCommand().size()) { + auto step = new PostLaunchCommand(pptr); + step->setWorkingDirectory(gameRoot()); + process->appendStep(step); + } + if (session) { + process->setCensorFilter(createCensorFilterFromSession(session)); + } + m_launchProcess = process; + emit launchTaskChanged(m_launchProcess); + return m_launchProcess; +} + +QString MinecraftInstance::launchMethod() +{ + return m_settings->get("MCLaunchMethod").toString(); +} + +JavaVersion MinecraftInstance::getJavaVersion() const +{ + return JavaVersion(settings()->get("JavaVersion").toString()); +} + +std::shared_ptr<ModFolderModel> MinecraftInstance::loaderModList() const +{ + if (!m_loader_mod_list) { + m_loader_mod_list.reset(new ModFolderModel(modsRoot())); + m_loader_mod_list->disableInteraction(isRunning()); + connect(this, &BaseInstance::runningStatusChanged, + m_loader_mod_list.get(), &ModFolderModel::disableInteraction); + } + return m_loader_mod_list; +} + +std::shared_ptr<ModFolderModel> MinecraftInstance::coreModList() const +{ + if (!m_core_mod_list) { + m_core_mod_list.reset(new ModFolderModel(coreModsDir())); + m_core_mod_list->disableInteraction(isRunning()); + connect(this, &BaseInstance::runningStatusChanged, + m_core_mod_list.get(), &ModFolderModel::disableInteraction); + } + return m_core_mod_list; +} + +std::shared_ptr<ModFolderModel> MinecraftInstance::resourcePackList() const +{ + if (!m_resource_pack_list) { + m_resource_pack_list.reset( + new ResourcePackFolderModel(resourcePacksDir())); + m_resource_pack_list->disableInteraction(isRunning()); + connect(this, &BaseInstance::runningStatusChanged, + m_resource_pack_list.get(), + &ModFolderModel::disableInteraction); + } + return m_resource_pack_list; +} + +std::shared_ptr<ModFolderModel> MinecraftInstance::texturePackList() const +{ + if (!m_texture_pack_list) { + m_texture_pack_list.reset( + new TexturePackFolderModel(texturePacksDir())); + m_texture_pack_list->disableInteraction(isRunning()); + connect(this, &BaseInstance::runningStatusChanged, + m_texture_pack_list.get(), &ModFolderModel::disableInteraction); + } + return m_texture_pack_list; +} + +std::shared_ptr<ModFolderModel> MinecraftInstance::shaderPackList() const +{ + if (!m_shader_pack_list) { + m_shader_pack_list.reset(new ResourcePackFolderModel(shaderPacksDir())); + m_shader_pack_list->disableInteraction(isRunning()); + connect(this, &BaseInstance::runningStatusChanged, + m_shader_pack_list.get(), &ModFolderModel::disableInteraction); + } + return m_shader_pack_list; +} + +std::shared_ptr<WorldList> MinecraftInstance::worldList() const +{ + if (!m_world_list) { + m_world_list.reset(new WorldList(worldDir())); + } + return m_world_list; +} + +std::shared_ptr<GameOptions> MinecraftInstance::gameOptionsModel() const +{ + if (!m_game_options) { + m_game_options.reset( + new GameOptions(FS::PathCombine(gameRoot(), "options.txt"))); + } + return m_game_options; +} + +QList<Mod> MinecraftInstance::getJarMods() const +{ + auto profile = m_components->getProfile(); + QList<Mod> mods; + for (auto jarmod : profile->getJarMods()) { + QStringList jar, temp1, temp2, temp3; + jarmod->getApplicableFiles(currentSystem, jar, temp1, temp2, temp3, + jarmodsPath().absolutePath()); + // QString filePath = + // jarmodsPath().absoluteFilePath(jarmod->filename(currentSystem)); + mods.push_back(Mod(QFileInfo(jar[0]))); + } + return mods; +} + +#include "MinecraftInstance.moc" diff --git a/meshmc/launcher/minecraft/MinecraftInstance.h b/meshmc/launcher/minecraft/MinecraftInstance.h new file mode 100644 index 0000000000..1d54e85c00 --- /dev/null +++ b/meshmc/launcher/minecraft/MinecraftInstance.h @@ -0,0 +1,162 @@ +/* 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 "BaseInstance.h" +#include <java/JavaVersion.h> +#include "minecraft/mod/Mod.h" +#include <QProcess> +#include <QDir> +#include "minecraft/launch/MinecraftServerTarget.h" + +class ModFolderModel; +class WorldList; +class GameOptions; +class LaunchStep; +class PackProfile; + +class MinecraftInstance : public BaseInstance +{ + Q_OBJECT + public: + MinecraftInstance(SettingsObjectPtr globalSettings, + SettingsObjectPtr settings, const QString& rootDir); + virtual ~MinecraftInstance() {}; + virtual void saveNow() override; + + // FIXME: remove + QString typeName() const override; + // FIXME: remove + QSet<QString> traits() const override; + + bool canEdit() const override + { + return true; + } + + bool canExport() const override + { + return true; + } + + ////// Directories and files ////// + QString jarModsDir() const; + QString resourcePacksDir() const; + QString texturePacksDir() const; + QString shaderPacksDir() const; + QString modsRoot() const override; + QString coreModsDir() const; + QString modsCacheLocation() const; + QString libDir() const; + QString worldDir() const; + QString resourcesDir() const; + QDir jarmodsPath() const; + QDir librariesPath() const; + QDir versionsPath() const; + QString instanceConfigFolder() const override; + + // Path to the instance's minecraft directory. + QString gameRoot() const override; + + // Path to the instance's minecraft bin directory. + QString binRoot() const; + + // where to put the natives during/before launch + QString getNativePath() const; + + // where the instance-local libraries should be + QString getLocalLibraryPath() const; + + ////// Profile management ////// + std::shared_ptr<PackProfile> getPackProfile() const; + + ////// Mod Lists ////// + std::shared_ptr<ModFolderModel> loaderModList() const; + std::shared_ptr<ModFolderModel> coreModList() const; + std::shared_ptr<ModFolderModel> resourcePackList() const; + std::shared_ptr<ModFolderModel> texturePackList() const; + std::shared_ptr<ModFolderModel> shaderPackList() const; + std::shared_ptr<WorldList> worldList() const; + std::shared_ptr<GameOptions> gameOptionsModel() const; + + ////// Launch stuff ////// + Task::Ptr createUpdateTask(Net::Mode mode) override; + shared_qobject_ptr<LaunchTask> + createLaunchTask(AuthSessionPtr account, + MinecraftServerTargetPtr serverToJoin) override; + QStringList extraArguments() const override; + QStringList + verboseDescription(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) override; + QList<Mod> getJarMods() const; + QString createLaunchScript(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin); + /// get arguments passed to java + QStringList javaArguments() const; + + /// get variables for launch command variable substitution/environment + QMap<QString, QString> getVariables() const override; + + /// create an environment for launching processes + QProcessEnvironment createEnvironment() override; + + /// guess log level from a line of minecraft log + MessageLevel::Enum guessLevel(const QString& line, + MessageLevel::Enum level) override; + + IPathMatcher::Ptr getLogFileMatcher() override; + + QString getLogFileRoot() override; + + QString getStatusbarDescription() override; + + // FIXME: remove + virtual QStringList getClassPath() const; + // FIXME: remove + virtual QStringList getNativeJars() const; + // FIXME: remove + virtual QString getMainClass() const; + + // FIXME: remove + virtual QStringList + processMinecraftArgs(AuthSessionPtr account, + MinecraftServerTargetPtr serverToJoin) const; + + virtual JavaVersion getJavaVersion() const; + + protected: + QMap<QString, QString> + createCensorFilterFromSession(AuthSessionPtr session); + QStringList validLaunchMethods(); + QString launchMethod(); + + protected: // data + std::shared_ptr<PackProfile> m_components; + mutable std::shared_ptr<ModFolderModel> m_loader_mod_list; + mutable std::shared_ptr<ModFolderModel> m_core_mod_list; + mutable std::shared_ptr<ModFolderModel> m_resource_pack_list; + mutable std::shared_ptr<ModFolderModel> m_shader_pack_list; + mutable std::shared_ptr<ModFolderModel> m_texture_pack_list; + mutable std::shared_ptr<WorldList> m_world_list; + mutable std::shared_ptr<GameOptions> m_game_options; +}; + +typedef std::shared_ptr<MinecraftInstance> MinecraftInstancePtr; diff --git a/meshmc/launcher/minecraft/MinecraftLoadAndCheck.cpp b/meshmc/launcher/minecraft/MinecraftLoadAndCheck.cpp new file mode 100644 index 0000000000..2ab43aebf5 --- /dev/null +++ b/meshmc/launcher/minecraft/MinecraftLoadAndCheck.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 "MinecraftLoadAndCheck.h" +#include "MinecraftInstance.h" +#include "PackProfile.h" + +MinecraftLoadAndCheck::MinecraftLoadAndCheck(MinecraftInstance* inst, + QObject* parent) + : Task(parent), m_inst(inst) +{ +} + +void MinecraftLoadAndCheck::executeTask() +{ + // add offline metadata load task + auto components = m_inst->getPackProfile(); + components->reload(Net::Mode::Offline); + m_task = components->getCurrentTask(); + + if (!m_task) { + emitSucceeded(); + return; + } + connect(m_task.get(), &Task::succeeded, this, + &MinecraftLoadAndCheck::subtaskSucceeded); + connect(m_task.get(), &Task::failed, this, + &MinecraftLoadAndCheck::subtaskFailed); + connect(m_task.get(), &Task::progress, this, + &MinecraftLoadAndCheck::progress); + connect(m_task.get(), &Task::status, this, + &MinecraftLoadAndCheck::setStatus); +} + +void MinecraftLoadAndCheck::subtaskSucceeded() +{ + if (isFinished()) { + qCritical() << "MinecraftUpdate: Subtask" << sender() + << "succeeded, but work was already done!"; + return; + } + emitSucceeded(); +} + +void MinecraftLoadAndCheck::subtaskFailed(QString error) +{ + if (isFinished()) { + qCritical() << "MinecraftUpdate: Subtask" << sender() + << "failed, but work was already done!"; + return; + } + emitFailed(error); +} diff --git a/meshmc/launcher/minecraft/MinecraftLoadAndCheck.h b/meshmc/launcher/minecraft/MinecraftLoadAndCheck.h new file mode 100644 index 0000000000..7ec79bb48e --- /dev/null +++ b/meshmc/launcher/minecraft/MinecraftLoadAndCheck.h @@ -0,0 +1,70 @@ +/* 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 <QObject> +#include <QList> +#include <QUrl> + +#include "tasks/Task.h" + +#include "QObjectPtr.h" + +class MinecraftVersion; +class MinecraftInstance; + +class MinecraftLoadAndCheck : public Task +{ + Q_OBJECT + public: + explicit MinecraftLoadAndCheck(MinecraftInstance* inst, + QObject* parent = 0); + virtual ~MinecraftLoadAndCheck() {}; + void executeTask() override; + + private slots: + void subtaskSucceeded(); + void subtaskFailed(QString error); + + private: + MinecraftInstance* m_inst = nullptr; + Task::Ptr m_task; + QString m_preFailure; + QString m_fail_reason; +}; diff --git a/meshmc/launcher/minecraft/MinecraftUpdate.cpp b/meshmc/launcher/minecraft/MinecraftUpdate.cpp new file mode 100644 index 0000000000..356d717ed3 --- /dev/null +++ b/meshmc/launcher/minecraft/MinecraftUpdate.cpp @@ -0,0 +1,200 @@ +/* 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 "MinecraftUpdate.h" +#include "MinecraftInstance.h" + +#include <QFile> +#include <QFileInfo> +#include <QTextStream> +#include <QDataStream> + +#include "BaseInstance.h" +#include "minecraft/PackProfile.h" +#include "minecraft/Library.h" +#include <FileSystem.h> + +#include "update/FoldersTask.h" +#include "update/LibrariesTask.h" +#include "update/FMLLibrariesTask.h" +#include "update/AssetUpdateTask.h" + +#include <meta/Index.h> +#include <meta/Version.h> + +MinecraftUpdate::MinecraftUpdate(MinecraftInstance* inst, QObject* parent) + : Task(parent), m_inst(inst) +{ +} + +void MinecraftUpdate::executeTask() +{ + m_tasks.clear(); + // create folders + { + m_tasks.append(std::make_shared<FoldersTask>(m_inst)); + } + + // add metadata update task if necessary + { + auto components = m_inst->getPackProfile(); + components->reload(Net::Mode::Online); + auto task = components->getCurrentTask(); + if (task) { + m_tasks.append(task.unwrap()); + } + } + + // libraries download + { + m_tasks.append(std::make_shared<LibrariesTask>(m_inst)); + } + + // FML libraries download and copy into the instance + { + m_tasks.append(std::make_shared<FMLLibrariesTask>(m_inst)); + } + + // assets update + { + m_tasks.append(std::make_shared<AssetUpdateTask>(m_inst)); + } + + if (!m_preFailure.isEmpty()) { + emitFailed(m_preFailure); + return; + } + next(); +} + +void MinecraftUpdate::next() +{ + if (m_abort) { + emitFailed(tr("Aborted by user.")); + return; + } + if (m_failed_out_of_order) { + emitFailed(m_fail_reason); + return; + } + m_currentTask++; + if (m_currentTask > 0) { + auto task = m_tasks[m_currentTask - 1]; + disconnect(task.get(), &Task::succeeded, this, + &MinecraftUpdate::subtaskSucceeded); + disconnect(task.get(), &Task::failed, this, + &MinecraftUpdate::subtaskFailed); + disconnect(task.get(), &Task::progress, this, + &MinecraftUpdate::progress); + disconnect(task.get(), &Task::status, this, + &MinecraftUpdate::setStatus); + } + if (m_currentTask == m_tasks.size()) { + emitSucceeded(); + return; + } + auto task = m_tasks[m_currentTask]; + // if the task is already finished by the time we look at it, skip it + if (task->isFinished()) { + qCritical() << "MinecraftUpdate: Skipping finished subtask" + << m_currentTask << ":" << task.get(); + next(); + } + connect(task.get(), &Task::succeeded, this, + &MinecraftUpdate::subtaskSucceeded); + connect(task.get(), &Task::failed, this, &MinecraftUpdate::subtaskFailed); + connect(task.get(), &Task::progress, this, &MinecraftUpdate::progress); + connect(task.get(), &Task::status, this, &MinecraftUpdate::setStatus); + // if the task is already running, do not start it again + if (!task->isRunning()) { + task->start(); + } +} + +void MinecraftUpdate::subtaskSucceeded() +{ + if (isFinished()) { + qCritical() << "MinecraftUpdate: Subtask" << sender() + << "succeeded, but work was already done!"; + return; + } + auto senderTask = QObject::sender(); + auto currentTask = m_tasks[m_currentTask].get(); + if (senderTask != currentTask) { + qDebug() << "MinecraftUpdate: Subtask" << sender() + << "succeeded out of order."; + return; + } + next(); +} + +void MinecraftUpdate::subtaskFailed(QString error) +{ + if (isFinished()) { + qCritical() << "MinecraftUpdate: Subtask" << sender() + << "failed, but work was already done!"; + return; + } + auto senderTask = QObject::sender(); + auto currentTask = m_tasks[m_currentTask].get(); + if (senderTask != currentTask) { + qDebug() << "MinecraftUpdate: Subtask" << sender() + << "failed out of order."; + m_failed_out_of_order = true; + m_fail_reason = error; + return; + } + emitFailed(error); +} + +bool MinecraftUpdate::abort() +{ + if (!m_abort) { + m_abort = true; + auto task = m_tasks[m_currentTask]; + if (task->canAbort()) { + return task->abort(); + } + } + return true; +} + +bool MinecraftUpdate::canAbort() const +{ + return true; +} diff --git a/meshmc/launcher/minecraft/MinecraftUpdate.h b/meshmc/launcher/minecraft/MinecraftUpdate.h new file mode 100644 index 0000000000..cec09a3244 --- /dev/null +++ b/meshmc/launcher/minecraft/MinecraftUpdate.h @@ -0,0 +1,78 @@ +/* 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 <QObject> +#include <QList> +#include <QUrl> + +#include "net/NetJob.h" +#include "tasks/Task.h" +#include "minecraft/VersionFilterData.h" + +class MinecraftVersion; +class MinecraftInstance; + +class MinecraftUpdate : public Task +{ + Q_OBJECT + public: + explicit MinecraftUpdate(MinecraftInstance* inst, QObject* parent = 0); + virtual ~MinecraftUpdate() {}; + + void executeTask() override; + bool canAbort() const override; + + private slots: + bool abort() override; + void subtaskSucceeded(); + void subtaskFailed(QString error); + + private: + void next(); + + private: + MinecraftInstance* m_inst = nullptr; + QList<std::shared_ptr<Task>> m_tasks; + QString m_preFailure; + int m_currentTask = -1; + bool m_abort = false; + bool m_failed_out_of_order = false; + QString m_fail_reason; +}; diff --git a/meshmc/launcher/minecraft/MojangDownloadInfo.h b/meshmc/launcher/minecraft/MojangDownloadInfo.h new file mode 100644 index 0000000000..d62e4747e8 --- /dev/null +++ b/meshmc/launcher/minecraft/MojangDownloadInfo.h @@ -0,0 +1,95 @@ +/* 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 <QMap> +#include <memory> + +struct MojangDownloadInfo { + // types + typedef std::shared_ptr<MojangDownloadInfo> Ptr; + + // data + /// Local filesystem path. WARNING: not used, only here so we can pass + /// through mojang files unmolested! + QString path; + /// absolute URL of this file + QString url; + /// sha-1 checksum of the file + QString sha1; + /// size of the file in bytes + int size; +}; + +struct MojangLibraryDownloadInfo { + MojangLibraryDownloadInfo(MojangDownloadInfo::Ptr artifact) + : artifact(artifact) {}; + MojangLibraryDownloadInfo() {}; + + // types + typedef std::shared_ptr<MojangLibraryDownloadInfo> Ptr; + + // methods + MojangDownloadInfo* getDownloadInfo(QString classifier) + { + if (classifier.isNull()) { + return artifact.get(); + } + + return classifiers[classifier].get(); + } + + // data + MojangDownloadInfo::Ptr artifact; + QMap<QString, MojangDownloadInfo::Ptr> classifiers; +}; + +struct MojangAssetIndexInfo : public MojangDownloadInfo { + // types + typedef std::shared_ptr<MojangAssetIndexInfo> Ptr; + + // methods + MojangAssetIndexInfo() {} + + MojangAssetIndexInfo(QString id) + { + this->id = id; + // HACK: ignore assets from other version files than Minecraft + // workaround for stupid assets issue caused by amazon: + // https://www.theregister.co.uk/2017/02/28/aws_is_awol_as_s3_goes_haywire/ + if (id == "legacy") { + url = "https://launchermeta.mojang.com/mc/assets/legacy/" + "c0fd82e8ce9fbc93119e40d96d5a4e62cfa3f729/legacy.json"; + } + // HACK + else { + url = "https://s3.amazonaws.com/Minecraft.Download/indexes/" + id + + ".json"; + } + known = false; + } + + // data + int totalSize; + QString id; + bool known = true; +}; diff --git a/meshmc/launcher/minecraft/MojangVersionFormat.cpp b/meshmc/launcher/minecraft/MojangVersionFormat.cpp new file mode 100644 index 0000000000..0943da3e56 --- /dev/null +++ b/meshmc/launcher/minecraft/MojangVersionFormat.cpp @@ -0,0 +1,387 @@ +/* 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 "MojangVersionFormat.h" +#include "OneSixVersionFormat.h" +#include "MojangDownloadInfo.h" + +#include "Json.h" +using namespace Json; +#include "ParseUtils.h" +#include <BuildConfig.h> + +static const int CURRENT_MINIMUM_MESHMC_VERSION = 18; + +static MojangAssetIndexInfo::Ptr assetIndexFromJson(const QJsonObject& obj); +static MojangDownloadInfo::Ptr downloadInfoFromJson(const QJsonObject& obj); +static MojangLibraryDownloadInfo::Ptr +libDownloadInfoFromJson(const QJsonObject& libObj); +static QJsonObject assetIndexToJson(MojangAssetIndexInfo::Ptr assetidxinfo); +static QJsonObject +libDownloadInfoToJson(MojangLibraryDownloadInfo::Ptr libinfo); +static QJsonObject downloadInfoToJson(MojangDownloadInfo::Ptr info); + +namespace Bits +{ + static void readString(const QJsonObject& root, const QString& key, + QString& variable) + { + if (root.contains(key)) { + variable = requireString(root.value(key)); + } + } + + static void readDownloadInfo(MojangDownloadInfo::Ptr out, + const QJsonObject& obj) + { + // optional, not used + readString(obj, "path", out->path); + // required! + out->sha1 = requireString(obj, "sha1"); + out->url = requireString(obj, "url"); + out->size = requireInteger(obj, "size"); + } + + static void readAssetIndex(MojangAssetIndexInfo::Ptr out, + const QJsonObject& obj) + { + out->totalSize = requireInteger(obj, "totalSize"); + out->id = requireString(obj, "id"); + // out->known = true; + } +} // namespace Bits + +MojangDownloadInfo::Ptr downloadInfoFromJson(const QJsonObject& obj) +{ + auto out = std::make_shared<MojangDownloadInfo>(); + Bits::readDownloadInfo(out, obj); + return out; +} + +MojangAssetIndexInfo::Ptr assetIndexFromJson(const QJsonObject& obj) +{ + auto out = std::make_shared<MojangAssetIndexInfo>(); + Bits::readDownloadInfo(out, obj); + Bits::readAssetIndex(out, obj); + return out; +} + +QJsonObject downloadInfoToJson(MojangDownloadInfo::Ptr info) +{ + QJsonObject out; + if (!info->path.isNull()) { + out.insert("path", info->path); + } + out.insert("sha1", info->sha1); + out.insert("size", info->size); + out.insert("url", info->url); + return out; +} + +MojangLibraryDownloadInfo::Ptr +libDownloadInfoFromJson(const QJsonObject& libObj) +{ + auto out = std::make_shared<MojangLibraryDownloadInfo>(); + auto dlObj = requireObject(libObj.value("downloads")); + if (dlObj.contains("artifact")) { + out->artifact = downloadInfoFromJson(requireObject(dlObj, "artifact")); + } + if (dlObj.contains("classifiers")) { + auto classifiersObj = requireObject(dlObj, "classifiers"); + for (auto iter = classifiersObj.begin(); iter != classifiersObj.end(); + iter++) { + auto classifier = iter.key(); + auto classifierObj = requireObject(iter.value()); + out->classifiers[classifier] = downloadInfoFromJson(classifierObj); + } + } + return out; +} + +QJsonObject libDownloadInfoToJson(MojangLibraryDownloadInfo::Ptr libinfo) +{ + QJsonObject out; + if (libinfo->artifact) { + out.insert("artifact", downloadInfoToJson(libinfo->artifact)); + } + if (libinfo->classifiers.size()) { + QJsonObject classifiersOut; + for (auto iter = libinfo->classifiers.begin(); + iter != libinfo->classifiers.end(); iter++) { + classifiersOut.insert(iter.key(), downloadInfoToJson(iter.value())); + } + out.insert("classifiers", classifiersOut); + } + return out; +} + +QJsonObject assetIndexToJson(MojangAssetIndexInfo::Ptr info) +{ + QJsonObject out; + if (!info->path.isNull()) { + out.insert("path", info->path); + } + out.insert("sha1", info->sha1); + out.insert("size", info->size); + out.insert("url", info->url); + out.insert("totalSize", info->totalSize); + out.insert("id", info->id); + return out; +} + +void MojangVersionFormat::readVersionProperties(const QJsonObject& in, + VersionFile* out) +{ + Bits::readString(in, "id", out->minecraftVersion); + Bits::readString(in, "mainClass", out->mainClass); + Bits::readString(in, "minecraftArguments", out->minecraftArguments); + if (out->minecraftArguments.isEmpty()) { + QString processArguments; + Bits::readString(in, "processArguments", processArguments); + QString toCompare = processArguments.toLower(); + if (toCompare == "legacy") { + out->minecraftArguments = " ${auth_player_name} ${auth_session}"; + } else if (toCompare == "username_session") { + out->minecraftArguments = + "--username ${auth_player_name} --session ${auth_session}"; + } else if (toCompare == "username_session_version") { + out->minecraftArguments = + "--username ${auth_player_name} --session ${auth_session} " + "--version ${profile_name}"; + } else if (!toCompare.isEmpty()) { + out->addProblem( + ProblemSeverity::Error, + QObject::tr("processArguments is set to unknown value '%1'") + .arg(processArguments)); + } + } + Bits::readString(in, "type", out->type); + + Bits::readString(in, "assets", out->assets); + if (in.contains("assetIndex")) { + out->mojangAssetIndex = + assetIndexFromJson(requireObject(in, "assetIndex")); + } else if (!out->assets.isNull()) { + out->mojangAssetIndex = + std::make_shared<MojangAssetIndexInfo>(out->assets); + } + + out->releaseTime = timeFromS3Time(in.value("releaseTime").toString("")); + out->updateTime = timeFromS3Time(in.value("time").toString("")); + + if (in.contains("minimumLauncherVersion")) { + out->minimumMeshMCVersion = + requireInteger(in.value("minimumLauncherVersion")); + if (out->minimumMeshMCVersion > CURRENT_MINIMUM_MESHMC_VERSION) { + out->addProblem( + ProblemSeverity::Warning, + QObject::tr("The 'minimumMeshMCVersion' value of this version " + "(%1) is higher than supported by %3 (%2). It " + "might not work properly!") + .arg(out->minimumMeshMCVersion) + .arg(CURRENT_MINIMUM_MESHMC_VERSION) + .arg(BuildConfig.MESHMC_NAME)); + } + } + if (in.contains("downloads")) { + auto downloadsObj = requireObject(in, "downloads"); + for (auto iter = downloadsObj.begin(); iter != downloadsObj.end(); + iter++) { + auto classifier = iter.key(); + auto classifierObj = requireObject(iter.value()); + out->mojangDownloads[classifier] = + downloadInfoFromJson(classifierObj); + } + } +} + +VersionFilePtr +MojangVersionFormat::versionFileFromJson(const QJsonDocument& doc, + const QString& filename) +{ + VersionFilePtr out(new VersionFile()); + if (doc.isEmpty() || doc.isNull()) { + throw JSONValidationError(filename + " is empty or null"); + } + if (!doc.isObject()) { + throw JSONValidationError(filename + " is not an object"); + } + + QJsonObject root = doc.object(); + + readVersionProperties(root, out.get()); + + out->name = "Minecraft"; + out->uid = "net.minecraft"; + out->version = out->minecraftVersion; + // out->filename = filename; + + if (root.contains("libraries")) { + for (auto libVal : requireArray(root.value("libraries"))) { + auto libObj = requireObject(libVal); + + auto lib = + MojangVersionFormat::libraryFromJson(*out, libObj, filename); + out->libraries.append(lib); + } + } + return out; +} + +void MojangVersionFormat::writeVersionProperties(const VersionFile* in, + QJsonObject& out) +{ + writeString(out, "id", in->minecraftVersion); + writeString(out, "mainClass", in->mainClass); + writeString(out, "minecraftArguments", in->minecraftArguments); + writeString(out, "type", in->type); + if (!in->releaseTime.isNull()) { + writeString(out, "releaseTime", timeToS3Time(in->releaseTime)); + } + if (!in->updateTime.isNull()) { + writeString(out, "time", timeToS3Time(in->updateTime)); + } + if (in->minimumMeshMCVersion != -1) { + out.insert("minimumLauncherVersion", in->minimumMeshMCVersion); + } + writeString(out, "assets", in->assets); + if (in->mojangAssetIndex && in->mojangAssetIndex->known) { + out.insert("assetIndex", assetIndexToJson(in->mojangAssetIndex)); + } + if (in->mojangDownloads.size()) { + QJsonObject downloadsOut; + for (auto iter = in->mojangDownloads.begin(); + iter != in->mojangDownloads.end(); iter++) { + downloadsOut.insert(iter.key(), downloadInfoToJson(iter.value())); + } + out.insert("downloads", downloadsOut); + } +} + +QJsonDocument +MojangVersionFormat::versionFileToJson(const VersionFilePtr& patch) +{ + QJsonObject root; + writeVersionProperties(patch.get(), root); + if (!patch->libraries.isEmpty()) { + QJsonArray array; + for (auto value : patch->libraries) { + array.append(MojangVersionFormat::libraryToJson(value.get())); + } + root.insert("libraries", array); + } + + // write the contents to a json document. + { + QJsonDocument out; + out.setObject(root); + return out; + } +} + +LibraryPtr MojangVersionFormat::libraryFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename) +{ + LibraryPtr out(new Library()); + if (!libObj.contains("name")) { + throw JSONValidationError( + filename + "contains a library that doesn't have a 'name' field"); + } + auto rawName = libObj.value("name").toString(); + out->m_name = rawName; + if (!out->m_name.valid()) { + problems.addProblem( + ProblemSeverity::Error, + QObject::tr("Library %1 name is broken and cannot be processed.") + .arg(rawName)); + } + + Bits::readString(libObj, "url", out->m_repositoryURL); + if (libObj.contains("extract")) { + out->m_hasExcludes = true; + auto extractObj = requireObject(libObj.value("extract")); + for (auto excludeVal : requireArray(extractObj.value("exclude"))) { + out->m_extractExcludes.append(requireString(excludeVal)); + } + } + if (libObj.contains("natives")) { + QJsonObject nativesObj = requireObject(libObj.value("natives")); + for (auto it = nativesObj.begin(); it != nativesObj.end(); ++it) { + if (!it.value().isString()) { + qWarning() << filename + << "contains an invalid native (skipping)"; + } + OpSys opSys = OpSys_fromString(it.key()); + if (opSys != Os_Other) { + out->m_nativeClassifiers[opSys] = it.value().toString(); + } + } + } + if (libObj.contains("rules")) { + out->applyRules = true; + out->m_rules = rulesFromJsonV4(libObj); + } + if (libObj.contains("downloads")) { + out->m_mojangDownloads = libDownloadInfoFromJson(libObj); + } + return out; +} + +QJsonObject MojangVersionFormat::libraryToJson(Library* library) +{ + QJsonObject libRoot; + libRoot.insert("name", library->m_name.serialize()); + if (!library->m_repositoryURL.isEmpty()) { + libRoot.insert("url", library->m_repositoryURL); + } + if (library->isNative()) { + QJsonObject nativeList; + auto iter = library->m_nativeClassifiers.begin(); + while (iter != library->m_nativeClassifiers.end()) { + nativeList.insert(OpSys_toString(iter.key()), iter.value()); + iter++; + } + libRoot.insert("natives", nativeList); + if (library->m_extractExcludes.size()) { + QJsonArray excludes; + QJsonObject extract; + for (auto exclude : library->m_extractExcludes) { + excludes.append(exclude); + } + extract.insert("exclude", excludes); + libRoot.insert("extract", extract); + } + } + if (library->m_rules.size()) { + QJsonArray allRules; + for (auto& rule : library->m_rules) { + QJsonObject ruleObj = rule->toJson(); + allRules.append(ruleObj); + } + libRoot.insert("rules", allRules); + } + if (library->m_mojangDownloads) { + auto downloadsObj = libDownloadInfoToJson(library->m_mojangDownloads); + libRoot.insert("downloads", downloadsObj); + } + return libRoot; +} diff --git a/meshmc/launcher/minecraft/MojangVersionFormat.h b/meshmc/launcher/minecraft/MojangVersionFormat.h new file mode 100644 index 0000000000..7d47cc3b16 --- /dev/null +++ b/meshmc/launcher/minecraft/MojangVersionFormat.h @@ -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/>. + */ + +#pragma once + +#include <minecraft/VersionFile.h> +#include <minecraft/Library.h> +#include <QJsonDocument> +#include <ProblemProvider.h> + +class MojangVersionFormat +{ + friend class OneSixVersionFormat; + + protected: + // does not include libraries + static void readVersionProperties(const QJsonObject& in, VersionFile* out); + // does not include libraries + static void writeVersionProperties(const VersionFile* in, QJsonObject& out); + + public: + // version files / profile patches + static VersionFilePtr versionFileFromJson(const QJsonDocument& doc, + const QString& filename); + static QJsonDocument versionFileToJson(const VersionFilePtr& patch); + + // libraries + static LibraryPtr libraryFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename); + static QJsonObject libraryToJson(Library* library); +}; diff --git a/meshmc/launcher/minecraft/MojangVersionFormat_test.cpp b/meshmc/launcher/minecraft/MojangVersionFormat_test.cpp new file mode 100644 index 0000000000..ba506778a2 --- /dev/null +++ b/meshmc/launcher/minecraft/MojangVersionFormat_test.cpp @@ -0,0 +1,77 @@ +/* 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 <QDebug> +#include "TestUtil.h" + +#include "minecraft/MojangVersionFormat.h" + +class MojangVersionFormatTest : public QObject +{ + Q_OBJECT + + static QJsonDocument readJson(const char* file) + { + auto path = QFINDTESTDATA(file); + QFile jsonFile(path); + if (!jsonFile.open(QIODevice::ReadOnly)) + return QJsonDocument(); + auto data = jsonFile.readAll(); + jsonFile.close(); + return QJsonDocument::fromJson(data); + } + static void writeJson(const char* file, QJsonDocument doc) + { + QFile jsonFile(file); + if (!jsonFile.open(QIODevice::WriteOnly | QIODevice::Text)) + return; + auto data = doc.toJson(QJsonDocument::Indented); + qDebug() << QString::fromUtf8(data); + jsonFile.write(data); + jsonFile.close(); + } + + private slots: + void test_Through_Simple() + { + QJsonDocument doc = readJson("data/1.9-simple.json"); + auto vfile = + MojangVersionFormat::versionFileFromJson(doc, "1.9-simple.json"); + auto doc2 = MojangVersionFormat::versionFileToJson(vfile); + writeJson("1.9-simple-passthorugh.json", doc2); + + QCOMPARE(doc.toJson(), doc2.toJson()); + } + + void test_Through() + { + QJsonDocument doc = readJson("data/1.9.json"); + auto vfile = MojangVersionFormat::versionFileFromJson(doc, "1.9.json"); + auto doc2 = MojangVersionFormat::versionFileToJson(vfile); + writeJson("1.9-passthorugh.json", doc2); + QCOMPARE(doc.toJson(), doc2.toJson()); + } +}; + +QTEST_GUILESS_MAIN(MojangVersionFormatTest) + +#include "MojangVersionFormat_test.moc" diff --git a/meshmc/launcher/minecraft/OneSixVersionFormat.cpp b/meshmc/launcher/minecraft/OneSixVersionFormat.cpp new file mode 100644 index 0000000000..990b845d95 --- /dev/null +++ b/meshmc/launcher/minecraft/OneSixVersionFormat.cpp @@ -0,0 +1,386 @@ +/* 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 "OneSixVersionFormat.h" +#include <Json.h> +#include "minecraft/ParseUtils.h" +#include <minecraft/MojangVersionFormat.h> + +using namespace Json; + +static void readString(const QJsonObject& root, const QString& key, + QString& variable) +{ + if (root.contains(key)) { + variable = requireString(root.value(key)); + } +} + +LibraryPtr OneSixVersionFormat::libraryFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename) +{ + LibraryPtr out = + MojangVersionFormat::libraryFromJson(problems, libObj, filename); + readString(libObj, "MMC-hint", out->m_hint); + readString(libObj, "MMC-absulute_url", out->m_absoluteURL); + readString(libObj, "MMC-absoluteUrl", out->m_absoluteURL); + readString(libObj, "MMC-filename", out->m_filename); + readString(libObj, "MMC-displayname", out->m_displayname); + return out; +} + +QJsonObject OneSixVersionFormat::libraryToJson(Library* library) +{ + QJsonObject libRoot = MojangVersionFormat::libraryToJson(library); + if (library->m_absoluteURL.size()) + libRoot.insert("MMC-absoluteUrl", library->m_absoluteURL); + if (library->m_hint.size()) + libRoot.insert("MMC-hint", library->m_hint); + if (library->m_filename.size()) + libRoot.insert("MMC-filename", library->m_filename); + if (library->m_displayname.size()) + libRoot.insert("MMC-displayname", library->m_displayname); + return libRoot; +} + +VersionFilePtr OneSixVersionFormat::versionFileFromJson( + const QJsonDocument& doc, const QString& filename, const bool requireOrder) +{ + VersionFilePtr out(new VersionFile()); + if (doc.isEmpty() || doc.isNull()) { + throw JSONValidationError(filename + " is empty or null"); + } + if (!doc.isObject()) { + throw JSONValidationError(filename + " is not an object"); + } + + QJsonObject root = doc.object(); + + Meta::MetadataVersion formatVersion = Meta::parseFormatVersion(root, false); + switch (formatVersion) { + case Meta::MetadataVersion::InitialRelease: + break; + case Meta::MetadataVersion::Invalid: + throw JSONValidationError(filename + + " does not contain a recognizable " + "version of the metadata format."); + } + + if (requireOrder) { + if (root.contains("order")) { + out->order = requireInteger(root.value("order")); + } else { + // FIXME: evaluate if we don't want to throw exceptions here instead + qCritical() << filename << "doesn't contain an order field"; + } + } + + out->name = root.value("name").toString(); + + if (root.contains("uid")) { + out->uid = root.value("uid").toString(); + } else { + out->uid = root.value("fileId").toString(); + } + + out->version = root.value("version").toString(); + + MojangVersionFormat::readVersionProperties(root, out.get()); + + // added for legacy Minecraft window embedding, TODO: remove + readString(root, "appletClass", out->appletClass); + + if (root.contains("+tweakers")) { + for (auto tweakerVal : requireArray(root.value("+tweakers"))) { + out->addTweakers.append(requireString(tweakerVal)); + } + } + + if (root.contains("+traits")) { + for (auto tweakerVal : requireArray(root.value("+traits"))) { + out->traits.insert(requireString(tweakerVal)); + } + } + + if (root.contains("jarMods")) { + for (auto libVal : requireArray(root.value("jarMods"))) { + QJsonObject libObj = requireObject(libVal); + // parse the jarmod + auto lib = + OneSixVersionFormat::jarModFromJson(*out, libObj, filename); + // and add to jar mods + out->jarMods.append(lib); + } + } else if (root.contains( + "+jarMods")) // DEPRECATED: old style '+jarMods' are only + // here for backwards compatibility + { + for (auto libVal : requireArray(root.value("+jarMods"))) { + QJsonObject libObj = requireObject(libVal); + // parse the jarmod + auto lib = OneSixVersionFormat::plusJarModFromJson( + *out, libObj, filename, out->name); + // and add to jar mods + out->jarMods.append(lib); + } + } + + if (root.contains("mods")) { + for (auto libVal : requireArray(root.value("mods"))) { + QJsonObject libObj = requireObject(libVal); + // parse the jarmod + auto lib = OneSixVersionFormat::modFromJson(*out, libObj, filename); + // and add to jar mods + out->mods.append(lib); + } + } + + auto readLibs = [&](const char* which, QList<LibraryPtr>& outList) { + for (auto libVal : requireArray(root.value(which))) { + QJsonObject libObj = requireObject(libVal); + // parse the library + auto lib = libraryFromJson(*out, libObj, filename); + outList.append(lib); + } + }; + bool hasPlusLibs = root.contains("+libraries"); + bool hasLibs = root.contains("libraries"); + if (hasPlusLibs && hasLibs) { + out->addProblem( + ProblemSeverity::Warning, + QObject::tr("Version file has both '+libraries' and 'libraries'. " + "This is no longer supported.")); + readLibs("libraries", out->libraries); + readLibs("+libraries", out->libraries); + } else if (hasLibs) { + readLibs("libraries", out->libraries); + } else if (hasPlusLibs) { + readLibs("+libraries", out->libraries); + } + + if (root.contains("mavenFiles")) { + readLibs("mavenFiles", out->mavenFiles); + } + + // if we have mainJar, just use it + if (root.contains("mainJar")) { + QJsonObject libObj = requireObject(root, "mainJar"); + out->mainJar = libraryFromJson(*out, libObj, filename); + } + // else reconstruct it from downloads and id ... if that's available + else if (!out->minecraftVersion.isEmpty()) { + auto lib = std::make_shared<Library>(); + lib->setRawName( + GradleSpecifier(QString("com.mojang:minecraft:%1:client") + .arg(out->minecraftVersion))); + // we have a reliable client download, use it. + if (out->mojangDownloads.contains("client")) { + auto LibDLInfo = std::make_shared<MojangLibraryDownloadInfo>(); + LibDLInfo->artifact = out->mojangDownloads["client"]; + lib->setMojangDownloadInfo(LibDLInfo); + } + // we got nothing... + else { + out->addProblem( + ProblemSeverity::Error, + QObject::tr( + "URL for the main jar could not be determined - Mojang " + "removed the server that we used as fallback.")); + } + out->mainJar = lib; + } + + if (root.contains("requires")) { + Meta::parseRequires(root, &out->requirements); + } + QString dependsOnMinecraftVersion = root.value("mcVersion").toString(); + if (!dependsOnMinecraftVersion.isEmpty()) { + Meta::Require mcReq; + mcReq.uid = "net.minecraft"; + mcReq.equalsVersion = dependsOnMinecraftVersion; + if (out->requirements.count(mcReq) == 0) { + out->requirements.insert(mcReq); + } + } + if (root.contains("conflicts")) { + Meta::parseRequires(root, &out->conflicts); + } + if (root.contains("volatile")) { + out->m_volatile = requireBoolean(root, "volatile"); + } + + /* removed features that shouldn't be used */ + if (root.contains("tweakers")) { + out->addProblem( + ProblemSeverity::Error, + QObject::tr( + "Version file contains unsupported element 'tweakers'")); + } + if (root.contains("-libraries")) { + out->addProblem( + ProblemSeverity::Error, + QObject::tr( + "Version file contains unsupported element '-libraries'")); + } + if (root.contains("-tweakers")) { + out->addProblem( + ProblemSeverity::Error, + QObject::tr( + "Version file contains unsupported element '-tweakers'")); + } + if (root.contains("-minecraftArguments")) { + out->addProblem(ProblemSeverity::Error, + QObject::tr("Version file contains unsupported element " + "'-minecraftArguments'")); + } + if (root.contains("+minecraftArguments")) { + out->addProblem(ProblemSeverity::Error, + QObject::tr("Version file contains unsupported element " + "'+minecraftArguments'")); + } + return out; +} + +QJsonDocument +OneSixVersionFormat::versionFileToJson(const VersionFilePtr& patch) +{ + QJsonObject root; + writeString(root, "name", patch->name); + + writeString(root, "uid", patch->uid); + + writeString(root, "version", patch->version); + + Meta::serializeFormatVersion(root, Meta::MetadataVersion::InitialRelease); + + MojangVersionFormat::writeVersionProperties(patch.get(), root); + + if (patch->mainJar) { + root.insert("mainJar", libraryToJson(patch->mainJar.get())); + } + writeString(root, "appletClass", patch->appletClass); + writeStringList(root, "+tweakers", patch->addTweakers); + writeStringList(root, "+traits", patch->traits.values()); + if (!patch->libraries.isEmpty()) { + QJsonArray array; + for (auto value : patch->libraries) { + array.append(OneSixVersionFormat::libraryToJson(value.get())); + } + root.insert("libraries", array); + } + if (!patch->mavenFiles.isEmpty()) { + QJsonArray array; + for (auto value : patch->mavenFiles) { + array.append(OneSixVersionFormat::libraryToJson(value.get())); + } + root.insert("mavenFiles", array); + } + if (!patch->jarMods.isEmpty()) { + QJsonArray array; + for (auto value : patch->jarMods) { + array.append(OneSixVersionFormat::jarModtoJson(value.get())); + } + root.insert("jarMods", array); + } + if (!patch->mods.isEmpty()) { + QJsonArray array; + for (auto value : patch->jarMods) { + array.append(OneSixVersionFormat::modtoJson(value.get())); + } + root.insert("mods", array); + } + if (!patch->requirements.empty()) { + Meta::serializeRequires(root, &patch->requirements, "requires"); + } + if (!patch->conflicts.empty()) { + Meta::serializeRequires(root, &patch->conflicts, "conflicts"); + } + if (patch->m_volatile) { + root.insert("volatile", true); + } + // write the contents to a json document. + { + QJsonDocument out; + out.setObject(root); + return out; + } +} + +LibraryPtr OneSixVersionFormat::plusJarModFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename, + const QString& originalName) +{ + LibraryPtr out(new Library()); + if (!libObj.contains("name")) { + throw JSONValidationError( + filename + "contains a jarmod that doesn't have a 'name' field"); + } + + // just make up something unique on the spot for the library name. + auto uuid = QUuid::createUuid(); + QString id = uuid.toString().remove('{').remove('}'); + out->setRawName(GradleSpecifier("org.projecttick.jarmods:" + id + ":1")); + + // filename override is the old name + out->setFilename(libObj.value("name").toString()); + + // it needs to be local, it is stored in the instance jarmods folder + out->setHint("local"); + + // read the original name if present - some versions did not set it + // it is the original jar mod filename before it got renamed at the point of + // addition + auto displayName = libObj.value("originalName").toString(); + if (displayName.isEmpty()) { + auto fixed = originalName; + fixed.remove(" (jar mod)"); + out->setDisplayName(fixed); + } else { + out->setDisplayName(displayName); + } + return out; +} + +LibraryPtr OneSixVersionFormat::jarModFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename) +{ + return libraryFromJson(problems, libObj, filename); +} + +QJsonObject OneSixVersionFormat::jarModtoJson(Library* jarmod) +{ + return libraryToJson(jarmod); +} + +LibraryPtr OneSixVersionFormat::modFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename) +{ + return libraryFromJson(problems, libObj, filename); +} + +QJsonObject OneSixVersionFormat::modtoJson(Library* jarmod) +{ + return libraryToJson(jarmod); +} diff --git a/meshmc/launcher/minecraft/OneSixVersionFormat.h b/meshmc/launcher/minecraft/OneSixVersionFormat.h new file mode 100644 index 0000000000..64d7bbf40d --- /dev/null +++ b/meshmc/launcher/minecraft/OneSixVersionFormat.h @@ -0,0 +1,62 @@ +/* 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 <minecraft/VersionFile.h> +#include <minecraft/PackProfile.h> +#include <minecraft/Library.h> +#include <QJsonDocument> +#include <ProblemProvider.h> + +class OneSixVersionFormat +{ + public: + // version files / profile patches + static VersionFilePtr versionFileFromJson(const QJsonDocument& doc, + const QString& filename, + const bool requireOrder); + static QJsonDocument versionFileToJson(const VersionFilePtr& patch); + + // libraries + static LibraryPtr libraryFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename); + static QJsonObject libraryToJson(Library* library); + + // DEPRECATED: old 'plus' jar mods generated by the application + static LibraryPtr plusJarModFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename, + const QString& originalName); + + // new jar mods derived from libraries + static LibraryPtr jarModFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename); + static QJsonObject jarModtoJson(Library* jarmod); + + // mods, also derived from libraries + static LibraryPtr modFromJson(ProblemContainer& problems, + const QJsonObject& libObj, + const QString& filename); + static QJsonObject modtoJson(Library* jarmod); +}; diff --git a/meshmc/launcher/minecraft/OpSys.cpp b/meshmc/launcher/minecraft/OpSys.cpp new file mode 100644 index 0000000000..98375854cd --- /dev/null +++ b/meshmc/launcher/minecraft/OpSys.cpp @@ -0,0 +1,68 @@ +/* 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 "OpSys.h" + +OpSys OpSys_fromString(QString name) +{ + if (name == "freebsd") + return Os_FreeBSD; + if (name == "linux") + return Os_Linux; + if (name == "windows") + return Os_Windows; + if (name == "osx") + return Os_OSX; + return Os_Other; +} + +QString OpSys_toString(OpSys name) +{ + switch (name) { + case Os_FreeBSD: + return "freebsd"; + case Os_Linux: + return "linux"; + case Os_OSX: + return "osx"; + case Os_Windows: + return "windows"; + default: + return "other"; + } +}
\ No newline at end of file diff --git a/meshmc/launcher/minecraft/OpSys.h b/meshmc/launcher/minecraft/OpSys.h new file mode 100644 index 0000000000..49a5404a41 --- /dev/null +++ b/meshmc/launcher/minecraft/OpSys.h @@ -0,0 +1,54 @@ +/* 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 <QString> +enum OpSys { Os_Windows, Os_FreeBSD, Os_Linux, Os_OSX, Os_Other }; + +OpSys OpSys_fromString(QString); +QString OpSys_toString(OpSys); + +#ifdef Q_OS_WIN32 +#define currentSystem Os_Windows +#elif defined Q_OS_MAC +#define currentSystem Os_OSX +#elif defined Q_OS_FREEBSD +#define currentSystem Os_FreeBSD +#else +#define currentSystem Os_Linux +#endif diff --git a/meshmc/launcher/minecraft/PackProfile.cpp b/meshmc/launcher/minecraft/PackProfile.cpp new file mode 100644 index 0000000000..0bff119c66 --- /dev/null +++ b/meshmc/launcher/minecraft/PackProfile.cpp @@ -0,0 +1,1191 @@ +/* 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 <QFile> +#include <QCryptographicHash> +#include <Version.h> +#include <QDir> +#include <QJsonDocument> +#include <QJsonArray> +#include <QDebug> +#include <QSaveFile> +#include <QUuid> +#include <QTimer> + +#include "Exception.h" +#include "minecraft/OneSixVersionFormat.h" +#include "FileSystem.h" +#include "meta/Index.h" +#include "minecraft/MinecraftInstance.h" +#include "Json.h" + +#include "PackProfile.h" +#include "PackProfile_p.h" +#include "ComponentUpdateTask.h" + +#include "Application.h" + +PackProfile::PackProfile(MinecraftInstance* instance) : QAbstractListModel() +{ + d.reset(new PackProfileData); + d->m_instance = instance; + d->m_saveTimer.setSingleShot(true); + d->m_saveTimer.setInterval(5000); + d->interactionDisabled = instance->isRunning(); + connect(d->m_instance, &BaseInstance::runningStatusChanged, this, + &PackProfile::disableInteraction); + connect(&d->m_saveTimer, &QTimer::timeout, this, + &PackProfile::save_internal); +} + +PackProfile::~PackProfile() +{ + saveNow(); +} + +// BEGIN: component file format + +static const int currentComponentsFileVersion = 1; + +static QJsonObject componentToJsonV1(ComponentPtr component) +{ + QJsonObject obj; + // critical + obj.insert("uid", component->m_uid); + if (!component->m_version.isEmpty()) { + obj.insert("version", component->m_version); + } + if (component->m_dependencyOnly) { + obj.insert("dependencyOnly", true); + } + if (component->m_important) { + obj.insert("important", true); + } + if (component->m_disabled) { + obj.insert("disabled", true); + } + + // cached + if (!component->m_cachedVersion.isEmpty()) { + obj.insert("cachedVersion", component->m_cachedVersion); + } + if (!component->m_cachedName.isEmpty()) { + obj.insert("cachedName", component->m_cachedName); + } + Meta::serializeRequires(obj, &component->m_cachedRequires, + "cachedRequires"); + Meta::serializeRequires(obj, &component->m_cachedConflicts, + "cachedConflicts"); + if (component->m_cachedVolatile) { + obj.insert("cachedVolatile", true); + } + return obj; +} + +static ComponentPtr componentFromJsonV1(PackProfile* parent, + const QString& componentJsonPattern, + const QJsonObject& obj) +{ + // critical + auto uid = Json::requireString(obj.value("uid")); + auto filePath = componentJsonPattern.arg(uid); + auto component = new Component(parent, uid); + component->m_version = Json::ensureString(obj.value("version")); + component->m_dependencyOnly = + Json::ensureBoolean(obj.value("dependencyOnly"), false); + component->m_important = Json::ensureBoolean(obj.value("important"), false); + + // cached + // TODO @RESILIENCE: ignore invalid values/structure here? + component->m_cachedVersion = Json::ensureString(obj.value("cachedVersion")); + component->m_cachedName = Json::ensureString(obj.value("cachedName")); + Meta::parseRequires(obj, &component->m_cachedRequires, "cachedRequires"); + Meta::parseRequires(obj, &component->m_cachedConflicts, "cachedConflicts"); + component->m_cachedVolatile = + Json::ensureBoolean(obj.value("volatile"), false); + bool disabled = Json::ensureBoolean(obj.value("disabled"), false); + component->setEnabled(!disabled); + return component; +} + +// Save the given component container data to a file +static bool savePackProfile(const QString& filename, + const ComponentContainer& container) +{ + QJsonObject obj; + obj.insert("formatVersion", currentComponentsFileVersion); + QJsonArray orderArray; + for (auto component : container) { + orderArray.append(componentToJsonV1(component)); + } + obj.insert("components", orderArray); + QSaveFile outFile(filename); + if (!outFile.open(QFile::WriteOnly)) { + qCritical() << "Couldn't open" << outFile.fileName() + << "for writing:" << outFile.errorString(); + return false; + } + auto data = QJsonDocument(obj).toJson(QJsonDocument::Indented); + if (outFile.write(data) != data.size()) { + qCritical() << "Couldn't write all the data into" << outFile.fileName() + << "because:" << outFile.errorString(); + return false; + } + if (!outFile.commit()) { + qCritical() << "Couldn't save" << outFile.fileName() + << "because:" << outFile.errorString(); + } + return true; +} + +// Read the given file into component containers +static bool loadPackProfile(PackProfile* parent, const QString& filename, + const QString& componentJsonPattern, + ComponentContainer& container) +{ + QFile componentsFile(filename); + if (!componentsFile.exists()) { + qWarning() + << "Components file doesn't exist. This should never happen."; + return false; + } + if (!componentsFile.open(QFile::ReadOnly)) { + qCritical() << "Couldn't open" << componentsFile.fileName() + << " for reading:" << componentsFile.errorString(); + qWarning() << "Ignoring overriden order"; + return false; + } + + // and it's valid JSON + QJsonParseError error; + QJsonDocument doc = + QJsonDocument::fromJson(componentsFile.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCritical() << "Couldn't parse" << componentsFile.fileName() << ":" + << error.errorString(); + qWarning() << "Ignoring overriden order"; + return false; + } + + // and then read it and process it if all above is true. + try { + auto obj = Json::requireObject(doc); + // check order file version. + auto version = Json::requireInteger(obj.value("formatVersion")); + if (version != currentComponentsFileVersion) { + throw JSONValidationError( + QObject::tr("Invalid component file version, expected %1") + .arg(currentComponentsFileVersion)); + } + auto orderArray = Json::requireArray(obj.value("components")); + for (auto item : orderArray) { + auto obj = + Json::requireObject(item, "Component must be an object."); + container.append( + componentFromJsonV1(parent, componentJsonPattern, obj)); + } + } catch (const JSONValidationError& err) { + qCritical() << "Couldn't parse" << componentsFile.fileName() + << ": bad file format"; + container.clear(); + return false; + } + return true; +} + +// END: component file format + +// BEGIN: save/load logic + +void PackProfile::saveNow() +{ + if (saveIsScheduled()) { + d->m_saveTimer.stop(); + save_internal(); + } +} + +bool PackProfile::saveIsScheduled() const +{ + return d->dirty; +} + +void PackProfile::buildingFromScratch() +{ + d->loaded = true; + d->dirty = true; +} + +void PackProfile::scheduleSave() +{ + if (!d->loaded) { + qDebug() << "Component list should never save if it didn't " + "successfully load, instance:" + << d->m_instance->name(); + return; + } + if (!d->dirty) { + d->dirty = true; + qDebug() << "Component list save is scheduled for" + << d->m_instance->name(); + } + d->m_saveTimer.start(); +} + +QString PackProfile::componentsFilePath() const +{ + return FS::PathCombine(d->m_instance->instanceRoot(), "mmc-pack.json"); +} + +QString PackProfile::patchesPattern() const +{ + return FS::PathCombine(d->m_instance->instanceRoot(), "patches", "%1.json"); +} + +QString PackProfile::patchFilePathForUid(const QString& uid) const +{ + return patchesPattern().arg(uid); +} + +void PackProfile::save_internal() +{ + qDebug() << "Component list save performed now for" + << d->m_instance->name(); + auto filename = componentsFilePath(); + savePackProfile(filename, d->components); + d->dirty = false; +} + +bool PackProfile::load() +{ + auto filename = componentsFilePath(); + QFile componentsFile(filename); + + // migrate old config to new one, if needed + if (!componentsFile.exists()) { + if (!migratePreComponentConfig()) { + // FIXME: the user should be notified... + qCritical() + << "Failed to convert old pre-component config for instance" + << d->m_instance->name(); + return false; + } + } + + // load the new component list and swap it with the current one... + ComponentContainer newComponents; + if (!loadPackProfile(this, filename, patchesPattern(), newComponents)) { + qCritical() << "Failed to load the component config for instance" + << d->m_instance->name(); + return false; + } else { + // FIXME: actually use fine-grained updates, not this... + beginResetModel(); + // disconnect all the old components + for (auto component : d->components) { + disconnect(component.get(), &Component::dataChanged, this, + &PackProfile::componentDataChanged); + } + d->components.clear(); + d->componentIndex.clear(); + for (auto component : newComponents) { + if (d->componentIndex.contains(component->m_uid)) { + qWarning() << "Ignoring duplicate component entry" + << component->m_uid; + continue; + } + connect(component.get(), &Component::dataChanged, this, + &PackProfile::componentDataChanged); + d->components.append(component); + d->componentIndex[component->m_uid] = component; + } + endResetModel(); + d->loaded = true; + return true; + } +} + +void PackProfile::reload(Net::Mode netmode) +{ + // Do not reload when the update/resolve task is running. It is in control. + if (d->m_updateTask) { + return; + } + + // flush any scheduled saves to not lose state + saveNow(); + + // FIXME: differentiate when a reapply is required by propagating state from + // components + invalidateLaunchProfile(); + + if (load()) { + resolve(netmode); + } +} + +Task::Ptr PackProfile::getCurrentTask() +{ + return d->m_updateTask; +} + +void PackProfile::resolve(Net::Mode netmode) +{ + auto updateTask = new ComponentUpdateTask( + ComponentUpdateTask::Mode::Resolution, netmode, this); + d->m_updateTask.reset(updateTask); + connect(updateTask, &ComponentUpdateTask::succeeded, this, + &PackProfile::updateSucceeded); + connect(updateTask, &ComponentUpdateTask::failed, this, + &PackProfile::updateFailed); + d->m_updateTask->start(); +} + +void PackProfile::updateSucceeded() +{ + qDebug() << "Component list update/resolve task succeeded for" + << d->m_instance->name(); + d->m_updateTask.reset(); + invalidateLaunchProfile(); +} + +void PackProfile::updateFailed(const QString& error) +{ + qDebug() << "Component list update/resolve task failed for" + << d->m_instance->name() << "Reason:" << error; + d->m_updateTask.reset(); + invalidateLaunchProfile(); +} + +// NOTE this is really old stuff, and only needs to be used when loading the old +// hardcoded component-unaware format (loadPreComponentConfig). +static void upgradeDeprecatedFiles(QString root, QString instanceName) +{ + auto versionJsonPath = FS::PathCombine(root, "version.json"); + auto customJsonPath = FS::PathCombine(root, "custom.json"); + auto mcJson = FS::PathCombine(root, "patches", "net.minecraft.json"); + + QString sourceFile; + QString renameFile; + + // convert old crap. + if (QFile::exists(customJsonPath)) { + sourceFile = customJsonPath; + renameFile = versionJsonPath; + } else if (QFile::exists(versionJsonPath)) { + sourceFile = versionJsonPath; + } + if (!sourceFile.isEmpty() && !QFile::exists(mcJson)) { + if (!FS::ensureFilePathExists(mcJson)) { + qWarning() << "Couldn't create patches folder for" << instanceName; + return; + } + if (!renameFile.isEmpty() && QFile::exists(renameFile)) { + if (!QFile::rename(renameFile, renameFile + ".old")) { + qWarning() << "Couldn't rename" << renameFile << "to" + << renameFile + ".old" << "in" << instanceName; + return; + } + } + auto file = ProfileUtils::parseJsonFile(QFileInfo(sourceFile), false); + ProfileUtils::removeLwjglFromPatch(file); + file->uid = "net.minecraft"; + file->version = file->minecraftVersion; + file->name = "Minecraft"; + + Meta::Require needsLwjgl; + needsLwjgl.uid = "org.lwjgl"; + file->requirements.insert(needsLwjgl); + + if (!ProfileUtils::saveJsonFile( + OneSixVersionFormat::versionFileToJson(file), mcJson)) { + return; + } + if (!QFile::rename(sourceFile, sourceFile + ".old")) { + qWarning() << "Couldn't rename" << sourceFile << "to" + << sourceFile + ".old" << "in" << instanceName; + return; + } + } +} + +/* + * Migrate old layout to the component based one... + * - Part of the version information is taken from `instance.cfg` (fed to this + * class from outside). + * - Part is taken from the old order.json file. + * - Part is loaded from loose json files in the instance's `patches` directory. + */ +bool PackProfile::migratePreComponentConfig() +{ + // upgrade the very old files from the beginnings of MeshMC + upgradeDeprecatedFiles(d->m_instance->instanceRoot(), + d->m_instance->name()); + + QList<ComponentPtr> components; + QSet<QString> loaded; + + auto addBuiltinPatch = + [&](const QString& uid, bool asDependency, const QString& emptyVersion, + const Meta::Require& req, const Meta::Require& conflict) { + auto jsonFilePath = FS::PathCombine(d->m_instance->instanceRoot(), + "patches", uid + ".json"); + auto intendedVersion = d->getOldConfigVersion(uid); + // load up the base minecraft patch + ComponentPtr component; + if (QFile::exists(jsonFilePath)) { + if (intendedVersion.isEmpty()) { + intendedVersion = emptyVersion; + } + auto file = + ProfileUtils::parseJsonFile(QFileInfo(jsonFilePath), false); + // fix uid + file->uid = uid; + // if version is missing, add it from the outside. + if (file->version.isEmpty()) { + file->version = intendedVersion; + } + // if this is a dependency (LWJGL), mark it also as volatile + if (asDependency) { + file->m_volatile = true; + } + // insert requirements if needed + if (!req.uid.isEmpty()) { + file->requirements.insert(req); + } + // insert conflicts if needed + if (!conflict.uid.isEmpty()) { + file->conflicts.insert(conflict); + } + // FIXME: @QUALITY do not ignore return value + ProfileUtils::saveJsonFile( + OneSixVersionFormat::versionFileToJson(file), jsonFilePath); + component = new Component(this, uid, file); + component->m_version = intendedVersion; + } else if (!intendedVersion.isEmpty()) { + auto metaVersion = + APPLICATION->metadataIndex()->get(uid, intendedVersion); + component = new Component(this, metaVersion); + } else { + return; + } + component->m_dependencyOnly = asDependency; + component->m_important = !asDependency; + components.append(component); + }; + // TODO: insert depends and conflicts here if these are customized files... + Meta::Require reqLwjgl; + reqLwjgl.uid = "org.lwjgl"; + reqLwjgl.suggests = "2.9.1"; + Meta::Require conflictLwjgl3; + conflictLwjgl3.uid = "org.lwjgl3"; + Meta::Require nullReq; + addBuiltinPatch("org.lwjgl", true, "2.9.1", nullReq, conflictLwjgl3); + addBuiltinPatch("net.minecraft", false, QString(), reqLwjgl, nullReq); + + // first, collect all other file-based patches and load them + QMap<QString, ComponentPtr> loadedComponents; + QDir patchesDir(FS::PathCombine(d->m_instance->instanceRoot(), "patches")); + for (auto info : + patchesDir.entryInfoList(QStringList() << "*.json", QDir::Files)) { + // parse the file + qDebug() << "Reading" << info.fileName(); + auto file = ProfileUtils::parseJsonFile(info, true); + + // correct missing or wrong uid based on the file name + QString uid = info.completeBaseName(); + + // ignore builtins, they've been handled already + if (uid == "net.minecraft") + continue; + if (uid == "org.lwjgl") + continue; + + // handle horrible corner cases + if (uid.isEmpty()) { + // if you have a file named '.json', make it just go away. + // FIXME: @QUALITY do not ignore return value + QFile::remove(info.absoluteFilePath()); + continue; + } + file->uid = uid; + // FIXME: @QUALITY do not ignore return value + ProfileUtils::saveJsonFile(OneSixVersionFormat::versionFileToJson(file), + info.absoluteFilePath()); + + auto component = new Component(this, file->uid, file); + auto version = d->getOldConfigVersion(file->uid); + if (!version.isEmpty()) { + component->m_version = version; + } + loadedComponents[file->uid] = component; + } + // try to load the other 'hardcoded' patches (forge, liteloader), if they + // weren't loaded from files + auto loadSpecial = [&](const QString& uid, int order) { + auto patchVersion = d->getOldConfigVersion(uid); + if (!patchVersion.isEmpty() && !loadedComponents.contains(uid)) { + auto patch = new Component( + this, APPLICATION->metadataIndex()->get(uid, patchVersion)); + patch->setOrder(order); + loadedComponents[uid] = patch; + } + }; + loadSpecial("net.minecraftforge", 5); + loadSpecial("com.mumfrey.liteloader", 10); + + // load the old order.json file, if present + ProfileUtils::PatchOrder userOrder; + ProfileUtils::readOverrideOrders( + FS::PathCombine(d->m_instance->instanceRoot(), "order.json"), + userOrder); + + // now add all the patches by user sort order + for (auto uid : userOrder) { + // ignore builtins + if (uid == "net.minecraft") + continue; + if (uid == "org.lwjgl") + continue; + // ordering has a patch that is gone? + if (!loadedComponents.contains(uid)) { + continue; + } + components.append(loadedComponents.take(uid)); + } + + // is there anything left to sort? - this is used when there are leftover + // components that aren't part of the order.json + if (!loadedComponents.isEmpty()) { + // inserting into multimap by order number as key sorts the patches and + // detects duplicates + QMultiMap<int, ComponentPtr> files; + auto iter = loadedComponents.begin(); + while (iter != loadedComponents.end()) { + files.insert((*iter)->getOrder(), *iter); + iter++; + } + + // then just extract the patches and put them in the list + for (auto order : files.keys()) { + const auto& values = files.values(order); + for (auto& value : values) { + // TODO: put back the insertion of problem messages here, so the + // user knows about the id duplication + components.append(value); + } + } + } + // new we have a complete list of components... + return savePackProfile(componentsFilePath(), components); +} + +// END: save/load + +void PackProfile::appendComponent(ComponentPtr component) +{ + insertComponent(d->components.size(), component); +} + +void PackProfile::insertComponent(size_t index, ComponentPtr component) +{ + auto id = component->getID(); + if (id.isEmpty()) { + qWarning() << "Attempt to add a component with empty ID!"; + return; + } + if (d->componentIndex.contains(id)) { + qWarning() << "Attempt to add a component that is already present!"; + return; + } + beginInsertRows(QModelIndex(), index, index); + d->components.insert(index, component); + d->componentIndex[id] = component; + endInsertRows(); + connect(component.get(), &Component::dataChanged, this, + &PackProfile::componentDataChanged); + scheduleSave(); +} + +void PackProfile::componentDataChanged() +{ + auto objPtr = qobject_cast<Component*>(sender()); + if (!objPtr) { + qWarning() + << "PackProfile got dataChenged signal from a non-Component!"; + return; + } + if (objPtr->getID() == "net.minecraft") { + emit minecraftChanged(); + } + // figure out which one is it... in a seriously dumb way. + int index = 0; + for (auto component : d->components) { + if (component.get() == objPtr) { + emit dataChanged( + createIndex(index, 0), + createIndex(index, columnCount(QModelIndex()) - 1)); + scheduleSave(); + return; + } + index++; + } + qWarning() << "PackProfile got dataChenged signal from a Component which " + "does not belong to it!"; +} + +bool PackProfile::remove(const int index) +{ + auto patch = getComponent(index); + if (!patch->isRemovable()) { + qWarning() << "Patch" << patch->getID() << "is non-removable"; + return false; + } + + if (!removeComponent_internal(patch)) { + qCritical() << "Patch" << patch->getID() << "could not be removed"; + return false; + } + + beginRemoveRows(QModelIndex(), index, index); + d->components.removeAt(index); + d->componentIndex.remove(patch->getID()); + endRemoveRows(); + invalidateLaunchProfile(); + scheduleSave(); + return true; +} + +bool PackProfile::remove(const QString id) +{ + int i = 0; + for (auto patch : d->components) { + if (patch->getID() == id) { + return remove(i); + } + i++; + } + return false; +} + +bool PackProfile::customize(int index) +{ + auto patch = getComponent(index); + if (!patch->isCustomizable()) { + qDebug() << "Patch" << patch->getID() << "is not customizable"; + return false; + } + if (!patch->customize()) { + qCritical() << "Patch" << patch->getID() << "could not be customized"; + return false; + } + invalidateLaunchProfile(); + scheduleSave(); + return true; +} + +bool PackProfile::revertToBase(int index) +{ + auto patch = getComponent(index); + if (!patch->isRevertible()) { + qDebug() << "Patch" << patch->getID() << "is not revertible"; + return false; + } + if (!patch->revert()) { + qCritical() << "Patch" << patch->getID() << "could not be reverted"; + return false; + } + invalidateLaunchProfile(); + scheduleSave(); + return true; +} + +Component* PackProfile::getComponent(const QString& id) +{ + auto iter = d->componentIndex.find(id); + if (iter == d->componentIndex.end()) { + return nullptr; + } + return (*iter).get(); +} + +Component* PackProfile::getComponent(int index) +{ + if (index < 0 || index >= d->components.size()) { + return nullptr; + } + return d->components[index].get(); +} + +QVariant PackProfile::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + + if (row < 0 || row >= d->components.size()) + return QVariant(); + + auto patch = d->components.at(row); + + switch (role) { + case Qt::CheckStateRole: { + switch (column) { + case NameColumn: { + return patch->isEnabled() ? Qt::Checked : Qt::Unchecked; + } + default: + return QVariant(); + } + } + case Qt::DisplayRole: { + switch (column) { + case NameColumn: + return patch->getName(); + case VersionColumn: { + if (patch->isCustom()) { + return QString("%1 (Custom)").arg(patch->getVersion()); + } else { + return patch->getVersion(); + } + } + default: + return QVariant(); + } + } + case Qt::DecorationRole: { + switch (column) { + case NameColumn: { + auto severity = patch->getProblemSeverity(); + switch (severity) { + case ProblemSeverity::Warning: + return "warning"; + case ProblemSeverity::Error: + return "error"; + default: + return QVariant(); + } + } + default: { + return QVariant(); + } + } + } + } + return QVariant(); +} + +bool PackProfile::setData(const QModelIndex& index, const QVariant& value, + int role) +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount(index)) { + return false; + } + + if (role == Qt::CheckStateRole) { + auto component = d->components[index.row()]; + if (component->setEnabled(!component->isEnabled())) { + return true; + } + } + return false; +} + +QVariant PackProfile::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + switch (section) { + case NameColumn: + return tr("Name"); + case VersionColumn: + return tr("Version"); + default: + return QVariant(); + } + } + } + return QVariant(); +} + +// FIXME: zero precision mess +Qt::ItemFlags PackProfile::flags(const QModelIndex& index) const +{ + if (!index.isValid()) { + return Qt::NoItemFlags; + } + + Qt::ItemFlags outFlags = Qt::ItemIsSelectable | Qt::ItemIsEnabled; + + int row = index.row(); + + if (row < 0 || row >= d->components.size()) { + return Qt::NoItemFlags; + } + + auto patch = d->components.at(row); + // TODO: this will need fine-tuning later... + if (patch->canBeDisabled() && !d->interactionDisabled) { + outFlags |= Qt::ItemIsUserCheckable; + } + return outFlags; +} + +int PackProfile::rowCount(const QModelIndex& parent) const +{ + return d->components.size(); +} + +int PackProfile::columnCount(const QModelIndex& parent) const +{ + return NUM_COLUMNS; +} + +void PackProfile::move(const int index, const MoveDirection direction) +{ + int theirIndex; + if (direction == MoveUp) { + theirIndex = index - 1; + } else { + theirIndex = index + 1; + } + + if (index < 0 || index >= d->components.size()) + return; + if (theirIndex >= rowCount()) + theirIndex = rowCount() - 1; + if (theirIndex == -1) + theirIndex = rowCount() - 1; + if (index == theirIndex) + return; + int togap = theirIndex > index ? theirIndex + 1 : theirIndex; + + auto from = getComponent(index); + auto to = getComponent(theirIndex); + + if (!from || !to || !to->isMoveable() || !from->isMoveable()) { + return; + } + beginMoveRows(QModelIndex(), index, index, QModelIndex(), togap); + d->components.swapItemsAt(index, theirIndex); + endMoveRows(); + invalidateLaunchProfile(); + scheduleSave(); +} + +void PackProfile::invalidateLaunchProfile() +{ + d->m_profile.reset(); +} + +void PackProfile::installJarMods(QStringList selectedFiles) +{ + installJarMods_internal(selectedFiles); +} + +void PackProfile::installCustomJar(QString selectedFile) +{ + installCustomJar_internal(selectedFile); +} + +bool PackProfile::installEmpty(const QString& uid, const QString& name) +{ + QString patchDir = + FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + auto f = std::make_shared<VersionFile>(); + f->name = name; + f->uid = uid; + f->version = "1"; + QString patchFileName = FS::PathCombine(patchDir, uid + ".json"); + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCritical() << "Error opening" << file.fileName() + << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + appendComponent(new Component(this, f->uid, f)); + scheduleSave(); + invalidateLaunchProfile(); + return true; +} + +bool PackProfile::removeComponent_internal(ComponentPtr patch) +{ + bool ok = true; + // first, remove the patch file. this ensures it's not used anymore + auto fileName = patch->getFilename(); + if (fileName.size()) { + QFile patchFile(fileName); + if (patchFile.exists() && !patchFile.remove()) { + qCritical() << "File" << fileName << "could not be removed because:" + << patchFile.errorString(); + return false; + } + } + + // FIXME: we need a generic way of removing local resources, not just jar + // mods... + auto preRemoveJarMod = [&](LibraryPtr jarMod) -> bool { + if (!jarMod->isLocal()) { + return true; + } + QStringList jar, temp1, temp2, temp3; + jarMod->getApplicableFiles(currentSystem, jar, temp1, temp2, temp3, + d->m_instance->jarmodsPath().absolutePath()); + QFileInfo finfo(jar[0]); + if (finfo.exists()) { + QFile jarModFile(jar[0]); + if (!jarModFile.remove()) { + qCritical() + << "File" << jar[0] << "could not be removed because:" + << jarModFile.errorString(); + return false; + } + return true; + } + return true; + }; + + auto vFile = patch->getVersionFile(); + if (vFile) { + auto& jarMods = vFile->jarMods; + for (auto& jarmod : jarMods) { + ok &= preRemoveJarMod(jarmod); + } + } + return ok; +} + +bool PackProfile::installJarMods_internal(QStringList filepaths) +{ + QString patchDir = + FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + + if (!FS::ensureFolderPathExists(d->m_instance->jarModsDir())) { + return false; + } + + for (auto filepath : filepaths) { + QFileInfo sourceInfo(filepath); + auto uuid = QUuid::createUuid(); + QString id = uuid.toString().remove('{').remove('}'); + QString target_filename = id + ".jar"; + QString target_id = "org.projecttick.jarmod." + id; + QString target_name = sourceInfo.completeBaseName() + " (jar mod)"; + QString finalPath = + FS::PathCombine(d->m_instance->jarModsDir(), target_filename); + + QFileInfo targetInfo(finalPath); + if (targetInfo.exists()) { + return false; + } + + if (!QFile::copy(sourceInfo.absoluteFilePath(), + QFileInfo(finalPath).absoluteFilePath())) { + return false; + } + + auto f = std::make_shared<VersionFile>(); + auto jarMod = std::make_shared<Library>(); + jarMod->setRawName( + GradleSpecifier("org.projecttick.jarmods:" + id + ":1")); + jarMod->setFilename(target_filename); + jarMod->setDisplayName(sourceInfo.completeBaseName()); + jarMod->setHint("local"); + f->jarMods.append(jarMod); + f->name = target_name; + f->uid = target_id; + QString patchFileName = FS::PathCombine(patchDir, target_id + ".json"); + + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCritical() << "Error opening" << file.fileName() + << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + appendComponent(new Component(this, f->uid, f)); + } + scheduleSave(); + invalidateLaunchProfile(); + return true; +} + +bool PackProfile::installCustomJar_internal(QString filepath) +{ + QString patchDir = + FS::PathCombine(d->m_instance->instanceRoot(), "patches"); + if (!FS::ensureFolderPathExists(patchDir)) { + return false; + } + + QString libDir = d->m_instance->getLocalLibraryPath(); + if (!FS::ensureFolderPathExists(libDir)) { + return false; + } + + auto specifier = GradleSpecifier("org.projecttick:customjar:1"); + QFileInfo sourceInfo(filepath); + QString target_filename = specifier.getFileName(); + QString target_id = specifier.artifactId(); + QString target_name = sourceInfo.completeBaseName() + " (custom jar)"; + QString finalPath = FS::PathCombine(libDir, target_filename); + + QFileInfo jarInfo(finalPath); + if (jarInfo.exists()) { + if (!QFile::remove(finalPath)) { + return false; + } + } + if (!QFile::copy(filepath, finalPath)) { + return false; + } + + auto f = std::make_shared<VersionFile>(); + auto jarMod = std::make_shared<Library>(); + jarMod->setRawName(specifier); + jarMod->setDisplayName(sourceInfo.completeBaseName()); + jarMod->setHint("local"); + f->mainJar = jarMod; + f->name = target_name; + f->uid = target_id; + QString patchFileName = FS::PathCombine(patchDir, target_id + ".json"); + + QFile file(patchFileName); + if (!file.open(QFile::WriteOnly)) { + qCritical() << "Error opening" << file.fileName() + << "for reading:" << file.errorString(); + return false; + } + file.write(OneSixVersionFormat::versionFileToJson(f).toJson()); + file.close(); + + appendComponent(new Component(this, f->uid, f)); + + scheduleSave(); + invalidateLaunchProfile(); + return true; +} + +std::shared_ptr<LaunchProfile> PackProfile::getProfile() const +{ + if (!d->m_profile) { + try { + auto profile = std::make_shared<LaunchProfile>(); + for (auto file : d->components) { + qDebug() << "Applying" << file->getID() + << (file->getProblemSeverity() == + ProblemSeverity::Error + ? "ERROR" + : "GOOD"); + file->applyTo(profile.get()); + } + d->m_profile = profile; + } catch (const Exception& error) { + qWarning() << "Couldn't apply profile patches because: " + << error.cause(); + } + } + return d->m_profile; +} + +void PackProfile::setOldConfigVersion(const QString& uid, + const QString& version) +{ + if (version.isEmpty()) { + return; + } + d->m_oldConfigVersions[uid] = version; +} + +bool PackProfile::setComponentVersion(const QString& uid, + const QString& version, bool important) +{ + auto iter = d->componentIndex.find(uid); + if (iter != d->componentIndex.end()) { + ComponentPtr component = *iter; + // set existing + if (component->revert()) { + component->setVersion(version); + component->setImportant(important); + return true; + } + return false; + } else { + // add new + auto component = new Component(this, uid); + component->m_version = version; + component->m_important = important; + appendComponent(component); + return true; + } +} + +QString PackProfile::getComponentVersion(const QString& uid) const +{ + const auto iter = d->componentIndex.find(uid); + if (iter != d->componentIndex.end()) { + return (*iter)->getVersion(); + } + return QString(); +} + +void PackProfile::disableInteraction(bool disable) +{ + if (d->interactionDisabled != disable) { + d->interactionDisabled = disable; + auto size = d->components.size(); + if (size) { + emit dataChanged(index(0), index(size - 1)); + } + } +} diff --git a/meshmc/launcher/minecraft/PackProfile.h b/meshmc/launcher/minecraft/PackProfile.h new file mode 100644 index 0000000000..cba387d461 --- /dev/null +++ b/meshmc/launcher/minecraft/PackProfile.h @@ -0,0 +1,178 @@ +/* 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 <QAbstractListModel> + +#include <QString> +#include <QList> +#include <memory> + +#include "Library.h" +#include "LaunchProfile.h" +#include "Component.h" +#include "ProfileUtils.h" +#include "BaseVersion.h" +#include "MojangDownloadInfo.h" +#include "net/Mode.h" + +class MinecraftInstance; +struct PackProfileData; +class ComponentUpdateTask; + +class PackProfile : public QAbstractListModel +{ + Q_OBJECT + friend ComponentUpdateTask; + + public: + enum Columns { NameColumn = 0, VersionColumn, NUM_COLUMNS }; + + explicit PackProfile(MinecraftInstance* instance); + virtual ~PackProfile(); + + 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; + virtual QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + virtual int + rowCount(const QModelIndex& parent = QModelIndex()) const override; + virtual int columnCount(const QModelIndex& parent) const override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + + /// call this to explicitly mark the component list as loaded - this is used + /// to build a new component list from scratch. + void buildingFromScratch(); + + /// install more jar mods + void installJarMods(QStringList selectedFiles); + + /// install a jar/zip as a replacement for the main jar + void installCustomJar(QString selectedFile); + + enum MoveDirection { MoveUp, MoveDown }; + /// move component file # up or down the list + void move(const int index, const MoveDirection direction); + + /// remove component file # - including files/records + bool remove(const int index); + + /// remove component file by id - including files/records + bool remove(const QString id); + + bool customize(int index); + + bool revertToBase(int index); + + /// reload the list, reload all components, resolve dependencies + void reload(Net::Mode netmode); + + // reload all components, resolve dependencies + void resolve(Net::Mode netmode); + + /// get current running task... + Task::Ptr getCurrentTask(); + + std::shared_ptr<LaunchProfile> getProfile() const; + + // NOTE: used ONLY by MinecraftInstance to provide legacy version mappings + // from instance config + void setOldConfigVersion(const QString& uid, const QString& version); + + QString getComponentVersion(const QString& uid) const; + + bool setComponentVersion(const QString& uid, const QString& version, + bool important = false); + + bool installEmpty(const QString& uid, const QString& name); + + QString patchFilePathForUid(const QString& uid) const; + + /// if there is a save scheduled, do it now. + void saveNow(); + + signals: + void minecraftChanged(); + + public: + /// get the profile component by id + Component* getComponent(const QString& id); + + /// get the profile component by index + Component* getComponent(int index); + + /// Add the component to the internal list of patches + // todo(merged): is this the best approach + void appendComponent(ComponentPtr component); + + private: + void scheduleSave(); + bool saveIsScheduled() const; + + /// apply the component patches. Catches all the errors and returns + /// true/false for success/failure + void invalidateLaunchProfile(); + + /// insert component so that its index is ideally the specified one (returns + /// real index) + void insertComponent(size_t index, ComponentPtr component); + + QString componentsFilePath() const; + QString patchesPattern() const; + + private slots: + void save_internal(); + void updateSucceeded(); + void updateFailed(const QString& error); + void componentDataChanged(); + void disableInteraction(bool disable); + + private: + bool load(); + bool installJarMods_internal(QStringList filepaths); + bool installCustomJar_internal(QString filepath); + bool removeComponent_internal(ComponentPtr patch); + + bool migratePreComponentConfig(); + + private: /* data */ + std::unique_ptr<PackProfileData> d; +}; diff --git a/meshmc/launcher/minecraft/PackProfile_p.h b/meshmc/launcher/minecraft/PackProfile_p.h new file mode 100644 index 0000000000..b3dedc64a3 --- /dev/null +++ b/meshmc/launcher/minecraft/PackProfile_p.h @@ -0,0 +1,61 @@ +/* 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 "Component.h" +#include <map> +#include <QTimer> +#include <QList> +#include <QMap> + +class MinecraftInstance; +using ComponentContainer = QList<ComponentPtr>; +using ComponentIndex = QMap<QString, ComponentPtr>; + +struct PackProfileData { + // the instance this belongs to + MinecraftInstance* m_instance; + + // the launch profile (volatile, temporary thing created on demand) + std::shared_ptr<LaunchProfile> m_profile; + + // version information migrated from instance.cfg file. Single use on + // migration! + std::map<QString, QString> m_oldConfigVersions; + QString getOldConfigVersion(const QString& uid) const + { + const auto iter = m_oldConfigVersions.find(uid); + if (iter != m_oldConfigVersions.cend()) { + return (*iter).second; + } + return QString(); + } + + // persistent list of components and related machinery + ComponentContainer components; + ComponentIndex componentIndex; + bool dirty = false; + QTimer m_saveTimer; + Task::Ptr m_updateTask; + bool loaded = false; + bool interactionDisabled = true; +}; diff --git a/meshmc/launcher/minecraft/ParseUtils.cpp b/meshmc/launcher/minecraft/ParseUtils.cpp new file mode 100644 index 0000000000..3a25cd684c --- /dev/null +++ b/meshmc/launcher/minecraft/ParseUtils.cpp @@ -0,0 +1,55 @@ +/* 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 <QDateTime> +#include <QString> +#include "ParseUtils.h" +#include <QDebug> +#include <cstdlib> + +QDateTime timeFromS3Time(QString str) +{ + return QDateTime::fromString(str, Qt::ISODate); +} + +QString timeToS3Time(QDateTime time) +{ + // this all because Qt can't format timestamps right. + int offsetRaw = time.offsetFromUtc(); + bool negative = offsetRaw < 0; + int offsetAbs = std::abs(offsetRaw); + + int offsetSeconds = offsetAbs % 60; + offsetAbs -= offsetSeconds; + + int offsetMinutes = offsetAbs % 3600; + offsetAbs -= offsetMinutes; + offsetMinutes /= 60; + + int offsetHours = offsetAbs / 3600; + + QString raw = time.toString("yyyy-MM-ddTHH:mm:ss"); + raw += (negative ? QChar('-') : QChar('+')); + raw += QString("%1").arg(offsetHours, 2, 10, QChar('0')); + raw += ":"; + raw += QString("%1").arg(offsetMinutes, 2, 10, QChar('0')); + return raw; +} diff --git a/meshmc/launcher/minecraft/ParseUtils.h b/meshmc/launcher/minecraft/ParseUtils.h new file mode 100644 index 0000000000..f2a4ba717a --- /dev/null +++ b/meshmc/launcher/minecraft/ParseUtils.h @@ -0,0 +1,30 @@ +/* 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 <QDateTime> + +/// take the timestamp used by S3 and turn it into QDateTime +QDateTime timeFromS3Time(QString str); + +/// take a timestamp and convert it into an S3 timestamp +QString timeToS3Time(QDateTime); diff --git a/meshmc/launcher/minecraft/ParseUtils_test.cpp b/meshmc/launcher/minecraft/ParseUtils_test.cpp new file mode 100644 index 0000000000..413e6d9b40 --- /dev/null +++ b/meshmc/launcher/minecraft/ParseUtils_test.cpp @@ -0,0 +1,57 @@ +/* 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 "TestUtil.h" + +#include "minecraft/ParseUtils.h" + +class ParseUtilsTest : public QObject +{ + Q_OBJECT + private slots: + void test_Through_data() + { + QTest::addColumn<QString>("timestamp"); + const char* timestamps[] = { + "2016-02-29T13:49:54+01:00", "2016-02-26T15:21:11+00:01", + "2016-02-24T15:52:36+01:13", "2016-02-18T17:41:00+00:00", + "2016-02-17T15:23:19+00:00", "2016-02-16T15:22:39+09:22", + "2016-02-10T15:06:41+00:00", "2016-02-04T15:28:02-05:33"}; + for (unsigned i = 0; i < (sizeof(timestamps) / sizeof(const char*)); + i++) { + QTest::newRow(timestamps[i]) << QString(timestamps[i]); + } + } + void test_Through() + { + QFETCH(QString, timestamp); + + auto time_parsed = timeFromS3Time(timestamp); + auto time_serialized = timeToS3Time(time_parsed); + + QCOMPARE(time_serialized, timestamp); + } +}; + +QTEST_GUILESS_MAIN(ParseUtilsTest) + +#include "ParseUtils_test.moc" diff --git a/meshmc/launcher/minecraft/ProfileUtils.cpp b/meshmc/launcher/minecraft/ProfileUtils.cpp new file mode 100644 index 0000000000..2bc6650ed5 --- /dev/null +++ b/meshmc/launcher/minecraft/ProfileUtils.cpp @@ -0,0 +1,204 @@ +/* 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 "ProfileUtils.h" +#include "minecraft/VersionFilterData.h" +#include "minecraft/OneSixVersionFormat.h" +#include "Json.h" +#include <QDebug> + +#include <QJsonDocument> +#include <QJsonArray> +#include <QRegularExpression> +#include <QSaveFile> + +namespace ProfileUtils +{ + + static const int currentOrderFileVersion = 1; + + bool readOverrideOrders(QString path, PatchOrder& order) + { + QFile orderFile(path); + if (!orderFile.exists()) { + qWarning() << "Order file doesn't exist. Ignoring."; + return false; + } + if (!orderFile.open(QFile::ReadOnly)) { + qCritical() << "Couldn't open" << orderFile.fileName() + << " for reading:" << orderFile.errorString(); + qWarning() << "Ignoring overriden order"; + return false; + } + + // and it's valid JSON + QJsonParseError error; + QJsonDocument doc = + QJsonDocument::fromJson(orderFile.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCritical() << "Couldn't parse" << orderFile.fileName() << ":" + << error.errorString(); + qWarning() << "Ignoring overriden order"; + return false; + } + + // and then read it and process it if all above is true. + try { + auto obj = Json::requireObject(doc); + // check order file version. + auto version = Json::requireInteger(obj.value("version")); + if (version != currentOrderFileVersion) { + throw JSONValidationError( + QObject::tr("Invalid order file version, expected %1") + .arg(currentOrderFileVersion)); + } + auto orderArray = Json::requireArray(obj.value("order")); + for (auto item : orderArray) { + order.append(Json::requireString(item)); + } + } catch (const JSONValidationError& err) { + qCritical() << "Couldn't parse" << orderFile.fileName() + << ": bad file format"; + qWarning() << "Ignoring overriden order"; + order.clear(); + return false; + } + return true; + } + + static VersionFilePtr + createErrorVersionFile(QString fileId, QString filepath, QString error) + { + auto outError = std::make_shared<VersionFile>(); + outError->uid = outError->name = fileId; + // outError->filename = filepath; + outError->addProblem(ProblemSeverity::Error, error); + return outError; + } + + static VersionFilePtr guardedParseJson(const QJsonDocument& doc, + const QString& fileId, + const QString& filepath, + const bool& requireOrder) + { + try { + return OneSixVersionFormat::versionFileFromJson(doc, filepath, + requireOrder); + } catch (const Exception& e) { + return createErrorVersionFile(fileId, filepath, e.cause()); + } + } + + VersionFilePtr parseJsonFile(const QFileInfo& fileInfo, + const bool requireOrder) + { + QFile file(fileInfo.absoluteFilePath()); + if (!file.open(QFile::ReadOnly)) { + auto errorStr = + QObject::tr("Unable to open the version file %1: %2.") + .arg(fileInfo.fileName(), file.errorString()); + return createErrorVersionFile(fileInfo.completeBaseName(), + fileInfo.absoluteFilePath(), + errorStr); + } + QJsonParseError error; + auto data = file.readAll(); + QJsonDocument doc = QJsonDocument::fromJson(data, &error); + file.close(); + if (error.error != QJsonParseError::NoError) { + int line = 1; + int column = 0; + for (int i = 0; i < error.offset; i++) { + if (data[i] == '\n') { + line++; + column = 0; + continue; + } + column++; + } + auto errorStr = QObject::tr("Unable to process the version file " + "%1: %2 at line %3 column %4.") + .arg(fileInfo.fileName(), error.errorString()) + .arg(line) + .arg(column); + return createErrorVersionFile(fileInfo.completeBaseName(), + fileInfo.absoluteFilePath(), + errorStr); + } + return guardedParseJson(doc, fileInfo.completeBaseName(), + fileInfo.absoluteFilePath(), requireOrder); + } + + bool saveJsonFile(const QJsonDocument doc, const QString& filename) + { + auto data = doc.toJson(); + QSaveFile jsonFile(filename); + if (!jsonFile.open(QIODevice::WriteOnly)) { + jsonFile.cancelWriting(); + qWarning() << "Couldn't open" << filename << "for writing"; + return false; + } + jsonFile.write(data); + if (!jsonFile.commit()) { + qWarning() << "Couldn't save" << filename; + return false; + } + return true; + } + + VersionFilePtr parseBinaryJsonFile(const QFileInfo& fileInfo) + { + QFile file(fileInfo.absoluteFilePath()); + if (!file.open(QFile::ReadOnly)) { + auto errorStr = + QObject::tr("Unable to open the version file %1: %2.") + .arg(fileInfo.fileName(), file.errorString()); + return createErrorVersionFile(fileInfo.completeBaseName(), + fileInfo.absoluteFilePath(), + errorStr); + } + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (doc.isNull()) { + file.remove(); + throw JSONValidationError( + QObject::tr("Unable to process the version file %1.") + .arg(fileInfo.fileName())); + } + return guardedParseJson(doc, fileInfo.completeBaseName(), + fileInfo.absoluteFilePath(), false); + } + + void removeLwjglFromPatch(VersionFilePtr patch) + { + auto filter = [](QList<LibraryPtr>& libs) { + QList<LibraryPtr> filteredLibs; + for (auto lib : libs) { + if (!g_VersionFilterData.lwjglWhitelist.contains( + lib->artifactPrefix())) { + filteredLibs.append(lib); + } + } + libs = filteredLibs; + }; + filter(patch->libraries); + } +} // namespace ProfileUtils diff --git a/meshmc/launcher/minecraft/ProfileUtils.h b/meshmc/launcher/minecraft/ProfileUtils.h new file mode 100644 index 0000000000..1b285f079d --- /dev/null +++ b/meshmc/launcher/minecraft/ProfileUtils.h @@ -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/>. + */ + +#pragma once +#include "Library.h" +#include "VersionFile.h" + +namespace ProfileUtils +{ + typedef QStringList PatchOrder; + + /// Read and parse a OneSix format order file + bool readOverrideOrders(QString path, PatchOrder& order); + + /// Write a OneSix format order file + bool writeOverrideOrders(QString path, const PatchOrder& order); + + /// Parse a version file in JSON format + VersionFilePtr parseJsonFile(const QFileInfo& fileInfo, + const bool requireOrder); + + /// Save a JSON file (in any format) + bool saveJsonFile(const QJsonDocument doc, const QString& filename); + + /// Parse a version file in binary JSON format + VersionFilePtr parseBinaryJsonFile(const QFileInfo& fileInfo); + + /// Remove LWJGL from a patch file. This is applied to all Mojang-like + /// profile files. + void removeLwjglFromPatch(VersionFilePtr patch); + +} // namespace ProfileUtils diff --git a/meshmc/launcher/minecraft/Rule.cpp b/meshmc/launcher/minecraft/Rule.cpp new file mode 100644 index 0000000000..c77193790f --- /dev/null +++ b/meshmc/launcher/minecraft/Rule.cpp @@ -0,0 +1,114 @@ +/* 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 <QJsonObject> +#include <QJsonArray> + +#include "Rule.h" + +RuleAction RuleAction_fromString(QString name) +{ + if (name == "allow") + return Allow; + if (name == "disallow") + return Disallow; + return Defer; +} + +QList<std::shared_ptr<Rule>> rulesFromJsonV4(const QJsonObject& objectWithRules) +{ + QList<std::shared_ptr<Rule>> rules; + auto rulesVal = objectWithRules.value("rules"); + if (!rulesVal.isArray()) + return rules; + + QJsonArray ruleList = rulesVal.toArray(); + for (auto ruleVal : ruleList) { + std::shared_ptr<Rule> rule; + if (!ruleVal.isObject()) + continue; + auto ruleObj = ruleVal.toObject(); + auto actionVal = ruleObj.value("action"); + if (!actionVal.isString()) + continue; + auto action = RuleAction_fromString(actionVal.toString()); + if (action == Defer) + continue; + + auto osVal = ruleObj.value("os"); + if (!osVal.isObject()) { + // add a new implicit action rule + rules.append(ImplicitRule::create(action)); + continue; + } + + auto osObj = osVal.toObject(); + auto osNameVal = osObj.value("name"); + if (!osNameVal.isString()) + continue; + OpSys requiredOs = OpSys_fromString(osNameVal.toString()); + QString versionRegex = osObj.value("version").toString(); + // add a new OS rule + rules.append(OsRule::create(action, requiredOs, versionRegex)); + } + return rules; +} + +QJsonObject ImplicitRule::toJson() +{ + QJsonObject ruleObj; + ruleObj.insert("action", + m_result == Allow ? QString("allow") : QString("disallow")); + return ruleObj; +} + +QJsonObject OsRule::toJson() +{ + QJsonObject ruleObj; + ruleObj.insert("action", + m_result == Allow ? QString("allow") : QString("disallow")); + QJsonObject osObj; + { + osObj.insert("name", OpSys_toString(m_system)); + if (!m_version_regexp.isEmpty()) { + osObj.insert("version", m_version_regexp); + } + } + ruleObj.insert("os", osObj); + return ruleObj; +} diff --git a/meshmc/launcher/minecraft/Rule.h b/meshmc/launcher/minecraft/Rule.h new file mode 100644 index 0000000000..fa36b73b94 --- /dev/null +++ b/meshmc/launcher/minecraft/Rule.h @@ -0,0 +1,117 @@ +/* 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 <QString> +#include <QList> +#include <QJsonObject> +#include <memory> +#include "OpSys.h" + +class Library; +class Rule; + +enum RuleAction { Allow, Disallow, Defer }; + +QList<std::shared_ptr<Rule>> +rulesFromJsonV4(const QJsonObject& objectWithRules); + +class Rule +{ + protected: + RuleAction m_result; + virtual bool applies(const Library* parent) = 0; + + public: + Rule(RuleAction result) : m_result(result) {} + virtual ~Rule() {}; + virtual QJsonObject toJson() = 0; + RuleAction apply(const Library* parent) + { + if (applies(parent)) + return m_result; + else + return Defer; + } +}; + +class OsRule : public Rule +{ + private: + // the OS + OpSys m_system; + // the OS version regexp + QString m_version_regexp; + + protected: + virtual bool applies(const Library*) + { + return (m_system == currentSystem); + } + OsRule(RuleAction result, OpSys system, QString version_regexp) + : Rule(result), m_system(system), m_version_regexp(version_regexp) + { + } + + public: + virtual QJsonObject toJson(); + static std::shared_ptr<OsRule> create(RuleAction result, OpSys system, + QString version_regexp) + { + return std::shared_ptr<OsRule>( + new OsRule(result, system, version_regexp)); + } +}; + +class ImplicitRule : public Rule +{ + protected: + virtual bool applies(const Library*) + { + return true; + } + ImplicitRule(RuleAction result) : Rule(result) {} + + public: + virtual QJsonObject toJson(); + static std::shared_ptr<ImplicitRule> create(RuleAction result) + { + return std::shared_ptr<ImplicitRule>(new ImplicitRule(result)); + } +}; diff --git a/meshmc/launcher/minecraft/VersionFile.cpp b/meshmc/launcher/minecraft/VersionFile.cpp new file mode 100644 index 0000000000..59fb779b28 --- /dev/null +++ b/meshmc/launcher/minecraft/VersionFile.cpp @@ -0,0 +1,80 @@ +/* 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 <QJsonArray> +#include <QJsonDocument> + +#include <QDebug> + +#include "minecraft/VersionFile.h" +#include "minecraft/Library.h" +#include "minecraft/PackProfile.h" +#include "ParseUtils.h" + +#include <Version.h> + +static bool isMinecraftVersion(const QString& uid) +{ + return uid == "net.minecraft"; +} + +void VersionFile::applyTo(LaunchProfile* profile) +{ + // Only real Minecraft can set those. Don't let anything override them. + if (isMinecraftVersion(uid)) { + profile->applyMinecraftVersion(minecraftVersion); + profile->applyMinecraftVersionType(type); + // HACK: ignore assets from other version files than Minecraft + // workaround for stupid assets issue caused by amazon: + // https://www.theregister.co.uk/2017/02/28/aws_is_awol_as_s3_goes_haywire/ + profile->applyMinecraftAssets(mojangAssetIndex); + } + + profile->applyMainJar(mainJar); + profile->applyMainClass(mainClass); + profile->applyAppletClass(appletClass); + profile->applyMinecraftArguments(minecraftArguments); + profile->applyTweakers(addTweakers); + profile->applyJarMods(jarMods); + profile->applyMods(mods); + profile->applyTraits(traits); + + for (auto library : libraries) { + profile->applyLibrary(library); + } + for (auto mavenFile : mavenFiles) { + profile->applyMavenFile(mavenFile); + } + profile->applyProblemSeverity(getProblemSeverity()); +} + +/* + auto theirVersion = profile->getMinecraftVersion(); + if (!theirVersion.isNull() && !dependsOnMinecraftVersion.isNull()) + { + if (QRegExp(dependsOnMinecraftVersion, Qt::CaseInsensitive, + QRegExp::Wildcard).indexIn(theirVersion) == -1) + { + throw MinecraftVersionMismatch(uid, dependsOnMinecraftVersion, + theirVersion); + } + } +*/ diff --git a/meshmc/launcher/minecraft/VersionFile.h b/meshmc/launcher/minecraft/VersionFile.h new file mode 100644 index 0000000000..fdca5e54c6 --- /dev/null +++ b/meshmc/launcher/minecraft/VersionFile.h @@ -0,0 +1,141 @@ +/* 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> +#include <QDateTime> +#include <QSet> + +#include <memory> +#include "minecraft/OpSys.h" +#include "minecraft/Rule.h" +#include "ProblemProvider.h" +#include "Library.h" +#include <meta/JsonFormat.h> + +class PackProfile; +class VersionFile; +class LaunchProfile; +struct MojangDownloadInfo; +struct MojangAssetIndexInfo; + +using VersionFilePtr = std::shared_ptr<VersionFile>; +class VersionFile : public ProblemContainer +{ + friend class MojangVersionFormat; + friend class OneSixVersionFormat; + + public: /* methods */ + void applyTo(LaunchProfile* profile); + + public: /* data */ + /// MeshMC: order hint for this version file if no explicit order is set + int order = 0; + + /// MeshMC: human readable name of this package + QString name; + + /// MeshMC: package ID of this package + QString uid; + + /// MeshMC: version of this package + QString version; + + /// MeshMC: DEPRECATED dependency on a Minecraft version + QString dependsOnMinecraftVersion; + + /// Mojang: DEPRECATED used to version the Mojang version format + int minimumMeshMCVersion = -1; + + /// Mojang: DEPRECATED version of Minecraft this is + QString minecraftVersion; + + /// Mojang: class to launch Minecraft with + QString mainClass; + + /// MeshMC: class to launch legacy Minecraft with (embed in a custom window) + QString appletClass; + + /// Mojang: Minecraft launch arguments (may contain placeholders for + /// variable substitution) + QString minecraftArguments; + + /// Mojang: type of the Minecraft version + QString type; + + /// Mojang: the time this version was actually released by Mojang + QDateTime releaseTime; + + /// Mojang: DEPRECATED the time this version was last updated by Mojang + QDateTime updateTime; + + /// Mojang: DEPRECATED asset group to be used with Minecraft + QString assets; + + /// MeshMC: list of tweaker mod arguments for launchwrapper + QStringList addTweakers; + + /// Mojang: list of libraries to add to the version + QList<LibraryPtr> libraries; + + /// MeshMC: list of maven files to put in the libraries folder, but not in + /// classpath + QList<LibraryPtr> mavenFiles; + + /// The main jar (Minecraft version library, normally) + LibraryPtr mainJar; + + /// MeshMC: list of attached traits of this version file - used to enable + /// features + QSet<QString> traits; + + /// MeshMC: list of jar mods added to this version + QList<LibraryPtr> jarMods; + + /// MeshMC: list of mods added to this version + QList<LibraryPtr> mods; + + /** + * MeshMC: set of packages this depends on + * NOTE: this is shared with the meta format!!! + */ + Meta::RequireSet requirements; + + /** + * MeshMC: set of packages this conflicts with + * NOTE: this is shared with the meta format!!! + */ + Meta::RequireSet conflicts; + + /// is volatile -- may be removed as soon as it is no longer needed by + /// something else + bool m_volatile = false; + + public: + // Mojang: DEPRECATED list of 'downloads' - client jar, server jar, windows + // server exe, maybe more. + QMap<QString, std::shared_ptr<MojangDownloadInfo>> mojangDownloads; + + // Mojang: extended asset index download information + std::shared_ptr<MojangAssetIndexInfo> mojangAssetIndex; +}; diff --git a/meshmc/launcher/minecraft/VersionFilterData.cpp b/meshmc/launcher/minecraft/VersionFilterData.cpp new file mode 100644 index 0000000000..6fb146b690 --- /dev/null +++ b/meshmc/launcher/minecraft/VersionFilterData.cpp @@ -0,0 +1,98 @@ +/* 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 "VersionFilterData.h" +#include "ParseUtils.h" + +VersionFilterData g_VersionFilterData = VersionFilterData(); + +VersionFilterData::VersionFilterData() +{ + // 1.3.* + auto libs13 = QList<FMLlib>{ + {"argo-2.25.jar", "bb672829fde76cb163004752b86b0484bd0a7f4b"}, + {"guava-12.0.1.jar", "b8e78b9af7bf45900e14c6f958486b6ca682195f"}, + {"asm-all-4.0.jar", "98308890597acb64047f7e896638e0d98753ae82"}}; + + fmlLibsMapping["1.3.2"] = libs13; + + // 1.4.* + auto libs14 = QList<FMLlib>{ + {"argo-2.25.jar", "bb672829fde76cb163004752b86b0484bd0a7f4b"}, + {"guava-12.0.1.jar", "b8e78b9af7bf45900e14c6f958486b6ca682195f"}, + {"asm-all-4.0.jar", "98308890597acb64047f7e896638e0d98753ae82"}, + {"bcprov-jdk15on-147.jar", "b6f5d9926b0afbde9f4dbe3db88c5247be7794bb"}}; + + fmlLibsMapping["1.4"] = libs14; + fmlLibsMapping["1.4.1"] = libs14; + fmlLibsMapping["1.4.2"] = libs14; + fmlLibsMapping["1.4.3"] = libs14; + fmlLibsMapping["1.4.4"] = libs14; + fmlLibsMapping["1.4.5"] = libs14; + fmlLibsMapping["1.4.6"] = libs14; + fmlLibsMapping["1.4.7"] = libs14; + + // 1.5 + fmlLibsMapping["1.5"] = QList<FMLlib>{ + {"argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51"}, + {"guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a"}, + {"asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58"}, + {"bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65"}, + {"deobfuscation_data_1.5.zip", + "5f7c142d53776f16304c0bbe10542014abad6af8"}, + {"scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85"}}; + + // 1.5.1 + fmlLibsMapping["1.5.1"] = QList<FMLlib>{ + {"argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51"}, + {"guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a"}, + {"asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58"}, + {"bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65"}, + {"deobfuscation_data_1.5.1.zip", + "22e221a0d89516c1f721d6cab056a7e37471d0a6"}, + {"scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85"}}; + + // 1.5.2 + fmlLibsMapping["1.5.2"] = QList<FMLlib>{ + {"argo-small-3.2.jar", "58912ea2858d168c50781f956fa5b59f0f7c6b51"}, + {"guava-14.0-rc3.jar", "931ae21fa8014c3ce686aaa621eae565fefb1a6a"}, + {"asm-all-4.1.jar", "054986e962b88d8660ae4566475658469595ef58"}, + {"bcprov-jdk15on-148.jar", "960dea7c9181ba0b17e8bab0c06a43f0a5f04e65"}, + {"deobfuscation_data_1.5.2.zip", + "446e55cd986582c70fcf12cb27bc00114c5adfd9"}, + {"scala-library.jar", "458d046151ad179c85429ed7420ffb1eaf6ddf85"}}; + + // don't use installers for those. + forgeInstallerBlacklist = QSet<QString>({"1.5.2"}); + + // FIXME: remove, used for deciding when core mods should display + legacyCutoffDate = timeFromS3Time("2013-06-25T15:08:56+02:00"); + lwjglWhitelist = QSet<QString>{ + "net.java.jinput:jinput", "net.java.jinput:jinput-platform", + "net.java.jutils:jutils", "org.lwjgl.lwjgl:lwjgl", + "org.lwjgl.lwjgl:lwjgl_util", "org.lwjgl.lwjgl:lwjgl-platform"}; + + java8BeginsDate = timeFromS3Time("2017-03-30T09:32:19+00:00"); + java16BeginsDate = timeFromS3Time("2021-05-12T11:19:15+00:00"); + java17BeginsDate = timeFromS3Time("2021-11-16T17:04:48+00:00"); + java21BeginsDate = timeFromS3Time("2024-04-03T00:00:00+00:00"); + java25BeginsDate = timeFromS3Time("2025-10-15T00:00:00+00:00"); +} diff --git a/meshmc/launcher/minecraft/VersionFilterData.h b/meshmc/launcher/minecraft/VersionFilterData.h new file mode 100644 index 0000000000..7cd22f4683 --- /dev/null +++ b/meshmc/launcher/minecraft/VersionFilterData.h @@ -0,0 +1,54 @@ +/* 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 <QMap> +#include <QString> +#include <QSet> +#include <QDateTime> + +struct FMLlib { + QString filename; + QString checksum; +}; + +struct VersionFilterData { + VersionFilterData(); + // mapping between minecraft versions and FML libraries required + QMap<QString, QList<FMLlib>> fmlLibsMapping; + // set of minecraft versions for which using forge installers is blacklisted + QSet<QString> forgeInstallerBlacklist; + // no new versions below this date will be accepted from Mojang servers + QDateTime legacyCutoffDate; + // Libraries that belong to LWJGL + QSet<QString> lwjglWhitelist; + // release date of first version to require Java 8 (17w13a) + QDateTime java8BeginsDate; + // release data of first version to require Java 16 (21w19a) + QDateTime java16BeginsDate; + // release data of first version to require Java 17 (1.18 Pre Release 2) + QDateTime java17BeginsDate; + // release date of first version to require Java 21 (24w14a / 1.20.5) + QDateTime java21BeginsDate; + // release date of first version to require Java 25 + QDateTime java25BeginsDate; +}; +extern VersionFilterData g_VersionFilterData; diff --git a/meshmc/launcher/minecraft/World.cpp b/meshmc/launcher/minecraft/World.cpp new file mode 100644 index 0000000000..4ae59afaf5 --- /dev/null +++ b/meshmc/launcher/minecraft/World.cpp @@ -0,0 +1,478 @@ +/* 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 2015-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 <QDebug> +#include <QSaveFile> +#include "World.h" + +#include "GZip.h" +#include <MMCZip.h> +#include <FileSystem.h> +#include <sstream> +#include <io/stream_reader.h> +#include <tag_string.h> +#include <tag_primitive.h> + +#include <QCoreApplication> + +#include <nonstd/optional> + +using nonstd::nullopt; +using nonstd::optional; + +GameType::GameType(nonstd::optional<int> original) : original(original) +{ + if (!original) { + return; + } + switch (*original) { + case 0: + type = GameType::Survival; + break; + case 1: + type = GameType::Creative; + break; + case 2: + type = GameType::Adventure; + break; + case 3: + type = GameType::Spectator; + break; + default: + break; + } +} + +QString GameType::toTranslatedString() const +{ + switch (type) { + case GameType::Survival: + return QCoreApplication::translate("GameType", "Survival"); + case GameType::Creative: + return QCoreApplication::translate("GameType", "Creative"); + case GameType::Adventure: + return QCoreApplication::translate("GameType", "Adventure"); + case GameType::Spectator: + return QCoreApplication::translate("GameType", "Spectator"); + default: + break; + } + if (original) { + return QCoreApplication::translate("GameType", "Unknown (%1)") + .arg(*original); + } + return QCoreApplication::translate("GameType", "Undefined"); +} + +QString GameType::toLogString() const +{ + switch (type) { + case GameType::Survival: + return "Survival"; + case GameType::Creative: + return "Creative"; + case GameType::Adventure: + return "Adventure"; + case GameType::Spectator: + return "Spectator"; + default: + break; + } + if (original) { + return QString("Unknown (%1)").arg(*original); + } + return "Undefined"; +} + +std::unique_ptr<nbt::tag_compound> parseLevelDat(QByteArray data) +{ + QByteArray output; + if (!GZip::unzip(data, output)) { + return nullptr; + } + std::istringstream foo(std::string(output.constData(), output.size())); + try { + auto pair = nbt::io::read_compound(foo); + + if (pair.first != "") + return nullptr; + + if (pair.second == nullptr) + return nullptr; + + return std::move(pair.second); + } catch (const nbt::io::input_error& e) { + qWarning() << "Unable to parse level.dat:" << e.what(); + return nullptr; + } +} + +QByteArray serializeLevelDat(nbt::tag_compound* levelInfo) +{ + std::ostringstream s; + nbt::io::write_tag("", *levelInfo, s); + QByteArray val(s.str().data(), (int)s.str().size()); + return val; +} + +QString getLevelDatFromFS(const QFileInfo& file) +{ + QDir worldDir(file.filePath()); + if (!file.isDir() || !worldDir.exists("level.dat")) { + return QString(); + } + return worldDir.absoluteFilePath("level.dat"); +} + +QByteArray getLevelDatDataFromFS(const QFileInfo& file) +{ + auto fullFilePath = getLevelDatFromFS(file); + if (fullFilePath.isNull()) { + return QByteArray(); + } + QFile f(fullFilePath); + if (!f.open(QIODevice::ReadOnly)) { + return QByteArray(); + } + return f.readAll(); +} + +bool putLevelDatDataToFS(const QFileInfo& file, QByteArray& data) +{ + auto fullFilePath = getLevelDatFromFS(file); + if (fullFilePath.isNull()) { + return false; + } + QSaveFile f(fullFilePath); + if (!f.open(QIODevice::WriteOnly)) { + return false; + } + QByteArray compressed; + if (!GZip::zip(data, compressed)) { + return false; + } + if (f.write(compressed) != compressed.size()) { + f.cancelWriting(); + return false; + } + return f.commit(); +} + +World::World(const QFileInfo& file) +{ + repath(file); +} + +void World::repath(const QFileInfo& file) +{ + m_containerFile = file; + m_folderName = file.fileName(); + if (file.isFile() && file.suffix() == "zip") { + m_iconFile = QString(); + readFromZip(file); + } else if (file.isDir()) { + QFileInfo assumedIconPath(file.absoluteFilePath() + "/icon.png"); + if (assumedIconPath.exists()) { + m_iconFile = assumedIconPath.absoluteFilePath(); + } + readFromFS(file); + } +} + +bool World::resetIcon() +{ + if (m_iconFile.isNull()) { + return false; + } + if (QFile(m_iconFile).remove()) { + m_iconFile = QString(); + return true; + } + return false; +} + +void World::readFromFS(const QFileInfo& file) +{ + auto bytes = getLevelDatDataFromFS(file); + if (bytes.isEmpty()) { + is_valid = false; + return; + } + loadFromLevelDat(bytes); + levelDatTime = file.lastModified(); +} + +void World::readFromZip(const QFileInfo& file) +{ + QString zipPath = file.absoluteFilePath(); + auto location = MMCZip::findFolderOfFileInZip(zipPath, "level.dat"); + is_valid = !location.isEmpty(); + if (!is_valid) { + return; + } + m_containerOffsetPath = location; + QByteArray levelDatData = + MMCZip::readFileFromZip(zipPath, location + "level.dat"); + is_valid = !levelDatData.isEmpty(); + if (!is_valid) { + return; + } + levelDatTime = MMCZip::getEntryModTime(zipPath, location + "level.dat"); + loadFromLevelDat(levelDatData); +} + +bool World::install(const QString& to, const QString& name) +{ + auto finalPath = + FS::PathCombine(to, FS::DirNameFromString(m_actualName, to)); + if (!FS::ensureFolderPathExists(finalPath)) { + return false; + } + bool ok = false; + if (m_containerFile.isFile()) { + auto result = MMCZip::extractSubDir(m_containerFile.absoluteFilePath(), + m_containerOffsetPath, finalPath); + ok = result.has_value(); + } else if (m_containerFile.isDir()) { + QString from = m_containerFile.filePath(); + ok = FS::copy(from, finalPath)(); + } + + if (ok && !name.isEmpty() && m_actualName != name) { + World newWorld{QFileInfo(finalPath)}; + if (newWorld.isValid()) { + newWorld.rename(name); + } + } + return ok; +} + +bool World::rename(const QString& newName) +{ + if (m_containerFile.isFile()) { + return false; + } + + auto data = getLevelDatDataFromFS(m_containerFile); + if (data.isEmpty()) { + return false; + } + + auto worldData = parseLevelDat(data); + if (!worldData) { + return false; + } + auto& val = worldData->at("Data"); + if (val.get_type() != nbt::tag_type::Compound) { + return false; + } + auto& dataCompound = val.as<nbt::tag_compound>(); + dataCompound.put("LevelName", + nbt::value_initializer(newName.toUtf8().data())); + data = serializeLevelDat(worldData.get()); + + putLevelDatDataToFS(m_containerFile, data); + + m_actualName = newName; + + QDir parentDir(m_containerFile.absoluteFilePath()); + parentDir.cdUp(); + QFile container(m_containerFile.absoluteFilePath()); + auto dirName = + FS::DirNameFromString(m_actualName, parentDir.absolutePath()); + container.rename(parentDir.absoluteFilePath(dirName)); + + return true; +} + +namespace +{ + + optional<QString> read_string(nbt::value& parent, const char* name) + { + try { + auto& namedValue = parent.at(name); + if (namedValue.get_type() != nbt::tag_type::String) { + return nullopt; + } + auto& tag_str = namedValue.as<nbt::tag_string>(); + return QString::fromStdString(tag_str.get()); + } catch (const std::out_of_range& e) { + // fallback for old world formats + qWarning() << "String NBT tag" << name << "could not be found."; + return nullopt; + } catch (const std::bad_cast& e) { + // type mismatch + qWarning() << "NBT tag" << name + << "could not be converted to string."; + return nullopt; + } + } + + optional<int64_t> read_long(nbt::value& parent, const char* name) + { + try { + auto& namedValue = parent.at(name); + if (namedValue.get_type() != nbt::tag_type::Long) { + return nullopt; + } + auto& tag_str = namedValue.as<nbt::tag_long>(); + return tag_str.get(); + } catch (const std::out_of_range& e) { + // fallback for old world formats + qWarning() << "Long NBT tag" << name << "could not be found."; + return nullopt; + } catch (const std::bad_cast& e) { + // type mismatch + qWarning() << "NBT tag" << name + << "could not be converted to long."; + return nullopt; + } + } + + optional<int> read_int(nbt::value& parent, const char* name) + { + try { + auto& namedValue = parent.at(name); + if (namedValue.get_type() != nbt::tag_type::Int) { + return nullopt; + } + auto& tag_str = namedValue.as<nbt::tag_int>(); + return tag_str.get(); + } catch (const std::out_of_range& e) { + // fallback for old world formats + qWarning() << "Int NBT tag" << name << "could not be found."; + return nullopt; + } catch (const std::bad_cast& e) { + // type mismatch + qWarning() << "NBT tag" << name << "could not be converted to int."; + return nullopt; + } + } + + GameType read_gametype(nbt::value& parent, const char* name) + { + return GameType(read_int(parent, name)); + } + +} // namespace + +void World::loadFromLevelDat(QByteArray data) +{ + auto levelData = parseLevelDat(data); + if (!levelData) { + is_valid = false; + return; + } + + nbt::value* valPtr = nullptr; + try { + valPtr = &levelData->at("Data"); + } catch (const std::out_of_range& e) { + qWarning() << "Unable to read NBT tags from " << m_folderName << ":" + << e.what(); + is_valid = false; + return; + } + nbt::value& val = *valPtr; + + is_valid = val.get_type() == nbt::tag_type::Compound; + if (!is_valid) + return; + + auto name = read_string(val, "LevelName"); + m_actualName = name ? *name : m_folderName; + + auto timestamp = read_long(val, "LastPlayed"); + m_lastPlayed = + timestamp ? QDateTime::fromMSecsSinceEpoch(*timestamp) : levelDatTime; + + m_gameType = read_gametype(val, "GameType"); + + optional<int64_t> randomSeed; + try { + auto& WorldGen_val = val.at("WorldGenSettings"); + randomSeed = read_long(WorldGen_val, "seed"); + } catch (const std::out_of_range&) { + } + if (!randomSeed) { + randomSeed = read_long(val, "RandomSeed"); + } + m_randomSeed = randomSeed ? *randomSeed : 0; + + qDebug() << "World Name:" << m_actualName; + qDebug() << "Last Played:" << m_lastPlayed.toString(); + if (randomSeed) { + qDebug() << "Seed:" << *randomSeed; + } + qDebug() << "GameType:" << m_gameType.toLogString(); +} + +bool World::replace(World& with) +{ + if (!destroy()) + return false; + bool success = + FS::copy(with.m_containerFile.filePath(), m_containerFile.path())(); + if (success) { + m_folderName = with.m_folderName; + m_containerFile.refresh(); + } + return success; +} + +bool World::destroy() +{ + if (!is_valid) + return false; + if (m_containerFile.isDir()) { + QDir d(m_containerFile.filePath()); + return d.removeRecursively(); + } else if (m_containerFile.isFile()) { + QFile file(m_containerFile.absoluteFilePath()); + return file.remove(); + } + return true; +} + +bool World::operator==(const World& other) const +{ + return is_valid == other.is_valid && folderName() == other.folderName(); +} diff --git a/meshmc/launcher/minecraft/World.h b/meshmc/launcher/minecraft/World.h new file mode 100644 index 0000000000..b873a8e884 --- /dev/null +++ b/meshmc/launcher/minecraft/World.h @@ -0,0 +1,133 @@ +/* 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 2015-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 <nonstd/optional> + +struct GameType { + GameType() = default; + GameType(nonstd::optional<int> original); + + QString toTranslatedString() const; + QString toLogString() const; + + enum { + Unknown = -1, + Survival = 0, + Creative, + Adventure, + Spectator + } type = Unknown; + nonstd::optional<int> original; +}; + +class World +{ + public: + World(const QFileInfo& file); + QString folderName() const + { + return m_folderName; + } + QString name() const + { + return m_actualName; + } + QString iconFile() const + { + return m_iconFile; + } + QDateTime lastPlayed() const + { + return m_lastPlayed; + } + GameType gameType() const + { + return m_gameType; + } + int64_t seed() const + { + return m_randomSeed; + } + bool isValid() const + { + return is_valid; + } + bool isOnFS() const + { + return m_containerFile.isDir(); + } + QFileInfo container() const + { + return m_containerFile; + } + // delete all the files of this world + bool destroy(); + // replace this world with a copy of the other + bool replace(World& with); + // change the world's filesystem path (used by world lists for *MAGIC* + // purposes) + void repath(const QFileInfo& file); + // remove the icon file, if any + bool resetIcon(); + + bool rename(const QString& to); + bool install(const QString& to, const QString& name = QString()); + + // WEAK compare operator - used for replacing worlds + bool operator==(const World& other) const; + + private: + void readFromZip(const QFileInfo& file); + void readFromFS(const QFileInfo& file); + void loadFromLevelDat(QByteArray data); + + protected: + QFileInfo m_containerFile; + QString m_containerOffsetPath; + QString m_folderName; + QString m_actualName; + QString m_iconFile; + QDateTime levelDatTime; + QDateTime m_lastPlayed; + int64_t m_randomSeed = 0; + GameType m_gameType; + bool is_valid = false; +}; diff --git a/meshmc/launcher/minecraft/WorldList.cpp b/meshmc/launcher/minecraft/WorldList.cpp new file mode 100644 index 0000000000..b09b2423b6 --- /dev/null +++ b/meshmc/launcher/minecraft/WorldList.cpp @@ -0,0 +1,380 @@ +/* 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 2015-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 "WorldList.h" +#include <FileSystem.h> +#include <QMimeData> +#include <QUrl> +#include <QUuid> +#include <QString> +#include <QFileSystemWatcher> +#include <QDebug> + +WorldList::WorldList(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); + is_watching = false; + connect(m_watcher, SIGNAL(directoryChanged(QString)), this, + SLOT(directoryChanged(QString))); +} + +void WorldList::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 WorldList::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 WorldList::update() +{ + if (!isValid()) + return false; + + QList<World> newWorlds; + m_dir.refresh(); + auto folderContents = m_dir.entryInfoList(); + // if there are any untracked files... + for (QFileInfo entry : folderContents) { + if (!entry.isDir()) + continue; + + World w(entry); + if (w.isValid()) { + newWorlds.append(w); + } + } + beginResetModel(); + worlds.swap(newWorlds); + endResetModel(); + return true; +} + +void WorldList::directoryChanged(QString path) +{ + update(); +} + +bool WorldList::isValid() +{ + return m_dir.exists() && m_dir.isReadable(); +} + +bool WorldList::deleteWorld(int index) +{ + if (index >= worlds.size() || index < 0) + return false; + World& m = worlds[index]; + if (m.destroy()) { + beginRemoveRows(QModelIndex(), index, index); + worlds.removeAt(index); + endRemoveRows(); + emit changed(); + return true; + } + return false; +} + +bool WorldList::deleteWorlds(int first, int last) +{ + for (int i = first; i <= last; i++) { + World& m = worlds[i]; + m.destroy(); + } + beginRemoveRows(QModelIndex(), first, last); + worlds.erase(worlds.begin() + first, worlds.begin() + last + 1); + endRemoveRows(); + emit changed(); + return true; +} + +bool WorldList::resetIcon(int row) +{ + if (row >= worlds.size() || row < 0) + return false; + World& m = worlds[row]; + if (m.resetIcon()) { + emit dataChanged(index(row), index(row), {WorldList::IconFileRole}); + return true; + } + return false; +} + +int WorldList::columnCount(const QModelIndex& parent) const +{ + return 3; +} + +QVariant WorldList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + + if (row < 0 || row >= worlds.size()) + return QVariant(); + + auto& world = worlds[row]; + switch (role) { + case Qt::DisplayRole: + switch (column) { + case NameColumn: + return world.name(); + + case GameModeColumn: + return world.gameType().toTranslatedString(); + + case LastPlayedColumn: + return world.lastPlayed(); + + default: + return QVariant(); + } + + case Qt::ToolTipRole: { + return world.folderName(); + } + case ObjectRole: { + return QVariant::fromValue<void*>((void*)&world); + } + case FolderRole: { + return QDir::toNativeSeparators( + dir().absoluteFilePath(world.folderName())); + } + case SeedRole: { + return QVariant::fromValue<qlonglong>(world.seed()); + } + case NameRole: { + return world.name(); + } + case LastPlayedRole: { + return world.lastPlayed(); + } + case IconFileRole: { + return world.iconFile(); + } + default: + return QVariant(); + } +} + +QVariant WorldList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case NameColumn: + return tr("Name"); + case GameModeColumn: + return tr("Game Mode"); + case LastPlayedColumn: + return tr("Last Played"); + default: + return QVariant(); + } + + case Qt::ToolTipRole: + switch (section) { + case NameColumn: + return tr("The name of the world."); + case GameModeColumn: + return tr("Game mode of the world."); + case LastPlayedColumn: + return tr("Date and time the world was last played."); + default: + return QVariant(); + } + default: + return QVariant(); + } + return QVariant(); +} + +QStringList WorldList::mimeTypes() const +{ + QStringList types; + types << "text/uri-list"; + return types; +} + +class WorldMimeData : public QMimeData +{ + Q_OBJECT + + public: + WorldMimeData(QList<World> worlds) + { + m_worlds = worlds; + } + QStringList formats() const override + { + return QMimeData::formats() << "text/uri-list"; + } + + protected: + QVariant retrieveData(const QString& mimetype, + QMetaType type) const override + { + QList<QUrl> urls; + for (auto& world : m_worlds) { + if (!world.isValid() || !world.isOnFS()) + continue; + QString worldPath = world.container().absoluteFilePath(); + qDebug() << worldPath; + urls.append(QUrl::fromLocalFile(worldPath)); + } + const_cast<WorldMimeData*>(this)->setUrls(urls); + return QMimeData::retrieveData(mimetype, type); + } + + private: + QList<World> m_worlds; +}; + +QMimeData* WorldList::mimeData(const QModelIndexList& indexes) const +{ + if (indexes.size() == 0) + return new QMimeData(); + + QList<World> worlds; + for (auto idx : indexes) { + if (idx.column() != 0) + continue; + int row = idx.row(); + if (row < 0 || row >= this->worlds.size()) + continue; + worlds.append(this->worlds[row]); + } + if (!worlds.size()) { + return new QMimeData(); + } + return new WorldMimeData(worlds); +} + +Qt::ItemFlags WorldList::flags(const QModelIndex& index) const +{ + Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index); + if (index.isValid()) + return Qt::ItemIsUserCheckable | Qt::ItemIsDragEnabled | + Qt::ItemIsDropEnabled | defaultFlags; + else + return Qt::ItemIsDropEnabled | defaultFlags; +} + +Qt::DropActions WorldList::supportedDragActions() const +{ + // move to other mod lists or VOID + return Qt::MoveAction; +} + +Qt::DropActions WorldList::supportedDropActions() const +{ + // copy from outside, move from within and other mod lists + return Qt::CopyAction | Qt::MoveAction; +} + +void WorldList::installWorld(QFileInfo filename) +{ + qDebug() << "installing: " << filename.absoluteFilePath(); + World w(filename); + if (!w.isValid()) { + return; + } + w.install(m_dir.absolutePath()); +} + +bool WorldList::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent) +{ + 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()) { + bool was_watching = is_watching; + if (was_watching) + stopWatching(); + auto urls = data->urls(); + for (auto url : urls) { + // only local files may be dropped... + if (!url.isLocalFile()) + continue; + QString filename = url.toLocalFile(); + + QFileInfo worldInfo(filename); + + if (!m_dir.entryInfoList().contains(worldInfo)) { + installWorld(worldInfo); + } + } + if (was_watching) + startWatching(); + return true; + } + return false; +} + +#include "WorldList.moc" diff --git a/meshmc/launcher/minecraft/WorldList.h b/meshmc/launcher/minecraft/WorldList.h new file mode 100644 index 0000000000..3df0ee1a6e --- /dev/null +++ b/meshmc/launcher/minecraft/WorldList.h @@ -0,0 +1,148 @@ +/* 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 2015-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 <QString> +#include <QDir> +#include <QAbstractListModel> +#include <QMimeData> +#include "minecraft/World.h" + +class QFileSystemWatcher; + +class WorldList : public QAbstractListModel +{ + Q_OBJECT + public: + enum Columns { NameColumn, GameModeColumn, LastPlayedColumn }; + + enum Roles { + ObjectRole = Qt::UserRole + 1, + FolderRole, + SeedRole, + NameRole, + GameModeRole, + LastPlayedRole, + IconFileRole + }; + + WorldList(const QString& dir); + + virtual QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const; + + virtual int rowCount(const QModelIndex& parent = QModelIndex()) const + { + return size(); + }; + virtual QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const; + virtual int columnCount(const QModelIndex& parent) const; + + size_t size() const + { + return worlds.size(); + }; + bool empty() const + { + return size() == 0; + } + World& operator[](size_t index) + { + return worlds[index]; + } + + /// Reloads the mod list and returns true if the list changed. + virtual bool update(); + + /// Install a world from location + void installWorld(QFileInfo filename); + + /// Deletes the mod at the given index. + virtual bool deleteWorld(int index); + + /// Removes the world icon, if any + virtual bool resetIcon(int index); + + /// Deletes all the selected mods + virtual bool deleteWorlds(int first, int last); + + /// flags, mostly to support drag&drop + virtual Qt::ItemFlags flags(const QModelIndex& index) const; + /// get data for drag action + virtual QMimeData* mimeData(const QModelIndexList& indexes) const; + /// get the supported mime types + virtual QStringList mimeTypes() const; + /// process data from drop action + virtual bool dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, const QModelIndex& parent); + /// what drag actions do we support? + virtual Qt::DropActions supportedDragActions() const; + + /// what drop actions do we support? + virtual Qt::DropActions supportedDropActions() const; + + void startWatching(); + void stopWatching(); + + virtual bool isValid(); + + QDir dir() const + { + return m_dir; + } + + const QList<World>& allWorlds() const + { + return worlds; + } + + private slots: + void directoryChanged(QString path); + + signals: + void changed(); + + protected: + QFileSystemWatcher* m_watcher; + bool is_watching; + QDir m_dir; + QList<World> worlds; +}; diff --git a/meshmc/launcher/minecraft/auth/AccountData.cpp b/meshmc/launcher/minecraft/auth/AccountData.cpp new file mode 100644 index 0000000000..74d67c0478 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountData.cpp @@ -0,0 +1,362 @@ +/* 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 "AccountData.h" +#include <QJsonDocument> +#include <QJsonObject> +#include <QJsonArray> +#include <QDebug> +#include <QUuid> +#include <QRegularExpression> + +namespace +{ + void tokenToJSONV3(QJsonObject& parent, Katabasis::Token t, + const char* tokenName) + { + if (!t.persistent) { + return; + } + QJsonObject out; + if (t.issueInstant.isValid()) { + out["iat"] = QJsonValue(t.issueInstant.toMSecsSinceEpoch() / 1000); + } + + if (t.notAfter.isValid()) { + out["exp"] = QJsonValue(t.notAfter.toMSecsSinceEpoch() / 1000); + } + + bool save = false; + if (!t.token.isEmpty()) { + out["token"] = QJsonValue(t.token); + save = true; + } + if (!t.refresh_token.isEmpty()) { + out["refresh_token"] = QJsonValue(t.refresh_token); + save = true; + } + if (t.extra.size()) { + out["extra"] = QJsonObject::fromVariantMap(t.extra); + save = true; + } + if (save) { + parent[tokenName] = out; + } + } + + Katabasis::Token tokenFromJSONV3(const QJsonObject& parent, + const char* tokenName) + { + Katabasis::Token out; + auto tokenObject = parent.value(tokenName).toObject(); + if (tokenObject.isEmpty()) { + return out; + } + auto issueInstant = tokenObject.value("iat"); + if (issueInstant.isDouble()) { + out.issueInstant = QDateTime::fromMSecsSinceEpoch( + ((int64_t)issueInstant.toDouble()) * 1000); + } + + auto notAfter = tokenObject.value("exp"); + if (notAfter.isDouble()) { + out.notAfter = QDateTime::fromMSecsSinceEpoch( + ((int64_t)notAfter.toDouble()) * 1000); + } + + auto token = tokenObject.value("token"); + if (token.isString()) { + out.token = token.toString(); + out.validity = Katabasis::Validity::Assumed; + } + + auto refresh_token = tokenObject.value("refresh_token"); + if (refresh_token.isString()) { + out.refresh_token = refresh_token.toString(); + } + + auto extra = tokenObject.value("extra"); + if (extra.isObject()) { + out.extra = extra.toObject().toVariantMap(); + } + return out; + } + + void profileToJSONV3(QJsonObject& parent, MinecraftProfile p, + const char* tokenName) + { + if (p.id.isEmpty()) { + return; + } + QJsonObject out; + out["id"] = QJsonValue(p.id); + out["name"] = QJsonValue(p.name); + if (!p.currentCape.isEmpty()) { + out["cape"] = p.currentCape; + } + + { + QJsonObject skinObj; + skinObj["id"] = p.skin.id; + skinObj["url"] = p.skin.url; + skinObj["variant"] = p.skin.variant; + if (p.skin.data.size()) { + skinObj["data"] = QString::fromLatin1(p.skin.data.toBase64()); + } + out["skin"] = skinObj; + } + + QJsonArray capesArray; + for (auto& cape : p.capes) { + QJsonObject capeObj; + capeObj["id"] = cape.id; + capeObj["url"] = cape.url; + capeObj["alias"] = cape.alias; + if (cape.data.size()) { + capeObj["data"] = QString::fromLatin1(cape.data.toBase64()); + } + capesArray.push_back(capeObj); + } + out["capes"] = capesArray; + parent[tokenName] = out; + } + + MinecraftProfile profileFromJSONV3(const QJsonObject& parent, + const char* tokenName) + { + MinecraftProfile out; + auto tokenObject = parent.value(tokenName).toObject(); + if (tokenObject.isEmpty()) { + return out; + } + { + auto idV = tokenObject.value("id"); + auto nameV = tokenObject.value("name"); + if (!idV.isString() || !nameV.isString()) { + qWarning() << "mandatory profile attributes are missing or of " + "unexpected type"; + return MinecraftProfile(); + } + out.name = nameV.toString(); + out.id = idV.toString(); + } + + { + auto skinV = tokenObject.value("skin"); + if (!skinV.isObject()) { + qWarning() << "skin is missing"; + return MinecraftProfile(); + } + auto skinObj = skinV.toObject(); + auto idV = skinObj.value("id"); + auto urlV = skinObj.value("url"); + auto variantV = skinObj.value("variant"); + if (!idV.isString() || !urlV.isString() || !variantV.isString()) { + qWarning() << "mandatory skin attributes are missing or of " + "unexpected type"; + return MinecraftProfile(); + } + out.skin.id = idV.toString(); + out.skin.url = urlV.toString(); + out.skin.variant = variantV.toString(); + + // data for skin is optional + auto dataV = skinObj.value("data"); + if (dataV.isString()) { + // TODO: validate base64 + out.skin.data = + QByteArray::fromBase64(dataV.toString().toLatin1()); + } else if (!dataV.isUndefined()) { + qWarning() << "skin data is something unexpected"; + return MinecraftProfile(); + } + } + + { + auto capesV = tokenObject.value("capes"); + if (!capesV.isArray()) { + qWarning() << "capes is not an array!"; + return MinecraftProfile(); + } + auto capesArray = capesV.toArray(); + for (auto capeV : capesArray) { + if (!capeV.isObject()) { + qWarning() << "cape is not an object!"; + return MinecraftProfile(); + } + auto capeObj = capeV.toObject(); + auto idV = capeObj.value("id"); + auto urlV = capeObj.value("url"); + auto aliasV = capeObj.value("alias"); + if (!idV.isString() || !urlV.isString() || !aliasV.isString()) { + qWarning() << "mandatory skin attributes are missing or of " + "unexpected type"; + return MinecraftProfile(); + } + Cape cape; + cape.id = idV.toString(); + cape.url = urlV.toString(); + cape.alias = aliasV.toString(); + + // data for cape is optional. + auto dataV = capeObj.value("data"); + if (dataV.isString()) { + // TODO: validate base64 + cape.data = + QByteArray::fromBase64(dataV.toString().toLatin1()); + } else if (!dataV.isUndefined()) { + qWarning() << "cape data is something unexpected"; + return MinecraftProfile(); + } + out.capes[cape.id] = cape; + } + } + // current cape + { + auto capeV = tokenObject.value("cape"); + if (capeV.isString()) { + auto currentCape = capeV.toString(); + if (out.capes.contains(currentCape)) { + out.currentCape = currentCape; + } + } + } + out.validity = Katabasis::Validity::Assumed; + return out; + } + + void entitlementToJSONV3(QJsonObject& parent, MinecraftEntitlement p) + { + if (p.validity == Katabasis::Validity::None) { + return; + } + QJsonObject out; + out["ownsMinecraft"] = QJsonValue(p.ownsMinecraft); + out["canPlayMinecraft"] = QJsonValue(p.canPlayMinecraft); + parent["entitlement"] = out; + } + + bool entitlementFromJSONV3(const QJsonObject& parent, + MinecraftEntitlement& out) + { + auto entitlementObject = parent.value("entitlement").toObject(); + if (entitlementObject.isEmpty()) { + return false; + } + { + auto ownsMinecraftV = entitlementObject.value("ownsMinecraft"); + auto canPlayMinecraftV = + entitlementObject.value("canPlayMinecraft"); + if (!ownsMinecraftV.isBool() || !canPlayMinecraftV.isBool()) { + qWarning() + << "mandatory attributes are missing or of unexpected type"; + return false; + } + out.canPlayMinecraft = canPlayMinecraftV.toBool(false); + out.ownsMinecraft = ownsMinecraftV.toBool(false); + out.validity = Katabasis::Validity::Assumed; + } + return true; + } + +} // namespace + +bool AccountData::resumeStateFromV3(QJsonObject data) +{ + auto typeV = data.value("type"); + if (!typeV.isString()) { + qWarning() << "Failed to parse account data: type is missing."; + return false; + } + auto typeS = typeV.toString(); + if (typeS == "MSA") { + type = AccountType::MSA; + } else { + qWarning() << "Failed to parse account data: type is not recognized " + "(only MSA is supported)."; + return false; + } + + msaToken = tokenFromJSONV3(data, "msa"); + userToken = tokenFromJSONV3(data, "utoken"); + xboxApiToken = tokenFromJSONV3(data, "xrp-main"); + mojangservicesToken = tokenFromJSONV3(data, "xrp-mc"); + + yggdrasilToken = tokenFromJSONV3(data, "ygg"); + minecraftProfile = profileFromJSONV3(data, "profile"); + if (!entitlementFromJSONV3(data, minecraftEntitlement)) { + if (minecraftProfile.validity != Katabasis::Validity::None) { + minecraftEntitlement.canPlayMinecraft = true; + minecraftEntitlement.ownsMinecraft = true; + minecraftEntitlement.validity = Katabasis::Validity::Assumed; + } + } + + validity_ = minecraftProfile.validity; + return true; +} + +QJsonObject AccountData::saveState() const +{ + QJsonObject output; + output["type"] = "MSA"; + tokenToJSONV3(output, msaToken, "msa"); + tokenToJSONV3(output, userToken, "utoken"); + tokenToJSONV3(output, xboxApiToken, "xrp-main"); + tokenToJSONV3(output, mojangservicesToken, "xrp-mc"); + + tokenToJSONV3(output, yggdrasilToken, "ygg"); + profileToJSONV3(output, minecraftProfile, "profile"); + entitlementToJSONV3(output, minecraftEntitlement); + return output; +} + +QString AccountData::accessToken() const +{ + return yggdrasilToken.token; +} + +QString AccountData::profileId() const +{ + return minecraftProfile.id; +} + +QString AccountData::profileName() const +{ + if (minecraftProfile.name.size() == 0) { + return QObject::tr("No profile (%1)").arg(accountDisplayString()); + } else { + return minecraftProfile.name; + } +} + +QString AccountData::accountDisplayString() const +{ + if (xboxApiToken.extra.contains("gtg")) { + return xboxApiToken.extra["gtg"].toString(); + } + return "Xbox profile missing"; +} + +QString AccountData::lastError() const +{ + return errorString; +} diff --git a/meshmc/launcher/minecraft/auth/AccountData.h b/meshmc/launcher/minecraft/auth/AccountData.h new file mode 100644 index 0000000000..9e791c568e --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountData.h @@ -0,0 +1,103 @@ +/* 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 <QByteArray> +#include <QVector> +#include <katabasis/Bits.h> +#include <QJsonObject> + +struct Skin { + QString id; + QString url; + QString variant; + + QByteArray data; +}; + +struct Cape { + QString id; + QString url; + QString alias; + + QByteArray data; +}; + +struct MinecraftEntitlement { + bool ownsMinecraft = false; + bool canPlayMinecraft = false; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + +struct MinecraftProfile { + QString id; + QString name; + Skin skin; + QString currentCape; + QMap<QString, Cape> capes; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + +enum class AccountType { MSA }; + +enum class AccountState { + Unchecked, + Offline, + Working, + Online, + Errored, + Expired, + Gone +}; + +struct AccountData { + QJsonObject saveState() const; + bool resumeStateFromV3(QJsonObject data); + + //! Gamertag for MSA + QString accountDisplayString() const; + + //! Yggdrasil access token, as passed to the game. + QString accessToken() const; + + QString profileId() const; + QString profileName() const; + + QString lastError() const; + + AccountType type = AccountType::MSA; + + Katabasis::Token msaToken; + Katabasis::Token userToken; + Katabasis::Token xboxApiToken; + Katabasis::Token mojangservicesToken; + + Katabasis::Token yggdrasilToken; + MinecraftProfile minecraftProfile; + MinecraftEntitlement minecraftEntitlement; + Katabasis::Validity validity_ = Katabasis::Validity::None; + + // runtime only information (not saved with the account) + QString internalId; + QString errorString; + AccountState accountState = AccountState::Unchecked; +}; diff --git a/meshmc/launcher/minecraft/auth/AccountList.cpp b/meshmc/launcher/minecraft/auth/AccountList.cpp new file mode 100644 index 0000000000..f12a00815c --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountList.cpp @@ -0,0 +1,722 @@ +/* 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 "AccountList.h" +#include "AccountData.h" +#include "AccountTask.h" + +#include <QIODevice> +#include <QFile> +#include <QTextStream> +#include <QJsonDocument> +#include <QJsonArray> +#include <QJsonObject> +#include <QJsonParseError> +#include <QDir> +#include <QTimer> + +#include <QDebug> + +#include <FileSystem.h> +#include <QSaveFile> + +#include <chrono> + +enum AccountListVersion { MojangOnly = 2, MojangMSA = 3 }; + +AccountList::AccountList(QObject* parent) : QAbstractListModel(parent) +{ + m_refreshTimer = new QTimer(this); + m_refreshTimer->setSingleShot(true); + connect(m_refreshTimer, &QTimer::timeout, this, &AccountList::fillQueue); + m_nextTimer = new QTimer(this); + m_nextTimer->setSingleShot(true); + connect(m_nextTimer, &QTimer::timeout, this, &AccountList::tryNext); +} + +AccountList::~AccountList() noexcept {} + +int AccountList::findAccountByProfileId(const QString& profileId) const +{ + for (int i = 0; i < count(); i++) { + MinecraftAccountPtr account = at(i); + if (account->profileId() == profileId) { + return i; + } + } + return -1; +} + +MinecraftAccountPtr +AccountList::getAccountByProfileName(const QString& profileName) const +{ + for (int i = 0; i < count(); i++) { + MinecraftAccountPtr account = at(i); + if (account->profileName() == profileName) { + return account; + } + } + return nullptr; +} + +const MinecraftAccountPtr AccountList::at(int i) const +{ + return MinecraftAccountPtr(m_accounts.at(i)); +} + +QStringList AccountList::profileNames() const +{ + QStringList out; + for (auto& account : m_accounts) { + auto profileName = account->profileName(); + if (profileName.isEmpty()) { + continue; + } + out.append(profileName); + } + return out; +} + +void AccountList::addAccount(const MinecraftAccountPtr account) +{ + // NOTE: Do not allow adding something that's already there + if (m_accounts.contains(account)) { + return; + } + + // hook up notifications for changes in the account + connect(account.get(), &MinecraftAccount::changed, this, + &AccountList::accountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, + &AccountList::accountActivityChanged); + + // override/replace existing account with the same profileId + auto profileId = account->profileId(); + if (profileId.size()) { + auto existingAccount = findAccountByProfileId(profileId); + if (existingAccount != -1) { + MinecraftAccountPtr existingAccountPtr = + m_accounts[existingAccount]; + m_accounts[existingAccount] = account; + if (m_defaultAccount == existingAccountPtr) { + m_defaultAccount = account; + } + // disconnect notifications for changes in the account being + // replaced + existingAccountPtr->disconnect(this); + emit dataChanged( + index(existingAccount), + index(existingAccount, columnCount(QModelIndex()) - 1)); + onListChanged(); + return; + } + } + + // if we don't have this profileId yet, add the account to the end + int row = m_accounts.count(); + beginInsertRows(QModelIndex(), row, row); + m_accounts.append(account); + endInsertRows(); + onListChanged(); +} + +void AccountList::removeAccount(QModelIndex index) +{ + int row = index.row(); + if (index.isValid() && row >= 0 && row < m_accounts.size()) { + auto& account = m_accounts[row]; + if (account == m_defaultAccount) { + m_defaultAccount = nullptr; + onDefaultAccountChanged(); + } + account->disconnect(this); + + beginRemoveRows(QModelIndex(), row, row); + m_accounts.removeAt(index.row()); + endRemoveRows(); + onListChanged(); + } +} + +MinecraftAccountPtr AccountList::defaultAccount() const +{ + return m_defaultAccount; +} + +void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount) +{ + if (!newAccount && m_defaultAccount) { + int idx = 0; + auto previousDefaultAccount = m_defaultAccount; + m_defaultAccount = nullptr; + for (MinecraftAccountPtr account : m_accounts) { + if (account == previousDefaultAccount) { + emit dataChanged(index(idx), + index(idx, columnCount(QModelIndex()) - 1)); + } + idx++; + } + onDefaultAccountChanged(); + } else { + auto currentDefaultAccount = m_defaultAccount; + int currentDefaultAccountIdx = -1; + auto newDefaultAccount = m_defaultAccount; + int newDefaultAccountIdx = -1; + int idx = 0; + for (MinecraftAccountPtr account : m_accounts) { + if (account == newAccount) { + newDefaultAccount = account; + newDefaultAccountIdx = idx; + } + if (currentDefaultAccount == account) { + currentDefaultAccountIdx = idx; + } + idx++; + } + if (currentDefaultAccount != newDefaultAccount) { + emit dataChanged(index(currentDefaultAccountIdx), + index(currentDefaultAccountIdx, + columnCount(QModelIndex()) - 1)); + emit dataChanged( + index(newDefaultAccountIdx), + index(newDefaultAccountIdx, columnCount(QModelIndex()) - 1)); + m_defaultAccount = newDefaultAccount; + onDefaultAccountChanged(); + } + } +} + +void AccountList::accountChanged() +{ + // the list changed. there is no doubt. + onListChanged(); +} + +void AccountList::accountActivityChanged(bool active) +{ + MinecraftAccount* account = qobject_cast<MinecraftAccount*>(sender()); + bool found = false; + for (int i = 0; i < count(); i++) { + if (at(i).get() == account) { + emit dataChanged(index(i), + index(i, columnCount(QModelIndex()) - 1)); + found = true; + break; + } + } + if (found) { + emit listActivityChanged(); + if (active) { + beginActivity(); + } else { + endActivity(); + } + } +} + +void AccountList::onListChanged() +{ + if (m_autosave) + // TODO: Alert the user if this fails. + saveList(); + + emit listChanged(); +} + +void AccountList::onDefaultAccountChanged() +{ + if (m_autosave) + saveList(); + + emit defaultAccountChanged(); +} + +int AccountList::count() const +{ + return m_accounts.count(); +} + +QVariant AccountList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() > count()) + return QVariant(); + + MinecraftAccountPtr account = at(index.row()); + + switch (role) { + case Qt::DisplayRole: + switch (index.column()) { + case NameColumn: + return account->accountDisplayString(); + + case TypeColumn: { + auto typeStr = account->typeString(); + typeStr[0] = typeStr[0].toUpper(); + return typeStr; + } + + case StatusColumn: { + switch (account->accountState()) { + case AccountState::Unchecked: { + return tr("Unchecked", "Account status"); + } + case AccountState::Offline: { + return tr("Offline", "Account status"); + } + case AccountState::Online: { + return tr("Online", "Account status"); + } + case AccountState::Working: { + return tr("Working", "Account status"); + } + case AccountState::Errored: { + return tr("Errored", "Account status"); + } + case AccountState::Expired: { + return tr("Expired", "Account status"); + } + case AccountState::Gone: { + return tr("Gone", "Account status"); + } + } + } + + case ProfileNameColumn: { + return account->profileName(); + } + + default: + return QVariant(); + } + + case Qt::ToolTipRole: + return account->accountDisplayString(); + + case PointerRole: + return QVariant::fromValue(account); + + case Qt::CheckStateRole: + switch (index.column()) { + case NameColumn: + return account == m_defaultAccount ? Qt::Checked + : Qt::Unchecked; + } + + default: + return QVariant(); + } +} + +QVariant AccountList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case NameColumn: + return tr("Account"); + case TypeColumn: + return tr("Type"); + case StatusColumn: + return tr("Status"); + case ProfileNameColumn: + return tr("Profile"); + default: + return QVariant(); + } + + case Qt::ToolTipRole: + switch (section) { + case NameColumn: + return tr("User name of the account."); + case TypeColumn: + return tr("Type of the account."); + case StatusColumn: + return tr("Current status of the account."); + case ProfileNameColumn: + return tr("Name of the Minecraft profile associated with " + "the account."); + default: + return QVariant(); + } + + default: + return QVariant(); + } +} + +int AccountList::rowCount(const QModelIndex&) const +{ + // Return count + return count(); +} + +int AccountList::columnCount(const QModelIndex&) const +{ + return NUM_COLUMNS; +} + +Qt::ItemFlags AccountList::flags(const QModelIndex& index) const +{ + if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) { + return Qt::NoItemFlags; + } + + return Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +bool AccountList::setData(const QModelIndex& idx, const QVariant& value, + int role) +{ + if (idx.row() < 0 || idx.row() >= rowCount(idx) || !idx.isValid()) { + return false; + } + + if (role == Qt::CheckStateRole) { + if (value == Qt::Checked) { + MinecraftAccountPtr account = at(idx.row()); + setDefaultAccount(account); + } + } + + emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1)); + return true; +} + +bool AccountList::loadList() +{ + if (m_listFilePath.isEmpty()) { + qCritical() << "Can't load account list. No file path given and no " + "default set."; + return false; + } + + QFile file(m_listFilePath); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << QString("Failed to read the account list file (%1).") + .arg(m_listFilePath) + .toUtf8(); + return false; + } + + // Read the file and close it. + QByteArray jsonData = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); + + // Fail if the JSON is invalid. + if (parseError.error != QJsonParseError::NoError) { + qCritical() << QString( + "Failed to parse account list file: %1 at offset %2") + .arg(parseError.errorString(), + QString::number(parseError.offset)) + .toUtf8(); + return false; + } + + // Make sure the root is an object. + if (!jsonDoc.isObject()) { + qCritical() << "Invalid account list JSON: Root should be an array."; + return false; + } + + QJsonObject root = jsonDoc.object(); + + // Make sure the format version matches. + auto listVersion = root.value("formatVersion").toVariant().toInt(); + switch (listVersion) { + case AccountListVersion::MojangMSA: { + return loadV3(root); + } break; + default: { + QString newName = "accounts-old.json"; + qWarning() << "Unknown format version when loading account list. " + "Existing one will be renamed to" + << newName; + // Attempt to rename the old version. + file.rename(newName); + return false; + } + } +} + +bool AccountList::loadV3(QJsonObject& root) +{ + beginResetModel(); + QJsonArray accounts = root.value("accounts").toArray(); + for (QJsonValue accountVal : accounts) { + QJsonObject accountObj = accountVal.toObject(); + MinecraftAccountPtr account = + MinecraftAccount::loadFromJsonV3(accountObj); + if (account.get() != nullptr) { + auto profileId = account->profileId(); + if (profileId.size()) { + if (findAccountByProfileId(profileId) != -1) { + continue; + } + } + connect(account.get(), &MinecraftAccount::changed, this, + &AccountList::accountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, + &AccountList::accountActivityChanged); + m_accounts.append(account); + if (accountObj.value("active").toBool(false)) { + m_defaultAccount = account; + } + } else { + qWarning() << "Failed to load an account."; + } + } + endResetModel(); + return true; +} + +bool AccountList::saveList() +{ + if (m_listFilePath.isEmpty()) { + qCritical() << "Can't save account list. No file path given and no " + "default set."; + return false; + } + + // make sure the parent folder exists + if (!FS::ensureFilePathExists(m_listFilePath)) + return false; + + // make sure the file wasn't overwritten with a folder before (fixes a bug) + QFileInfo finfo(m_listFilePath); + if (finfo.isDir()) { + QDir badDir(m_listFilePath); + badDir.removeRecursively(); + } + + qDebug() << "Writing account list to" << m_listFilePath; + + qDebug() << "Building JSON data structure."; + // Build the JSON document to write to the list file. + QJsonObject root; + + root.insert("formatVersion", AccountListVersion::MojangMSA); + + // Build a list of accounts. + qDebug() << "Building account array."; + QJsonArray accounts; + for (MinecraftAccountPtr account : m_accounts) { + QJsonObject accountObj = account->saveToJson(); + if (m_defaultAccount == account) { + accountObj["active"] = true; + } + accounts.append(accountObj); + } + + // Insert the account list into the root object. + root.insert("accounts", accounts); + + // Create a JSON document object to convert our JSON to bytes. + QJsonDocument doc(root); + + // Now that we're done building the JSON object, we can write it to the + // file. + qDebug() << "Writing account list to file."; + QSaveFile file(m_listFilePath); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::WriteOnly)) { + qCritical() << QString("Failed to read the account list file (%1).") + .arg(m_listFilePath) + .toUtf8(); + return false; + } + + // Write the JSON to the file. + file.write(doc.toJson()); + file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser | + QFile::WriteUser); + if (file.commit()) { + qDebug() << "Saved account list to" << m_listFilePath; + return true; + } else { + qDebug() << "Failed to save accounts to" << m_listFilePath; + return false; + } +} + +void AccountList::setListFilePath(QString path, bool autosave) +{ + m_listFilePath = path; + m_autosave = autosave; +} + +bool AccountList::anyAccountIsValid() +{ + for (auto account : m_accounts) { + if (account->ownsMinecraft()) { + return true; + } + } + return false; +} + +void AccountList::fillQueue() +{ + + if (m_defaultAccount && m_defaultAccount->shouldRefresh()) { + auto idToRefresh = m_defaultAccount->internalId(); + m_refreshQueue.push_back(idToRefresh); + qDebug() << "AccountList: Queued default account with internal ID " + << idToRefresh << " to refresh first"; + } + + for (int i = 0; i < count(); i++) { + auto account = at(i); + if (account == m_defaultAccount) { + continue; + } + + if (account->shouldRefresh()) { + auto idToRefresh = account->internalId(); + queueRefresh(idToRefresh); + } + } + tryNext(); +} + +void AccountList::requestRefresh(QString accountId) +{ + auto index = m_refreshQueue.indexOf(accountId); + if (index != -1) { + m_refreshQueue.removeAt(index); + } + m_refreshQueue.push_front(accountId); + qDebug() << "AccountList: Pushed account with internal ID " << accountId + << " to the front of the queue"; + if (!isActive()) { + tryNext(); + } +} + +void AccountList::queueRefresh(QString accountId) +{ + if (m_refreshQueue.indexOf(accountId) != -1) { + return; + } + m_refreshQueue.push_back(accountId); + qDebug() << "AccountList: Queued account with internal ID " << accountId + << " to refresh"; +} + +void AccountList::tryNext() +{ + while (m_refreshQueue.length()) { + auto accountId = m_refreshQueue.front(); + m_refreshQueue.pop_front(); + for (int i = 0; i < count(); i++) { + auto account = at(i); + if (account->internalId() == accountId) { + m_currentTask = account->refresh(); + if (m_currentTask) { + connect(m_currentTask.get(), &AccountTask::succeeded, this, + &AccountList::authSucceeded); + connect(m_currentTask.get(), &AccountTask::failed, this, + &AccountList::authFailed); + m_currentTask->start(); + qDebug() << "RefreshSchedule: Processing account " + << account->accountDisplayString() + << " with internal ID " << accountId; + return; + } + } + } + qDebug() << "RefreshSchedule: Account with with internal ID " + << accountId << " not found."; + } + // if we get here, no account needed refreshing. Schedule refresh in an + // hour. + m_refreshTimer->start(1000 * 3600); +} + +void AccountList::authSucceeded() +{ + qDebug() << "RefreshSchedule: Background account refresh succeeded"; + m_currentTask.reset(); + m_nextTimer->start(1000 * 20); +} + +void AccountList::authFailed(QString reason) +{ + qDebug() << "RefreshSchedule: Background account refresh failed: " + << reason; + m_currentTask.reset(); + m_nextTimer->start(1000 * 20); +} + +bool AccountList::isActive() const +{ + return m_activityCount != 0; +} + +void AccountList::beginActivity() +{ + bool activating = m_activityCount == 0; + m_activityCount++; + if (activating) { + emit activityChanged(true); + } +} + +void AccountList::endActivity() +{ + if (m_activityCount == 0) { + qWarning() << m_name << " - Activity count would become below zero"; + return; + } + bool deactivating = m_activityCount == 1; + m_activityCount--; + if (deactivating) { + emit activityChanged(false); + } +} diff --git a/meshmc/launcher/minecraft/auth/AccountList.h b/meshmc/launcher/minecraft/auth/AccountList.h new file mode 100644 index 0000000000..2d352532a8 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountList.h @@ -0,0 +1,187 @@ +/* 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 "MinecraftAccount.h" + +#include <QObject> +#include <QVariant> +#include <QAbstractListModel> +#include <QSharedPointer> + +/*! + * List of available Mojang accounts. + * This should be loaded in the background by MeshMC on startup. + */ +class AccountList : public QAbstractListModel +{ + Q_OBJECT + public: + enum ModelRoles { PointerRole = 0x34B1CB48 }; + + enum VListColumns { + // TODO: Add icon column. + NameColumn = 0, + ProfileNameColumn, + TypeColumn, + StatusColumn, + + NUM_COLUMNS + }; + + explicit AccountList(QObject* parent = 0); + virtual ~AccountList() noexcept; + + const MinecraftAccountPtr at(int i) const; + int count() const; + + //////// List Model Functions //////// + QVariant data(const QModelIndex& index, int role) const override; + virtual QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + virtual int rowCount(const QModelIndex& parent) const override; + virtual int columnCount(const QModelIndex& parent) const override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + virtual bool setData(const QModelIndex& index, const QVariant& value, + int role) override; + + void addAccount(const MinecraftAccountPtr account); + void removeAccount(QModelIndex index); + int findAccountByProfileId(const QString& profileId) const; + MinecraftAccountPtr + getAccountByProfileName(const QString& profileName) const; + QStringList profileNames() const; + + // requesting a refresh pushes it to the front of the queue + void requestRefresh(QString accountId); + // queuing a refresh will let it go to the back of the queue (unless it's + // somewhere inside the queue already) + void queueRefresh(QString accountId); + + /*! + * Sets the path to load/save the list file from/to. + * If autosave is true, this list will automatically save to the given path + * whenever it changes. THIS FUNCTION DOES NOT LOAD THE LIST. If you set + * autosave, be sure to call loadList() immediately after calling this + * function to ensure an autosaved change doesn't overwrite the list you + * intended to load. + */ + void setListFilePath(QString path, bool autosave = false); + + bool loadList(); + bool loadV3(QJsonObject& root); + bool saveList(); + + MinecraftAccountPtr defaultAccount() const; + void setDefaultAccount(MinecraftAccountPtr profileId); + bool anyAccountIsValid(); + + bool isActive() const; + + protected: + void beginActivity(); + void endActivity(); + + private: + const char* m_name; + uint32_t m_activityCount = 0; + signals: + void listChanged(); + void listActivityChanged(); + void defaultAccountChanged(); + void activityChanged(bool active); + + public slots: + /** + * This is called when one of the accounts changes and the list needs to be + * updated + */ + void accountChanged(); + + /** + * This is called when a (refresh/login) task involving the account starts + * or ends + */ + void accountActivityChanged(bool active); + + /** + * This is initially to run background account refresh tasks, or on a hourly + * timer + */ + void fillQueue(); + + private slots: + void tryNext(); + + void authSucceeded(); + void authFailed(QString reason); + + protected: + QList<QString> m_refreshQueue; + QTimer* m_refreshTimer; + QTimer* m_nextTimer; + shared_qobject_ptr<AccountTask> m_currentTask; + + /*! + * Called whenever the list changes. + * This emits the listChanged() signal and autosaves the list (if autosave + * is enabled). + */ + void onListChanged(); + + /*! + * Called whenever the active account changes. + * Emits the defaultAccountChanged() signal and autosaves the list if + * enabled. + */ + void onDefaultAccountChanged(); + + QList<MinecraftAccountPtr> m_accounts; + + MinecraftAccountPtr m_defaultAccount; + + //! Path to the account list file. Empty string if there isn't one. + QString m_listFilePath; + + /*! + * If true, the account list will automatically save to the account list + * path when it changes. Ignored if m_listFilePath is blank. + */ + bool m_autosave = false; +}; diff --git a/meshmc/launcher/minecraft/auth/AccountTask.cpp b/meshmc/launcher/minecraft/auth/AccountTask.cpp new file mode 100644 index 0000000000..ddcf1918db --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountTask.cpp @@ -0,0 +1,129 @@ +/* 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 "AccountTask.h" +#include "MinecraftAccount.h" + +#include <QObject> +#include <QString> +#include <QJsonObject> +#include <QJsonDocument> +#include <QNetworkReply> +#include <QByteArray> + +#include <QDebug> + +AccountTask::AccountTask(AccountData* data, QObject* parent) + : Task(parent), m_data(data) +{ + changeState(AccountTaskState::STATE_CREATED); +} + +QString AccountTask::getStateMessage() const +{ + switch (m_taskState) { + case AccountTaskState::STATE_CREATED: + return "Waiting..."; + case AccountTaskState::STATE_WORKING: + return tr("Sending request to auth servers..."); + case AccountTaskState::STATE_SUCCEEDED: + return tr("Authentication task succeeded."); + case AccountTaskState::STATE_OFFLINE: + return tr("Failed to contact the authentication server."); + case AccountTaskState::STATE_FAILED_SOFT: + return tr("Encountered an error during authentication."); + case AccountTaskState::STATE_FAILED_HARD: + return tr("Failed to authenticate. The session has expired."); + case AccountTaskState::STATE_FAILED_GONE: + return tr("Failed to authenticate. The account no longer exists."); + default: + return tr("..."); + } +} + +bool AccountTask::changeState(AccountTaskState newState, QString reason) +{ + m_taskState = newState; + setStatus(getStateMessage()); + switch (newState) { + case AccountTaskState::STATE_CREATED: { + m_data->errorString.clear(); + return true; + } + case AccountTaskState::STATE_WORKING: { + m_data->accountState = AccountState::Working; + return true; + } + case AccountTaskState::STATE_SUCCEEDED: { + m_data->accountState = AccountState::Online; + emitSucceeded(); + return false; + } + case AccountTaskState::STATE_OFFLINE: { + m_data->errorString = reason; + m_data->accountState = AccountState::Offline; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_SOFT: { + m_data->errorString = reason; + m_data->accountState = AccountState::Errored; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_HARD: { + m_data->errorString = reason; + m_data->accountState = AccountState::Expired; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_GONE: { + m_data->errorString = reason; + m_data->accountState = AccountState::Gone; + emitFailed(reason); + return false; + } + default: { + QString error = + tr("Unknown account task state: %1").arg(int(newState)); + m_data->accountState = AccountState::Errored; + emitFailed(error); + return false; + } + } +} diff --git a/meshmc/launcher/minecraft/auth/AccountTask.h b/meshmc/launcher/minecraft/auth/AccountTask.h new file mode 100644 index 0000000000..184b8b4c01 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountTask.h @@ -0,0 +1,101 @@ +/* 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 <tasks/Task.h> + +#include <QString> +#include <QJsonObject> +#include <QTimer> +#include <qsslerror.h> + +#include "MinecraftAccount.h" + +class QNetworkReply; + +/** + * Enum for describing the state of the current task. + * Used by the getStateMessage function to determine what the status message + * should be. + */ +enum class AccountTaskState { + STATE_CREATED, + STATE_WORKING, + STATE_SUCCEEDED, + STATE_FAILED_SOFT, //!< soft failure. authentication went through partially + STATE_FAILED_HARD, //!< hard failure. main tokens are invalid + STATE_FAILED_GONE, //!< hard failure. main tokens are invalid, and the + //!< account no longer exists + STATE_OFFLINE //!< soft failure. authentication failed in the first step in + //!< a 'soft' way +}; + +class AccountTask : public Task +{ + Q_OBJECT + public: + explicit AccountTask(AccountData* data, QObject* parent = 0); + virtual ~AccountTask() {}; + + AccountTaskState m_taskState = AccountTaskState::STATE_CREATED; + + AccountTaskState taskState() + { + return m_taskState; + } + + signals: + void authorizeWithBrowser(const QUrl& url); + + protected: + /** + * Returns the state message for the given state. + * Used to set the status message for the task. + * Should be overridden by subclasses that want to change messages for a + * given state. + */ + virtual QString getStateMessage() const; + + protected slots: + // NOTE: true -> non-terminal state, false -> terminal state + bool changeState(AccountTaskState newState, QString reason = QString()); + + protected: + AccountData* m_data = nullptr; +}; diff --git a/meshmc/launcher/minecraft/auth/AuthRequest.cpp b/meshmc/launcher/minecraft/auth/AuthRequest.cpp new file mode 100644 index 0000000000..9edf238c57 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthRequest.cpp @@ -0,0 +1,162 @@ +/* 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 <cassert> + +#include <QDebug> +#include <QTimer> +#include <QBuffer> +#include <QUrlQuery> + +#include "Application.h" +#include "AuthRequest.h" +#include "katabasis/Globals.h" + +AuthRequest::AuthRequest(QObject* parent) : QObject(parent) {} + +AuthRequest::~AuthRequest() {} + +void AuthRequest::get(const QNetworkRequest& req, int timeout /* = 60*1000*/) +{ + setup(req, QNetworkAccessManager::GetOperation); + reply_ = APPLICATION->network()->get(request_); + status_ = Requesting; + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); +} + +void AuthRequest::post(const QNetworkRequest& req, const QByteArray& data, + int timeout /* = 60*1000*/) +{ + setup(req, QNetworkAccessManager::PostOperation); + data_ = data; + status_ = Requesting; + reply_ = APPLICATION->network()->post(request_, data_); + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); + connect(reply_, SIGNAL(uploadProgress(qint64, qint64)), this, + SLOT(onUploadProgress(qint64, qint64))); +} + +void AuthRequest::onRequestFinished() +{ + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast<QNetworkReply*>(sender())) { + return; + } + httpStatus_ = + reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + finish(); +} + +void AuthRequest::onRequestError(QNetworkReply::NetworkError error) +{ + qWarning() << "AuthRequest::onRequestError: Error" << (int)error; + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast<QNetworkReply*>(sender())) { + return; + } + errorString_ = reply_->errorString(); + httpStatus_ = + reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + error_ = error; + qWarning() << "AuthRequest::onRequestError: Error string: " << errorString_; + qWarning() << "AuthRequest::onRequestError: HTTP status" << httpStatus_ + << reply_->attribute(QNetworkRequest::HttpReasonPhraseAttribute) + .toString(); + + // QTimer::singleShot(10, this, SLOT(finish())); +} + +void AuthRequest::onSslErrors(QList<QSslError> errors) +{ + int i = 1; + for (auto error : errors) { + qCritical() << "LOGIN SSL Error #" << i << " : " << error.errorString(); + auto cert = error.certificate(); + qCritical() << "Certificate in question:\n" << cert.toText(); + i++; + } +} + +void AuthRequest::onUploadProgress(qint64 uploaded, qint64 total) +{ + if (status_ == Idle) { + qWarning() << "AuthRequest::onUploadProgress: No pending request"; + return; + } + if (reply_ != qobject_cast<QNetworkReply*>(sender())) { + return; + } + // Restart timeout because request in progress + Katabasis::Reply* o2Reply = timedReplies_.find(reply_); + if (o2Reply) { + o2Reply->start(); + } + emit uploadProgress(uploaded, total); +} + +void AuthRequest::setup(const QNetworkRequest& req, + QNetworkAccessManager::Operation operation, + const QByteArray& verb) +{ + request_ = req; + operation_ = operation; + url_ = req.url(); + + QUrl url = url_; + request_.setUrl(url); + + if (!verb.isEmpty()) { + request_.setRawHeader(Katabasis::HTTP_HTTP_HEADER, verb); + } + + status_ = Requesting; + error_ = QNetworkReply::NoError; + errorString_.clear(); + httpStatus_ = 0; +} + +void AuthRequest::finish() +{ + QByteArray data; + if (status_ == Idle) { + qWarning() << "AuthRequest::finish: No pending request"; + return; + } + data = reply_->readAll(); + status_ = Idle; + timedReplies_.remove(reply_); + reply_->disconnect(this); + reply_->deleteLater(); + QList<QNetworkReply::RawHeaderPair> headers = reply_->rawHeaderPairs(); + emit finished(error_, data, headers); +} diff --git a/meshmc/launcher/minecraft/auth/AuthRequest.h b/meshmc/launcher/minecraft/auth/AuthRequest.h new file mode 100644 index 0000000000..cd57fa34db --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthRequest.h @@ -0,0 +1,93 @@ +/* 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 <QObject> +#include <QNetworkRequest> +#include <QNetworkReply> +#include <QNetworkAccessManager> +#include <QUrl> +#include <QByteArray> + +#include "katabasis/Reply.h" + +/// Makes authentication requests. +class AuthRequest : public QObject +{ + Q_OBJECT + + public: + explicit AuthRequest(QObject* parent = 0); + ~AuthRequest(); + + public slots: + void get(const QNetworkRequest& req, int timeout = 60 * 1000); + void post(const QNetworkRequest& req, const QByteArray& data, + int timeout = 60 * 1000); + + signals: + + /// Emitted when a request has been completed or failed. + void finished(QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers); + + /// Emitted when an upload has progressed. + void uploadProgress(qint64 bytesSent, qint64 bytesTotal); + + protected slots: + + /// Handle request finished. + void onRequestFinished(); + + /// Handle request error. + void onRequestError(QNetworkReply::NetworkError error); + + /// Handle ssl errors. + void onSslErrors(QList<QSslError> errors); + + /// Finish the request, emit finished() signal. + void finish(); + + /// Handle upload progress. + void onUploadProgress(qint64 uploaded, qint64 total); + + public: + QNetworkReply::NetworkError error_; + int httpStatus_ = 0; + QString errorString_; + + protected: + void setup(const QNetworkRequest& request, + QNetworkAccessManager::Operation operation, + const QByteArray& verb = QByteArray()); + + enum Status { Idle, Requesting, ReRequesting }; + + QNetworkRequest request_; + QByteArray data_; + QNetworkReply* reply_; + Status status_; + QNetworkAccessManager::Operation operation_; + QUrl url_; + Katabasis::ReplyList timedReplies_; + + QTimer* timer_; +}; diff --git a/meshmc/launcher/minecraft/auth/AuthSession.cpp b/meshmc/launcher/minecraft/auth/AuthSession.cpp new file mode 100644 index 0000000000..53366077ae --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthSession.cpp @@ -0,0 +1,57 @@ +/* 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 "AuthSession.h" +#include <QJsonObject> +#include <QJsonArray> +#include <QJsonDocument> +#include <QStringList> + +QString AuthSession::serializeUserProperties() +{ + QJsonObject userAttrs; + /* + for (auto key : u.properties.keys()) + { + auto array = QJsonArray::fromStringList(u.properties.values(key)); + userAttrs.insert(key, array); + } + */ + QJsonDocument value(userAttrs); + return value.toJson(QJsonDocument::Compact); +} + +bool AuthSession::MakeOffline(QString offline_playername) +{ + if (status != PlayableOffline && status != PlayableOnline) { + return false; + } + session = "-"; + player_name = offline_playername; + status = PlayableOffline; + return true; +} + +void AuthSession::MakeDemo() +{ + player_name = "Player"; + demo = true; +} diff --git a/meshmc/launcher/minecraft/auth/AuthSession.h b/meshmc/launcher/minecraft/auth/AuthSession.h new file mode 100644 index 0000000000..80525bb972 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthSession.h @@ -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/>. + */ + +#pragma once + +#include <QString> +#include <QMultiMap> +#include <memory> +#include "QObjectPtr.h" + +class MinecraftAccount; +class QNetworkAccessManager; + +struct AuthSession { + bool MakeOffline(QString offline_playername); + void MakeDemo(); + + QString serializeUserProperties(); + + enum Status { + Undetermined, + RequiresOAuth, + RequiresPassword, + RequiresProfileSetup, + PlayableOffline, + PlayableOnline, + GoneOrMigrated + } status = Undetermined; + + // client token + QString client_token; + // account user name + QString username; + // combined session ID + QString session; + // volatile auth token + QString access_token; + // profile name + QString player_name; + // profile ID + QString uuid; + // 'legacy' or 'mojang', depending on account type + QString user_type; + // Did the auth server reply? + bool auth_server_online = false; + // Did the user request online mode? + bool wants_online = true; + + // Is this a demo session? + bool demo = false; +}; + +typedef std::shared_ptr<AuthSession> AuthSessionPtr; diff --git a/meshmc/launcher/minecraft/auth/AuthStep.cpp b/meshmc/launcher/minecraft/auth/AuthStep.cpp new file mode 100644 index 0000000000..459d74d63d --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthStep.cpp @@ -0,0 +1,26 @@ +/* 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 "AuthStep.h" + +AuthStep::AuthStep(AccountData* data) : QObject(nullptr), m_data(data) {} + +AuthStep::~AuthStep() noexcept = default; diff --git a/meshmc/launcher/minecraft/auth/AuthStep.h b/meshmc/launcher/minecraft/auth/AuthStep.h new file mode 100644 index 0000000000..0c9c758b4e --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthStep.h @@ -0,0 +1,54 @@ +/* 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 <QObject> +#include <QList> +#include <QNetworkReply> + +#include "QObjectPtr.h" +#include "minecraft/auth/AccountData.h" +#include "AccountTask.h" + +class AuthStep : public QObject +{ + Q_OBJECT + + public: + using Ptr = shared_qobject_ptr<AuthStep>; + + public: + explicit AuthStep(AccountData* data); + virtual ~AuthStep() noexcept; + + virtual QString describe() = 0; + + public slots: + virtual void perform() = 0; + virtual void rehydrate() = 0; + + signals: + void finished(AccountTaskState resultingState, QString message); + void authorizeWithBrowser(const QUrl& url); + + protected: + AccountData* m_data; +}; diff --git a/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp b/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp new file mode 100644 index 0000000000..53e77bbed0 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp @@ -0,0 +1,257 @@ +/* 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 "MinecraftAccount.h" + +#include <QUuid> +#include <QJsonObject> +#include <QJsonArray> +#include <QRegularExpression> +#include <QStringList> +#include <QJsonDocument> + +#include <QDebug> + +#include <QPainter> + +#include "flows/MSA.h" + +MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent) +{ + data.internalId = + QUuid::createUuid().toString().remove(QRegularExpression("[{}-]")); +} + +MinecraftAccountPtr MinecraftAccount::loadFromJsonV3(const QJsonObject& json) +{ + MinecraftAccountPtr account(new MinecraftAccount()); + if (account->data.resumeStateFromV3(json)) { + return account; + } + return nullptr; +} + +MinecraftAccountPtr MinecraftAccount::createBlankMSA() +{ + MinecraftAccountPtr account(new MinecraftAccount()); + account->data.type = AccountType::MSA; + return account; +} + +QJsonObject MinecraftAccount::saveToJson() const +{ + return data.saveState(); +} + +AccountState MinecraftAccount::accountState() const +{ + return data.accountState; +} + +QPixmap MinecraftAccount::getFace() const +{ + QPixmap skinTexture; + if (!skinTexture.loadFromData(data.minecraftProfile.skin.data, "PNG")) { + return QPixmap(); + } + QPixmap skin = QPixmap(8, 8); + QPainter painter(&skin); + painter.drawPixmap(0, 0, skinTexture.copy(8, 8, 8, 8)); + painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8)); + return skin.scaled(64, 64, Qt::KeepAspectRatio); +} + +shared_qobject_ptr<AccountTask> MinecraftAccount::loginMSA() +{ + Q_ASSERT(m_currentTask.get() == nullptr); + + m_currentTask.reset(new MSAInteractive(&data)); + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), + SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr<AccountTask> MinecraftAccount::refresh() +{ + if (m_currentTask) { + return m_currentTask; + } + + m_currentTask.reset(new MSASilent(&data)); + + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), + SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr<AccountTask> MinecraftAccount::currentTask() +{ + return m_currentTask; +} + +void MinecraftAccount::authSucceeded() +{ + m_currentTask.reset(); + emit changed(); + emit activityChanged(false); +} + +void MinecraftAccount::authFailed(QString reason) +{ + switch (m_currentTask->taskState()) { + case AccountTaskState::STATE_OFFLINE: + case AccountTaskState::STATE_FAILED_SOFT: { + // NOTE: this doesn't do much. There was an error of some sort. + } break; + case AccountTaskState::STATE_FAILED_HARD: { + data.msaToken.token = QString(); + data.msaToken.refresh_token = QString(); + data.msaToken.validity = Katabasis::Validity::None; + data.validity_ = Katabasis::Validity::None; + emit changed(); + } break; + case AccountTaskState::STATE_FAILED_GONE: { + data.validity_ = Katabasis::Validity::None; + emit changed(); + } break; + case AccountTaskState::STATE_CREATED: + case AccountTaskState::STATE_WORKING: + case AccountTaskState::STATE_SUCCEEDED: { + // Not reachable here, as they are not failures. + } + } + m_currentTask.reset(); + emit activityChanged(false); +} + +bool MinecraftAccount::isActive() const +{ + return m_currentTask; +} + +bool MinecraftAccount::shouldRefresh() const +{ + /* + * Never refresh accounts that are being used by the game, it breaks the + * game session. Always refresh accounts that have not been refreshed yet + * during this session. Don't refresh broken accounts. Refresh accounts that + * would expire in the next 12 hours (fresh token validity is 24 hours). + */ + if (isInUse()) { + return false; + } + switch (data.validity_) { + case Katabasis::Validity::Certain: { + break; + } + case Katabasis::Validity::None: { + return false; + } + case Katabasis::Validity::Assumed: { + return true; + } + } + auto now = QDateTime::currentDateTimeUtc(); + auto issuedTimestamp = data.msaToken.issueInstant; + auto expiresTimestamp = data.msaToken.notAfter; + + if (!expiresTimestamp.isValid()) { + expiresTimestamp = issuedTimestamp.addSecs(24 * 3600); + } + if (now.secsTo(expiresTimestamp) < (12 * 3600)) { + return true; + } + return false; +} + +void MinecraftAccount::fillSession(AuthSessionPtr session) +{ + if (ownsMinecraft() && !hasProfile()) { + session->status = AuthSession::RequiresProfileSetup; + } else { + if (session->wants_online) { + session->status = AuthSession::PlayableOnline; + } else { + session->status = AuthSession::PlayableOffline; + } + } + + // the user name + session->username = data.profileName(); + // volatile auth token + session->access_token = data.accessToken(); + // the semi-permanent client token + session->client_token = QString(); + // profile name + session->player_name = data.profileName(); + // profile ID + session->uuid = data.profileId(); + // 'legacy' or 'mojang', depending on account type + session->user_type = typeString(); + if (!session->access_token.isEmpty()) { + session->session = + "token:" + data.accessToken() + ":" + data.profileId(); + } else { + session->session = "-"; + } +} + +void MinecraftAccount::decrementUses() +{ + Usable::decrementUses(); + if (!isInUse()) { + emit changed(); + // FIXME: we now need a better way to identify accounts... + qWarning() << "Profile" << data.profileId() << "is no longer in use."; + } +} + +void MinecraftAccount::incrementUses() +{ + bool wasInUse = isInUse(); + Usable::incrementUses(); + if (!wasInUse) { + emit changed(); + // FIXME: we now need a better way to identify accounts... + qWarning() << "Profile" << data.profileId() << "is now in use."; + } +} diff --git a/meshmc/launcher/minecraft/auth/MinecraftAccount.h b/meshmc/launcher/minecraft/auth/MinecraftAccount.h new file mode 100644 index 0000000000..1d25fa7d57 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/MinecraftAccount.h @@ -0,0 +1,198 @@ +/* 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 <QObject> +#include <QString> +#include <QList> +#include <QJsonObject> +#include <QPair> +#include <QMap> +#include <QPixmap> + +#include <memory> + +#include "AuthSession.h" +#include "Usable.h" +#include "AccountData.h" +#include "QObjectPtr.h" + +class Task; +class AccountTask; +class MinecraftAccount; + +typedef shared_qobject_ptr<MinecraftAccount> MinecraftAccountPtr; +Q_DECLARE_METATYPE(MinecraftAccountPtr) + +/** + * A profile within someone's Mojang account. + * + * Currently, the profile system has not been implemented by Mojang yet, + * but we might as well add some things for it in MeshMC right now so + * we don't have to rip the code to pieces to add it later. + */ +struct AccountProfile { + QString id; + QString name; + bool legacy; +}; + +/** + * Object that stores information about a certain Mojang account. + * + * Said information may include things such as that account's username, client + * token, and access token if the user chose to stay logged in. + */ +class MinecraftAccount : public QObject, public Usable +{ + Q_OBJECT + public: /* construction */ + //! Do not copy accounts. ever. + explicit MinecraftAccount(const MinecraftAccount& other, + QObject* parent) = delete; + + //! Default constructor + explicit MinecraftAccount(QObject* parent = 0); + + static MinecraftAccountPtr createBlankMSA(); + + static MinecraftAccountPtr loadFromJsonV3(const QJsonObject& json); + + //! Saves a MinecraftAccount to a JSON object and returns it. + QJsonObject saveToJson() const; + + public: /* manipulation */ + shared_qobject_ptr<AccountTask> loginMSA(); + + shared_qobject_ptr<AccountTask> refresh(); + + shared_qobject_ptr<AccountTask> currentTask(); + + public: /* queries */ + QString internalId() const + { + return data.internalId; + } + + QString accountDisplayString() const + { + return data.accountDisplayString(); + } + + QString accessToken() const + { + return data.accessToken(); + } + + QString profileId() const + { + return data.profileId(); + } + + QString profileName() const + { + return data.profileName(); + } + + bool isActive() const; + + bool isMSA() const + { + return data.type == AccountType::MSA; + } + + bool ownsMinecraft() const + { + return data.minecraftEntitlement.ownsMinecraft; + } + + bool hasProfile() const + { + return data.profileId().size() != 0; + } + + QString typeString() const + { + return "msa"; + } + + QPixmap getFace() const; + + //! Returns the current state of the account + AccountState accountState() const; + + AccountData* accountData() + { + return &data; + } + + bool shouldRefresh() const; + + void fillSession(AuthSessionPtr session); + + QString lastError() const + { + return data.lastError(); + } + + signals: + /** + * This signal is emitted when the account changes + */ + void changed(); + + void activityChanged(bool active); + + // TODO: better signalling for the various possible state changes - + // especially errors + + protected: /* variables */ + AccountData data; + + // current task we are executing here + shared_qobject_ptr<AccountTask> m_currentTask; + + protected: /* methods */ + void incrementUses() override; + void decrementUses() override; + + private slots: + void authSucceeded(); + void authFailed(QString reason); +}; diff --git a/meshmc/launcher/minecraft/auth/Parsers.cpp b/meshmc/launcher/minecraft/auth/Parsers.cpp new file mode 100644 index 0000000000..6a4690942c --- /dev/null +++ b/meshmc/launcher/minecraft/auth/Parsers.cpp @@ -0,0 +1,366 @@ +/* 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 "Parsers.h" + +#include <QJsonDocument> +#include <QJsonArray> +#include <QDebug> + +namespace Parsers +{ + + bool getDateTime(QJsonValue value, QDateTime& out) + { + if (!value.isString()) { + return false; + } + out = QDateTime::fromString(value.toString(), Qt::ISODate); + return out.isValid(); + } + + bool getString(QJsonValue value, QString& out) + { + if (!value.isString()) { + return false; + } + out = value.toString(); + return true; + } + + bool getNumber(QJsonValue value, double& out) + { + if (!value.isDouble()) { + return false; + } + out = value.toDouble(); + return true; + } + + bool getNumber(QJsonValue value, int64_t& out) + { + if (!value.isDouble()) { + return false; + } + out = (int64_t)value.toDouble(); + return true; + } + + bool getBool(QJsonValue value, bool& out) + { + if (!value.isBool()) { + return false; + } + out = value.toBool(); + return true; + } + + /* + { + "IssueInstant":"2020-12-07T19:52:08.4463796Z", + "NotAfter":"2020-12-21T19:52:08.4463796Z", + "Token":"token", + "DisplayClaims":{ + "xui":[ + { + "uhs":"userhash" + } + ] + } + } + */ + // TODO: handle error responses ... + /* + { + "Identity":"0", + "XErr":2148916238, + "Message":"", + "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" + } + // 2148916233 = missing XBox account + // 2148916238 = child account not linked to a family + */ + + bool parseXTokenResponse(QByteArray& data, Katabasis::Token& output, + QString name) + { + qDebug() << "Parsing" << name << ":"; +#ifndef NDEBUG + qDebug() << data; +#endif + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "user.auth.xboxlive.com as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if (!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { + qWarning() << "User IssueInstant is not a timestamp"; + return false; + } + if (!getDateTime(obj.value("NotAfter"), output.notAfter)) { + qWarning() << "User NotAfter is not a timestamp"; + return false; + } + if (!getString(obj.value("Token"), output.token)) { + qWarning() << "User Token is not a string"; + return false; + } + auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); + if (!arrayVal.isArray()) { + qWarning() << "Missing xui claims array"; + return false; + } + bool foundUHS = false; + for (auto item : arrayVal.toArray()) { + if (!item.isObject()) { + continue; + } + auto obj = item.toObject(); + if (obj.contains("uhs")) { + foundUHS = true; + } else { + continue; + } + // consume all 'display claims' ... whatever that means + for (auto iter = obj.begin(); iter != obj.end(); iter++) { + QString claim; + if (!getString(obj.value(iter.key()), claim)) { + qWarning() << "display claim " << iter.key() + << " is not a string..."; + return false; + } + output.extra[iter.key()] = claim; + } + + break; + } + if (!foundUHS) { + qWarning() << "Missing uhs"; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << name << "is valid."; + return true; + } + + bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output) + { + qDebug() << "Parsing Minecraft profile..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "user.auth.xboxlive.com as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if (!getString(obj.value("id"), output.id)) { + qWarning() << "Minecraft profile id is not a string"; + return false; + } + + if (!getString(obj.value("name"), output.name)) { + qWarning() << "Minecraft profile name is not a string"; + return false; + } + + auto skinsArray = obj.value("skins").toArray(); + for (auto skin : skinsArray) { + auto skinObj = skin.toObject(); + Skin skinOut; + if (!getString(skinObj.value("id"), skinOut.id)) { + continue; + } + QString state; + if (!getString(skinObj.value("state"), state)) { + continue; + } + if (state != "ACTIVE") { + continue; + } + if (!getString(skinObj.value("url"), skinOut.url)) { + continue; + } + if (!getString(skinObj.value("variant"), skinOut.variant)) { + continue; + } + // we deal with only the active skin + output.skin = skinOut; + break; + } + auto capesArray = obj.value("capes").toArray(); + + QString currentCape; + for (auto cape : capesArray) { + auto capeObj = cape.toObject(); + Cape capeOut; + if (!getString(capeObj.value("id"), capeOut.id)) { + continue; + } + QString state; + if (!getString(capeObj.value("state"), state)) { + continue; + } + if (state == "ACTIVE") { + currentCape = capeOut.id; + } + if (!getString(capeObj.value("url"), capeOut.url)) { + continue; + } + if (!getString(capeObj.value("alias"), capeOut.alias)) { + continue; + } + + output.capes[capeOut.id] = capeOut; + } + output.currentCape = currentCape; + output.validity = Katabasis::Validity::Certain; + return true; + } + + bool parseMinecraftEntitlements(QByteArray& data, + MinecraftEntitlement& output) + { + qDebug() << "Parsing Minecraft entitlements..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "user.auth.xboxlive.com as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + output.canPlayMinecraft = false; + output.ownsMinecraft = false; + + auto itemsArray = obj.value("items").toArray(); + for (auto item : itemsArray) { + auto itemObj = item.toObject(); + QString name; + if (!getString(itemObj.value("name"), name)) { + continue; + } + if (name == "game_minecraft") { + output.canPlayMinecraft = true; + } + if (name == "product_minecraft") { + output.ownsMinecraft = true; + } + } + output.validity = Katabasis::Validity::Certain; + return true; + } + + bool parseRolloutResponse(QByteArray& data, bool& result) + { + qDebug() << "Parsing Rollout response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "https://api.minecraftservices.com/rollout/v1/" + "msamigration as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + QString feature; + if (!getString(obj.value("feature"), feature)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + if (feature != "msamigration") { + qWarning() << "Rollout feature is not what we expected " + "(msamigration), but is instead \"" + << feature << "\""; + return false; + } + if (!getBool(obj.value("rollout"), result)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + return true; + } + + bool parseMojangResponse(QByteArray& data, Katabasis::Token& output) + { + QJsonParseError jsonError; + qDebug() << "Parsing Mojang response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "api.minecraftservices.com/launcher/login as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + double expires_in = 0; + if (!getNumber(obj.value("expires_in"), expires_in)) { + qWarning() << "expires_in is not a valid number"; + return false; + } + auto currentTime = QDateTime::currentDateTimeUtc(); + output.issueInstant = currentTime; + output.notAfter = currentTime.addSecs(expires_in); + + QString username; + if (!getString(obj.value("username"), username)) { + qWarning() << "username is not valid"; + return false; + } + + // TODO: it's a JWT... validate it? + if (!getString(obj.value("access_token"), output.token)) { + qWarning() << "access_token is not valid"; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << "Mojang response is valid."; + return true; + } + +} // namespace Parsers diff --git a/meshmc/launcher/minecraft/auth/Parsers.h b/meshmc/launcher/minecraft/auth/Parsers.h new file mode 100644 index 0000000000..62fd056b92 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/Parsers.h @@ -0,0 +1,42 @@ +/* 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 "AccountData.h" + +namespace Parsers +{ + bool getDateTime(QJsonValue value, QDateTime& out); + bool getString(QJsonValue value, QString& out); + bool getNumber(QJsonValue value, double& out); + bool getNumber(QJsonValue value, int64_t& out); + bool getBool(QJsonValue value, bool& out); + + bool parseXTokenResponse(QByteArray& data, Katabasis::Token& output, + QString name); + bool parseMojangResponse(QByteArray& data, Katabasis::Token& output); + + bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output); + bool parseMinecraftEntitlements(QByteArray& data, + MinecraftEntitlement& output); + bool parseRolloutResponse(QByteArray& data, bool& result); +} // namespace Parsers diff --git a/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp b/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp new file mode 100644 index 0000000000..ef29e9e77f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp @@ -0,0 +1,93 @@ +/* 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 <QNetworkAccessManager> +#include <QNetworkRequest> +#include <QNetworkReply> +#include <QDebug> + +#include "AuthFlow.h" +#include "katabasis/Globals.h" + +#include <Application.h> + +AuthFlow::AuthFlow(AccountData* data, QObject* parent) + : AccountTask(data, parent) +{ +} + +void AuthFlow::succeed() +{ + m_data->validity_ = Katabasis::Validity::Certain; + changeState(AccountTaskState::STATE_SUCCEEDED, + tr("Finished all authentication steps")); +} + +void AuthFlow::executeTask() +{ + if (m_currentStep) { + return; + } + changeState(AccountTaskState::STATE_WORKING, tr("Initializing")); + nextStep(); +} + +void AuthFlow::nextStep() +{ + if (m_steps.size() == 0) { + // we got to the end without an incident... assume this is all. + m_currentStep.reset(); + succeed(); + return; + } + m_currentStep = m_steps.front(); + qDebug() << "AuthFlow:" << m_currentStep->describe(); + m_steps.pop_front(); + connect(m_currentStep.get(), &AuthStep::finished, this, + &AuthFlow::stepFinished); + connect(m_currentStep.get(), &AuthStep::authorizeWithBrowser, this, + &AuthFlow::authorizeWithBrowser); + + m_currentStep->perform(); +} + +QString AuthFlow::getStateMessage() const +{ + switch (m_taskState) { + case AccountTaskState::STATE_WORKING: { + if (m_currentStep) { + return m_currentStep->describe(); + } else { + return tr("Working..."); + } + } + default: { + return AccountTask::getStateMessage(); + } + } +} + +void AuthFlow::stepFinished(AccountTaskState resultingState, QString message) +{ + if (changeState(resultingState, message)) { + nextStep(); + } +} diff --git a/meshmc/launcher/minecraft/auth/flows/AuthFlow.h b/meshmc/launcher/minecraft/auth/flows/AuthFlow.h new file mode 100644 index 0000000000..0a4a431b71 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/AuthFlow.h @@ -0,0 +1,64 @@ +/* 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 <QObject> +#include <QList> +#include <QVector> +#include <QSet> +#include <QNetworkReply> +#include <QImage> + +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/AccountTask.h" +#include "minecraft/auth/AuthStep.h" + +class AuthFlow : public AccountTask +{ + Q_OBJECT + + public: + explicit AuthFlow(AccountData* data, QObject* parent = 0); + + Katabasis::Validity validity() + { + return m_data->validity_; + }; + + QString getStateMessage() const override; + + void executeTask() override; + + signals: + // No extra signals needed - authorizeWithBrowser is on AccountTask + + private slots: + void stepFinished(AccountTaskState resultingState, QString message); + + protected: + void succeed(); + void nextStep(); + + protected: + QList<AuthStep::Ptr> m_steps; + AuthStep::Ptr m_currentStep; +}; diff --git a/meshmc/launcher/minecraft/auth/flows/MSA.cpp b/meshmc/launcher/minecraft/auth/flows/MSA.cpp new file mode 100644 index 0000000000..2b5908932a --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/MSA.cpp @@ -0,0 +1,65 @@ +/* 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 "MSA.h" + +#include "minecraft/auth/steps/MSAStep.h" +#include "minecraft/auth/steps/XboxUserStep.h" +#include "minecraft/auth/steps/XboxAuthorizationStep.h" +#include "minecraft/auth/steps/MeshMCLoginStep.h" +#include "minecraft/auth/steps/XboxProfileStep.h" +#include "minecraft/auth/steps/EntitlementsStep.h" +#include "minecraft/auth/steps/MinecraftProfileStep.h" +#include "minecraft/auth/steps/GetSkinStep.h" + +MSASilent::MSASilent(AccountData* data, QObject* parent) + : AuthFlow(data, parent) +{ + m_steps.append(new MSAStep(m_data, MSAStep::Action::Refresh)); + m_steps.append(new XboxUserStep(m_data)); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken, + "http://xboxlive.com", "Xbox")); + m_steps.append( + new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken, + "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(new MeshMCLoginStep(m_data)); + m_steps.append(new XboxProfileStep(m_data)); + m_steps.append(new EntitlementsStep(m_data)); + m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new GetSkinStep(m_data)); +} + +MSAInteractive::MSAInteractive(AccountData* data, QObject* parent) + : AuthFlow(data, parent) +{ + m_steps.append(new MSAStep(m_data, MSAStep::Action::Login)); + m_steps.append(new XboxUserStep(m_data)); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken, + "http://xboxlive.com", "Xbox")); + m_steps.append( + new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken, + "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(new MeshMCLoginStep(m_data)); + m_steps.append(new XboxProfileStep(m_data)); + m_steps.append(new EntitlementsStep(m_data)); + m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new GetSkinStep(m_data)); +} diff --git a/meshmc/launcher/minecraft/auth/flows/MSA.h b/meshmc/launcher/minecraft/auth/flows/MSA.h new file mode 100644 index 0000000000..cdeb1ff30f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/MSA.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 "AuthFlow.h" + +class MSAInteractive : public AuthFlow +{ + Q_OBJECT + public: + explicit MSAInteractive(AccountData* data, QObject* parent = 0); +}; + +class MSASilent : public AuthFlow +{ + Q_OBJECT + public: + explicit MSASilent(AccountData* data, QObject* parent = 0); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp new file mode 100644 index 0000000000..8d0418042a --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp @@ -0,0 +1,80 @@ +/* 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 "EntitlementsStep.h" + +#include <QNetworkRequest> +#include <QUuid> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +EntitlementsStep::EntitlementsStep(AccountData* data) : AuthStep(data) {} + +EntitlementsStep::~EntitlementsStep() noexcept = default; + +QString EntitlementsStep::describe() +{ + return tr("Determining game ownership."); +} + +void EntitlementsStep::perform() +{ + auto uuid = QUuid::createUuid(); + m_entitlementsRequestId = uuid.toString().remove('{').remove('}'); + auto url = + "https://api.minecraftservices.com/entitlements/license?requestId=" + + m_entitlementsRequestId; + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader( + "Authorization", + QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &EntitlementsStep::onRequestDone); + requestor->get(request); + qDebug() << "Getting entitlements..."; +} + +void EntitlementsStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void EntitlementsStep::onRequestDone( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + + // TODO: check presence of same entitlementsRequestId? + // TODO: validate JWTs? + Parsers::parseMinecraftEntitlements(data, m_data->minecraftEntitlement); + + emit finished(AccountTaskState::STATE_WORKING, tr("Got entitlements")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h new file mode 100644 index 0000000000..bd97a1c59e --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h @@ -0,0 +1,47 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class EntitlementsStep : public AuthStep +{ + Q_OBJECT + + public: + explicit EntitlementsStep(AccountData* data); + virtual ~EntitlementsStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); + + private: + QString m_entitlementsRequestId; +}; diff --git a/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp new file mode 100644 index 0000000000..abf5db950f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp @@ -0,0 +1,64 @@ +/* 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 "GetSkinStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +GetSkinStep::GetSkinStep(AccountData* data) : AuthStep(data) {} + +GetSkinStep::~GetSkinStep() noexcept = default; + +QString GetSkinStep::describe() +{ + return tr("Getting skin."); +} + +void GetSkinStep::perform() +{ + auto url = QUrl(m_data->minecraftProfile.skin.url); + QNetworkRequest request = QNetworkRequest(url); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &GetSkinStep::onRequestDone); + requestor->get(request); +} + +void GetSkinStep::rehydrate() +{ + // NOOP, for now. +} + +void GetSkinStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + if (error == QNetworkReply::NoError) { + m_data->minecraftProfile.skin.data = data; + } + emit finished(AccountTaskState::STATE_SUCCEEDED, tr("Got skin")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h new file mode 100644 index 0000000000..ed6a288cdb --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h @@ -0,0 +1,44 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class GetSkinStep : public AuthStep +{ + Q_OBJECT + + public: + explicit GetSkinStep(AccountData* data); + virtual ~GetSkinStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp b/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp new file mode 100644 index 0000000000..9be4761549 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp @@ -0,0 +1,161 @@ +/* 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 "MSAStep.h" + +#include <QNetworkRequest> +#include <QDesktopServices> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +#include "Application.h" + +MSAStep::MSAStep(AccountData* data, Action action) + : AuthStep(data), m_action(action) +{ + m_replyHandler = new QOAuthHttpServerReplyHandler(this); + m_replyHandler->setCallbackText( + tr("Login successful! You can close this page and return to MeshMC.")); + + m_oauth2 = new QOAuth2AuthorizationCodeFlow(this); + m_oauth2->setClientIdentifier(APPLICATION->msaClientId()); + m_oauth2->setAuthorizationUrl(QUrl( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize")); + m_oauth2->setTokenUrl( + QUrl("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")); + m_oauth2->setScope("XboxLive.signin offline_access"); + m_oauth2->setReplyHandler(m_replyHandler); + m_oauth2->setNetworkAccessManager(APPLICATION->network().get()); + + connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this, + &MSAStep::onGranted); + connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::requestFailed, this, + &MSAStep::onRequestFailed); + connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, + &MSAStep::onOpenBrowser); +} + +MSAStep::~MSAStep() noexcept = default; + +QString MSAStep::describe() +{ + return tr("Logging in with Microsoft account."); +} + +void MSAStep::rehydrate() +{ + switch (m_action) { + case Refresh: { + // TODO: check the tokens and see if they are old (older than a day) + return; + } + case Login: { + // NOOP + return; + } + } +} + +void MSAStep::perform() +{ + switch (m_action) { + case Refresh: { + // Load the refresh token from stored account data + m_oauth2->setRefreshToken(m_data->msaToken.refresh_token); + m_oauth2->refreshTokens(); + return; + } + case Login: { + *m_data = AccountData(); + if (!m_replyHandler->isListening()) { + if (!m_replyHandler->listen(QHostAddress::LocalHost)) { + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Failed to start local HTTP server for " + "OAuth2 callback.")); + return; + } + } + m_oauth2->setModifyParametersFunction( + [](QAbstractOAuth::Stage stage, + QMultiMap<QString, QVariant>* parameters) { + if (stage == + QAbstractOAuth::Stage::RequestingAuthorization) { + parameters->insert("prompt", "select_account"); + } + }); + m_oauth2->grant(); + return; + } + } +} + +void MSAStep::onOpenBrowser(const QUrl& url) +{ + emit authorizeWithBrowser(url); + QDesktopServices::openUrl(url); +} + +void MSAStep::onGranted() +{ + m_replyHandler->close(); + + // Store the tokens in account data + m_data->msaToken.token = m_oauth2->token(); + m_data->msaToken.refresh_token = m_oauth2->refreshToken(); + m_data->msaToken.issueInstant = QDateTime::currentDateTimeUtc(); + m_data->msaToken.notAfter = m_oauth2->expirationAt(); + if (!m_data->msaToken.notAfter.isValid()) { + m_data->msaToken.notAfter = m_data->msaToken.issueInstant.addSecs(3600); + } + m_data->msaToken.validity = Katabasis::Validity::Certain; + + emit finished(AccountTaskState::STATE_WORKING, tr("Got MSA token.")); +} + +void MSAStep::onRequestFailed(QAbstractOAuth::Error error) +{ + m_replyHandler->close(); + + switch (error) { + case QAbstractOAuth::Error::NetworkError: + emit finished( + AccountTaskState::STATE_OFFLINE, + tr("Microsoft authentication failed due to a network error.")); + return; + case QAbstractOAuth::Error::ServerError: + case QAbstractOAuth::Error::OAuthTokenNotFoundError: + case QAbstractOAuth::Error::OAuthTokenSecretNotFoundError: + case QAbstractOAuth::Error::OAuthCallbackNotVerified: + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Microsoft authentication failed.")); + return; + case QAbstractOAuth::Error::ExpiredError: + emit finished(AccountTaskState::STATE_FAILED_GONE, + tr("Microsoft authentication token expired.")); + return; + default: + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Microsoft authentication failed with an " + "unrecognized error.")); + return; + } +} diff --git a/meshmc/launcher/minecraft/auth/steps/MSAStep.h b/meshmc/launcher/minecraft/auth/steps/MSAStep.h new file mode 100644 index 0000000000..2e223024e3 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MSAStep.h @@ -0,0 +1,55 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +#include <QOAuth2AuthorizationCodeFlow> +#include <QOAuthHttpServerReplyHandler> + +class MSAStep : public AuthStep +{ + Q_OBJECT + public: + enum Action { Refresh, Login }; + + public: + explicit MSAStep(AccountData* data, Action action); + virtual ~MSAStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onGranted(); + void onRequestFailed(QAbstractOAuth::Error error); + void onOpenBrowser(const QUrl& url); + + private: + QOAuth2AuthorizationCodeFlow* m_oauth2 = nullptr; + QOAuthHttpServerReplyHandler* m_replyHandler = nullptr; + Action m_action; +}; diff --git a/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp new file mode 100644 index 0000000000..19afcda3fc --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp @@ -0,0 +1,98 @@ +/* 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 "MeshMCLoginStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" +#include "minecraft/auth/AccountTask.h" + +MeshMCLoginStep::MeshMCLoginStep(AccountData* data) : AuthStep(data) {} + +MeshMCLoginStep::~MeshMCLoginStep() noexcept = default; + +QString MeshMCLoginStep::describe() +{ + return tr("Accessing Mojang services."); +} + +void MeshMCLoginStep::perform() +{ + auto requestURL = "https://api.minecraftservices.com/launcher/login"; + auto uhs = m_data->mojangservicesToken.extra["uhs"].toString(); + auto xToken = m_data->mojangservicesToken.token; + + QString mc_auth_template = R"XXX( +{ + "xtoken": "XBL3.0 x=%1;%2", + "platform": "PC_LAUNCHER" +} +)XXX"; + auto requestBody = mc_auth_template.arg(uhs, xToken); + + QNetworkRequest request = QNetworkRequest(QUrl(requestURL)); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &MeshMCLoginStep::onRequestDone); + requestor->post(request, requestBody.toUtf8()); + qDebug() << "Getting Minecraft access token..."; +} + +void MeshMCLoginStep::rehydrate() +{ + // TODO: check the token validity +} + +void MeshMCLoginStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + qDebug() << data; + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; +#ifndef NDEBUG + qDebug() << data; +#endif + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to get Minecraft access token: %1") + .arg(requestor->errorString_)); + return; + } + + if (!Parsers::parseMojangResponse(data, m_data->yggdrasilToken)) { + qWarning() << "Could not parse login_with_xbox response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to parse the Minecraft access token response.")); + return; + } + emit finished(AccountTaskState::STATE_WORKING, tr("")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h new file mode 100644 index 0000000000..859ae867f3 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h @@ -0,0 +1,44 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class MeshMCLoginStep : public AuthStep +{ + Q_OBJECT + + public: + explicit MeshMCLoginStep(AccountData* data); + virtual ~MeshMCLoginStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp new file mode 100644 index 0000000000..9955ff9738 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp @@ -0,0 +1,101 @@ +/* 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 "MinecraftProfileStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +MinecraftProfileStep::MinecraftProfileStep(AccountData* data) : AuthStep(data) +{ +} + +MinecraftProfileStep::~MinecraftProfileStep() noexcept = default; + +QString MinecraftProfileStep::describe() +{ + return tr("Fetching the Minecraft profile."); +} + +void MinecraftProfileStep::perform() +{ + auto url = QUrl("https://api.minecraftservices.com/minecraft/profile"); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader( + "Authorization", + QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &MinecraftProfileStep::onRequestDone); + requestor->get(request); +} + +void MinecraftProfileStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void MinecraftProfileStep::onRequestDone( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + if (error == QNetworkReply::ContentNotFoundError) { + // NOTE: Succeed even if we do not have a profile. This is a valid + // account state. + m_data->minecraftProfile = MinecraftProfile(); + emit finished(AccountTaskState::STATE_SUCCEEDED, + tr("Account has no Minecraft profile.")); + return; + } + if (error != QNetworkReply::NoError) { + qWarning() << "Error getting profile:"; + qWarning() << " HTTP Status: " << requestor->httpStatus_; + qWarning() << " Internal error no.: " << error; + qWarning() << " Error string: " << requestor->errorString_; + + qWarning() << " Response:"; + qWarning() << QString::fromUtf8(data); + + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile acquisition failed.")); + return; + } + if (!Parsers::parseMinecraftProfile(data, m_data->minecraftProfile)) { + m_data->minecraftProfile = MinecraftProfile(); + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile response could not be parsed")); + return; + } + + emit finished(AccountTaskState::STATE_WORKING, + tr("Minecraft Java profile acquisition succeeded.")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h new file mode 100644 index 0000000000..eb0594bdf8 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h @@ -0,0 +1,44 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class MinecraftProfileStep : public AuthStep +{ + Q_OBJECT + + public: + explicit MinecraftProfileStep(AccountData* data); + virtual ~MinecraftProfileStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp new file mode 100644 index 0000000000..b54ad2a32b --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp @@ -0,0 +1,191 @@ +/* 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 "XboxAuthorizationStep.h" + +#include <QNetworkRequest> +#include <QJsonParseError> +#include <QJsonDocument> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +XboxAuthorizationStep::XboxAuthorizationStep(AccountData* data, + Katabasis::Token* token, + QString relyingParty, + QString authorizationKind) + : AuthStep(data), m_token(token), m_relyingParty(relyingParty), + m_authorizationKind(authorizationKind) +{ +} + +XboxAuthorizationStep::~XboxAuthorizationStep() noexcept = default; + +QString XboxAuthorizationStep::describe() +{ + return tr("Getting authorization to access %1 services.") + .arg(m_authorizationKind); +} + +void XboxAuthorizationStep::rehydrate() +{ + // FIXME: check if the tokens are good? +} + +void XboxAuthorizationStep::perform() +{ + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "%2", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = + xbox_auth_template.arg(m_data->userToken.token, m_relyingParty); + // http://xboxlive.com + QNetworkRequest request = + QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &XboxAuthorizationStep::onRequestDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Getting authorization token for " << m_relyingParty; +} + +void XboxAuthorizationStep::onRequestDone( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + if (!processSTSError(error, data, headers)) { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to get authorization for %1 services. Error %1.") + .arg(m_authorizationKind, error)); + } + return; + } + + Katabasis::Token temp; + if (!Parsers::parseXTokenResponse(data, temp, m_authorizationKind)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Could not parse authorization response for access to " + "%1 services.") + .arg(m_authorizationKind)); + return; + } + + if (temp.extra["uhs"] != m_data->userToken.extra["uhs"]) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Server has changed %1 authorization user hash in the " + "reply. Something is wrong.") + .arg(m_authorizationKind)); + return; + } + auto& token = *m_token; + token = temp; + + emit finished(AccountTaskState::STATE_WORKING, + tr("Got authorization to access %1").arg(m_relyingParty)); +} + +bool XboxAuthorizationStep::processSTSError( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + if (error == QNetworkReply::AuthenticationRequiredError) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Cannot parse error XSTS response as JSON: " + << jsonError.errorString(); + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Cannot parse %1 authorization error response as JSON: %2") + .arg(m_authorizationKind, jsonError.errorString())); + return true; + } + + int64_t errorCode = -1; + auto obj = doc.object(); + if (!Parsers::getNumber(obj.value("XErr"), errorCode)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XErr element is missing from %1 authorization " + "error response.") + .arg(m_authorizationKind)); + return true; + } + switch (errorCode) { + case 2148916233: { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account does not have an XBox Live " + "profile. Buy the game on %1 first.") + .arg("<a " + "href=\"https://www.minecraft.net/en-us/store/" + "minecraft-java-edition\">minecraft.net</a>")); + return true; + } + case 2148916235: { + // NOTE: this is the Grulovia error + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XBox Live is not available in your country. " + "You've been blocked.")); + return true; + } + case 2148916238: { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account is underaged and is not linked " + "to a family.\n\nPlease set up your account according " + "to %1.") + .arg( + "<a " + "href=\"https://help.minecraft.net/hc/en-us/" + "articles/4403181904525\">help.minecraft.net</a>")); + return true; + } + default: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XSTS authentication ended with unrecognized " + "error(s):\n\n%1") + .arg(errorCode)); + return true; + } + } + } + return false; +} diff --git a/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h new file mode 100644 index 0000000000..a8413c939f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h @@ -0,0 +1,55 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class XboxAuthorizationStep : public AuthStep +{ + Q_OBJECT + + public: + explicit XboxAuthorizationStep(AccountData* data, Katabasis::Token* token, + QString relyingParty, + QString authorizationKind); + virtual ~XboxAuthorizationStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private: + bool processSTSError(QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers); + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); + + private: + Katabasis::Token* m_token; + QString m_relyingParty; + QString m_authorizationKind; +}; diff --git a/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp new file mode 100644 index 0000000000..aae94b0403 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp @@ -0,0 +1,96 @@ +/* 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 "XboxProfileStep.h" + +#include <QNetworkRequest> +#include <QUrlQuery> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +XboxProfileStep::XboxProfileStep(AccountData* data) : AuthStep(data) {} + +XboxProfileStep::~XboxProfileStep() noexcept = default; + +QString XboxProfileStep::describe() +{ + return tr("Fetching Xbox profile."); +} + +void XboxProfileStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void XboxProfileStep::perform() +{ + auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings"); + QUrlQuery q; + q.addQueryItem( + "settings", + "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw," + "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag," + "ModernGamertagSuffix," + "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep," + "PreferredColor,Location,Bio,Watermarks," + "RealName,RealNameOverride,IsQuarantined"); + url.setQuery(q); + + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("x-xbl-contract-version", "3"); + request.setRawHeader("Authorization", + QString("XBL3.0 x=%1;%2") + .arg(m_data->userToken.extra["uhs"].toString(), + m_data->xboxApiToken.token) + .toUtf8()); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &XboxProfileStep::onRequestDone); + requestor->get(request); + qDebug() << "Getting Xbox profile..."; +} + +void XboxProfileStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; +#ifndef NDEBUG + qDebug() << data; +#endif + finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to retrieve the Xbox profile.")); + return; + } + +#ifndef NDEBUG + qDebug() << "XBox profile: " << data; +#endif + + emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox profile")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h new file mode 100644 index 0000000000..cf2c0c3c9b --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h @@ -0,0 +1,44 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class XboxProfileStep : public AuthStep +{ + Q_OBJECT + + public: + explicit XboxProfileStep(AccountData* data); + virtual ~XboxProfileStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp new file mode 100644 index 0000000000..77afa17fb9 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp @@ -0,0 +1,93 @@ +/* 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 "XboxUserStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +XboxUserStep::XboxUserStep(AccountData* data) : AuthStep(data) {} + +XboxUserStep::~XboxUserStep() noexcept = default; + +QString XboxUserStep::describe() +{ + return tr("Logging in as an Xbox user."); +} + +void XboxUserStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void XboxUserStep::perform() +{ + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=%1" + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->msaToken.token); + + QNetworkRequest request = QNetworkRequest( + QUrl("https://user.auth.xboxlive.com/user/authenticate")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + auto* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &XboxUserStep::onRequestDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "First layer of XBox auth ... commencing."; +} + +void XboxUserStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XBox user authentication failed.")); + return; + } + + Katabasis::Token temp; + if (!Parsers::parseXTokenResponse(data, temp, "UToken")) { + qWarning() << "Could not parse user authentication response..."; + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("XBox user authentication response could not be understood.")); + return; + } + m_data->userToken = temp; + emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox user token")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h new file mode 100644 index 0000000000..d783b534c9 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h @@ -0,0 +1,44 @@ +/* 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class XboxUserStep : public AuthStep +{ + Q_OBJECT + + public: + explicit XboxUserStep(AccountData* data); + virtual ~XboxUserStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/gameoptions/GameOptions.cpp b/meshmc/launcher/minecraft/gameoptions/GameOptions.cpp new file mode 100644 index 0000000000..f799785543 --- /dev/null +++ b/meshmc/launcher/minecraft/gameoptions/GameOptions.cpp @@ -0,0 +1,155 @@ +/* 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 "GameOptions.h" +#include "FileSystem.h" +#include <QDebug> +#include <QSaveFile> + +namespace +{ + bool load(const QString& path, std::vector<GameOptionItem>& contents, + int& version) + { + contents.clear(); + QFile file(path); + if (!file.open(QFile::ReadOnly)) { + qWarning() << "Failed to read options file."; + return false; + } + version = 0; + while (!file.atEnd()) { + auto line = file.readLine(); + if (line.endsWith('\n')) { + line.chop(1); + } + auto separatorIndex = line.indexOf(':'); + if (separatorIndex == -1) { + continue; + } + auto key = QString::fromUtf8(line.data(), separatorIndex); + auto value = QString::fromUtf8(line.data() + separatorIndex + 1, + line.size() - 1 - separatorIndex); + qDebug() << "!!" << key << "!!"; + if (key == "version") { + version = value.toInt(); + continue; + } + contents.emplace_back(GameOptionItem{key, value}); + } + qDebug() << "Loaded" << path << "with version:" << version; + return true; + } + bool save(const QString& path, std::vector<GameOptionItem>& mapping, + int version) + { + QSaveFile out(path); + if (!out.open(QIODevice::WriteOnly)) { + return false; + } + if (version != 0) { + QString versionLine = QString("version:%1\n").arg(version); + out.write(versionLine.toUtf8()); + } + auto iter = mapping.begin(); + while (iter != mapping.end()) { + out.write(iter->key.toUtf8()); + out.write(":"); + out.write(iter->value.toUtf8()); + out.write("\n"); + iter++; + } + return out.commit(); + } +} // namespace + +GameOptions::GameOptions(const QString& path) : path(path) +{ + reload(); +} + +QVariant GameOptions::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (role != Qt::DisplayRole) { + return QAbstractListModel::headerData(section, orientation, role); + } + switch (section) { + case 0: + return tr("Key"); + case 1: + return tr("Value"); + default: + return QVariant(); + } +} + +QVariant GameOptions::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + int column = index.column(); + + if (row < 0 || row >= int(contents.size())) + return QVariant(); + + switch (role) { + case Qt::DisplayRole: + if (column == 0) { + return contents[row].key; + } else { + return contents[row].value; + } + default: + return QVariant(); + } + return QVariant(); +} + +int GameOptions::rowCount(const QModelIndex&) const +{ + return contents.size(); +} + +int GameOptions::columnCount(const QModelIndex&) const +{ + return 2; +} + +bool GameOptions::isLoaded() const +{ + return loaded; +} + +bool GameOptions::reload() +{ + beginResetModel(); + loaded = load(path, contents, version); + endResetModel(); + return loaded; +} + +bool GameOptions::save() +{ + return ::save(path, contents, version); +} diff --git a/meshmc/launcher/minecraft/gameoptions/GameOptions.h b/meshmc/launcher/minecraft/gameoptions/GameOptions.h new file mode 100644 index 0000000000..69197178a7 --- /dev/null +++ b/meshmc/launcher/minecraft/gameoptions/GameOptions.h @@ -0,0 +1,56 @@ +/* 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 <map> +#include <QString> +#include <QAbstractListModel> + +struct GameOptionItem { + QString key; + QString value; +}; + +class GameOptions : public QAbstractListModel +{ + Q_OBJECT + public: + explicit GameOptions(const QString& path); + virtual ~GameOptions() = default; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + + bool isLoaded() const; + bool reload(); + bool save(); + + private: + std::vector<GameOptionItem> contents; + bool loaded = false; + QString path; + int version = 0; +}; diff --git a/meshmc/launcher/minecraft/launch/ClaimAccount.cpp b/meshmc/launcher/minecraft/launch/ClaimAccount.cpp new file mode 100644 index 0000000000..a1ed6fdb69 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ClaimAccount.cpp @@ -0,0 +1,49 @@ +/* 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 "ClaimAccount.h" +#include <launch/LaunchTask.h> + +#include "Application.h" +#include "minecraft/auth/AccountList.h" + +ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session) + : LaunchStep(parent) +{ + if (session->status == AuthSession::Status::PlayableOnline && + !session->demo) { + auto accounts = APPLICATION->accounts(); + m_account = accounts->getAccountByProfileName(session->player_name); + } +} + +void ClaimAccount::executeTask() +{ + if (m_account) { + lock.reset(new UseLock(m_account)); + emitSucceeded(); + } +} + +void ClaimAccount::finalize() +{ + lock.reset(); +} diff --git a/meshmc/launcher/minecraft/launch/ClaimAccount.h b/meshmc/launcher/minecraft/launch/ClaimAccount.h new file mode 100644 index 0000000000..d3f64bd8ed --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ClaimAccount.h @@ -0,0 +1,61 @@ +/* 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 <launch/LaunchStep.h> +#include <minecraft/auth/MinecraftAccount.h> + +class ClaimAccount : public LaunchStep +{ + Q_OBJECT + public: + explicit ClaimAccount(LaunchTask* parent, AuthSessionPtr session); + virtual ~ClaimAccount() {}; + + void executeTask() override; + void finalize() override; + bool canAbort() const override + { + return false; + } + + private: + std::unique_ptr<UseLock> lock; + MinecraftAccountPtr m_account; +}; diff --git a/meshmc/launcher/minecraft/launch/CreateGameFolders.cpp b/meshmc/launcher/minecraft/launch/CreateGameFolders.cpp new file mode 100644 index 0000000000..32cd010905 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/CreateGameFolders.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 "CreateGameFolders.h" +#include "minecraft/MinecraftInstance.h" +#include "launch/LaunchTask.h" +#include "FileSystem.h" + +CreateGameFolders::CreateGameFolders(LaunchTask* parent) : LaunchStep(parent) {} + +void CreateGameFolders::executeTask() +{ + auto instance = m_parent->instance(); + std::shared_ptr<MinecraftInstance> minecraftInstance = + std::dynamic_pointer_cast<MinecraftInstance>(instance); + + if (!FS::ensureFolderPathExists(minecraftInstance->gameRoot())) { + emit logLine("Couldn't create the main game folder", + MessageLevel::Error); + emitFailed(tr("Couldn't create the main game folder")); + return; + } + + // HACK: this is a workaround for MCL-3732 - 'server-resource-packs' folder + // is created. + if (!FS::ensureFolderPathExists(FS::PathCombine( + minecraftInstance->gameRoot(), "server-resource-packs"))) { + emit logLine("Couldn't create the 'server-resource-packs' folder", + MessageLevel::Error); + } + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/launch/CreateGameFolders.h b/meshmc/launcher/minecraft/launch/CreateGameFolders.h new file mode 100644 index 0000000000..76a9225c21 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/CreateGameFolders.h @@ -0,0 +1,58 @@ +/* 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 <launch/LaunchStep.h> +#include <LoggedProcess.h> +#include <minecraft/auth/AuthSession.h> + +// Create the main .minecraft for the instance and any other necessary folders +class CreateGameFolders : public LaunchStep +{ + Q_OBJECT + public: + explicit CreateGameFolders(LaunchTask* parent); + virtual ~CreateGameFolders() {}; + + virtual void executeTask(); + virtual bool canAbort() const + { + return false; + } +}; diff --git a/meshmc/launcher/minecraft/launch/DirectJavaLaunch.cpp b/meshmc/launcher/minecraft/launch/DirectJavaLaunch.cpp new file mode 100644 index 0000000000..c1dc249eac --- /dev/null +++ b/meshmc/launcher/minecraft/launch/DirectJavaLaunch.cpp @@ -0,0 +1,173 @@ +/* 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 "DirectJavaLaunch.h" +#include <launch/LaunchTask.h> +#include <minecraft/MinecraftInstance.h> +#include <FileSystem.h> +#include <Commandline.h> +#include <QStandardPaths> + +DirectJavaLaunch::DirectJavaLaunch(LaunchTask* parent) : LaunchStep(parent) +{ + connect(&m_process, &LoggedProcess::log, this, &DirectJavaLaunch::logLines); + connect(&m_process, &LoggedProcess::stateChanged, this, + &DirectJavaLaunch::on_state); +} + +void DirectJavaLaunch::executeTask() +{ + auto instance = m_parent->instance(); + std::shared_ptr<MinecraftInstance> minecraftInstance = + std::dynamic_pointer_cast<MinecraftInstance>(instance); + QStringList args = minecraftInstance->javaArguments(); + + args.append("-Djava.library.path=" + minecraftInstance->getNativePath()); + + auto classPathEntries = minecraftInstance->getClassPath(); + args.append("-cp"); + QString classpath; +#ifdef Q_OS_WIN32 + classpath = classPathEntries.join(';'); +#else + classpath = classPathEntries.join(':'); +#endif + args.append(classpath); + args.append(minecraftInstance->getMainClass()); + + QString allArgs = args.join(", "); + emit logLine("Java Arguments:\n[" + m_parent->censorPrivateInfo(allArgs) + + "]\n\n", + MessageLevel::MeshMC); + + auto javaPath = + FS::ResolveExecutable(instance->settings()->get("JavaPath").toString()); + + m_process.setProcessEnvironment(instance->createEnvironment()); + + // make detachable - this will keep the process running even if the object + // is destroyed + m_process.setDetachable(true); + + auto mcArgs = + minecraftInstance->processMinecraftArgs(m_session, m_serverToJoin); + args.append(mcArgs); + + QString wrapperCommandStr = instance->getWrapperCommand().trimmed(); + if (!wrapperCommandStr.isEmpty()) { + auto wrapperArgs = Commandline::splitArgs(wrapperCommandStr); + auto wrapperCommand = wrapperArgs.takeFirst(); + auto realWrapperCommand = + QStandardPaths::findExecutable(wrapperCommand); + if (realWrapperCommand.isEmpty()) { + const char* reason = + QT_TR_NOOP("The wrapper command \"%1\" couldn't be found."); + emit logLine(QString(reason).arg(wrapperCommand), + MessageLevel::Fatal); + emitFailed(tr(reason).arg(wrapperCommand)); + return; + } + emit logLine("Wrapper command is:\n" + wrapperCommandStr + "\n\n", + MessageLevel::MeshMC); + args.prepend(javaPath); + m_process.start(wrapperCommand, wrapperArgs + args); + } else { + m_process.start(javaPath, args); + } +} + +void DirectJavaLaunch::on_state(LoggedProcess::State state) +{ + switch (state) { + case LoggedProcess::FailedToStart: { + //: Error message displayed if instance can't start + const char* reason = QT_TR_NOOP("Could not launch minecraft!"); + emit logLine(reason, MessageLevel::Fatal); + emitFailed(tr(reason)); + return; + } + case LoggedProcess::Aborted: + case LoggedProcess::Crashed: { + m_parent->setPid(-1); + emitFailed(tr("Game crashed.")); + return; + } + case LoggedProcess::Finished: { + m_parent->setPid(-1); + // if the exit code wasn't 0, report this as a crash + auto exitCode = m_process.exitCode(); + if (exitCode != 0) { + emitFailed(tr("Game crashed.")); + return; + } + // FIXME: make this work again + // m_postlaunchprocess.processEnvironment().insert("INST_EXITCODE", + // QString(exitCode)); run post-exit + emitSucceeded(); + break; + } + case LoggedProcess::Running: + emit logLine(QString("Minecraft process ID: %1\n\n") + .arg(m_process.processId()), + MessageLevel::MeshMC); + m_parent->setPid(m_process.processId()); + m_parent->instance()->setLastLaunch(); + break; + default: + break; + } +} + +void DirectJavaLaunch::setWorkingDirectory(const QString& wd) +{ + m_process.setWorkingDirectory(wd); +} + +void DirectJavaLaunch::proceed() +{ + // nil +} + +bool DirectJavaLaunch::abort() +{ + auto state = m_process.state(); + if (state == LoggedProcess::Running || state == LoggedProcess::Starting) { + m_process.kill(); + } + return true; +} diff --git a/meshmc/launcher/minecraft/launch/DirectJavaLaunch.h b/meshmc/launcher/minecraft/launch/DirectJavaLaunch.h new file mode 100644 index 0000000000..f696e999ea --- /dev/null +++ b/meshmc/launcher/minecraft/launch/DirectJavaLaunch.h @@ -0,0 +1,80 @@ +/* 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 <launch/LaunchStep.h> +#include <LoggedProcess.h> +#include <minecraft/auth/AuthSession.h> + +#include "MinecraftServerTarget.h" + +class DirectJavaLaunch : public LaunchStep +{ + Q_OBJECT + public: + explicit DirectJavaLaunch(LaunchTask* parent); + virtual ~DirectJavaLaunch() {}; + + virtual void executeTask(); + virtual bool abort(); + virtual void proceed(); + virtual bool canAbort() const + { + return true; + } + void setWorkingDirectory(const QString& wd); + void setAuthSession(AuthSessionPtr session) + { + m_session = session; + } + + void setServerToJoin(MinecraftServerTargetPtr serverToJoin) + { + m_serverToJoin = std::move(serverToJoin); + } + + private slots: + void on_state(LoggedProcess::State state); + + private: + LoggedProcess m_process; + QString m_command; + AuthSessionPtr m_session; + MinecraftServerTargetPtr m_serverToJoin; +}; diff --git a/meshmc/launcher/minecraft/launch/ExtractNatives.cpp b/meshmc/launcher/minecraft/launch/ExtractNatives.cpp new file mode 100644 index 0000000000..706820468b --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ExtractNatives.cpp @@ -0,0 +1,133 @@ +/* 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 "ExtractNatives.h" +#include <minecraft/MinecraftInstance.h> +#include <launch/LaunchTask.h> + +#include "MMCZip.h" +#include "FileSystem.h" +#include <QDir> + +#ifdef major +#undef major +#endif +#ifdef minor +#undef minor +#endif + +static QString replaceSuffix(QString target, const QString& suffix, + const QString& replacement) +{ + if (!target.endsWith(suffix)) { + return target; + } + target.resize(target.length() - suffix.length()); + return target + replacement; +} + +static bool unzipNatives(QString source, QString targetFolder, + bool applyJnilibHack, bool nativeOpenAL, + bool nativeGLFW) +{ + QDir directory(targetFolder); + QStringList entries = MMCZip::listEntries(source); + if (entries.isEmpty()) { + return false; + } + for (const auto& name : entries) { + auto lowercase = name.toLower(); + if (nativeGLFW && name.contains("glfw")) { + continue; + } + if (nativeOpenAL && name.contains("openal")) { + continue; + } + // Skip directories + if (name.endsWith('/')) + continue; + + QString outName = name; + if (applyJnilibHack) { + outName = replaceSuffix(outName, ".jnilib", ".dylib"); + } + QString absFilePath = directory.absoluteFilePath(outName); + if (!MMCZip::extractRelFile(source, name, absFilePath)) { + return false; + } + } + return true; +} + +void ExtractNatives::executeTask() +{ + auto instance = m_parent->instance(); + std::shared_ptr<MinecraftInstance> minecraftInstance = + std::dynamic_pointer_cast<MinecraftInstance>(instance); + auto toExtract = minecraftInstance->getNativeJars(); + if (toExtract.isEmpty()) { + emitSucceeded(); + return; + } + auto settings = minecraftInstance->settings(); + bool nativeOpenAL = settings->get("UseNativeOpenAL").toBool(); + bool nativeGLFW = settings->get("UseNativeGLFW").toBool(); + + auto outputPath = minecraftInstance->getNativePath(); + auto javaVersion = minecraftInstance->getJavaVersion(); + bool jniHackEnabled = javaVersion.major() >= 8; + for (const auto& source : toExtract) { + if (!unzipNatives(source, outputPath, jniHackEnabled, nativeOpenAL, + nativeGLFW)) { + const char* reason = QT_TR_NOOP( + "Couldn't extract native jar '%1' to destination '%2'"); + emit logLine(QString(reason).arg(source, outputPath), + MessageLevel::Fatal); + emitFailed(tr(reason).arg(source, outputPath)); + } + } + emitSucceeded(); +} + +void ExtractNatives::finalize() +{ + auto instance = m_parent->instance(); + QString target_dir = FS::PathCombine(instance->instanceRoot(), "natives/"); + QDir dir(target_dir); + dir.removeRecursively(); +} diff --git a/meshmc/launcher/minecraft/launch/ExtractNatives.h b/meshmc/launcher/minecraft/launch/ExtractNatives.h new file mode 100644 index 0000000000..0550603f7e --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ExtractNatives.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/>. + * + * 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 <launch/LaunchStep.h> +#include <memory> +#include "minecraft/auth/AuthSession.h" + +// FIXME: temporary wrapper for existing task. +class ExtractNatives : public LaunchStep +{ + Q_OBJECT + public: + explicit ExtractNatives(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ExtractNatives() {}; + + void executeTask() override; + bool canAbort() const override + { + return false; + } + void finalize() override; +}; diff --git a/meshmc/launcher/minecraft/launch/MeshMCPartLaunch.cpp b/meshmc/launcher/minecraft/launch/MeshMCPartLaunch.cpp new file mode 100644 index 0000000000..83f8c4436d --- /dev/null +++ b/meshmc/launcher/minecraft/launch/MeshMCPartLaunch.cpp @@ -0,0 +1,237 @@ +/* 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 "MeshMCPartLaunch.h" + +#include <QStandardPaths> + +#include "launch/LaunchTask.h" +#include "minecraft/MinecraftInstance.h" +#include "FileSystem.h" +#include "Commandline.h" +#include "Application.h" + +MeshMCPartLaunch::MeshMCPartLaunch(LaunchTask* parent) : LaunchStep(parent) +{ + connect(&m_process, &LoggedProcess::log, this, &MeshMCPartLaunch::logLines); + connect(&m_process, &LoggedProcess::stateChanged, this, + &MeshMCPartLaunch::on_state); +} + +#ifdef Q_OS_WIN +// returns 8.3 file format from long path +#include <windows.h> +QString shortPathName(const QString& file) +{ + auto input = file.toStdWString(); + std::wstring output; + long length = GetShortPathNameW(input.c_str(), NULL, 0); + // NOTE: this resizing might seem weird... + // when GetShortPathNameW fails, it returns length including null character + // when it succeeds, it returns length excluding null character + // See: + // https://msdn.microsoft.com/en-us/library/windows/desktop/aa364989(v=vs.85).aspx + output.resize(length); + GetShortPathNameW(input.c_str(), (LPWSTR)output.c_str(), length); + output.resize(length - 1); + QString ret = QString::fromStdWString(output); + return ret; +} +#endif + +// if the string survives roundtrip through local 8bit encoding... +bool fitsInLocal8bit(const QString& string) +{ + return string == QString::fromLocal8Bit(string.toLocal8Bit()); +} + +void MeshMCPartLaunch::executeTask() +{ + auto instance = m_parent->instance(); + std::shared_ptr<MinecraftInstance> minecraftInstance = + std::dynamic_pointer_cast<MinecraftInstance>(instance); + + m_launchScript = + minecraftInstance->createLaunchScript(m_session, m_serverToJoin); + QStringList args = minecraftInstance->javaArguments(); + QString allArgs = args.join(", "); + emit logLine("Java Arguments:\n[" + m_parent->censorPrivateInfo(allArgs) + + "]\n\n", + MessageLevel::MeshMC); + + auto javaPath = + FS::ResolveExecutable(instance->settings()->get("JavaPath").toString()); + + m_process.setProcessEnvironment(instance->createEnvironment()); + + // make detachable - this will keep the process running even if the object + // is destroyed + m_process.setDetachable(true); + + auto classPath = minecraftInstance->getClassPath(); + classPath.prepend( + FS::PathCombine(APPLICATION->getJarsPath(), "NewLaunch.jar")); + + auto natPath = minecraftInstance->getNativePath(); +#ifdef Q_OS_WIN + if (!fitsInLocal8bit(natPath)) { + args << "-Djava.library.path=" + shortPathName(natPath); + } else { + args << "-Djava.library.path=" + natPath; + } +#else + args << "-Djava.library.path=" + natPath; +#endif + + args << "-cp"; +#ifdef Q_OS_WIN + QStringList processed; + for (auto& item : classPath) { + if (!fitsInLocal8bit(item)) { + processed << shortPathName(item); + } else { + processed << item; + } + } + args << processed.join(';'); +#else + args << classPath.join(':'); +#endif + args << "org.projecttick.EntryPoint"; + + qDebug() << args.join(' '); + + QString wrapperCommandStr = instance->getWrapperCommand().trimmed(); + if (!wrapperCommandStr.isEmpty()) { + auto wrapperArgs = Commandline::splitArgs(wrapperCommandStr); + auto wrapperCommand = wrapperArgs.takeFirst(); + auto realWrapperCommand = + QStandardPaths::findExecutable(wrapperCommand); + if (realWrapperCommand.isEmpty()) { + const char* reason = + QT_TR_NOOP("The wrapper command \"%1\" couldn't be found."); + emit logLine(QString(reason).arg(wrapperCommand), + MessageLevel::Fatal); + emitFailed(tr(reason).arg(wrapperCommand)); + return; + } + emit logLine("Wrapper command is:\n" + wrapperCommandStr + "\n\n", + MessageLevel::MeshMC); + args.prepend(javaPath); + m_process.start(wrapperCommand, wrapperArgs + args); + } else { + m_process.start(javaPath, args); + } +} + +void MeshMCPartLaunch::on_state(LoggedProcess::State state) +{ + switch (state) { + case LoggedProcess::FailedToStart: { + //: Error message displayed if instace can't start + const char* reason = QT_TR_NOOP("Could not launch minecraft!"); + emit logLine(reason, MessageLevel::Fatal); + emitFailed(tr(reason)); + return; + } + case LoggedProcess::Aborted: + case LoggedProcess::Crashed: { + m_parent->setPid(-1); + emitFailed(tr("Game crashed.")); + return; + } + case LoggedProcess::Finished: { + m_parent->setPid(-1); + // if the exit code wasn't 0, report this as a crash + auto exitCode = m_process.exitCode(); + if (exitCode != 0) { + emitFailed(tr("Game crashed.")); + return; + } + // FIXME: make this work again + // m_postlaunchprocess.processEnvironment().insert("INST_EXITCODE", + // QString(exitCode)); run post-exit + emitSucceeded(); + break; + } + case LoggedProcess::Running: + emit logLine(QString("Minecraft process ID: %1\n\n") + .arg(m_process.processId()), + MessageLevel::MeshMC); + m_parent->setPid(m_process.processId()); + m_parent->instance()->setLastLaunch(); + // send the launch script to MeshMC part + m_process.write(m_launchScript.toUtf8()); + + mayProceed = true; + emit readyForLaunch(); + break; + default: + break; + } +} + +void MeshMCPartLaunch::setWorkingDirectory(const QString& wd) +{ + m_process.setWorkingDirectory(wd); +} + +void MeshMCPartLaunch::proceed() +{ + if (mayProceed) { + QString launchString("launch\n"); + m_process.write(launchString.toUtf8()); + mayProceed = false; + } +} + +bool MeshMCPartLaunch::abort() +{ + if (mayProceed) { + mayProceed = false; + QString launchString("abort\n"); + m_process.write(launchString.toUtf8()); + } else { + auto state = m_process.state(); + if (state == LoggedProcess::Running || + state == LoggedProcess::Starting) { + m_process.kill(); + } + } + return true; +} diff --git a/meshmc/launcher/minecraft/launch/MeshMCPartLaunch.h b/meshmc/launcher/minecraft/launch/MeshMCPartLaunch.h new file mode 100644 index 0000000000..ff4285365f --- /dev/null +++ b/meshmc/launcher/minecraft/launch/MeshMCPartLaunch.h @@ -0,0 +1,83 @@ +/* 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 <launch/LaunchStep.h> +#include <LoggedProcess.h> +#include <minecraft/auth/AuthSession.h> + +#include "MinecraftServerTarget.h" + +class MeshMCPartLaunch : public LaunchStep +{ + Q_OBJECT + public: + explicit MeshMCPartLaunch(LaunchTask* parent); + virtual ~MeshMCPartLaunch() {}; + + virtual void executeTask(); + virtual bool abort(); + virtual void proceed(); + virtual bool canAbort() const + { + return true; + } + void setWorkingDirectory(const QString& wd); + void setAuthSession(AuthSessionPtr session) + { + m_session = session; + } + + void setServerToJoin(MinecraftServerTargetPtr serverToJoin) + { + m_serverToJoin = std::move(serverToJoin); + } + + private slots: + void on_state(LoggedProcess::State state); + + private: + LoggedProcess m_process; + QString m_command; + AuthSessionPtr m_session; + QString m_launchScript; + MinecraftServerTargetPtr m_serverToJoin; + + bool mayProceed = false; +}; diff --git a/meshmc/launcher/minecraft/launch/MinecraftServerTarget.cpp b/meshmc/launcher/minecraft/launch/MinecraftServerTarget.cpp new file mode 100644 index 0000000000..a3443ce6a0 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/MinecraftServerTarget.cpp @@ -0,0 +1,85 @@ +/* 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 "MinecraftServerTarget.h" + +#include <QStringList> + +// FIXME: the way this is written, it can't ever do any sort of validation and +// can accept total junk +MinecraftServerTarget MinecraftServerTarget::parse(const QString& fullAddress) +{ + QStringList split = fullAddress.split(":"); + + // The logic below replicates the exact logic minecraft uses for parsing + // server addresses. While the conversion is not lossless and eats errors, + // it ensures the same behavior within Minecraft and MeshMC when entering + // server addresses. + if (fullAddress.startsWith("[")) { + int bracket = fullAddress.indexOf("]"); + if (bracket > 0) { + QString ipv6 = fullAddress.mid(1, bracket - 1); + QString port = fullAddress.mid(bracket + 1).trimmed(); + + if (port.startsWith(":") && !ipv6.isEmpty()) { + port = port.mid(1); + split = QStringList({ipv6, port}); + } else { + split = QStringList({ipv6}); + } + } + } + + if (split.size() > 2) { + split = QStringList({fullAddress}); + } + + QString realAddress = split[0]; + + quint16 realPort = 25565; + if (split.size() > 1) { + bool ok; + realPort = split[1].toUInt(&ok); + + if (!ok) { + realPort = 25565; + } + } + + return MinecraftServerTarget{realAddress, realPort}; +} diff --git a/meshmc/launcher/minecraft/launch/MinecraftServerTarget.h b/meshmc/launcher/minecraft/launch/MinecraftServerTarget.h new file mode 100644 index 0000000000..ae453d637d --- /dev/null +++ b/meshmc/launcher/minecraft/launch/MinecraftServerTarget.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/>. + * + * 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 <memory> + +#include <QString> + +struct MinecraftServerTarget { + QString address; + quint16 port; + + static MinecraftServerTarget parse(const QString& fullAddress); +}; + +typedef std::shared_ptr<MinecraftServerTarget> MinecraftServerTargetPtr; diff --git a/meshmc/launcher/minecraft/launch/ModMinecraftJar.cpp b/meshmc/launcher/minecraft/launch/ModMinecraftJar.cpp new file mode 100644 index 0000000000..72da25ec64 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ModMinecraftJar.cpp @@ -0,0 +1,103 @@ +/* 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 "ModMinecraftJar.h" +#include "launch/LaunchTask.h" +#include "MMCZip.h" +#include "minecraft/OpSys.h" +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +void ModMinecraftJar::executeTask() +{ + auto m_inst = + std::dynamic_pointer_cast<MinecraftInstance>(m_parent->instance()); + + if (!m_inst->getJarMods().size()) { + emitSucceeded(); + return; + } + // nuke obsolete stripped jar(s) if needed + if (!FS::ensureFolderPathExists(m_inst->binRoot())) { + emitFailed(tr("Couldn't create the bin folder for Minecraft.jar")); + } + + auto finalJarPath = + QDir(m_inst->binRoot()).absoluteFilePath("minecraft.jar"); + if (!removeJar()) { + emitFailed(tr("Couldn't remove stale jar file: %1").arg(finalJarPath)); + } + + // create temporary modded jar, if needed + auto components = m_inst->getPackProfile(); + auto profile = components->getProfile(); + auto jarMods = m_inst->getJarMods(); + if (jarMods.size()) { + auto mainJar = profile->getMainJar(); + QStringList jars, temp1, temp2, temp3, temp4; + mainJar->getApplicableFiles(currentSystem, jars, temp1, temp2, temp3, + m_inst->getLocalLibraryPath()); + auto sourceJarPath = jars[0]; + if (!MMCZip::createModdedJar(sourceJarPath, finalJarPath, jarMods)) { + emitFailed(tr("Failed to create the custom Minecraft jar file.")); + return; + } + } + emitSucceeded(); +} + +void ModMinecraftJar::finalize() +{ + removeJar(); +} + +bool ModMinecraftJar::removeJar() +{ + auto m_inst = + std::dynamic_pointer_cast<MinecraftInstance>(m_parent->instance()); + auto finalJarPath = + QDir(m_inst->binRoot()).absoluteFilePath("minecraft.jar"); + QFile finalJar(finalJarPath); + if (finalJar.exists()) { + if (!finalJar.remove()) { + return false; + } + } + return true; +} diff --git a/meshmc/launcher/minecraft/launch/ModMinecraftJar.h b/meshmc/launcher/minecraft/launch/ModMinecraftJar.h new file mode 100644 index 0000000000..9453dd233e --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ModMinecraftJar.h @@ -0,0 +1,60 @@ +/* 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 <launch/LaunchStep.h> +#include <memory> + +class ModMinecraftJar : public LaunchStep +{ + Q_OBJECT + public: + explicit ModMinecraftJar(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ModMinecraftJar() {}; + + virtual void executeTask() override; + virtual bool canAbort() const override + { + return false; + } + void finalize() override; + + private: + bool removeJar(); +}; diff --git a/meshmc/launcher/minecraft/launch/PrintInstanceInfo.cpp b/meshmc/launcher/minecraft/launch/PrintInstanceInfo.cpp new file mode 100644 index 0000000000..b7fabeb606 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/PrintInstanceInfo.cpp @@ -0,0 +1,166 @@ +/* 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 <fstream> +#include <string> + +#include "PrintInstanceInfo.h" +#include <launch/LaunchTask.h> + +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) +namespace +{ +#if defined(Q_OS_LINUX) + void probeProcCpuinfo(QStringList& log) + { + std::ifstream cpuin("/proc/cpuinfo"); + for (std::string line; std::getline(cpuin, line);) { + if (strncmp(line.c_str(), "model name", 10) == 0) { + log << QString::fromStdString( + line.substr(13, std::string::npos)); + break; + } + } + } + + void runLspci(QStringList& log) + { + // FIXME: fixed size buffers... + char buff[512]; + int gpuline = -1; + int cline = 0; + FILE* lspci = popen("lspci -k", "r"); + + if (!lspci) + return; + + while (fgets(buff, 512, lspci) != NULL) { + std::string str(buff); + if (str.length() < 9) + continue; + if (str.substr(8, 3) == "VGA") { + gpuline = cline; + log << QString::fromStdString( + str.substr(35, std::string::npos)); + } + if (gpuline > -1 && gpuline != cline) { + if (cline - gpuline < 3) { + log << QString::fromStdString( + str.substr(1, std::string::npos)); + } + } + cline++; + } + pclose(lspci); + } +#elif defined(Q_OS_FREEBSD) + void runSysctlHwModel(QStringList& log) + { + char buff[512]; + FILE* hwmodel = popen("sysctl hw.model", "r"); + while (fgets(buff, 512, hwmodel) != NULL) { + log << QString::fromUtf8(buff); + break; + } + pclose(hwmodel); + } + + void runPciconf(QStringList& log) + { + char buff[512]; + std::string strcard; + FILE* pciconf = popen("pciconf -lv -a vgapci0", "r"); + while (fgets(buff, 512, pciconf) != NULL) { + if (strncmp(buff, " vendor", 10) == 0) { + std::string str(buff); + strcard.append(str.substr(str.find_first_of("'") + 1, + str.find_last_not_of("'") - + (str.find_first_of("'") + 2))); + strcard.append(" "); + } else if (strncmp(buff, " device", 10) == 0) { + std::string str2(buff); + strcard.append(str2.substr(str2.find_first_of("'") + 1, + str2.find_last_not_of("'") - + (str2.find_first_of("'") + 2))); + } + log << QString::fromStdString(strcard); + break; + } + pclose(pciconf); + } +#endif + void runGlxinfo(QStringList& log) + { + // FIXME: fixed size buffers... + char buff[512]; + FILE* glxinfo = popen("glxinfo", "r"); + if (!glxinfo) + return; + + while (fgets(buff, 512, glxinfo) != NULL) { + if (strncmp(buff, "OpenGL version string:", 22) == 0) { + log << QString::fromUtf8(buff); + break; + } + } + pclose(glxinfo); + } + +} // namespace +#endif + +void PrintInstanceInfo::executeTask() +{ + auto instance = m_parent->instance(); + QStringList log; + +#if defined(Q_OS_LINUX) + ::probeProcCpuinfo(log); + ::runLspci(log); + ::runGlxinfo(log); +#elif defined(Q_OS_FREEBSD) + ::runSysctlHwModel(log); + ::runPciconf(log); + ::runGlxinfo(log); +#endif + + logLines(log, MessageLevel::MeshMC); + logLines(instance->verboseDescription(m_session, m_serverToJoin), + MessageLevel::MeshMC); + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/launch/PrintInstanceInfo.h b/meshmc/launcher/minecraft/launch/PrintInstanceInfo.h new file mode 100644 index 0000000000..572c5c84c7 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/PrintInstanceInfo.h @@ -0,0 +1,66 @@ +/* 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 <launch/LaunchStep.h> +#include <memory> +#include "minecraft/auth/AuthSession.h" +#include "minecraft/launch/MinecraftServerTarget.h" + +// FIXME: temporary wrapper for existing task. +class PrintInstanceInfo : public LaunchStep +{ + Q_OBJECT + public: + explicit PrintInstanceInfo(LaunchTask* parent, AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) + : LaunchStep(parent), m_session(session), + m_serverToJoin(serverToJoin) {}; + virtual ~PrintInstanceInfo() {}; + + virtual void executeTask(); + virtual bool canAbort() const + { + return false; + } + + private: + AuthSessionPtr m_session; + MinecraftServerTargetPtr m_serverToJoin; +}; diff --git a/meshmc/launcher/minecraft/launch/ReconstructAssets.cpp b/meshmc/launcher/minecraft/launch/ReconstructAssets.cpp new file mode 100644 index 0000000000..95c9bedda3 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ReconstructAssets.cpp @@ -0,0 +1,61 @@ +/* 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 "ReconstructAssets.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "minecraft/AssetsUtils.h" +#include "launch/LaunchTask.h" + +void ReconstructAssets::executeTask() +{ + auto instance = m_parent->instance(); + std::shared_ptr<MinecraftInstance> minecraftInstance = + std::dynamic_pointer_cast<MinecraftInstance>(instance); + auto components = minecraftInstance->getPackProfile(); + auto profile = components->getProfile(); + auto assets = profile->getMinecraftAssets(); + + if (!AssetsUtils::reconstructAssets(assets->id, + minecraftInstance->resourcesDir())) { + emit logLine("Failed to reconstruct Minecraft assets.", + MessageLevel::Error); + } + + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/launch/ReconstructAssets.h b/meshmc/launcher/minecraft/launch/ReconstructAssets.h new file mode 100644 index 0000000000..1f080485c0 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ReconstructAssets.h @@ -0,0 +1,56 @@ +/* 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 <launch/LaunchStep.h> +#include <memory> + +class ReconstructAssets : public LaunchStep +{ + Q_OBJECT + public: + explicit ReconstructAssets(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ReconstructAssets() {}; + + void executeTask() override; + bool canAbort() const override + { + return false; + } +}; diff --git a/meshmc/launcher/minecraft/launch/ScanModFolders.cpp b/meshmc/launcher/minecraft/launch/ScanModFolders.cpp new file mode 100644 index 0000000000..e9829718c7 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ScanModFolders.cpp @@ -0,0 +1,85 @@ +/* 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 "ScanModFolders.h" +#include "launch/LaunchTask.h" +#include "MMCZip.h" +#include "minecraft/OpSys.h" +#include "FileSystem.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/mod/ModFolderModel.h" + +void ScanModFolders::executeTask() +{ + auto m_inst = + std::dynamic_pointer_cast<MinecraftInstance>(m_parent->instance()); + + auto loaders = m_inst->loaderModList(); + connect(loaders.get(), &ModFolderModel::updateFinished, this, + &ScanModFolders::modsDone); + if (!loaders->update()) { + m_modsDone = true; + } + + auto cores = m_inst->coreModList(); + connect(cores.get(), &ModFolderModel::updateFinished, this, + &ScanModFolders::coreModsDone); + if (!cores->update()) { + m_coreModsDone = true; + } + checkDone(); +} + +void ScanModFolders::modsDone() +{ + m_modsDone = true; + checkDone(); +} + +void ScanModFolders::coreModsDone() +{ + m_coreModsDone = true; + checkDone(); +} + +void ScanModFolders::checkDone() +{ + if (m_modsDone && m_coreModsDone) { + emitSucceeded(); + } +} diff --git a/meshmc/launcher/minecraft/launch/ScanModFolders.h b/meshmc/launcher/minecraft/launch/ScanModFolders.h new file mode 100644 index 0000000000..e572022c90 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/ScanModFolders.h @@ -0,0 +1,66 @@ +/* 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 <launch/LaunchStep.h> +#include <memory> + +class ScanModFolders : public LaunchStep +{ + Q_OBJECT + public: + explicit ScanModFolders(LaunchTask* parent) : LaunchStep(parent) {}; + virtual ~ScanModFolders() {}; + + virtual void executeTask() override; + virtual bool canAbort() const override + { + return false; + } + private slots: + void coreModsDone(); + void modsDone(); + + private: + void checkDone(); + + private: // DATA + bool m_modsDone = false; + bool m_coreModsDone = false; +}; diff --git a/meshmc/launcher/minecraft/launch/VerifyJavaInstall.cpp b/meshmc/launcher/minecraft/launch/VerifyJavaInstall.cpp new file mode 100644 index 0000000000..0f58b5efa4 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/VerifyJavaInstall.cpp @@ -0,0 +1,374 @@ +/* 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 "VerifyJavaInstall.h" + +#include <QDir> +#include <QDirIterator> +#include <QFileInfo> +#include <QJsonDocument> +#include <QJsonObject> +#include <QJsonArray> +#include <QProcess> +#include <QRegularExpression> + +#include <launch/LaunchTask.h> +#include <minecraft/MinecraftInstance.h> +#include <minecraft/PackProfile.h> +#include <minecraft/VersionFilterData.h> + +#include "Application.h" +#include "FileSystem.h" +#include "Json.h" +#include "java/JavaUtils.h" + +#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER +#include "BuildConfig.h" +#include "net/Download.h" +#endif + +#ifdef major +#undef major +#endif +#ifdef minor +#undef minor +#endif + +namespace +{ + std::optional<JavaVersion> probeJavaVersion(const QString& javaPath) + { + const auto checkerJar = + FS::PathCombine(APPLICATION->getJarsPath(), "JavaCheck.jar"); + if (!QFileInfo::exists(checkerJar)) { + return std::nullopt; + } + + QProcess process; + process.setProgram(javaPath); + process.setArguments({"-jar", checkerJar}); + process.setProcessEnvironment(CleanEnviroment()); + process.setProcessChannelMode(QProcess::SeparateChannels); + process.start(); + + if (!process.waitForFinished(15000) || + process.exitStatus() != QProcess::NormalExit || + process.exitCode() != 0) { + return std::nullopt; + } + + const auto stdoutData = + QString::fromLocal8Bit(process.readAllStandardOutput()); + const auto lines = stdoutData.split('\n', Qt::SkipEmptyParts); + for (const auto& rawLine : lines) { + const auto line = rawLine.trimmed(); + if (!line.startsWith("java.version=")) { + continue; + } + return JavaVersion(line.mid(QString("java.version=").size())); + } + + return std::nullopt; + } +} // namespace + +int VerifyJavaInstall::determineRequiredJavaMajor() const +{ + auto m_inst = + std::dynamic_pointer_cast<MinecraftInstance>(m_parent->instance()); + auto minecraftComponent = + m_inst->getPackProfile()->getComponent("net.minecraft"); + + if (minecraftComponent->getReleaseDateTime() >= + g_VersionFilterData.java25BeginsDate) + return 25; + if (minecraftComponent->getReleaseDateTime() >= + g_VersionFilterData.java21BeginsDate) + return 21; + if (minecraftComponent->getReleaseDateTime() >= + g_VersionFilterData.java17BeginsDate) + return 17; + if (minecraftComponent->getReleaseDateTime() >= + g_VersionFilterData.java16BeginsDate) + return 16; + if (minecraftComponent->getReleaseDateTime() >= + g_VersionFilterData.java8BeginsDate) + return 8; + return 0; +} + +QString VerifyJavaInstall::javaInstallDir() const +{ + return JavaUtils::managedJavaRoot(); +} + +QString VerifyJavaInstall::findInstalledJava(int requiredMajor) const +{ + JavaUtils javaUtils; + QList<QString> systemJavas = javaUtils.FindJavaPaths(); + QSet<QString> seenPaths; + for (const QString& javaPath : systemJavas) { + QString resolved = FS::ResolveExecutable(javaPath); + if (resolved.isEmpty() || seenPaths.contains(resolved)) + continue; + + seenPaths.insert(resolved); + const auto version = probeJavaVersion(resolved); + if (version.has_value() && version->major() >= requiredMajor) { + return resolved; + } + } + + return {}; +} + +void VerifyJavaInstall::executeTask() +{ + auto m_inst = + std::dynamic_pointer_cast<MinecraftInstance>(m_parent->instance()); + + auto javaVersion = m_inst->getJavaVersion(); + int requiredMajor = determineRequiredJavaMajor(); + + // No Java requirement or already met + if (requiredMajor == 0 || javaVersion.major() >= requiredMajor) { + emitSucceeded(); + return; + } + + // Java version insufficient — try to find an already-downloaded one + emit logLine( + tr("Current Java version %1 does not meet the requirement of Java %2.") + .arg(javaVersion.toString()) + .arg(requiredMajor), + MessageLevel::Warning); + + QString existingJava = findInstalledJava(requiredMajor); + if (!existingJava.isEmpty()) { + emit logLine(tr("Found installed Java %1 at: %2") + .arg(requiredMajor) + .arg(existingJava), + MessageLevel::MeshMC); +#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER + setJavaPathAndSucceed(existingJava); +#else + m_inst->settings()->set("OverrideJavaLocation", true); + m_inst->settings()->set("JavaPath", existingJava); + emit logLine(tr("Java path set to: %1").arg(existingJava), + MessageLevel::MeshMC); + emitSucceeded(); +#endif + return; + } + +#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER + // Not found — auto-download + emit logLine( + tr("No installed Java %1 found. Downloading...").arg(requiredMajor), + MessageLevel::MeshMC); + autoDownloadJava(requiredMajor); +#else + emitFailed( + tr("Java %1 is required but not installed. Please install it manually.") + .arg(requiredMajor)); +#endif +} + +#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER +void VerifyJavaInstall::autoDownloadJava(int requiredMajor) +{ + // Fetch version list from net.minecraft.java (Mojang) + fetchVersionList(requiredMajor); +} + +void VerifyJavaInstall::fetchVersionList(int requiredMajor) +{ + m_fetchData.clear(); + QString uid = "net.minecraft.java"; + QString url = QString("%1%2/index.json").arg(BuildConfig.META_URL, uid); + + m_fetchJob = new NetJob(tr("Fetch Java versions"), APPLICATION->network()); + auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData); + m_fetchJob->addNetAction(dl); + + connect( + m_fetchJob.get(), &NetJob::succeeded, this, + [this, uid, requiredMajor]() { + m_fetchJob.reset(); + + QJsonDocument doc; + try { + doc = Json::requireDocument(m_fetchData); + } catch (const Exception& e) { + emitFailed( + tr("Failed to parse Java version list from meta server: %1") + .arg(e.cause())); + return; + } + if (!doc.isObject()) { + emitFailed( + tr("Failed to parse Java version list from meta server.")); + return; + } + + auto versions = JavaDownload::parseVersionIndex(doc.object(), uid); + + // Find the matching version (e.g., "java25" for requiredMajor=25) + QString targetVersionId = QString("java%1").arg(requiredMajor); + bool found = false; + for (const auto& ver : versions) { + if (ver.versionId == targetVersionId) { + found = true; + fetchRuntimes(ver.versionId, requiredMajor); + return; + } + } + + if (!found) { + emitFailed(tr("Java %1 is not available for download from " + "Mojang. Please install it manually.") + .arg(requiredMajor)); + } + }); + + connect(m_fetchJob.get(), &NetJob::failed, this, + [this, requiredMajor](QString reason) { + emitFailed(tr("Failed to fetch Java version list: %1. Please " + "install Java %2 manually.") + .arg(reason) + .arg(requiredMajor)); + m_fetchJob.reset(); + }); + + m_fetchJob->start(); +} + +void VerifyJavaInstall::fetchRuntimes(const QString& versionId, + int requiredMajor) +{ + m_fetchData.clear(); + QString uid = "net.minecraft.java"; + QString url = + QString("%1%2/%3.json").arg(BuildConfig.META_URL, uid, versionId); + + m_fetchJob = + new NetJob(tr("Fetch Java runtime details"), APPLICATION->network()); + auto dl = Net::Download::makeByteArray(QUrl(url), &m_fetchData); + m_fetchJob->addNetAction(dl); + + connect(m_fetchJob.get(), &NetJob::succeeded, this, + [this, requiredMajor]() { + auto fetchJob = std::move(m_fetchJob); + + QJsonDocument doc; + try { + doc = Json::requireDocument(m_fetchData); + } catch (const Exception& e) { + emitFailed(tr("Failed to parse Java runtime details: %1") + .arg(e.cause())); + return; + } + if (!doc.isObject()) { + emitFailed(tr("Failed to parse Java runtime details.")); + return; + } + + auto allRuntimes = JavaDownload::parseRuntimes(doc.object()); + QString myOS = JavaDownload::currentRuntimeOS(); + + // Filter for current platform + for (const auto& rt : allRuntimes) { + if (rt.runtimeOS == myOS) { + emit logLine(tr("Downloading %1 (%2)...") + .arg(rt.name, rt.version.toString()), + MessageLevel::MeshMC); + startDownload(rt, requiredMajor); + return; + } + } + + emitFailed(tr("No Java %1 download available for your platform " + "(%2). Please install it manually.") + .arg(requiredMajor) + .arg(myOS)); + }); + + connect(m_fetchJob.get(), &NetJob::failed, this, [this](QString reason) { + emitFailed(tr("Failed to fetch Java runtime details: %1").arg(reason)); + m_fetchJob.reset(); + }); + + m_fetchJob->start(); +} + +void VerifyJavaInstall::startDownload(const JavaDownload::RuntimeEntry& runtime, + int requiredMajor) +{ + QString dirName = + QString("%1-%2").arg(runtime.name, runtime.version.toString()); + QString targetDir = + FS::PathCombine(javaInstallDir(), runtime.vendor, dirName); + + m_downloadTask = + std::make_unique<JavaDownloadTask>(runtime, targetDir, this); + + connect(m_downloadTask.get(), &Task::succeeded, this, [this]() { + QString javaPath = m_downloadTask->installedJavaPath(); + + if (javaPath.isEmpty()) { + emitFailed( + tr("Java was downloaded but the binary could not be found.")); + return; + } + + emit logLine(tr("Java downloaded and installed at: %1").arg(javaPath), + MessageLevel::MeshMC); + setJavaPathAndSucceed(javaPath); + }); + + connect(m_downloadTask.get(), &Task::failed, this, + [this, requiredMajor](const QString& reason) { + emitFailed(tr("Failed to download Java %1: %2") + .arg(requiredMajor) + .arg(reason)); + m_downloadTask.reset(); + }); + + connect(m_downloadTask.get(), &Task::status, this, + [this](const QString& status) { + emit logLine(status, MessageLevel::MeshMC); + }); + + m_downloadTask->start(); +} + +void VerifyJavaInstall::setJavaPathAndSucceed(const QString& javaPath) +{ + auto m_inst = + std::dynamic_pointer_cast<MinecraftInstance>(m_parent->instance()); + // Set Java path override on the instance only, not globally + m_inst->settings()->set("OverrideJavaLocation", true); + m_inst->settings()->set("JavaPath", javaPath); + emit logLine(tr("Java path set to: %1").arg(javaPath), + MessageLevel::MeshMC); + emitSucceeded(); +} +#endif diff --git a/meshmc/launcher/minecraft/launch/VerifyJavaInstall.h b/meshmc/launcher/minecraft/launch/VerifyJavaInstall.h new file mode 100644 index 0000000000..f54717e403 --- /dev/null +++ b/meshmc/launcher/minecraft/launch/VerifyJavaInstall.h @@ -0,0 +1,61 @@ +/* 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 <launch/LaunchStep.h> +#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER +#include "java/download/JavaRuntime.h" +#include "java/download/JavaDownloadTask.h" +#endif +#include "net/NetJob.h" + +class VerifyJavaInstall : public LaunchStep +{ + Q_OBJECT + + public: + explicit VerifyJavaInstall(LaunchTask* parent) : LaunchStep(parent) {}; + ~VerifyJavaInstall() override = default; + + void executeTask() override; + bool canAbort() const override + { + return false; + } + + private: + int determineRequiredJavaMajor() const; + QString findInstalledJava(int requiredMajor) const; + QString javaInstallDir() const; +#ifndef MeshMC_DISABLE_JAVA_DOWNLOADER + void autoDownloadJava(int requiredMajor); + void fetchVersionList(int requiredMajor); + void fetchRuntimes(const QString& versionId, int requiredMajor); + void startDownload(const JavaDownload::RuntimeEntry& runtime, + int requiredMajor); + void setJavaPathAndSucceed(const QString& javaPath); + + NetJob::Ptr m_fetchJob; + QByteArray m_fetchData; + std::unique_ptr<JavaDownloadTask> m_downloadTask; +#endif +}; diff --git a/meshmc/launcher/minecraft/legacy/LegacyInstance.cpp b/meshmc/launcher/minecraft/legacy/LegacyInstance.cpp new file mode 100644 index 0000000000..c010974481 --- /dev/null +++ b/meshmc/launcher/minecraft/legacy/LegacyInstance.cpp @@ -0,0 +1,270 @@ +/* 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 <QFileInfo> +#include <minecraft/launch/MeshMCPartLaunch.h> +#include <QDir> +#include <settings/Setting.h> + +#include "LegacyInstance.h" + +#include "minecraft/legacy/LegacyModList.h" +#include "minecraft/WorldList.h" +#include <MMCZip.h> +#include <FileSystem.h> + +LegacyInstance::LegacyInstance(SettingsObjectPtr globalSettings, + SettingsObjectPtr settings, + const QString& rootDir) + : BaseInstance(globalSettings, settings, rootDir) +{ + settings->registerSetting("NeedsRebuild", true); + settings->registerSetting("ShouldUpdate", false); + settings->registerSetting("JarVersion", QString()); + settings->registerSetting("IntendedJarVersion", QString()); + /* + * custom base jar has no default. it is determined in code... see the + * accessor methods for it + * + * for instances that DO NOT have the CustomBaseJar setting (legacy + * instances), + * [.]minecraft/bin/mcbackup.jar is the default base jar + */ + settings->registerSetting("UseCustomBaseJar", true); + settings->registerSetting("CustomBaseJar", ""); +} + +QString LegacyInstance::mainJarToPreserve() const +{ + bool customJar = m_settings->get("UseCustomBaseJar").toBool(); + if (customJar) { + auto base = baseJar(); + if (QFile::exists(base)) { + return base; + } + } + auto runnable = runnableJar(); + if (QFile::exists(runnable)) { + return runnable; + } + return QString(); +} + +QString LegacyInstance::baseJar() const +{ + bool customJar = m_settings->get("UseCustomBaseJar").toBool(); + if (customJar) { + return customBaseJar(); + } else + return defaultBaseJar(); +} + +QString LegacyInstance::customBaseJar() const +{ + QString value = m_settings->get("CustomBaseJar").toString(); + if (value.isNull() || value.isEmpty()) { + return defaultCustomBaseJar(); + } + return value; +} + +bool LegacyInstance::shouldUseCustomBaseJar() const +{ + return m_settings->get("UseCustomBaseJar").toBool(); +} + +Task::Ptr LegacyInstance::createUpdateTask(Net::Mode) +{ + return nullptr; +} + +std::shared_ptr<LegacyModList> LegacyInstance::jarModList() const +{ + if (!jar_mod_list) { + auto list = new LegacyModList(jarModsDir(), modListFile()); + jar_mod_list.reset(list); + } + jar_mod_list->update(); + return jar_mod_list; +} + +QString LegacyInstance::gameRoot() const +{ + QFileInfo mcDir(FS::PathCombine(instanceRoot(), "minecraft")); + QFileInfo dotMCDir(FS::PathCombine(instanceRoot(), ".minecraft")); + + if (mcDir.exists() && !dotMCDir.exists()) + return mcDir.filePath(); + else + return dotMCDir.filePath(); +} + +QString LegacyInstance::binRoot() const +{ + return FS::PathCombine(gameRoot(), "bin"); +} + +QString LegacyInstance::modsRoot() const +{ + return FS::PathCombine(gameRoot(), "mods"); +} + +QString LegacyInstance::jarModsDir() const +{ + return FS::PathCombine(instanceRoot(), "instMods"); +} + +QString LegacyInstance::libDir() const +{ + return FS::PathCombine(gameRoot(), "lib"); +} + +QString LegacyInstance::savesDir() const +{ + return FS::PathCombine(gameRoot(), "saves"); +} + +QString LegacyInstance::coreModsDir() const +{ + return FS::PathCombine(gameRoot(), "coremods"); +} + +QString LegacyInstance::resourceDir() const +{ + return FS::PathCombine(gameRoot(), "resources"); +} +QString LegacyInstance::texturePacksDir() const +{ + return FS::PathCombine(gameRoot(), "texturepacks"); +} + +QString LegacyInstance::runnableJar() const +{ + return FS::PathCombine(binRoot(), "minecraft.jar"); +} + +QString LegacyInstance::modListFile() const +{ + return FS::PathCombine(instanceRoot(), "modlist"); +} + +QString LegacyInstance::instanceConfigFolder() const +{ + return FS::PathCombine(gameRoot(), "config"); +} + +bool LegacyInstance::shouldRebuild() const +{ + return m_settings->get("NeedsRebuild").toBool(); +} + +QString LegacyInstance::currentVersionId() const +{ + return m_settings->get("JarVersion").toString(); +} + +QString LegacyInstance::intendedVersionId() const +{ + return m_settings->get("IntendedJarVersion").toString(); +} + +bool LegacyInstance::shouldUpdate() const +{ + QVariant var = settings()->get("ShouldUpdate"); + if (!var.isValid() || var.toBool() == false) { + return intendedVersionId() != currentVersionId(); + } + return true; +} + +QString LegacyInstance::defaultBaseJar() const +{ + return "versions/" + intendedVersionId() + "/" + intendedVersionId() + + ".jar"; +} + +QString LegacyInstance::defaultCustomBaseJar() const +{ + return FS::PathCombine(binRoot(), "mcbackup.jar"); +} + +std::shared_ptr<WorldList> LegacyInstance::worldList() const +{ + if (!m_world_list) { + m_world_list.reset(new WorldList(savesDir())); + } + return m_world_list; +} + +QString LegacyInstance::typeName() const +{ + return tr("Legacy"); +} + +QString LegacyInstance::getStatusbarDescription() +{ + return tr("Instance from previous versions."); +} + +QStringList +LegacyInstance::verboseDescription(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) +{ + QStringList out; + + auto alltraits = traits(); + if (alltraits.size()) { + out << "Traits:"; + for (auto trait : alltraits) { + out << " " + trait; + } + out << ""; + } + + QString windowParams; + if (settings()->get("LaunchMaximized").toBool()) { + out << "Window size: max (if available)"; + } else { + auto width = settings()->get("MinecraftWinWidth").toInt(); + auto height = settings()->get("MinecraftWinHeight").toInt(); + out << "Window size: " + QString::number(width) + " x " + + QString::number(height); + } + out << ""; + return out; +} diff --git a/meshmc/launcher/minecraft/legacy/LegacyInstance.h b/meshmc/launcher/minecraft/legacy/LegacyInstance.h new file mode 100644 index 0000000000..6edcee96db --- /dev/null +++ b/meshmc/launcher/minecraft/legacy/LegacyInstance.h @@ -0,0 +1,172 @@ +/* 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 "BaseInstance.h" +#include "launch/LaunchTask.h" + +class ModFolderModel; +class LegacyModList; +class WorldList; +class Task; +/* + * WHY: Legacy instances - from MultiMC 3 and 4 - are here only to provide a way + * to upgrade them to the current format. + */ +class LegacyInstance : public BaseInstance +{ + Q_OBJECT + public: + explicit LegacyInstance(SettingsObjectPtr globalSettings, + SettingsObjectPtr settings, const QString& rootDir); + + virtual void saveNow() override {} + + /// Path to the instance's minecraft.jar + QString runnableJar() const; + + //! Path to the instance's modlist file. + QString modListFile() const; + + ////// Directories ////// + QString libDir() const; + QString savesDir() const; + QString texturePacksDir() const; + QString jarModsDir() const; + QString coreModsDir() const; + QString resourceDir() const; + + QString instanceConfigFolder() const override; + + QString + gameRoot() const override; // Path to the instance's minecraft directory. + QString + modsRoot() const override; // Path to the instance's minecraft directory. + QString binRoot() const; // Path to the instance's minecraft bin directory. + + /// Get the curent base jar of this instance. By default, it's the + /// versions/$version/$version.jar + QString baseJar() const; + + /// the default base jar of this instance + QString defaultBaseJar() const; + /// the default custom base jar of this instance + QString defaultCustomBaseJar() const; + + // the main jar that we actually want to keep when migrating the instance + QString mainJarToPreserve() const; + + /*! + * Whether or not custom base jar is used + */ + bool shouldUseCustomBaseJar() const; + + /*! + * The value of the custom base jar + */ + QString customBaseJar() const; + + std::shared_ptr<LegacyModList> jarModList() const; + std::shared_ptr<WorldList> worldList() const; + + /*! + * Whether or not the instance's minecraft.jar needs to be rebuilt. + * If this is true, when the instance launches, its jar mods will be + * re-added to a fresh minecraft.jar file. + */ + bool shouldRebuild() const; + + QString currentVersionId() const; + QString intendedVersionId() const; + + QSet<QString> traits() const override + { + return {"legacy-instance", "texturepacks"}; + }; + + virtual bool shouldUpdate() const; + virtual Task::Ptr createUpdateTask(Net::Mode mode) override; + + virtual QString typeName() const override; + + bool canLaunch() const override + { + return false; + } + bool canEdit() const override + { + return true; + } + bool canExport() const override + { + return false; + } + shared_qobject_ptr<LaunchTask> + createLaunchTask(AuthSessionPtr account, + MinecraftServerTargetPtr serverToJoin) override + { + return nullptr; + } + IPathMatcher::Ptr getLogFileMatcher() override + { + return nullptr; + } + QString getLogFileRoot() override + { + return gameRoot(); + } + + QString getStatusbarDescription() override; + QStringList + verboseDescription(AuthSessionPtr session, + MinecraftServerTargetPtr serverToJoin) override; + + QProcessEnvironment createEnvironment() override + { + return QProcessEnvironment(); + } + QMap<QString, QString> getVariables() const override + { + return {}; + } + + protected: + mutable std::shared_ptr<LegacyModList> jar_mod_list; + mutable std::shared_ptr<WorldList> m_world_list; +}; diff --git a/meshmc/launcher/minecraft/legacy/LegacyModList.cpp b/meshmc/launcher/minecraft/legacy/LegacyModList.cpp new file mode 100644 index 0000000000..32158bf59e --- /dev/null +++ b/meshmc/launcher/minecraft/legacy/LegacyModList.cpp @@ -0,0 +1,150 @@ +/* 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 "LegacyModList.h" +#include <FileSystem.h> +#include <QString> +#include <QDebug> + +LegacyModList::LegacyModList(const QString& dir, const QString& list_file) + : m_dir(dir), m_list_file(list_file) +{ + 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); +} + +struct OrderItem { + QString id; + bool enabled = false; +}; +typedef QList<OrderItem> OrderList; + +static void internalSort(QList<LegacyModList::Mod>& what) +{ + auto predicate = [](const LegacyModList::Mod& left, + const LegacyModList::Mod& right) { + return left.fileName().localeAwareCompare(right.fileName()) < 0; + }; + std::sort(what.begin(), what.end(), predicate); +} + +static OrderList readListFile(const QString& m_list_file) +{ + OrderList itemList; + if (m_list_file.isNull() || m_list_file.isEmpty()) + return itemList; + + QFile textFile(m_list_file); + if (!textFile.open(QIODevice::ReadOnly | QIODevice::Text)) + return OrderList(); + + QTextStream textStream; + textStream.setAutoDetectUnicode(true); + textStream.setDevice(&textFile); + while (true) { + QString line = textStream.readLine(); + if (line.isNull() || line.isEmpty()) + break; + else { + OrderItem it; + it.enabled = !line.endsWith(".disabled"); + if (!it.enabled) { + line.chop(9); + } + it.id = line; + itemList.append(it); + } + } + textFile.close(); + return itemList; +} + +bool LegacyModList::update() +{ + if (!m_dir.exists() || !m_dir.isReadable()) + return false; + + QList<Mod> orderedMods; + QList<Mod> newMods; + m_dir.refresh(); + auto folderContents = m_dir.entryInfoList(); + + // first, process the ordered items (if any) + OrderList listOrder = readListFile(m_list_file); + for (auto item : listOrder) { + QFileInfo infoEnabled(m_dir.filePath(item.id)); + QFileInfo infoDisabled(m_dir.filePath(item.id + ".disabled")); + int idxEnabled = folderContents.indexOf(infoEnabled); + int idxDisabled = folderContents.indexOf(infoDisabled); + bool isEnabled; + // if both enabled and disabled versions are present, it's a special + // case... + if (idxEnabled >= 0 && idxDisabled >= 0) { + // we only process the one we actually have in the order file. + // and exactly as we have it. + // THIS IS A CORNER CASE + isEnabled = item.enabled; + } else { + // only one is present. + // we pick the one that we found. + // we assume the mod was enabled/disabled by external means + isEnabled = idxEnabled >= 0; + } + int idx = isEnabled ? idxEnabled : idxDisabled; + QFileInfo& info = isEnabled ? infoEnabled : infoDisabled; + // if the file from the index file exists + if (idx != -1) { + // remove from the actual folder contents list + folderContents.takeAt(idx); + // append the new mod + orderedMods.append(info); + } + } + // if there are any untracked files... append them sorted at the end + if (folderContents.size()) { + for (auto entry : folderContents) { + newMods.append(entry); + } + internalSort(newMods); + orderedMods.append(newMods); + } + mods.swap(orderedMods); + return true; +} diff --git a/meshmc/launcher/minecraft/legacy/LegacyModList.h b/meshmc/launcher/minecraft/legacy/LegacyModList.h new file mode 100644 index 0000000000..4a5627e7c8 --- /dev/null +++ b/meshmc/launcher/minecraft/legacy/LegacyModList.h @@ -0,0 +1,69 @@ +/* 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 <QString> +#include <QDir> + +class LegacyModList +{ + public: + using Mod = QFileInfo; + + LegacyModList(const QString& dir, const QString& list_file = QString()); + + /// Reloads the mod list and returns true if the list changed. + bool update(); + + QDir dir() + { + return m_dir; + } + + const QList<Mod>& allMods() + { + return mods; + } + + protected: + QDir m_dir; + QString m_list_file; + QList<Mod> mods; +}; diff --git a/meshmc/launcher/minecraft/legacy/LegacyUpgradeTask.cpp b/meshmc/launcher/minecraft/legacy/LegacyUpgradeTask.cpp new file mode 100644 index 0000000000..89b3506153 --- /dev/null +++ b/meshmc/launcher/minecraft/legacy/LegacyUpgradeTask.cpp @@ -0,0 +1,151 @@ +/* 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 "LegacyUpgradeTask.h" +#include "settings/INISettingsObject.h" +#include "FileSystem.h" +#include "NullInstance.h" +#include "pathmatcher/RegexpMatcher.h" +#include <QtConcurrentRun> +#include "LegacyInstance.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "LegacyModList.h" +#include "classparser.h" + +LegacyUpgradeTask::LegacyUpgradeTask(InstancePtr origInstance) +{ + m_origInstance = origInstance; +} + +void LegacyUpgradeTask::executeTask() +{ + setStatus(tr("Copying instance %1").arg(m_origInstance->name())); + + FS::copy folderCopy(m_origInstance->instanceRoot(), m_stagingPath); + folderCopy.followSymlinks(true); + + m_copyFuture = QtConcurrent::run(QThreadPool::globalInstance(), folderCopy); + connect(&m_copyFutureWatcher, &QFutureWatcher<bool>::finished, this, + &LegacyUpgradeTask::copyFinished); + connect(&m_copyFutureWatcher, &QFutureWatcher<bool>::canceled, this, + &LegacyUpgradeTask::copyAborted); + m_copyFutureWatcher.setFuture(m_copyFuture); +} + +static QString decideVersion(const QString& currentVersion, + const QString& intendedVersion) +{ + if (intendedVersion != currentVersion) { + if (!intendedVersion.isEmpty()) { + return intendedVersion; + } else if (!currentVersion.isEmpty()) { + return currentVersion; + } + } else { + if (!intendedVersion.isEmpty()) { + return intendedVersion; + } + } + return QString(); +} + +void LegacyUpgradeTask::copyFinished() +{ + auto successful = m_copyFuture.result(); + if (!successful) { + emitFailed(tr("Instance folder copy failed.")); + return; + } + auto legacyInst = std::dynamic_pointer_cast<LegacyInstance>(m_origInstance); + + auto instanceSettings = std::make_shared<INISettingsObject>( + FS::PathCombine(m_stagingPath, "instance.cfg")); + instanceSettings->registerSetting("InstanceType", "Legacy"); + instanceSettings->set("InstanceType", "OneSix"); + // NOTE: this scope ensures the instance is fully saved before we + // emitSucceeded + { + MinecraftInstance inst(m_globalSettings, instanceSettings, + m_stagingPath); + inst.setName(m_instName); + + QString preferredVersionNumber = decideVersion( + legacyInst->currentVersionId(), legacyInst->intendedVersionId()); + if (preferredVersionNumber.isNull()) { + // try to decide version based on the jar(s?) + preferredVersionNumber = + classparser::GetMinecraftJarVersion(legacyInst->baseJar()); + if (preferredVersionNumber.isNull()) { + preferredVersionNumber = classparser::GetMinecraftJarVersion( + legacyInst->runnableJar()); + if (preferredVersionNumber.isNull()) { + emitFailed(tr("Could not decide Minecraft version.")); + return; + } + } + } + auto components = inst.getPackProfile(); + components->buildingFromScratch(); + components->setComponentVersion("net.minecraft", preferredVersionNumber, + true); + + QString jarPath = legacyInst->mainJarToPreserve(); + if (!jarPath.isNull()) { + qDebug() << "Preserving base jar! : " << jarPath; + // FIXME: handle case when the jar is unreadable? + // TODO: check the hash, if it's the same as the upstream jar, do + // not do this + components->installCustomJar(jarPath); + } + + auto jarMods = legacyInst->jarModList()->allMods(); + for (auto& jarMod : jarMods) { + QString modPath = jarMod.absoluteFilePath(); + qDebug() << "jarMod: " << modPath; + components->installJarMods({modPath}); + } + + // remove all the extra garbage we no longer need + auto removeAll = [&](const QString& root, const QStringList& things) { + for (auto& thing : things) { + auto removePath = FS::PathCombine(root, thing); + QFileInfo stat(removePath); + if (stat.isDir()) { + FS::deletePath(removePath); + } else { + QFile::remove(removePath); + } + } + }; + QStringList rootRemovables = {"modlist", "version", "instMods"}; + QStringList mcRemovables = {"bin", "MeshMCLauncher.jar", "icon.png"}; + removeAll(inst.instanceRoot(), rootRemovables); + removeAll(inst.gameRoot(), mcRemovables); + } + emitSucceeded(); +} + +void LegacyUpgradeTask::copyAborted() +{ + emitFailed(tr("Instance folder copy has been aborted.")); + return; +} diff --git a/meshmc/launcher/minecraft/legacy/LegacyUpgradeTask.h b/meshmc/launcher/minecraft/legacy/LegacyUpgradeTask.h new file mode 100644 index 0000000000..a407cb8df8 --- /dev/null +++ b/meshmc/launcher/minecraft/legacy/LegacyUpgradeTask.h @@ -0,0 +1,49 @@ +/* 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 "InstanceTask.h" +#include "net/NetJob.h" +#include <QUrl> +#include <QFuture> +#include <QFutureWatcher> +#include "settings/SettingsObject.h" +#include "BaseVersion.h" +#include "BaseInstance.h" + +class LegacyUpgradeTask : public InstanceTask +{ + Q_OBJECT + public: + explicit LegacyUpgradeTask(InstancePtr origInstance); + + protected: + //! Entry point for tasks. + virtual void executeTask() override; + void copyFinished(); + void copyAborted(); + + private: /* data */ + InstancePtr m_origInstance; + QFuture<bool> m_copyFuture; + QFutureWatcher<bool> m_copyFutureWatcher; +}; 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; +}; diff --git a/meshmc/launcher/minecraft/services/CapeChange.cpp b/meshmc/launcher/minecraft/services/CapeChange.cpp new file mode 100644 index 0000000000..801082e7d0 --- /dev/null +++ b/meshmc/launcher/minecraft/services/CapeChange.cpp @@ -0,0 +1,96 @@ +/* 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 "CapeChange.h" + +#include <QNetworkRequest> +#include <QHttpMultiPart> + +#include "Application.h" + +CapeChange::CapeChange(QObject* parent, QString token, QString cape) + : Task(parent), m_capeId(cape), m_token(token) +{ +} + +void CapeChange::setCape(QString& cape) +{ + QNetworkRequest request(QUrl( + "https://api.minecraftservices.com/minecraft/profile/capes/active")); + auto requestString = QString("{\"capeId\":\"%1\"}").arg(m_capeId); + request.setRawHeader("Authorization", + QString("Bearer %1").arg(m_token).toLocal8Bit()); + QNetworkReply* rep = + APPLICATION->network()->put(request, requestString.toUtf8()); + + setStatus(tr("Equipping cape")); + + m_reply = shared_qobject_ptr<QNetworkReply>(rep); + connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); + connect(rep, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(downloadError(QNetworkReply::NetworkError))); + connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); +} + +void CapeChange::clearCape() +{ + QNetworkRequest request(QUrl( + "https://api.minecraftservices.com/minecraft/profile/capes/active")); + auto requestString = QString("{\"capeId\":\"%1\"}").arg(m_capeId); + request.setRawHeader("Authorization", + QString("Bearer %1").arg(m_token).toLocal8Bit()); + QNetworkReply* rep = APPLICATION->network()->deleteResource(request); + + setStatus(tr("Removing cape")); + + m_reply = shared_qobject_ptr<QNetworkReply>(rep); + connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); + connect(rep, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(downloadError(QNetworkReply::NetworkError))); + connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); +} + +void CapeChange::executeTask() +{ + if (m_capeId.isEmpty()) { + clearCape(); + } else { + setCape(m_capeId); + } +} + +void CapeChange::downloadError(QNetworkReply::NetworkError error) +{ + // error happened during download. + qCritical() << "Network error: " << error; + emitFailed(m_reply->errorString()); +} + +void CapeChange::downloadFinished() +{ + // if the download failed + if (m_reply->error() != QNetworkReply::NetworkError::NoError) { + emitFailed(QString("Network error: %1").arg(m_reply->errorString())); + m_reply.reset(); + return; + } + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/services/CapeChange.h b/meshmc/launcher/minecraft/services/CapeChange.h new file mode 100644 index 0000000000..9b2e0e8258 --- /dev/null +++ b/meshmc/launcher/minecraft/services/CapeChange.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 <QFile> +#include <QtNetwork/QtNetwork> +#include <memory> +#include "tasks/Task.h" +#include "QObjectPtr.h" + +class CapeChange : public Task +{ + Q_OBJECT + public: + CapeChange(QObject* parent, QString token, QString capeId); + virtual ~CapeChange() {} + + private: + void setCape(QString& cape); + void clearCape(); + + private: + QString m_capeId; + QString m_token; + shared_qobject_ptr<QNetworkReply> m_reply; + + protected: + virtual void executeTask(); + + public slots: + void downloadError(QNetworkReply::NetworkError); + void downloadFinished(); +}; diff --git a/meshmc/launcher/minecraft/services/SkinDelete.cpp b/meshmc/launcher/minecraft/services/SkinDelete.cpp new file mode 100644 index 0000000000..77f4e16937 --- /dev/null +++ b/meshmc/launcher/minecraft/services/SkinDelete.cpp @@ -0,0 +1,66 @@ +/* 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 "SkinDelete.h" + +#include <QNetworkRequest> +#include <QHttpMultiPart> + +#include "Application.h" + +SkinDelete::SkinDelete(QObject* parent, QString token) + : Task(parent), m_token(token) +{ +} + +void SkinDelete::executeTask() +{ + QNetworkRequest request(QUrl( + "https://api.minecraftservices.com/minecraft/profile/skins/active")); + request.setRawHeader("Authorization", + QString("Bearer %1").arg(m_token).toLocal8Bit()); + QNetworkReply* rep = APPLICATION->network()->deleteResource(request); + m_reply = shared_qobject_ptr<QNetworkReply>(rep); + + setStatus(tr("Deleting skin")); + connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); + connect(rep, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(downloadError(QNetworkReply::NetworkError))); + connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); +} + +void SkinDelete::downloadError(QNetworkReply::NetworkError error) +{ + // error happened during download. + qCritical() << "Network error: " << error; + emitFailed(m_reply->errorString()); +} + +void SkinDelete::downloadFinished() +{ + // if the download failed + if (m_reply->error() != QNetworkReply::NetworkError::NoError) { + emitFailed(QString("Network error: %1").arg(m_reply->errorString())); + m_reply.reset(); + return; + } + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/services/SkinDelete.h b/meshmc/launcher/minecraft/services/SkinDelete.h new file mode 100644 index 0000000000..b2c48aa3d8 --- /dev/null +++ b/meshmc/launcher/minecraft/services/SkinDelete.h @@ -0,0 +1,47 @@ +/* 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 <QFile> +#include <QtNetwork/QtNetwork> +#include "tasks/Task.h" + +typedef shared_qobject_ptr<class SkinDelete> SkinDeletePtr; + +class SkinDelete : public Task +{ + Q_OBJECT + public: + SkinDelete(QObject* parent, QString token); + virtual ~SkinDelete() = default; + + private: + QString m_token; + shared_qobject_ptr<QNetworkReply> m_reply; + + protected: + virtual void executeTask(); + + public slots: + void downloadError(QNetworkReply::NetworkError); + void downloadFinished(); +}; diff --git a/meshmc/launcher/minecraft/services/SkinUpload.cpp b/meshmc/launcher/minecraft/services/SkinUpload.cpp new file mode 100644 index 0000000000..243bd6a5fb --- /dev/null +++ b/meshmc/launcher/minecraft/services/SkinUpload.cpp @@ -0,0 +1,96 @@ +/* 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 "SkinUpload.h" + +#include <QNetworkRequest> +#include <QHttpMultiPart> + +#include "Application.h" + +QByteArray getVariant(SkinUpload::Model model) +{ + switch (model) { + default: + qDebug() << "Unknown skin type!"; + case SkinUpload::STEVE: + return "CLASSIC"; + case SkinUpload::ALEX: + return "SLIM"; + } +} + +SkinUpload::SkinUpload(QObject* parent, QString token, QByteArray skin, + SkinUpload::Model model) + : Task(parent), m_model(model), m_skin(skin), m_token(token) +{ +} + +void SkinUpload::executeTask() +{ + QNetworkRequest request( + QUrl("https://api.minecraftservices.com/minecraft/profile/skins")); + request.setRawHeader("Authorization", + QString("Bearer %1").arg(m_token).toLocal8Bit()); + QHttpMultiPart* multiPart = + new QHttpMultiPart(QHttpMultiPart::FormDataType); + + QHttpPart skin; + skin.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/png")); + skin.setHeader(QNetworkRequest::ContentDispositionHeader, + QVariant("form-data; name=\"file\"; filename=\"skin.png\"")); + skin.setBody(m_skin); + + QHttpPart model; + model.setHeader(QNetworkRequest::ContentDispositionHeader, + QVariant("form-data; name=\"variant\"")); + model.setBody(getVariant(m_model)); + + multiPart->append(skin); + multiPart->append(model); + + QNetworkReply* rep = APPLICATION->network()->post(request, multiPart); + m_reply = shared_qobject_ptr<QNetworkReply>(rep); + + setStatus(tr("Uploading skin")); + connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); + connect(rep, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(downloadError(QNetworkReply::NetworkError))); + connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); +} + +void SkinUpload::downloadError(QNetworkReply::NetworkError error) +{ + // error happened during download. + qCritical() << "Network error: " << error; + emitFailed(m_reply->errorString()); +} + +void SkinUpload::downloadFinished() +{ + // if the download failed + if (m_reply->error() != QNetworkReply::NetworkError::NoError) { + emitFailed(QString("Network error: %1").arg(m_reply->errorString())); + m_reply.reset(); + return; + } + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/services/SkinUpload.h b/meshmc/launcher/minecraft/services/SkinUpload.h new file mode 100644 index 0000000000..39609b5cfe --- /dev/null +++ b/meshmc/launcher/minecraft/services/SkinUpload.h @@ -0,0 +1,56 @@ +/* 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 <QFile> +#include <QtNetwork/QtNetwork> +#include <memory> +#include "tasks/Task.h" + +typedef shared_qobject_ptr<class SkinUpload> SkinUploadPtr; + +class SkinUpload : public Task +{ + Q_OBJECT + public: + enum Model { STEVE, ALEX }; + + // Note this class takes ownership of the file. + SkinUpload(QObject* parent, QString token, QByteArray skin, + Model model = STEVE); + virtual ~SkinUpload() {} + + private: + Model m_model; + QByteArray m_skin; + QString m_token; + shared_qobject_ptr<QNetworkReply> m_reply; + + protected: + virtual void executeTask(); + + public slots: + + void downloadError(QNetworkReply::NetworkError); + + void downloadFinished(); +}; diff --git a/meshmc/launcher/minecraft/testdata/1.9-simple.json b/meshmc/launcher/minecraft/testdata/1.9-simple.json new file mode 100644 index 0000000000..574c5b065b --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/1.9-simple.json @@ -0,0 +1,198 @@ +{ + "assets": "1.9", + "id": "1.9", + "libraries": [ + { + "name": "oshi-project:oshi-core:1.1" + }, + { + "name": "net.java.dev.jna:jna:3.4.0" + }, + { + "name": "net.java.dev.jna:platform:3.4.0" + }, + { + "name": "com.ibm.icu:icu4j-core-mojang:51.2" + }, + { + "name": "net.sf.jopt-simple:jopt-simple:4.6" + }, + { + "name": "com.paulscode:codecjorbis:20101023" + }, + { + "name": "com.paulscode:codecwav:20101023" + }, + { + "name": "com.paulscode:libraryjavasound:20101123" + }, + { + "name": "com.paulscode:librarylwjglopenal:20100824" + }, + { + "name": "com.paulscode:soundsystem:20120107" + }, + { + "name": "io.netty:netty-all:4.0.23.Final" + }, + { + "name": "com.google.guava:guava:17.0" + }, + { + "name": "org.apache.commons:commons-lang3:3.3.2" + }, + { + "name": "commons-io:commons-io:2.4" + }, + { + "name": "commons-codec:commons-codec:1.9" + }, + { + "name": "net.java.jinput:jinput:2.0.5" + }, + { + "name": "net.java.jutils:jutils:1.0.0" + }, + { + "name": "com.google.code.gson:gson:2.2.4" + }, + { + "name": "com.mojang:authlib:1.5.22" + }, + { + "name": "com.mojang:realms:1.8.4" + }, + { + "name": "org.apache.commons:commons-compress:1.8.1" + }, + { + "name": "org.apache.httpcomponents:httpclient:4.3.3" + }, + { + "name": "commons-logging:commons-logging:1.1.3" + }, + { + "name": "org.apache.httpcomponents:httpcore:4.3.2" + }, + { + "name": "org.apache.logging.log4j:log4j-api:2.0-beta9" + }, + { + "name": "org.apache.logging.log4j:log4j-core:2.0-beta9" + }, + { + "name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209", + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] + }, + { + "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209", + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] + }, + { + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + }, + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] + }, + { + "name": "org.lwjgl.lwjgl:lwjgl:2.9.2-nightly-20140822", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.2-nightly-20140822", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.2-nightly-20140822", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + }, + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "net.java.jinput:jinput-platform:2.0.5", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + } + } + ], + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} --assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} --accessToken ${auth_access_token} --userType ${user_type} --versionType ${version_type}", + "minimumLauncherVersion": 18, + "releaseTime": "2016-02-29T13:49:54+00:00", + "time": "2016-03-01T13:14:53+00:00", + "type": "release" +} diff --git a/meshmc/launcher/minecraft/testdata/1.9.json b/meshmc/launcher/minecraft/testdata/1.9.json new file mode 100644 index 0000000000..697c605909 --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/1.9.json @@ -0,0 +1,529 @@ +{ + "assetIndex": { + "id": "1.9", + "sha1": "cde65b47a43f638653ab1da3848b53f8a7477b16", + "size": 136916, + "totalSize": 119917473, + "url": "https://launchermeta.mojang.com/mc-staging/assets/1.9/cde65b47a43f638653ab1da3848b53f8a7477b16/1.9.json" + }, + "assets": "1.9", + "downloads": { + "client": { + "sha1": "2f67dfe8953299440d1902f9124f0f2c3a2c940f", + "size": 8697592, + "url": "https://launcher.mojang.com/mc/game/1.9/client/2f67dfe8953299440d1902f9124f0f2c3a2c940f/client.jar" + }, + "server": { + "sha1": "b4d449cf2918e0f3bd8aa18954b916a4d1880f0d", + "size": 8848015, + "url": "https://launcher.mojang.com/mc/game/1.9/server/b4d449cf2918e0f3bd8aa18954b916a4d1880f0d/server.jar" + } + }, + "id": "1.9", + "libraries": [ + { + "downloads": { + "artifact": { + "path": "oshi-project/oshi-core/1.1/oshi-core-1.1.jar", + "sha1": "9ddf7b048a8d701be231c0f4f95fd986198fd2d8", + "size": 30973, + "url": "https://libraries.minecraft.net/oshi-project/oshi-core/1.1/oshi-core-1.1.jar" + } + }, + "name": "oshi-project:oshi-core:1.1" + }, + { + "downloads": { + "artifact": { + "path": "net/java/dev/jna/jna/3.4.0/jna-3.4.0.jar", + "sha1": "803ff252fedbd395baffd43b37341dc4a150a554", + "size": 1008730, + "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/3.4.0/jna-3.4.0.jar" + } + }, + "name": "net.java.dev.jna:jna:3.4.0" + }, + { + "downloads": { + "artifact": { + "path": "net/java/dev/jna/platform/3.4.0/platform-3.4.0.jar", + "sha1": "e3f70017be8100d3d6923f50b3d2ee17714e9c13", + "size": 913436, + "url": "https://libraries.minecraft.net/net/java/dev/jna/platform/3.4.0/platform-3.4.0.jar" + } + }, + "name": "net.java.dev.jna:platform:3.4.0" + }, + { + "downloads": { + "artifact": { + "path": "com/ibm/icu/icu4j-core-mojang/51.2/icu4j-core-mojang-51.2.jar", + "sha1": "63d216a9311cca6be337c1e458e587f99d382b84", + "size": 1634692, + "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j-core-mojang/51.2/icu4j-core-mojang-51.2.jar" + } + }, + "name": "com.ibm.icu:icu4j-core-mojang:51.2" + }, + { + "downloads": { + "artifact": { + "path": "net/sf/jopt-simple/jopt-simple/4.6/jopt-simple-4.6.jar", + "sha1": "306816fb57cf94f108a43c95731b08934dcae15c", + "size": 62477, + "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/4.6/jopt-simple-4.6.jar" + } + }, + "name": "net.sf.jopt-simple:jopt-simple:4.6" + }, + { + "downloads": { + "artifact": { + "path": "com/paulscode/codecjorbis/20101023/codecjorbis-20101023.jar", + "sha1": "c73b5636faf089d9f00e8732a829577de25237ee", + "size": 103871, + "url": "https://libraries.minecraft.net/com/paulscode/codecjorbis/20101023/codecjorbis-20101023.jar" + } + }, + "name": "com.paulscode:codecjorbis:20101023" + }, + { + "downloads": { + "artifact": { + "path": "com/paulscode/codecwav/20101023/codecwav-20101023.jar", + "sha1": "12f031cfe88fef5c1dd36c563c0a3a69bd7261da", + "size": 5618, + "url": "https://libraries.minecraft.net/com/paulscode/codecwav/20101023/codecwav-20101023.jar" + } + }, + "name": "com.paulscode:codecwav:20101023" + }, + { + "downloads": { + "artifact": { + "path": "com/paulscode/libraryjavasound/20101123/libraryjavasound-20101123.jar", + "sha1": "5c5e304366f75f9eaa2e8cca546a1fb6109348b3", + "size": 21679, + "url": "https://libraries.minecraft.net/com/paulscode/libraryjavasound/20101123/libraryjavasound-20101123.jar" + } + }, + "name": "com.paulscode:libraryjavasound:20101123" + }, + { + "downloads": { + "artifact": { + "path": "com/paulscode/librarylwjglopenal/20100824/librarylwjglopenal-20100824.jar", + "sha1": "73e80d0794c39665aec3f62eee88ca91676674ef", + "size": 18981, + "url": "https://libraries.minecraft.net/com/paulscode/librarylwjglopenal/20100824/librarylwjglopenal-20100824.jar" + } + }, + "name": "com.paulscode:librarylwjglopenal:20100824" + }, + { + "downloads": { + "artifact": { + "path": "com/paulscode/soundsystem/20120107/soundsystem-20120107.jar", + "sha1": "419c05fe9be71f792b2d76cfc9b67f1ed0fec7f6", + "size": 65020, + "url": "https://libraries.minecraft.net/com/paulscode/soundsystem/20120107/soundsystem-20120107.jar" + } + }, + "name": "com.paulscode:soundsystem:20120107" + }, + { + "downloads": { + "artifact": { + "path": "io/netty/netty-all/4.0.23.Final/netty-all-4.0.23.Final.jar", + "sha1": "0294104aaf1781d6a56a07d561e792c5d0c95f45", + "size": 1779991, + "url": "https://libraries.minecraft.net/io/netty/netty-all/4.0.23.Final/netty-all-4.0.23.Final.jar" + } + }, + "name": "io.netty:netty-all:4.0.23.Final" + }, + { + "downloads": { + "artifact": { + "path": "com/google/guava/guava/17.0/guava-17.0.jar", + "sha1": "9c6ef172e8de35fd8d4d8783e4821e57cdef7445", + "size": 2243036, + "url": "https://libraries.minecraft.net/com/google/guava/guava/17.0/guava-17.0.jar" + } + }, + "name": "com.google.guava:guava:17.0" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/commons/commons-lang3/3.3.2/commons-lang3-3.3.2.jar", + "sha1": "90a3822c38ec8c996e84c16a3477ef632cbc87a3", + "size": 412739, + "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.3.2/commons-lang3-3.3.2.jar" + } + }, + "name": "org.apache.commons:commons-lang3:3.3.2" + }, + { + "downloads": { + "artifact": { + "path": "commons-io/commons-io/2.4/commons-io-2.4.jar", + "sha1": "b1b6ea3b7e4aa4f492509a4952029cd8e48019ad", + "size": 185140, + "url": "https://libraries.minecraft.net/commons-io/commons-io/2.4/commons-io-2.4.jar" + } + }, + "name": "commons-io:commons-io:2.4" + }, + { + "downloads": { + "artifact": { + "path": "commons-codec/commons-codec/1.9/commons-codec-1.9.jar", + "sha1": "9ce04e34240f674bc72680f8b843b1457383161a", + "size": 263965, + "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.9/commons-codec-1.9.jar" + } + }, + "name": "commons-codec:commons-codec:1.9" + }, + { + "downloads": { + "artifact": { + "path": "net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar", + "sha1": "39c7796b469a600f72380316f6b1f11db6c2c7c4", + "size": 208338, + "url": "https://libraries.minecraft.net/net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar" + } + }, + "name": "net.java.jinput:jinput:2.0.5" + }, + { + "downloads": { + "artifact": { + "path": "net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar", + "sha1": "e12fe1fda814bd348c1579329c86943d2cd3c6a6", + "size": 7508, + "url": "https://libraries.minecraft.net/net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar" + } + }, + "name": "net.java.jutils:jutils:1.0.0" + }, + { + "downloads": { + "artifact": { + "path": "com/google/code/gson/gson/2.2.4/gson-2.2.4.jar", + "sha1": "a60a5e993c98c864010053cb901b7eab25306568", + "size": 190432, + "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.2.4/gson-2.2.4.jar" + } + }, + "name": "com.google.code.gson:gson:2.2.4" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/authlib/1.5.22/authlib-1.5.22.jar", + "sha1": "afaa8f6df976fcb5520e76ef1d5798c9e6b5c0b2", + "size": 64539, + "url": "https://libraries.minecraft.net/com/mojang/authlib/1.5.22/authlib-1.5.22.jar" + } + }, + "name": "com.mojang:authlib:1.5.22" + }, + { + "downloads": { + "artifact": { + "path": "com/mojang/realms/1.8.4/realms-1.8.4.jar", + "sha1": "15f8dc326c97a96dee6e65392e145ad6d1cb46cb", + "size": 1131574, + "url": "https://libraries.minecraft.net/com/mojang/realms/1.8.4/realms-1.8.4.jar" + } + }, + "name": "com.mojang:realms:1.8.4" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar", + "sha1": "a698750c16740fd5b3871425f4cb3bbaa87f529d", + "size": 365552, + "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar" + } + }, + "name": "org.apache.commons:commons-compress:1.8.1" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar", + "sha1": "18f4247ff4572a074444572cee34647c43e7c9c7", + "size": 589512, + "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar" + } + }, + "name": "org.apache.httpcomponents:httpclient:4.3.3" + }, + { + "downloads": { + "artifact": { + "path": "commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar", + "sha1": "f6f66e966c70a83ffbdb6f17a0919eaf7c8aca7f", + "size": 62050, + "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar" + } + }, + "name": "commons-logging:commons-logging:1.1.3" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar", + "sha1": "31fbbff1ddbf98f3aa7377c94d33b0447c646b6e", + "size": 282269, + "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar" + } + }, + "name": "org.apache.httpcomponents:httpcore:4.3.2" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/logging/log4j/log4j-api/2.0-beta9/log4j-api-2.0-beta9.jar", + "sha1": "1dd66e68cccd907880229f9e2de1314bd13ff785", + "size": 108161, + "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.0-beta9/log4j-api-2.0-beta9.jar" + } + }, + "name": "org.apache.logging.log4j:log4j-api:2.0-beta9" + }, + { + "downloads": { + "artifact": { + "path": "org/apache/logging/log4j/log4j-core/2.0-beta9/log4j-core-2.0-beta9.jar", + "sha1": "678861ba1b2e1fccb594bb0ca03114bb05da9695", + "size": 681134, + "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.0-beta9/log4j-core-2.0-beta9.jar" + } + }, + "name": "org.apache.logging.log4j:log4j-core:2.0-beta9" + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl/2.9.4-nightly-20150209/lwjgl-2.9.4-nightly-20150209.jar", + "sha1": "697517568c68e78ae0b4544145af031c81082dfe", + "size": 1047168, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl/2.9.4-nightly-20150209/lwjgl-2.9.4-nightly-20150209.jar" + } + }, + "name": "org.lwjgl.lwjgl:lwjgl:2.9.4-nightly-20150209", + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl_util/2.9.4-nightly-20150209/lwjgl_util-2.9.4-nightly-20150209.jar", + "sha1": "d51a7c040a721d13efdfbd34f8b257b2df882ad0", + "size": 173887, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl_util/2.9.4-nightly-20150209/lwjgl_util-2.9.4-nightly-20150209.jar" + } + }, + "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.4-nightly-20150209", + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209.jar", + "sha1": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33", + "size": 22, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209.jar" + }, + "classifiers": { + "natives-linux": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar", + "sha1": "931074f46c795d2f7b30ed6395df5715cfd7675b", + "size": 578680, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar" + }, + "natives-osx": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar", + "sha1": "bcab850f8f487c3f4c4dbabde778bb82bd1a40ed", + "size": 426822, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar" + }, + "natives-windows": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar", + "sha1": "b84d5102b9dbfabfeb5e43c7e2828d98a7fc80e0", + "size": 613748, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar" + } + } + }, + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + }, + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl/2.9.2-nightly-20140822/lwjgl-2.9.2-nightly-20140822.jar", + "sha1": "7707204c9ffa5d91662de95f0a224e2f721b22af", + "size": 1045632, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl/2.9.2-nightly-20140822/lwjgl-2.9.2-nightly-20140822.jar" + } + }, + "name": "org.lwjgl.lwjgl:lwjgl:2.9.2-nightly-20140822", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl_util/2.9.2-nightly-20140822/lwjgl_util-2.9.2-nightly-20140822.jar", + "sha1": "f0e612c840a7639c1f77f68d72a28dae2f0c8490", + "size": 173887, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl_util/2.9.2-nightly-20140822/lwjgl_util-2.9.2-nightly-20140822.jar" + } + }, + "name": "org.lwjgl.lwjgl:lwjgl_util:2.9.2-nightly-20140822", + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "classifiers": { + "natives-linux": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-linux.jar", + "sha1": "d898a33b5d0a6ef3fed3a4ead506566dce6720a5", + "size": 578539, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-linux.jar" + }, + "natives-osx": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-osx.jar", + "sha1": "79f5ce2fea02e77fe47a3c745219167a542121d7", + "size": 468116, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-osx.jar" + }, + "natives-windows": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-windows.jar", + "sha1": "78b2a55ce4dc29c6b3ec4df8ca165eba05f9b341", + "size": 613680, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.2-nightly-20140822/lwjgl-platform-2.9.2-nightly-20140822-natives-windows.jar" + } + } + }, + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.2-nightly-20140822", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + }, + "rules": [ + { + "action": "allow", + "os": { + "name": "osx" + } + } + ] + }, + { + "downloads": { + "classifiers": { + "natives-linux": { + "path": "net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-linux.jar", + "sha1": "7ff832a6eb9ab6a767f1ade2b548092d0fa64795", + "size": 10362, + "url": "https://libraries.minecraft.net/net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-linux.jar" + }, + "natives-osx": { + "path": "net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-osx.jar", + "sha1": "53f9c919f34d2ca9de8c51fc4e1e8282029a9232", + "size": 12186, + "url": "https://libraries.minecraft.net/net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-osx.jar" + }, + "natives-windows": { + "path": "net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-windows.jar", + "sha1": "385ee093e01f587f30ee1c8a2ee7d408fd732e16", + "size": 155179, + "url": "https://libraries.minecraft.net/net/java/jinput/jinput-platform/2.0.5/jinput-platform-2.0.5-natives-windows.jar" + } + } + }, + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "net.java.jinput:jinput-platform:2.0.5", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + } + } + ], + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} --assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} --accessToken ${auth_access_token} --userType ${user_type} --versionType ${version_type}", + "minimumLauncherVersion": 18, + "releaseTime": "2016-02-29T13:49:54+00:00", + "time": "2016-03-01T13:14:53+00:00", + "type": "release" +} diff --git a/meshmc/launcher/minecraft/testdata/codecwav-20101023.jar b/meshmc/launcher/minecraft/testdata/codecwav-20101023.jar new file mode 100644 index 0000000000..f5236083c6 --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/codecwav-20101023.jar @@ -0,0 +1 @@ +dummy test file. diff --git a/meshmc/launcher/minecraft/testdata/lib-native-arch.json b/meshmc/launcher/minecraft/testdata/lib-native-arch.json new file mode 100644 index 0000000000..501826ae10 --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/lib-native-arch.json @@ -0,0 +1,46 @@ +{ + "downloads": { + "classifiers": { + "natives-osx": { + "path": "tv/twitch/twitch-platform/5.16/twitch-platform-5.16-natives-osx.jar", + "sha1": "62503ee712766cf77f97252e5902786fd834b8c5", + "size": 418331, + "url": "https://libraries.minecraft.net/tv/twitch/twitch-platform/5.16/twitch-platform-5.16-natives-osx.jar" + }, + "natives-windows-32": { + "path": "tv/twitch/twitch-platform/5.16/twitch-platform-5.16-natives-windows-32.jar", + "sha1": "7c6affe439099806a4f552da14c42f9d643d8b23", + "size": 386792, + "url": "https://libraries.minecraft.net/tv/twitch/twitch-platform/5.16/twitch-platform-5.16-natives-windows-32.jar" + }, + "natives-windows-64": { + "path": "tv/twitch/twitch-platform/5.16/twitch-platform-5.16-natives-windows-64.jar", + "sha1": "39d0c3d363735b4785598e0e7fbf8297c706a9f9", + "size": 463390, + "url": "https://libraries.minecraft.net/tv/twitch/twitch-platform/5.16/twitch-platform-5.16-natives-windows-64.jar" + } + } + }, + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "tv.twitch:twitch-platform:5.16", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows-${arch}" + }, + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "linux" + } + } + ] +} diff --git a/meshmc/launcher/minecraft/testdata/lib-native.json b/meshmc/launcher/minecraft/testdata/lib-native.json new file mode 100644 index 0000000000..5b9f3b5562 --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/lib-native.json @@ -0,0 +1,52 @@ +{ + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209.jar", + "sha1": "b04f3ee8f5e43fa3b162981b50bb72fe1acabb33", + "size": 22, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209.jar" + }, + "classifiers": { + "natives-linux": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar", + "sha1": "931074f46c795d2f7b30ed6395df5715cfd7675b", + "size": 578680, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-linux.jar" + }, + "natives-osx": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar", + "sha1": "bcab850f8f487c3f4c4dbabde778bb82bd1a40ed", + "size": 426822, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-osx.jar" + }, + "natives-windows": { + "path": "org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar", + "sha1": "b84d5102b9dbfabfeb5e43c7e2828d98a7fc80e0", + "size": 613748, + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/lwjgl-platform/2.9.4-nightly-20150209/lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar" + } + } + }, + "extract": { + "exclude": [ + "META-INF/" + ] + }, + "name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", + "natives": { + "linux": "natives-linux", + "osx": "natives-osx", + "windows": "natives-windows" + }, + "rules": [ + { + "action": "allow" + }, + { + "action": "disallow", + "os": { + "name": "osx" + } + } + ] +} diff --git a/meshmc/launcher/minecraft/testdata/lib-simple.json b/meshmc/launcher/minecraft/testdata/lib-simple.json new file mode 100644 index 0000000000..90bbff074d --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/lib-simple.json @@ -0,0 +1,11 @@ +{ + "downloads": { + "artifact": { + "path": "com/paulscode/codecwav/20101023/codecwav-20101023.jar", + "sha1": "12f031cfe88fef5c1dd36c563c0a3a69bd7261da", + "size": 5618, + "url": "https://libraries.minecraft.net/com/paulscode/codecwav/20101023/codecwav-20101023.jar" + } + }, + "name": "com.paulscode:codecwav:20101023" +} diff --git a/meshmc/launcher/minecraft/testdata/testname-testversion-linux-32.jar b/meshmc/launcher/minecraft/testdata/testname-testversion-linux-32.jar new file mode 100644 index 0000000000..f5236083c6 --- /dev/null +++ b/meshmc/launcher/minecraft/testdata/testname-testversion-linux-32.jar @@ -0,0 +1 @@ +dummy test file. diff --git a/meshmc/launcher/minecraft/update/AssetUpdateTask.cpp b/meshmc/launcher/minecraft/update/AssetUpdateTask.cpp new file mode 100644 index 0000000000..e6b2574df8 --- /dev/null +++ b/meshmc/launcher/minecraft/update/AssetUpdateTask.cpp @@ -0,0 +1,133 @@ +/* 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 "AssetUpdateTask.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" +#include "net/ChecksumValidator.h" +#include "minecraft/AssetsUtils.h" + +#include "Application.h" + +AssetUpdateTask::AssetUpdateTask(MinecraftInstance* inst) +{ + m_inst = inst; +} + +AssetUpdateTask::~AssetUpdateTask() {} + +void AssetUpdateTask::executeTask() +{ + setStatus(tr("Updating assets index...")); + auto components = m_inst->getPackProfile(); + auto profile = components->getProfile(); + auto assets = profile->getMinecraftAssets(); + QUrl indexUrl = assets->url; + QString localPath = assets->id + ".json"; + auto job = new NetJob(tr("Asset index for %1").arg(m_inst->name()), + APPLICATION->network()); + + auto metacache = APPLICATION->metacache(); + auto entry = metacache->resolveEntry("asset_indexes", localPath); + entry->setStale(true); + auto hexSha1 = assets->sha1.toLatin1(); + qDebug() << "Asset index SHA1:" << hexSha1; + auto dl = Net::Download::makeCached(indexUrl, entry); + auto rawSha1 = QByteArray::fromHex(assets->sha1.toLatin1()); + dl->addValidator( + new Net::ChecksumValidator(QCryptographicHash::Sha1, rawSha1)); + job->addNetAction(dl); + + downloadJob.reset(job); + + connect(downloadJob.get(), &NetJob::succeeded, this, + &AssetUpdateTask::assetIndexFinished); + connect(downloadJob.get(), &NetJob::failed, this, + &AssetUpdateTask::assetIndexFailed); + connect(downloadJob.get(), &NetJob::progress, this, + &AssetUpdateTask::progress); + + qDebug() << m_inst->name() << ": Starting asset index download"; + downloadJob->start(); +} + +bool AssetUpdateTask::canAbort() const +{ + return true; +} + +void AssetUpdateTask::assetIndexFinished() +{ + AssetsIndex index; + qDebug() << m_inst->name() << ": Finished asset index download"; + + auto components = m_inst->getPackProfile(); + auto profile = components->getProfile(); + auto assets = profile->getMinecraftAssets(); + + QString asset_fname = "assets/indexes/" + assets->id + ".json"; + // FIXME: this looks like a job for a generic validator based on json + // schema? + if (!AssetsUtils::loadAssetsIndexJson(assets->id, asset_fname, index)) { + auto metacache = APPLICATION->metacache(); + auto entry = + metacache->resolveEntry("asset_indexes", assets->id + ".json"); + metacache->evictEntry(entry); + emitFailed(tr("Failed to read the assets index!")); + } + + auto job = index.getDownloadJob(); + if (job) { + setStatus(tr("Getting the assets files from Mojang...")); + downloadJob = job; + connect(downloadJob.get(), &NetJob::succeeded, this, + &AssetUpdateTask::emitSucceeded); + connect(downloadJob.get(), &NetJob::failed, this, + &AssetUpdateTask::assetsFailed); + connect(downloadJob.get(), &NetJob::progress, this, + &AssetUpdateTask::progress); + downloadJob->start(); + return; + } + emitSucceeded(); +} + +void AssetUpdateTask::assetIndexFailed(QString reason) +{ + qDebug() << m_inst->name() << ": Failed asset index download"; + emitFailed(tr("Failed to download the assets index:\n%1").arg(reason)); +} + +void AssetUpdateTask::assetsFailed(QString reason) +{ + emitFailed(tr("Failed to download assets:\n%1").arg(reason)); +} + +bool AssetUpdateTask::abort() +{ + if (downloadJob) { + return downloadJob->abort(); + } else { + qWarning() << "Prematurely aborted AssetUpdateTask"; + } + return true; +} diff --git a/meshmc/launcher/minecraft/update/AssetUpdateTask.h b/meshmc/launcher/minecraft/update/AssetUpdateTask.h new file mode 100644 index 0000000000..02b49837b6 --- /dev/null +++ b/meshmc/launcher/minecraft/update/AssetUpdateTask.h @@ -0,0 +1,49 @@ +/* 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 "tasks/Task.h" +#include "net/NetJob.h" +class MinecraftInstance; + +class AssetUpdateTask : public Task +{ + Q_OBJECT + public: + AssetUpdateTask(MinecraftInstance* inst); + virtual ~AssetUpdateTask(); + + void executeTask() override; + + bool canAbort() const override; + + private slots: + void assetIndexFinished(); + void assetIndexFailed(QString reason); + void assetsFailed(QString reason); + + public slots: + bool abort() override; + + private: + MinecraftInstance* m_inst; + NetJob::Ptr downloadJob; +}; diff --git a/meshmc/launcher/minecraft/update/FMLLibrariesTask.cpp b/meshmc/launcher/minecraft/update/FMLLibrariesTask.cpp new file mode 100644 index 0000000000..635af84ef1 --- /dev/null +++ b/meshmc/launcher/minecraft/update/FMLLibrariesTask.cpp @@ -0,0 +1,147 @@ +/* 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 "FMLLibrariesTask.h" + +#include "FileSystem.h" +#include "minecraft/VersionFilterData.h" +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "BuildConfig.h" +#include "Application.h" + +FMLLibrariesTask::FMLLibrariesTask(MinecraftInstance* inst) +{ + m_inst = inst; +} +void FMLLibrariesTask::executeTask() +{ + // Get the mod list + MinecraftInstance* inst = (MinecraftInstance*)m_inst; + auto components = inst->getPackProfile(); + auto profile = components->getProfile(); + + if (!profile->hasTrait("legacyFML")) { + emitSucceeded(); + return; + } + + QString version = components->getComponentVersion("net.minecraft"); + auto& fmlLibsMapping = g_VersionFilterData.fmlLibsMapping; + if (!fmlLibsMapping.contains(version)) { + emitSucceeded(); + return; + } + + auto& libList = fmlLibsMapping[version]; + + // determine if we need some libs for FML or forge + setStatus(tr("Checking for FML libraries...")); + if (!components->getComponent("net.minecraftforge")) { + emitSucceeded(); + return; + } + + // now check the lib folder inside the instance for files. + for (auto& lib : libList) { + QFileInfo libInfo(FS::PathCombine(inst->libDir(), lib.filename)); + if (libInfo.exists()) + continue; + fmlLibsToProcess.append(lib); + } + + // if everything is in place, there's nothing to do here... + if (fmlLibsToProcess.isEmpty()) { + emitSucceeded(); + return; + } + + // download missing libs to our place + setStatus(tr("Downloading FML libraries...")); + auto dljob = new NetJob("FML libraries", APPLICATION->network()); + auto metacache = APPLICATION->metacache(); + for (auto& lib : fmlLibsToProcess) { + auto entry = metacache->resolveEntry("fmllibs", lib.filename); + QString urlString = BuildConfig.FMLLIBS_BASE_URL + lib.filename; + dljob->addNetAction(Net::Download::makeCached(QUrl(urlString), entry)); + } + + connect(dljob, &NetJob::succeeded, this, + &FMLLibrariesTask::fmllibsFinished); + connect(dljob, &NetJob::failed, this, &FMLLibrariesTask::fmllibsFailed); + connect(dljob, &NetJob::progress, this, &FMLLibrariesTask::progress); + downloadJob.reset(dljob); + downloadJob->start(); +} + +bool FMLLibrariesTask::canAbort() const +{ + return true; +} + +void FMLLibrariesTask::fmllibsFinished() +{ + downloadJob.reset(); + if (!fmlLibsToProcess.isEmpty()) { + setStatus(tr("Copying FML libraries into the instance...")); + MinecraftInstance* inst = (MinecraftInstance*)m_inst; + auto metacache = APPLICATION->metacache(); + int index = 0; + for (auto& lib : fmlLibsToProcess) { + progress(index, fmlLibsToProcess.size()); + auto entry = metacache->resolveEntry("fmllibs", lib.filename); + auto path = FS::PathCombine(inst->libDir(), lib.filename); + if (!FS::ensureFilePathExists(path)) { + emitFailed(tr( + "Failed creating FML library folder inside the instance.")); + return; + } + if (!QFile::copy(entry->getFullPath(), + FS::PathCombine(inst->libDir(), lib.filename))) { + emitFailed(tr("Failed copying Forge/FML library: %1.") + .arg(lib.filename)); + return; + } + index++; + } + progress(index, fmlLibsToProcess.size()); + } + emitSucceeded(); +} +void FMLLibrariesTask::fmllibsFailed(QString reason) +{ + QStringList failed = downloadJob->getFailedFiles(); + QString failed_all = failed.join("\n"); + emitFailed(tr("Failed to download the following " + "files:\n%1\n\nReason:%2\nPlease try again.") + .arg(failed_all, reason)); +} + +bool FMLLibrariesTask::abort() +{ + if (downloadJob) { + return downloadJob->abort(); + } else { + qWarning() << "Prematurely aborted FMLLibrariesTask"; + } + return true; +} diff --git a/meshmc/launcher/minecraft/update/FMLLibrariesTask.h b/meshmc/launcher/minecraft/update/FMLLibrariesTask.h new file mode 100644 index 0000000000..938e287aa3 --- /dev/null +++ b/meshmc/launcher/minecraft/update/FMLLibrariesTask.h @@ -0,0 +1,51 @@ +/* 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 "tasks/Task.h" +#include "net/NetJob.h" +#include "minecraft/VersionFilterData.h" + +class MinecraftInstance; + +class FMLLibrariesTask : public Task +{ + Q_OBJECT + public: + FMLLibrariesTask(MinecraftInstance* inst); + virtual ~FMLLibrariesTask() {}; + + void executeTask() override; + + bool canAbort() const override; + + private slots: + void fmllibsFinished(); + void fmllibsFailed(QString reason); + + public slots: + bool abort() override; + + private: + MinecraftInstance* m_inst; + NetJob::Ptr downloadJob; + QList<FMLlib> fmlLibsToProcess; +}; diff --git a/meshmc/launcher/minecraft/update/FoldersTask.cpp b/meshmc/launcher/minecraft/update/FoldersTask.cpp new file mode 100644 index 0000000000..9d48baf3c1 --- /dev/null +++ b/meshmc/launcher/minecraft/update/FoldersTask.cpp @@ -0,0 +1,40 @@ +/* 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 "FoldersTask.h" +#include "minecraft/MinecraftInstance.h" +#include <QDir> + +FoldersTask::FoldersTask(MinecraftInstance* inst) : Task() +{ + m_inst = inst; +} + +void FoldersTask::executeTask() +{ + // Make directories + QDir mcDir(m_inst->gameRoot()); + if (!mcDir.exists() && !mcDir.mkpath(".")) { + emitFailed(tr("Failed to create folder for minecraft binaries.")); + return; + } + emitSucceeded(); +} diff --git a/meshmc/launcher/minecraft/update/FoldersTask.h b/meshmc/launcher/minecraft/update/FoldersTask.h new file mode 100644 index 0000000000..be31135647 --- /dev/null +++ b/meshmc/launcher/minecraft/update/FoldersTask.h @@ -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/>. + */ + +#pragma once + +#include "tasks/Task.h" + +class MinecraftInstance; +class FoldersTask : public Task +{ + Q_OBJECT + public: + FoldersTask(MinecraftInstance* inst); + virtual ~FoldersTask() {}; + + void executeTask() override; + + private: + MinecraftInstance* m_inst; +}; diff --git a/meshmc/launcher/minecraft/update/LibrariesTask.cpp b/meshmc/launcher/minecraft/update/LibrariesTask.cpp new file mode 100644 index 0000000000..0b0524f4e3 --- /dev/null +++ b/meshmc/launcher/minecraft/update/LibrariesTask.cpp @@ -0,0 +1,122 @@ +/* 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 "LibrariesTask.h" + +#include "minecraft/MinecraftInstance.h" +#include "minecraft/PackProfile.h" + +#include "Application.h" + +LibrariesTask::LibrariesTask(MinecraftInstance* inst) +{ + m_inst = inst; +} + +void LibrariesTask::executeTask() +{ + setStatus(tr("Getting the library files from Mojang...")); + qDebug() << m_inst->name() << ": downloading libraries"; + MinecraftInstance* inst = (MinecraftInstance*)m_inst; + + // Build a list of URLs that will need to be downloaded. + auto components = inst->getPackProfile(); + auto profile = components->getProfile(); + + auto job = new NetJob(tr("Libraries for instance %1").arg(inst->name()), + APPLICATION->network()); + downloadJob.reset(job); + + auto metacache = APPLICATION->metacache(); + + auto processArtifactPool = [&](const QList<LibraryPtr>& pool, + QStringList& errors, + const QString& localPath) { + for (auto lib : pool) { + if (!lib) { + emitFailed( + tr("Null jar is specified in the metadata, aborting.")); + return false; + } + auto dls = lib->getDownloads(currentSystem, metacache.get(), errors, + localPath); + for (auto dl : dls) { + downloadJob->addNetAction(dl); + } + } + return true; + }; + + QStringList failedLocalLibraries; + QList<LibraryPtr> libArtifactPool; + libArtifactPool.append(profile->getLibraries()); + libArtifactPool.append(profile->getNativeLibraries()); + libArtifactPool.append(profile->getMavenFiles()); + libArtifactPool.append(profile->getMainJar()); + processArtifactPool(libArtifactPool, failedLocalLibraries, + inst->getLocalLibraryPath()); + + QStringList failedLocalJarMods; + processArtifactPool(profile->getJarMods(), failedLocalJarMods, + inst->jarModsDir()); + + if (!failedLocalJarMods.empty() || !failedLocalLibraries.empty()) { + downloadJob.reset(); + QString failed_all = + (failedLocalLibraries + failedLocalJarMods).join("\n"); + emitFailed(tr("Some artifacts marked as 'local' are missing their " + "files:\n%1\n\nYou need to either add the files, or " + "removed the packages that require them.\nYou'll have to " + "correct this problem manually.") + .arg(failed_all)); + return; + } + + connect(downloadJob.get(), &NetJob::succeeded, this, + &LibrariesTask::emitSucceeded); + connect(downloadJob.get(), &NetJob::failed, this, + &LibrariesTask::jarlibFailed); + connect(downloadJob.get(), &NetJob::progress, this, + &LibrariesTask::progress); + downloadJob->start(); +} + +bool LibrariesTask::canAbort() const +{ + return true; +} + +void LibrariesTask::jarlibFailed(QString reason) +{ + emitFailed(tr("Game update failed: it was impossible to fetch the required " + "libraries.\nReason:\n%1") + .arg(reason)); +} + +bool LibrariesTask::abort() +{ + if (downloadJob) { + return downloadJob->abort(); + } else { + qWarning() << "Prematurely aborted LibrariesTask"; + } + return true; +} diff --git a/meshmc/launcher/minecraft/update/LibrariesTask.h b/meshmc/launcher/minecraft/update/LibrariesTask.h new file mode 100644 index 0000000000..7ac7ec538c --- /dev/null +++ b/meshmc/launcher/minecraft/update/LibrariesTask.h @@ -0,0 +1,47 @@ +/* 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 "tasks/Task.h" +#include "net/NetJob.h" +class MinecraftInstance; + +class LibrariesTask : public Task +{ + Q_OBJECT + public: + LibrariesTask(MinecraftInstance* inst); + virtual ~LibrariesTask() {}; + + void executeTask() override; + + bool canAbort() const override; + + private slots: + void jarlibFailed(QString reason); + + public slots: + bool abort() override; + + private: + MinecraftInstance* m_inst; + NetJob::Ptr downloadJob; +}; |
