// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: 2026 Project Tick // SPDX-FileContributor: Project Tick Team /* * ProjT Launcher - Minecraft Launcher * 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, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * === Upstream License Block (Do Not Modify) ============================== * * * * Prism Launcher - Minecraft Launcher * Copyright (c) 2022 flowln * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * * 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 #include #include #include #include #include #include #include #include #include "AssetsUtils.h" #include "BuildConfig.h" #include "FileSystem.h" #include "net/ApiDownload.h" #include "net/ChecksumValidator.h" #include "net/Download.h" #include "Application.h" #include "net/NetRequest.h" namespace { // Helper function to get standard asset directory structure struct AssetDirs { QDir assets; QDir indexes; QDir objects; QDir virtualDir; }; AssetDirs getAssetDirectories() { AssetDirs dirs; dirs.assets = QDir("assets/"); dirs.indexes = QDir(FS::PathCombine(dirs.assets.path(), "indexes")); dirs.objects = QDir(FS::PathCombine(dirs.assets.path(), "objects")); dirs.virtualDir = QDir(FS::PathCombine(dirs.assets.path(), "virtual")); return dirs; } QSet collectPathsFromDir(QString dirPath) { QFileInfo dirInfo(dirPath); if (!dirInfo.exists()) { return {}; } QSet 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. if (!file.open(QIODevice::ReadOnly)) { qCritical() << "Failed to read assets index file" << path << ":" << file.errorString(); 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.toLongLong(); } } index.objects.insert(iter.key(), object); } return true; } QDir getAssetsDir(const QString& assetsId, const QString& resourcesFolder) { auto dirs = getAssetDirectories(); QDir indexDir = dirs.indexes; QDir objectDir = dirs.objects; QDir virtualDir = dirs.virtualDir; 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; } bool reconstructAssets(QString assetsId, QString resourcesFolder) { auto dirs = getAssetDirectories(); QDir indexDir = dirs.indexes; QDir objectDir = dirs.objects; QDir virtualDir = QDir(FS::PathCombine(dirs.assets.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" << dirs.assets.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); } } // Consider adding a function to update the .lastused file with the current timestamp for asset usage // tracking. if (removeLeftovers) { for (auto& file : presentFiles) { qDebug() << "Would remove" << file; } } } return true; } } // namespace AssetsUtils // ============================================================================ // AssetObject implementation // ============================================================================ QString AssetObject::getRelPath() const { return hash.left(2) + "/" + hash; } QString AssetObject::getLocalPath() const { return "assets/objects/" + getRelPath(); } QUrl AssetObject::getUrl() const { auto resourceURL = APPLICATION->settings()->get("ResourceURL").toString(); if (resourceURL.isEmpty()) { resourceURL = BuildConfig.DEFAULT_RESOURCE_BASE; } return resourceURL + getRelPath(); } bool AssetObject::isValid() const { QFileInfo objectFile(getLocalPath()); return objectFile.isFile() && objectFile.size() == size; } Net::NetRequest::Ptr AssetObject::getDownloadAction() const { if (isValid()) { return nullptr; } QFileInfo objectFile(getLocalPath()); auto objectDL = Net::ApiDownload::makeFile(getUrl(), objectFile.filePath()); if (!hash.isEmpty()) { objectDL->addValidator(new Net::ChecksumValidator(QCryptographicHash::Sha1, hash)); } objectDL->setProgress(objectDL->getProgress(), size); return objectDL; } // ============================================================================ // AssetsIndex implementation // ============================================================================ NetJob::Ptr AssetsIndex::getDownloadJob() const { auto job = makeShared(QObject::tr("Assets for %1").arg(id), APPLICATION->network()); for (const auto& object : objects.values()) { auto dl = object.getDownloadAction(); if (dl) { job->addNetAction(dl); } } return job->size() ? job : nullptr; } qint64 AssetsIndex::totalSize() const { qint64 total = 0; for (const auto& object : objects.values()) { total += object.size; } return total; } int AssetsIndex::missingAssetCount() const { int count = 0; for (const auto& object : objects.values()) { if (!object.isValid()) { count++; } } return count; } bool AssetsIndex::isComplete() const { return missingAssetCount() == 0; } // ============================================================================ // AssetsManager implementation // ============================================================================ QDir AssetsManager::assetsBaseDir() { return QDir("assets/"); } QDir AssetsManager::indexesDir() { return QDir(FS::PathCombine(assetsBaseDir().path(), "indexes")); } QDir AssetsManager::objectsDir() { return QDir(FS::PathCombine(assetsBaseDir().path(), "objects")); } QDir AssetsManager::virtualDir() { return QDir(FS::PathCombine(assetsBaseDir().path(), "virtual")); } std::optional AssetsManager::loadIndex(const QString& indexId, const QString& filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { qCritical() << "Failed to read assets index file" << filePath << ":" << file.errorString(); return std::nullopt; } QByteArray jsonData = file.readAll(); file.close(); QJsonParseError parseError; QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); if (parseError.error != QJsonParseError::NoError) { qCritical() << "Failed to parse assets index file:" << parseError.errorString() << "at offset" << QString::number(parseError.offset); return std::nullopt; } if (!jsonDoc.isObject()) { qCritical() << "Invalid assets index JSON: Root should be an object."; return std::nullopt; } QJsonObject root = jsonDoc.object(); AssetsIndex index; index.id = indexId; index.isVirtual = root.value("virtual").toBool(false); index.mapToResources = root.value("map_to_resources").toBool(false); QJsonValue objectsValue = root.value("objects"); QVariantMap map = objectsValue.toVariant().toMap(); for (auto iter = map.constBegin(); iter != map.constEnd(); ++iter) { QVariantMap nested = iter.value().toMap(); AssetObject obj; obj.hash = nested.value("hash").toString(); obj.size = nested.value("size").toLongLong(); index.objects.insert(iter.key(), obj); } return index; } QDir AssetsManager::getAssetsDir(const AssetsIndex& index, const QString& resourcesFolder) { if (index.isVirtual) { return QDir(FS::PathCombine(virtualDir().path(), index.id)); } else if (index.mapToResources) { return QDir(resourcesFolder); } return QDir(FS::PathCombine(virtualDir().path(), index.id)); } QDir AssetsManager::getAssetsDir(const QString& assetsId, const QString& resourcesFolder) { QString indexPath = FS::PathCombine(indexesDir().path(), assetsId + ".json"); auto index = loadIndex(assetsId, indexPath); if (!index) { qCritical() << "Failed to load asset index" << assetsId; return QDir(FS::PathCombine(virtualDir().path(), assetsId)); } return getAssetsDir(*index, resourcesFolder); } bool AssetsManager::reconstructAssets(const AssetsIndex& index, const QString& resourcesFolder) { QString targetPath; bool removeLeftovers = false; if (index.isVirtual) { targetPath = FS::PathCombine(virtualDir().path(), index.id); removeLeftovers = true; qDebug() << "Reconstructing virtual assets folder at" << targetPath; } else if (index.mapToResources) { targetPath = resourcesFolder; qDebug() << "Reconstructing resources folder at" << targetPath; } if (targetPath.isEmpty()) { return true; // Nothing to reconstruct } // Collect existing files QSet presentFiles = collectPathsFromDir(targetPath); // Copy missing assets for (auto iter = index.objects.constBegin(); iter != index.objects.constEnd(); ++iter) { const QString& assetPath = iter.key(); const AssetObject& asset = iter.value(); QString targetFilePath = FS::PathCombine(targetPath, assetPath); QString tlk = asset.hash.left(2); QString originalPath = FS::PathCombine(objectsDir().path(), tlk, asset.hash); QFile original(originalPath); if (!original.exists()) { continue; } presentFiles.remove(targetFilePath); QFile target(targetFilePath); if (!target.exists()) { QFileInfo info(targetFilePath); FS::ensureFolderPathExists(info.dir().path()); bool success = original.copy(targetFilePath); qDebug() << "Copying" << originalPath << "to" << targetFilePath << (success ? "OK" : "FAILED"); } } // Remove leftover files (only for virtual assets) if (removeLeftovers) { for (const auto& file : presentFiles) { qDebug() << "Removing leftover file:" << file; QFile::remove(file); } } return true; } bool AssetsManager::reconstructAssets(const QString& assetsId, const QString& resourcesFolder) { QString indexPath = FS::PathCombine(indexesDir().path(), assetsId + ".json"); auto index = loadIndex(assetsId, indexPath); if (!index) { qCritical() << "Failed to load asset index for reconstruction:" << assetsId; return false; } return reconstructAssets(*index, resourcesFolder); } QStringList AssetsManager::validateAssets(const AssetsIndex& index) { QStringList missing; for (auto iter = index.objects.constBegin(); iter != index.objects.constEnd(); ++iter) { const AssetObject& asset = iter.value(); if (!asset.isValid()) { missing.append(iter.key()); } } return missing; }