diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-03-28 20:13:26 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-03-28 20:13:26 +0300 |
| commit | 2ba08372b09725edfb92ef1bbe7745ffaac4a14e (patch) | |
| tree | bac0fa135017b956ffacee0c7c9452380a9b461b | |
| parent | 810e7b011d2910fe277e93674c8df8824ef85aac (diff) | |
| download | Project-Tick-2ba08372b09725edfb92ef1bbe7745ffaac4a14e.tar.gz Project-Tick-2ba08372b09725edfb92ef1bbe7745ffaac4a14e.zip | |
NOISSUE Modrinth implementation completed
Modrinth implementation completed, some bugs in CheckPatch.pl
fixed, issues with cmake install fixed, and tons of problems
found by checkpatch resolved.
Signed-off-by: Mehmet Samet Duman <yongdohyun@projecttick.org>
22 files changed, 2131 insertions, 665 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index aa6071339a..f60f82d460 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -323,7 +323,7 @@ elseif(UNIX) set(JARS_DEST_DIR "share/${MeshMC_Name}") # Set RPATH - SET(MeshMC_BINARY_RPATH "$ORIGIN/") + SET(MeshMC_BINARY_RPATH "$ORIGIN/../lib${LIB_SUFFIX}") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${MeshMC_AppID}.desktop DESTINATION ${KDE_INSTALL_APPDIR}) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${MeshMC_AppID}.metainfo.xml DESTINATION ${KDE_INSTALL_METAINFODIR}) diff --git a/launcher/Application.cpp b/launcher/Application.cpp index fbb0850297..30900547d3 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -105,7 +105,7 @@ #define WIN32_LEAN_AND_MEAN #endif #include <windows.h> -#include <stdio.h> +#include <cstdio> #endif #define STRINGIFY(x) #x @@ -115,8 +115,8 @@ static const QLatin1String liveCheckFile("live.check"); using namespace Commandline; -#define MACOS_HINT "If you are on macOS Sierra, you might have to move the app to your /Applications or ~/Applications folder. "\ - "This usually fixes the problem and you can move the application elsewhere afterwards.\n"\ +#define MACOS_HINT "If you are on macOS Sierra, you might have to move the app to your /Applications or ~/Applications"\ + "folder. This usually fixes the problem and you can move the application elsewhere afterwards.\n"\ "\n" namespace { @@ -154,24 +154,12 @@ QString getIdealPlatform(QString currentPlatform) { } } case Sys::KernelType::Windows: { - // FIXME: 5.15.2 is not stable on Windows, due to a large number of completely unpredictable and hard to reproduce issues + // FIXME: 5.15.2 is not stable on Windows, due to a large number + // of completely unpredictable and hard to reproduce issues break; -/* - if(info.kernelMajor == 6 && info.kernelMinor >= 1) { - // Windows 7 - return "win32-5.15.2"; - } - else if (info.kernelMajor > 6) { - // Above Windows 7 - return "win32-5.15.2"; - } - else { - // Below Windows 7 - return "win32"; - } -*/ } case Sys::KernelType::Undetermined: + [[fallthrough]]; case Sys::KernelType::Linux: { break; } @@ -183,18 +171,104 @@ QString getIdealPlatform(QString currentPlatform) { Application::Application(int &argc, char **argv) : QApplication(argc, argv) { + initPlatform(); + if (m_status != StartingUp) return; + + auto args = parseCommandLine(argc, argv); + if (m_status != StartingUp) return; + + QString origcwdPath, adjustedBy, dataPath; + if (!resolveDataPath(args, dataPath, adjustedBy, origcwdPath)) return; + + if(m_instanceIdToLaunch.isEmpty() && !m_serverToJoin.isEmpty()) + { + qWarning() << "--server can only be used in combination with --launch!"; + m_status = Application::Failed; + return; + } + + if(m_instanceIdToLaunch.isEmpty() && !m_profileToUse.isEmpty()) + { + qWarning() << "--account can only be used in combination with --launch!"; + m_status = Application::Failed; + return; + } + + if (!initPeerInstance()) return; + if (!initLogging(dataPath)) return; + + QString binPath = applicationDirPath(); + setupPaths(binPath, origcwdPath, adjustedBy); + + initSettings(); + +#ifndef QT_NO_ACCESSIBILITY + QAccessible::installFactory(groupViewAccessibleFactory); +#endif /* !QT_NO_ACCESSIBILITY */ + + initSubsystems(); + initAnalytics(); + + if(createSetupWizard()) + { + return; + } + performMainStartupAction(); +} + +void Application::initAnalytics() +{ + const int analyticsVersion = 2; + if(BuildConfig.ANALYTICS_ID.isEmpty()) + { + return; + } + + auto analyticsSetting = m_settings->getSetting("Analytics"); + connect(analyticsSetting.get(), &Setting::SettingChanged, this, &Application::analyticsSettingChanged); + QString clientID = m_settings->get("AnalyticsClientID").toString(); + if(clientID.isEmpty()) + { + clientID = QUuid::createUuid().toString(); + clientID.remove(QLatin1Char('{')); + clientID.remove(QLatin1Char('}')); + m_settings->set("AnalyticsClientID", clientID); + } + m_analytics = new GAnalytics(BuildConfig.ANALYTICS_ID, clientID, analyticsVersion, this); + m_analytics->setLogLevel(GAnalytics::Debug); + m_analytics->setAnonymizeIPs(true); + // FIXME: the ganalytics library has no idea about our fancy shared pointers... + m_analytics->setNetworkAccessManager(network().get()); + + if(m_settings->get("AnalyticsSeen").toInt() < m_analytics->version()) + { + qDebug() << "Analytics info not seen by user yet (or old version)."; + return; + } + if(!m_settings->get("Analytics").toBool()) + { + qDebug() << "Analytics disabled by user."; + return; + } + + m_analytics->enable(); + qDebug() << "<> Initialized analytics with tid" << BuildConfig.ANALYTICS_ID; +} + +void Application::initPlatform() +{ #if defined Q_OS_WIN32 // attach the parent console if(AttachConsole(ATTACH_PARENT_PROCESS)) { - // if attach succeeds, reopen and sync all the i/o + // Reopen and sync all the I/O after attaching to parent console if(freopen("CON", "w", stdout)) { - std::cout.sync_with_stdio(); + std::ios_base::sync_with_stdio(); } if(freopen("CON", "w", stderr)) { - std::cerr.sync_with_stdio(); + std::ios_base::sync_with_stdio(); } if(freopen("CON", "r", stdin)) { @@ -203,7 +277,7 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) auto out = GetStdHandle (STD_OUTPUT_HANDLE); DWORD written; const char * endline = "\n"; - WriteConsole(out, endline, strlen(endline), &written, NULL); + WriteConsole(out, endline, strlen(endline), &written, nullptr); consoleAttached = true; } #endif @@ -238,86 +312,103 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) // Don't quit on hiding the last window this->setQuitOnLastWindowClosed(false); +} - // Commandline parsing +QHash<QString, QVariant> Application::parseCommandLine(int &argc, char **argv) +{ QHash<QString, QVariant> args; - { - Parser parser(FlagStyle::GNU, ArgumentStyle::SpaceAndEquals); - - // --help - parser.addSwitch("help"); - parser.addShortOpt("help", 'h'); - parser.addDocumentation("help", "Display this help and exit."); - // --version - parser.addSwitch("version"); - parser.addShortOpt("version", 'V'); - parser.addDocumentation("version", "Display program version and exit."); - // --dir - parser.addOption("dir"); - parser.addShortOpt("dir", 'd'); - parser.addDocumentation("dir", "Use the supplied folder as application root instead of the binary location (use '.' for current)"); - // --launch - parser.addOption("launch"); - parser.addShortOpt("launch", 'l'); - parser.addDocumentation("launch", "Launch the specified instance (by instance ID)"); - // --server - parser.addOption("server"); - parser.addShortOpt("server", 's'); - parser.addDocumentation("server", "Join the specified server on launch (only valid in combination with --launch)"); - // --profile - parser.addOption("profile"); - parser.addShortOpt("profile", 'a'); - parser.addDocumentation("profile", "Use the account specified by its profile name (only valid in combination with --launch)"); - // --alive - parser.addSwitch("alive"); - parser.addDocumentation("alive", "Write a small '" + liveCheckFile + "' file after MeshMC starts"); - // --import - parser.addOption("import"); - parser.addShortOpt("import", 'I'); - parser.addDocumentation("import", "Import instance from specified zip (local path or URL)"); - - // parse the arguments - try - { - args = parser.parse(arguments()); - } - catch (const ParsingError &e) - { - std::cerr << "CommandLineError: " << e.what() << std::endl; - if(argc > 0) - std::cerr << "Try '" << argv[0] << " -h' to get help on command line parameters." - << std::endl; - m_status = Application::Failed; - return; - } - // display help and exit - if (args["help"].toBool()) - { - std::cout << qPrintable(parser.compileHelp(arguments()[0])); - m_status = Application::Succeeded; - return; - } + Parser parser(FlagStyle::GNU, ArgumentStyle::SpaceAndEquals); + + // --help + parser.addSwitch("help"); + parser.addShortOpt("help", 'h'); + parser.addDocumentation("help", "Display this help and exit."); + // --version + parser.addSwitch("version"); + parser.addShortOpt("version", 'V'); + parser.addDocumentation("version", "Display program version and exit."); + // --dir + parser.addOption("dir"); + parser.addShortOpt("dir", 'd'); + parser.addDocumentation( + "dir", + "Use the supplied folder as application root " + "instead of the binary location (use '.' for current)"); + // --launch + parser.addOption("launch"); + parser.addShortOpt("launch", 'l'); + parser.addDocumentation("launch", "Launch the specified instance (by instance ID)"); + // --server + parser.addOption("server"); + parser.addShortOpt("server", 's'); + parser.addDocumentation("server", "Join the specified server on launch (only valid in combination with --launch)"); + // --profile + parser.addOption("profile"); + parser.addShortOpt("profile", 'a'); + parser.addDocumentation( + "profile", + "Use the account specified by its profile name " + "(only valid in combination with --launch)"); + // --alive + parser.addSwitch("alive"); + parser.addDocumentation("alive", "Write a small '" + liveCheckFile + "' file after MeshMC starts"); + // --import + parser.addOption("import"); + parser.addShortOpt("import", 'I'); + parser.addDocumentation("import", "Import instance from specified zip (local path or URL)"); + + // parse the arguments + try + { + args = parser.parse(arguments()); + } + catch (const ParsingError &e) + { + qCritical() << "CommandLineError:" << e.what(); + if(argc > 0) + qCritical() << "Try '" << argv[0] + << "' -h' to get help on command line parameters."; + m_status = Application::Failed; + return args; + } - // display version and exit - if (args["version"].toBool()) - { - std::cout << "Version " << BuildConfig.printableVersionString().toStdString() << std::endl; - std::cout << "Git " << BuildConfig.GIT_COMMIT.toStdString() << std::endl; - m_status = Application::Succeeded; - return; - } + // display help and exit + if (args["help"].toBool()) + { + QTextStream(stdout) << parser.compileHelp(arguments()[0]); + m_status = Application::Succeeded; + return args; } + + // display version and exit + if (args["version"].toBool()) + { + QTextStream(stdout) << "Version " + << BuildConfig.printableVersionString() << "\n"; + QTextStream(stdout) << "Git " + << BuildConfig.GIT_COMMIT << "\n"; + m_status = Application::Succeeded; + return args; + } + m_instanceIdToLaunch = args["launch"].toString(); m_serverToJoin = args["server"].toString(); m_profileToUse = args["profile"].toString(); m_liveCheck = args["alive"].toBool(); m_zipToImport = args["import"].toUrl(); - QString origcwdPath = QDir::currentPath(); - QString binPath = applicationDirPath(); - QString adjustedBy; - QString dataPath; + return args; +} + +bool Application::resolveDataPath( + const QHash<QString, QVariant> &args, + QString &dataPath, + QString &adjustedBy, + QString &origcwdPath) +{ + origcwdPath = QDir::currentPath(); + // change folder QString dirParam = args["dir"].toString(); if (!dirParam.isEmpty()) @@ -383,7 +474,7 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) "MeshMC cannot continue until you fix this problem." ).arg(dataPath) ); - return; + return false; } if (!QDir::setCurrent(dataPath)) { @@ -401,28 +492,14 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) "MeshMC cannot continue until you fix this problem." ).arg(dataPath) ); - return; - } - - if(m_instanceIdToLaunch.isEmpty() && !m_serverToJoin.isEmpty()) - { - std::cerr << "--server can only be used in combination with --launch!" << std::endl; - m_status = Application::Failed; - return; - } - - if(m_instanceIdToLaunch.isEmpty() && !m_profileToUse.isEmpty()) - { - std::cerr << "--account can only be used in combination with --launch!" << std::endl; - m_status = Application::Failed; - return; + return false; } #if defined(Q_OS_MAC) // move user data to new location if on macOS and it still exists in Contents/MacOS QDir fi(applicationDirPath()); QString originalData = fi.absolutePath(); - // if the config file exists in Contents/MacOS, then user data is still there and needs to moved + // Config file in Contents/MacOS means user data still there and needs moving if (QFileInfo::exists(FS::PathCombine(originalData, BuildConfig.MESHMC_CONFIGFILE))) { if (!QFileInfo::exists(FS::PathCombine(originalData, "dontmovemacdata"))) @@ -431,7 +508,14 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) askMoveDialogue = QMessageBox::question( nullptr, BuildConfig.MESHMC_DISPLAYNAME, - "Would you like to move application data to a new data location? It will improve MeshMC's performance, but if you switch to older versions it will look like instances have disappeared. If you select no, you can migrate later in settings. You should select yes unless you're commonly switching between different versions (eg. develop and stable).", + "Would you like to move application data to a new " + "data location? It will improve MeshMC's " + "performance, but if you switch to older versions " + "it will look like instances have disappeared. " + "If you select no, you can migrate later in " + "settings. You should select yes unless you're " + "commonly switching between different versions " + "(eg. develop and stable).", QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes ); @@ -481,316 +565,325 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) } #endif + return true; +} + +bool Application::initPeerInstance() +{ /* * Establish the mechanism for communication with an already running MeshMC that uses the same data path. * If there is one, tell it what the user actually wanted to do and exit. * We want to initialize this before logging to avoid messing with the log of a potential already running copy. */ auto appID = ApplicationId::fromPathAndVersion(QDir::currentPath(), BuildConfig.printableVersionString()); - { - // FIXME: you can run the same binaries with multiple data dirs and they won't clash. This could cause issues for updates. - m_peerInstance = new LocalPeer(this, appID); - connect(m_peerInstance, &LocalPeer::messageReceived, this, &Application::messageReceived); - if(m_peerInstance->isClient()) { - int timeout = 2000; - if(m_instanceIdToLaunch.isEmpty()) + // FIXME: you can run the same binaries with multiple data dirs + // and they won't clash. This could cause issues for updates. + m_peerInstance = new LocalPeer(this, appID); + connect(m_peerInstance, &LocalPeer::messageReceived, this, &Application::messageReceived); + if(m_peerInstance->isClient()) { + int timeout = 2000; + + if(m_instanceIdToLaunch.isEmpty()) + { + ApplicationMessage activate; + activate.command = "activate"; + m_peerInstance->sendMessage(activate.serialize(), timeout); + + if(!m_zipToImport.isEmpty()) { - ApplicationMessage activate; - activate.command = "activate"; - m_peerInstance->sendMessage(activate.serialize(), timeout); - - if(!m_zipToImport.isEmpty()) - { - ApplicationMessage import; - import.command = "import"; - import.args.insert("path", m_zipToImport.toString()); - m_peerInstance->sendMessage(import.serialize(), timeout); - } + ApplicationMessage import; + import.command = "import"; + import.args.insert("path", m_zipToImport.toString()); + m_peerInstance->sendMessage(import.serialize(), timeout); } - else - { - ApplicationMessage launch; - launch.command = "launch"; - launch.args["id"] = m_instanceIdToLaunch; + } + else + { + ApplicationMessage launch; + launch.command = "launch"; + launch.args["id"] = m_instanceIdToLaunch; - if(!m_serverToJoin.isEmpty()) - { - launch.args["server"] = m_serverToJoin; - } - if(!m_profileToUse.isEmpty()) - { - launch.args["profile"] = m_profileToUse; - } - m_peerInstance->sendMessage(launch.serialize(), timeout); + if(!m_serverToJoin.isEmpty()) + { + launch.args["server"] = m_serverToJoin; } - m_status = Application::Succeeded; - return; + if(!m_profileToUse.isEmpty()) + { + launch.args["profile"] = m_profileToUse; + } + m_peerInstance->sendMessage(launch.serialize(), timeout); } + m_status = Application::Succeeded; + return false; } - // init the logger + return true; +} + +bool Application::initLogging(const QString &dataPath) +{ + static const QString logBase = BuildConfig.MESHMC_NAME + "-%0.log"; + auto moveFile = [](const QString &oldName, const QString &newName) { - static const QString logBase = BuildConfig.MESHMC_NAME + "-%0.log"; - auto moveFile = [](const QString &oldName, const QString &newName) - { - QFile::remove(newName); - QFile::copy(oldName, newName); - QFile::remove(oldName); - }; + static_cast<void>(QFile::remove(newName)); + static_cast<void>(QFile::copy(oldName, newName)); + static_cast<void>(QFile::remove(oldName)); + }; - moveFile(logBase.arg(3), logBase.arg(4)); - moveFile(logBase.arg(2), logBase.arg(3)); - moveFile(logBase.arg(1), logBase.arg(2)); - moveFile(logBase.arg(0), logBase.arg(1)); + moveFile(logBase.arg(3), logBase.arg(4)); + moveFile(logBase.arg(2), logBase.arg(3)); + moveFile(logBase.arg(1), logBase.arg(2)); + moveFile(logBase.arg(0), logBase.arg(1)); - logFile = std::unique_ptr<QFile>(new QFile(logBase.arg(0))); - if(!logFile->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) - { - showFatalErrorMessage( - "MeshMC data folder is not writable!", - QString( - "MeshMC couldn't create a log file - the data folder is not writable.\n" - "\n" - #if defined(Q_OS_MAC) - MACOS_HINT - #endif - "Make sure you have write permissions to the data folder.\n" - "(%1)\n" - "\n" - "MeshMC cannot continue until you fix this problem." - ).arg(dataPath) - ); - return; - } - qInstallMessageHandler(appDebugOutput); - qDebug() << "<> Log initialized."; + logFile = std::make_unique<QFile>(logBase.arg(0)); + if(!logFile->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) + { + showFatalErrorMessage( + "MeshMC data folder is not writable!", + QString( + "MeshMC couldn't create a log file - the data folder is not writable.\n" + "\n" +#if defined(Q_OS_MAC) + MACOS_HINT +#endif + "Make sure you have write permissions to the data folder.\n" + "(%1)\n" + "\n" + "MeshMC cannot continue until you fix this problem." + ).arg(dataPath) + ); + return false; } + qInstallMessageHandler(appDebugOutput); + qDebug() << "<> Log initialized."; + return true; +} - // Set up paths - { - // Root path is used for updates. +void Application::setupPaths(const QString &binPath, const QString &origcwdPath, const QString &adjustedBy) +{ + // Root path is used for updates. #if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) - QDir foo(FS::PathCombine(binPath, "..")); - m_rootPath = foo.absolutePath(); + QDir foo(FS::PathCombine(binPath, "..")); + m_rootPath = foo.absolutePath(); #elif defined(Q_OS_WIN32) - m_rootPath = binPath; + m_rootPath = binPath; #elif defined(Q_OS_MAC) - QDir foo(FS::PathCombine(binPath, "../..")); - m_rootPath = foo.absolutePath(); - // on macOS, touch the root to force Finder to reload the .app metadata (and fix any icon change issues) - FS::updateTimestamp(m_rootPath); + QDir foo(FS::PathCombine(binPath, "../..")); + m_rootPath = foo.absolutePath(); + // on macOS, touch the root to force Finder to reload the .app metadata (and fix any icon change issues) + FS::updateTimestamp(m_rootPath); #endif - qInfo() << BuildConfig.MESHMC_DISPLAYNAME << ", (c) 2026 " << BuildConfig.MESHMC_COPYRIGHT; - qInfo() << "Version : " << BuildConfig.printableVersionString(); - qInfo() << "Git commit : " << BuildConfig.GIT_COMMIT; - qInfo() << "Git refspec : " << BuildConfig.GIT_REFSPEC; - qInfo() << "Compiled for : " << BuildConfig.systemID(); - qInfo() << "Compiled by : " << BuildConfig.compilerID(); - qInfo() << "Build Artifact : " << BuildConfig.BUILD_ARTIFACT; - if (adjustedBy.size()) - { - qInfo() << "Work dir before adjustment : " << origcwdPath; - qInfo() << "Work dir after adjustment : " << QDir::currentPath(); - qInfo() << "Adjusted by : " << adjustedBy; - } - else - { - qInfo() << "Work dir : " << QDir::currentPath(); - } - qInfo() << "Binary path : " << binPath; - qInfo() << "Application root path : " << m_rootPath; - if(!m_instanceIdToLaunch.isEmpty()) - { - qInfo() << "ID of instance to launch : " << m_instanceIdToLaunch; - } - if(!m_serverToJoin.isEmpty()) - { - qInfo() << "Address of server to join :" << m_serverToJoin; - } - qInfo() << "<> Paths set."; + qInfo() << BuildConfig.MESHMC_DISPLAYNAME << ", (c) 2026 " << BuildConfig.MESHMC_COPYRIGHT; + qInfo() << "Version : " << BuildConfig.printableVersionString(); + qInfo() << "Git commit : " << BuildConfig.GIT_COMMIT; + qInfo() << "Git refspec : " << BuildConfig.GIT_REFSPEC; + qInfo() << "Compiled for : " << BuildConfig.systemID(); + qInfo() << "Compiled by : " << BuildConfig.compilerID(); + qInfo() << "Build Artifact : " << BuildConfig.BUILD_ARTIFACT; + if (adjustedBy.size()) + { + qInfo() << "Work dir before adjustment : " << origcwdPath; + qInfo() << "Work dir after adjustment : " << QDir::currentPath(); + qInfo() << "Adjusted by : " << adjustedBy; + } + else + { + qInfo() << "Work dir : " << QDir::currentPath(); + } + qInfo() << "Binary path : " << binPath; + qInfo() << "Application root path : " << m_rootPath; + if(!m_instanceIdToLaunch.isEmpty()) + { + qInfo() << "ID of instance to launch : " << m_instanceIdToLaunch; + } + if(!m_serverToJoin.isEmpty()) + { + qInfo() << "Address of server to join :" << m_serverToJoin; } + qInfo() << "<> Paths set."; - do // once + if(m_liveCheck) { - if(m_liveCheck) + auto appID = ApplicationId::fromPathAndVersion(QDir::currentPath(), BuildConfig.printableVersionString()); + QFile check(liveCheckFile); + if(check.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - QFile check(liveCheckFile); - if(!check.open(QIODevice::WriteOnly | QIODevice::Truncate)) - { - qWarning() << "Could not open" << liveCheckFile << "for writing!"; - break; - } auto payload = appID.toString().toUtf8(); if(check.write(payload) != payload.size()) { qWarning() << "Could not write into" << liveCheckFile << "!"; check.remove(); - break; } - check.close(); + else + { + check.close(); + } + } + else + { + qWarning() << "Could not open" << liveCheckFile << "for writing!"; } - } while(false); + } +} - // Initialize application settings - { - m_settings.reset(new INISettingsObject(BuildConfig.MESHMC_CONFIGFILE, this)); - // Updates - m_settings->registerSetting("UpdateChannel", BuildConfig.VERSION_CHANNEL); - m_settings->registerSetting("AutoUpdate", true); +void Application::initSettings() +{ + m_settings.reset(new INISettingsObject(BuildConfig.MESHMC_CONFIGFILE, this)); + // Updates + m_settings->registerSetting("UpdateChannel", BuildConfig.VERSION_CHANNEL); + m_settings->registerSetting("AutoUpdate", true); - // Theming - m_settings->registerSetting("IconTheme", QString("pe_colored")); - m_settings->registerSetting("ApplicationTheme", QString("system")); + // Theming + m_settings->registerSetting("IconTheme", QString("pe_colored")); + m_settings->registerSetting("ApplicationTheme", QString("system")); - // Notifications - m_settings->registerSetting("ShownNotifications", QString()); + // Notifications + m_settings->registerSetting("ShownNotifications", QString()); - // Remembered state - m_settings->registerSetting("LastUsedGroupForNewInstance", QString()); + // Remembered state + m_settings->registerSetting("LastUsedGroupForNewInstance", QString()); - QString defaultMonospace; - int defaultSize = 11; + QString defaultMonospace; + int defaultSize = 11; #ifdef Q_OS_WIN32 - defaultMonospace = "Courier"; - defaultSize = 10; + defaultMonospace = "Courier"; + defaultSize = 10; #elif defined(Q_OS_MAC) - defaultMonospace = "Menlo"; + defaultMonospace = "Menlo"; #else - defaultMonospace = "Monospace"; + defaultMonospace = "Monospace"; #endif - // resolve the font so the default actually matches - QFont consoleFont; - consoleFont.setFamily(defaultMonospace); - consoleFont.setStyleHint(QFont::Monospace); - consoleFont.setFixedPitch(true); - QFontInfo consoleFontInfo(consoleFont); - QString resolvedDefaultMonospace = consoleFontInfo.family(); - QFont resolvedFont(resolvedDefaultMonospace); - qDebug() << "Detected default console font:" << resolvedDefaultMonospace - << ", substitutions:" << resolvedFont.substitutions().join(','); - - m_settings->registerSetting("ConsoleFont", resolvedDefaultMonospace); - m_settings->registerSetting("ConsoleFontSize", defaultSize); - m_settings->registerSetting("ConsoleMaxLines", 100000); - m_settings->registerSetting("ConsoleOverflowStop", true); - - // Folders - m_settings->registerSetting("InstanceDir", "instances"); - m_settings->registerSetting({"CentralModsDir", "ModsDir"}, "mods"); - m_settings->registerSetting("IconsDir", "icons"); - - // Editors - m_settings->registerSetting("JsonEditor", QString()); - - // Language - m_settings->registerSetting("Language", QString()); - - // Console - m_settings->registerSetting("ShowConsole", false); - m_settings->registerSetting("AutoCloseConsole", false); - m_settings->registerSetting("ShowConsoleOnError", true); - m_settings->registerSetting("LogPrePostOutput", true); - - // Window Size - m_settings->registerSetting({"LaunchMaximized", "MCWindowMaximize"}, false); - m_settings->registerSetting({"MinecraftWinWidth", "MCWindowWidth"}, 854); - m_settings->registerSetting({"MinecraftWinHeight", "MCWindowHeight"}, 480); - - // Proxy Settings - m_settings->registerSetting("ProxyType", "None"); - m_settings->registerSetting({"ProxyAddr", "ProxyHostName"}, "127.0.0.1"); - m_settings->registerSetting("ProxyPort", 8080); - m_settings->registerSetting({"ProxyUser", "ProxyUsername"}, ""); - m_settings->registerSetting({"ProxyPass", "ProxyPassword"}, ""); - - // Memory - m_settings->registerSetting({"MinMemAlloc", "MinMemoryAlloc"}, 512); - m_settings->registerSetting({"MaxMemAlloc", "MaxMemoryAlloc"}, 1024); - m_settings->registerSetting("PermGen", 128); - - // Java Settings - m_settings->registerSetting("JavaPath", ""); - m_settings->registerSetting("JavaTimestamp", 0); - m_settings->registerSetting("JavaArchitecture", ""); - m_settings->registerSetting("JavaVersion", ""); - m_settings->registerSetting("JavaVendor", ""); - m_settings->registerSetting("LastHostname", ""); - m_settings->registerSetting("JvmArgs", ""); - - // Native library workarounds - m_settings->registerSetting("UseNativeOpenAL", false); - m_settings->registerSetting("UseNativeGLFW", false); - - // Game time - m_settings->registerSetting("ShowGameTime", true); - m_settings->registerSetting("ShowGlobalGameTime", true); - m_settings->registerSetting("RecordGameTime", true); - - // Minecraft launch method - m_settings->registerSetting("MCLaunchMethod", "MeshMCPart"); - - // Wrapper command for launch - m_settings->registerSetting("WrapperCommand", ""); - - // Custom Commands - m_settings->registerSetting({"PreLaunchCommand", "PreLaunchCmd"}, ""); - m_settings->registerSetting({"PostExitCommand", "PostExitCmd"}, ""); - - // The cat - m_settings->registerSetting("TheCat", false); - - m_settings->registerSetting("InstSortMode", "Name"); - m_settings->registerSetting("SelectedInstance", QString()); - - // Window state and geometry - m_settings->registerSetting("MainWindowState", ""); - m_settings->registerSetting("MainWindowGeometry", ""); - - m_settings->registerSetting("ConsoleWindowState", ""); - m_settings->registerSetting("ConsoleWindowGeometry", ""); - - m_settings->registerSetting("SettingsGeometry", ""); - - m_settings->registerSetting("PagedGeometry", ""); - - m_settings->registerSetting("NewInstanceGeometry", ""); - - m_settings->registerSetting("UpdateDialogGeometry", ""); - - // paste.ee API key - m_settings->registerSetting("PasteEEAPIKey", "meshmc"); - - if(!BuildConfig.ANALYTICS_ID.isEmpty()) - { - // Analytics - m_settings->registerSetting("Analytics", true); - m_settings->registerSetting("AnalyticsSeen", 0); - m_settings->registerSetting("AnalyticsClientID", QString()); - } + // resolve the font so the default actually matches + QFont consoleFont; + consoleFont.setFamily(defaultMonospace); + consoleFont.setStyleHint(QFont::Monospace); + consoleFont.setFixedPitch(true); + QFontInfo consoleFontInfo(consoleFont); + QString resolvedDefaultMonospace = consoleFontInfo.family(); + QFont resolvedFont(resolvedDefaultMonospace); + qDebug() << "Detected default console font:" << resolvedDefaultMonospace + << ", substitutions:" << resolvedFont.substitutions().join(','); - // Init page provider - { - m_globalSettingsProvider = std::make_shared<GenericPageProvider>(tr("Settings")); - m_globalSettingsProvider->addPage<MeshMCPage>(); - m_globalSettingsProvider->addPage<MinecraftPage>(); - m_globalSettingsProvider->addPage<JavaPage>(); - m_globalSettingsProvider->addPage<LanguagePage>(); - m_globalSettingsProvider->addPage<CustomCommandsPage>(); - m_globalSettingsProvider->addPage<ProxyPage>(); - m_globalSettingsProvider->addPage<ExternalToolsPage>(); - m_globalSettingsProvider->addPage<AccountListPage>(); - m_globalSettingsProvider->addPage<PasteEEPage>(); - } - qDebug() << "<> Settings loaded."; + m_settings->registerSetting("ConsoleFont", resolvedDefaultMonospace); + m_settings->registerSetting("ConsoleFontSize", defaultSize); + m_settings->registerSetting("ConsoleMaxLines", 100000); + m_settings->registerSetting("ConsoleOverflowStop", true); + + // Folders + m_settings->registerSetting("InstanceDir", "instances"); + m_settings->registerSetting({"CentralModsDir", "ModsDir"}, "mods"); + m_settings->registerSetting("IconsDir", "icons"); + + // Editors + m_settings->registerSetting("JsonEditor", QString()); + + // Language + m_settings->registerSetting("Language", QString()); + + // Console + m_settings->registerSetting("ShowConsole", false); + m_settings->registerSetting("AutoCloseConsole", false); + m_settings->registerSetting("ShowConsoleOnError", true); + m_settings->registerSetting("LogPrePostOutput", true); + + // Window Size + m_settings->registerSetting({"LaunchMaximized", "MCWindowMaximize"}, false); + m_settings->registerSetting({"MinecraftWinWidth", "MCWindowWidth"}, 854); + m_settings->registerSetting({"MinecraftWinHeight", "MCWindowHeight"}, 480); + + // Proxy Settings + m_settings->registerSetting("ProxyType", "None"); + m_settings->registerSetting({"ProxyAddr", "ProxyHostName"}, "127.0.0.1"); + m_settings->registerSetting("ProxyPort", 8080); + m_settings->registerSetting({"ProxyUser", "ProxyUsername"}, ""); + m_settings->registerSetting({"ProxyPass", "ProxyPassword"}, ""); + + // Memory + m_settings->registerSetting({"MinMemAlloc", "MinMemoryAlloc"}, 512); + m_settings->registerSetting({"MaxMemAlloc", "MaxMemoryAlloc"}, 1024); + m_settings->registerSetting("PermGen", 128); + + // Java Settings + m_settings->registerSetting("JavaPath", ""); + m_settings->registerSetting("JavaTimestamp", 0); + m_settings->registerSetting("JavaArchitecture", ""); + m_settings->registerSetting("JavaVersion", ""); + m_settings->registerSetting("JavaVendor", ""); + m_settings->registerSetting("LastHostname", ""); + m_settings->registerSetting("JvmArgs", ""); + + // Native library workarounds + m_settings->registerSetting("UseNativeOpenAL", false); + m_settings->registerSetting("UseNativeGLFW", false); + + // Game time + m_settings->registerSetting("ShowGameTime", true); + m_settings->registerSetting("ShowGlobalGameTime", true); + m_settings->registerSetting("RecordGameTime", true); + + // Minecraft launch method + m_settings->registerSetting("MCLaunchMethod", "MeshMCPart"); + + // Wrapper command for launch + m_settings->registerSetting("WrapperCommand", ""); + + // Custom Commands + m_settings->registerSetting({"PreLaunchCommand", "PreLaunchCmd"}, ""); + m_settings->registerSetting({"PostExitCommand", "PostExitCmd"}, ""); + + // The cat + m_settings->registerSetting("TheCat", false); + + m_settings->registerSetting("InstSortMode", "Name"); + m_settings->registerSetting("SelectedInstance", QString()); + + // Window state and geometry + m_settings->registerSetting("MainWindowState", ""); + m_settings->registerSetting("MainWindowGeometry", ""); + + m_settings->registerSetting("ConsoleWindowState", ""); + m_settings->registerSetting("ConsoleWindowGeometry", ""); + + m_settings->registerSetting("SettingsGeometry", ""); + + m_settings->registerSetting("PagedGeometry", ""); + + m_settings->registerSetting("NewInstanceGeometry", ""); + + m_settings->registerSetting("UpdateDialogGeometry", ""); + + // paste.ee API key + m_settings->registerSetting("PasteEEAPIKey", "meshmc"); + + if(!BuildConfig.ANALYTICS_ID.isEmpty()) + { + // Analytics + m_settings->registerSetting("Analytics", true); + m_settings->registerSetting("AnalyticsSeen", 0); + m_settings->registerSetting("AnalyticsClientID", QString()); } -#ifndef QT_NO_ACCESSIBILITY - QAccessible::installFactory(groupViewAccessibleFactory); -#endif /* !QT_NO_ACCESSIBILITY */ + // Init page provider + { + m_globalSettingsProvider = std::make_shared<GenericPageProvider>(tr("Settings")); + m_globalSettingsProvider->addPage<MeshMCPage>(); + m_globalSettingsProvider->addPage<MinecraftPage>(); + m_globalSettingsProvider->addPage<JavaPage>(); + m_globalSettingsProvider->addPage<LanguagePage>(); + m_globalSettingsProvider->addPage<CustomCommandsPage>(); + m_globalSettingsProvider->addPage<ProxyPage>(); + m_globalSettingsProvider->addPage<ExternalToolsPage>(); + m_globalSettingsProvider->addPage<AccountListPage>(); + m_globalSettingsProvider->addPage<PasteEEPage>(); + } + qDebug() << "<> Settings loaded."; +} +void Application::initSubsystems() +{ // initialize network access and proxy setup { m_network = new QNetworkAccessManager(); @@ -818,7 +911,10 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) auto platform = getIdealPlatform(BuildConfig.BUILD_PLATFORM); auto channelUrl = BuildConfig.UPDATER_BASE + platform + "/channels.json"; qDebug() << "Initializing updater with platform: " << platform << " -- " << channelUrl; - m_updateChecker.reset(new UpdateChecker(m_network, channelUrl, BuildConfig.VERSION_CHANNEL, BuildConfig.VERSION_BUILD)); + m_updateChecker.reset(new UpdateChecker( + m_network, channelUrl, + BuildConfig.VERSION_CHANNEL, + BuildConfig.VERSION_BUILD)); qDebug() << "<> Updater started."; } @@ -920,8 +1016,8 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) m_translations->downloadIndex(); //FIXME: what to do with these? - m_profilers.insert("jprofiler", std::shared_ptr<BaseProfilerFactory>(new JProfilerFactory())); - m_profilers.insert("jvisualvm", std::shared_ptr<BaseProfilerFactory>(new JVisualVMFactory())); + m_profilers.insert("jprofiler", std::make_shared<JProfilerFactory>()); + m_profilers.insert("jvisualvm", std::make_shared<JVisualVMFactory>()); for (auto profiler : m_profilers.values()) { profiler->registerSettings(m_settings); @@ -951,52 +1047,6 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) setApplicationTheme(settings()->get("ApplicationTheme").toString(), true); qDebug() << "<> Application theme set."; } - - // Initialize analytics - [this]() - { - const int analyticsVersion = 2; - if(BuildConfig.ANALYTICS_ID.isEmpty()) - { - return; - } - - auto analyticsSetting = m_settings->getSetting("Analytics"); - connect(analyticsSetting.get(), &Setting::SettingChanged, this, &Application::analyticsSettingChanged); - QString clientID = m_settings->get("AnalyticsClientID").toString(); - if(clientID.isEmpty()) - { - clientID = QUuid::createUuid().toString(); - clientID.remove(QLatin1Char('{')); - clientID.remove(QLatin1Char('}')); - m_settings->set("AnalyticsClientID", clientID); - } - m_analytics = new GAnalytics(BuildConfig.ANALYTICS_ID, clientID, analyticsVersion, this); - m_analytics->setLogLevel(GAnalytics::Debug); - m_analytics->setAnonymizeIPs(true); - // FIXME: the ganalytics library has no idea about our fancy shared pointers... - m_analytics->setNetworkAccessManager(network().get()); - - if(m_settings->get("AnalyticsSeen").toInt() < m_analytics->version()) - { - qDebug() << "Analytics info not seen by user yet (or old version)."; - return; - } - if(!m_settings->get("Analytics").toBool()) - { - qDebug() << "Analytics disabled by user."; - return; - } - - m_analytics->enable(); - qDebug() << "<> Initialized analytics with tid" << BuildConfig.ANALYTICS_ID; - }(); - - if(createSetupWizard()) - { - return; - } - performMainStartupAction(); } bool Application::createSetupWizard() @@ -1194,7 +1244,10 @@ void Application::messageReceived(const QByteArray& message) if(!profile.isEmpty()) { accountObject = accounts()->getAccountByProfileName(profile); if(!accountObject) { - qWarning() << "Launch command requires the specified profile to be valid. " << profile << "does not resolve to any account."; + qWarning() << "Launch command requires the specified" + << "profile to be valid." + << profile + << "does not resolve to any account."; return; } } @@ -1292,7 +1345,6 @@ bool Application::openJsonEditor(const QString &filename) } else { - //return DesktopServices::openFile(m_settings->get("JsonEditor").toString(), file); return DesktopServices::run(m_settings->get("JsonEditor").toString(), {file}); } } @@ -1482,8 +1534,12 @@ MainWindow* Application::showMainWindow(bool minimized) else { m_mainWindow = new MainWindow(); - m_mainWindow->restoreState(QByteArray::fromBase64(APPLICATION->settings()->get("MainWindowState").toByteArray())); - m_mainWindow->restoreGeometry(QByteArray::fromBase64(APPLICATION->settings()->get("MainWindowGeometry").toByteArray())); + m_mainWindow->restoreState( + QByteArray::fromBase64( + APPLICATION->settings()->get("MainWindowState").toByteArray())); + m_mainWindow->restoreGeometry( + QByteArray::fromBase64( + APPLICATION->settings()->get("MainWindowGeometry").toByteArray())); if(minimized) { m_mainWindow->showMinimized(); @@ -1698,7 +1754,18 @@ QString Application::getJarsPath() { if(m_jarsPath.isEmpty()) { +#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD) + auto appDir = QCoreApplication::applicationDirPath(); + auto installedPath = FS::PathCombine( + appDir, "..", "share", BuildConfig.MESHMC_NAME); + if (QDir(installedPath).exists()) + { + return installedPath; + } + return FS::PathCombine(appDir, "jars"); +#else return FS::PathCombine(QCoreApplication::applicationDirPath(), "jars"); +#endif } return m_jarsPath; } diff --git a/launcher/Application.h b/launcher/Application.h index 4e478e8134..6ae6001282 100644 --- a/launcher/Application.h +++ b/launcher/Application.h @@ -28,6 +28,7 @@ #include <QIcon> #include <QDateTime> #include <QUrl> +#include <QHash> #include <updater/GoUpdate.h> #include <BaseInstance.h> @@ -79,7 +80,7 @@ public: public: Application(int &argc, char **argv); - virtual ~Application(); + ~Application() override; GAnalytics *analytics() const { return m_analytics; @@ -194,6 +195,19 @@ private: // sets the fatal error message and m_status to Failed. void showFatalErrorMessage(const QString & title, const QString & content); + // Constructor initialization helpers + void initPlatform(); + QHash<QString, QVariant> parseCommandLine(int &argc, char **argv); + bool resolveDataPath(const QHash<QString, QVariant> &args, + QString &dataPath, QString &adjustedBy, + QString &origcwdPath); + bool initPeerInstance(); + bool initLogging(const QString &dataPath); + void setupPaths(const QString &binPath, const QString &origcwdPath, const QString &adjustedBy); + void initSettings(); + void initSubsystems(); + void initAnalytics(); + private: void addRunningInstance(); void subRunningInstance(); diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 93c045f9a6..2a53286894 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -527,6 +527,13 @@ set(ATLAUNCHER_SOURCES modplatform/atlauncher/ATLPackManifest.h ) +set(MODRINTH_SOURCES + modplatform/modrinth/ModrinthPackIndex.cpp + modplatform/modrinth/ModrinthPackIndex.h + modplatform/modrinth/ModrinthPackManifest.cpp + modplatform/modrinth/ModrinthPackManifest.h +) + add_unit_test(Index SOURCES meta/Index_test.cpp LIBS MeshMC_logic @@ -559,6 +566,7 @@ set(LOGIC_SOURCES ${MODPACKSCH_SOURCES} ${TECHNIC_SOURCES} ${ATLAUNCHER_SOURCES} + ${MODRINTH_SOURCES} ) SET(MESHMC_SOURCES @@ -732,6 +740,11 @@ SET(MESHMC_SOURCES ui/pages/modplatform/flame/FlamePage.cpp ui/pages/modplatform/flame/FlamePage.h + ui/pages/modplatform/modrinth/ModrinthModel.cpp + ui/pages/modplatform/modrinth/ModrinthModel.h + ui/pages/modplatform/modrinth/ModrinthPage.cpp + ui/pages/modplatform/modrinth/ModrinthPage.h + ui/pages/modplatform/technic/TechnicModel.cpp ui/pages/modplatform/technic/TechnicModel.h ui/pages/modplatform/technic/TechnicPage.cpp @@ -851,6 +864,7 @@ qt6_wrap_ui(MESHMC_UI ui/pages/modplatform/atlauncher/AtlPage.ui ui/pages/modplatform/VanillaPage.ui ui/pages/modplatform/flame/FlamePage.ui + ui/pages/modplatform/modrinth/ModrinthPage.ui ui/pages/modplatform/legacy_ftb/Page.ui ui/pages/modplatform/ImportPage.ui ui/pages/modplatform/ftb/FtbPage.ui diff --git a/launcher/InstanceImportTask.cpp b/launcher/InstanceImportTask.cpp index 306ce60d68..bcf205f6dd 100644 --- a/launcher/InstanceImportTask.cpp +++ b/launcher/InstanceImportTask.cpp @@ -17,7 +17,7 @@ * * 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: * @@ -52,6 +52,7 @@ #include "minecraft/PackProfile.h" #include "modplatform/flame/FileResolvingTask.h" #include "modplatform/flame/PackManifest.h" +#include "modplatform/modrinth/ModrinthPackManifest.h" #include "Json.h" #include <quazipdir.h> #include "modplatform/technic/TechnicPackProcessor.h" @@ -121,10 +122,12 @@ void InstanceImportTask::processZipPack() return; } - QStringList blacklist = {"instance.cfg", "manifest.json"}; + QStringList blacklist = {"instance.cfg", "manifest.json", "modrinth.index.json"}; QString mmcFound = MMCZip::findFolderOfFileInZip(m_packZip.get(), "instance.cfg"); - bool technicFound = QuaZipDir(m_packZip.get()).exists("/bin/modpack.jar") || QuaZipDir(m_packZip.get()).exists("/bin/version.json"); + bool technicFound = QuaZipDir(m_packZip.get()).exists("/bin/modpack.jar") || + QuaZipDir(m_packZip.get()).exists("/bin/version.json"); QString flameFound = MMCZip::findFolderOfFileInZip(m_packZip.get(), "manifest.json"); + QString modrinthFound = MMCZip::findFolderOfFileInZip(m_packZip.get(), "modrinth.index.json"); QString root; if(!mmcFound.isNull()) { @@ -133,6 +136,13 @@ void InstanceImportTask::processZipPack() root = mmcFound; m_modpackType = ModpackType::MeshMC; } + else if(!modrinthFound.isNull()) + { + // process as Modrinth pack + qDebug() << "Modrinth:" << modrinthFound; + root = modrinthFound; + m_modpackType = ModpackType::Modrinth; + } else if (technicFound) { // process as Technic pack @@ -155,9 +165,15 @@ void InstanceImportTask::processZipPack() } // make sure we extract just the pack - m_extractFuture = QtConcurrent::run(QThreadPool::globalInstance(), MMCZip::extractSubDir, m_packZip.get(), root, extractDir.absolutePath()); - connect(&m_extractFutureWatcher, &QFutureWatcher<QStringList>::finished, this, &InstanceImportTask::extractFinished); - connect(&m_extractFutureWatcher, &QFutureWatcher<QStringList>::canceled, this, &InstanceImportTask::extractAborted); + m_extractFuture = QtConcurrent::run( + QThreadPool::globalInstance(), MMCZip::extractSubDir, + m_packZip.get(), root, extractDir.absolutePath()); + connect(&m_extractFutureWatcher, + &QFutureWatcher<QStringList>::finished, + this, &InstanceImportTask::extractFinished); + connect(&m_extractFutureWatcher, + &QFutureWatcher<QStringList>::canceled, + this, &InstanceImportTask::extractAborted); m_extractFutureWatcher.setFuture(m_extractFuture); } @@ -182,7 +198,9 @@ void InstanceImportTask::extractFinished() if(file.isDir()) { // Folder +rwx for current user - permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser | QFileDevice::Permission::ExeUser; + permissions |= QFileDevice::Permission::ReadUser | + QFileDevice::Permission::WriteUser | + QFileDevice::Permission::ExeUser; } else { @@ -207,6 +225,9 @@ void InstanceImportTask::extractFinished() case ModpackType::Flame: processFlame(); return; + case ModpackType::Modrinth: + processModrinth(); + return; case ModpackType::MeshMC: processMeshMC(); return; @@ -227,18 +248,15 @@ void InstanceImportTask::extractAborted() void InstanceImportTask::processFlame() { - const static QMap<QString,QString> forgemap = { - {"1.2.5", "3.4.9.171"}, - {"1.4.2", "6.0.1.355"}, - {"1.4.7", "6.6.2.534"}, - {"1.5.2", "7.8.1.737"} - }; Flame::Manifest pack; try { QString configPath = FS::PathCombine(m_stagingPath, "manifest.json"); Flame::loadManifest(pack, configPath); - QFile::remove(configPath); + if (!QFile::remove(configPath)) + { + qWarning() << "Could not remove manifest.json from staging"; + } } catch (const JSONValidationError &e) { @@ -263,38 +281,77 @@ void InstanceImportTask::processFlame() } } - QString forgeVersion; - QString fabricVersion; - QString neoForgeVersion; - QString quiltVersion; + configureFlameInstance(pack); + + m_modIdResolver = new Flame::FileResolvingTask(APPLICATION->network(), pack); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, + this, &InstanceImportTask::onFlameFileResolutionSucceeded); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, [&](QString reason) + { + m_modIdResolver.reset(); + emitFailed(tr("Unable to resolve mod IDs:\n") + reason); + }); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::progress, [&](qint64 current, qint64 total) + { + setProgress(current, total); + }); + connect(m_modIdResolver.get(), &Flame::FileResolvingTask::status, [&](QString status) + { + setStatus(status); + }); + m_modIdResolver->start(); +} + +static QString selectFlameIcon(const QString &instIcon, const Flame::Manifest &pack) +{ + if (instIcon != "default") + return instIcon; + if (pack.name.contains("Direwolf20")) + return "steve"; + if (pack.name.contains("FTB") || pack.name.contains("Feed The Beast")) + return "ftb_logo"; + // default to something other than the MeshMC default to distinguish these + return "flame"; +} + +void InstanceImportTask::configureFlameInstance(Flame::Manifest &pack) +{ + const static QMap<QString,QString> forgemap = { + {"1.2.5", "3.4.9.171"}, + {"1.4.2", "6.0.1.355"}, + {"1.4.7", "6.6.2.534"}, + {"1.5.2", "7.8.1.737"} + }; + + struct FlameLoaderMapping { + const char *prefix; + QString version; + const char *componentId; + }; + FlameLoaderMapping loaderMappings[] = { + {"forge-", {}, "net.minecraftforge"}, + {"fabric-", {}, "net.fabricmc.fabric-loader"}, + {"neoforge-", {}, "net.neoforged"}, + {"quilt-", {}, "org.quiltmc.quilt-loader"}, + }; for(auto &loader: pack.minecraft.modLoaders) { auto id = loader.id; - if(id.startsWith("forge-")) - { - id.remove("forge-"); - forgeVersion = id; - continue; - } - if(id.startsWith("fabric-")) + bool matched = false; + for (auto &mapping : loaderMappings) { - id.remove("fabric-"); - fabricVersion = id; - continue; - } - if(id.startsWith("neoforge-")) - { - id.remove("neoforge-"); - neoForgeVersion = id; - continue; + if (id.startsWith(mapping.prefix)) + { + id.remove(mapping.prefix); + mapping.version = id; + matched = true; + break; + } } - if(id.startsWith("quilt-")) + if (!matched) { - id.remove("quilt-"); - quiltVersion = id; - continue; + logWarning(tr("Unknown mod loader in manifest: %1").arg(id)); } - logWarning(tr("Unknown mod loader in manifest: %1").arg(id)); } QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); @@ -312,54 +369,31 @@ void InstanceImportTask::processFlame() auto components = instance.getPackProfile(); components->buildingFromScratch(); components->setComponentVersion("net.minecraft", mcVersion, true); - if(!forgeVersion.isEmpty()) - { - // FIXME: dirty, nasty, hack. Proper solution requires dependency resolution and knowledge of the metadata. - if(forgeVersion == "recommended") - { - if(forgemap.contains(mcVersion)) - { - forgeVersion = forgemap[mcVersion]; - } - else - { - logWarning(tr("Could not map recommended forge version for Minecraft %1").arg(mcVersion)); - } - } - components->setComponentVersion("net.minecraftforge", forgeVersion); - } - if(!fabricVersion.isEmpty()) - { - components->setComponentVersion("net.fabricmc.fabric-loader", fabricVersion); - } - if(!neoForgeVersion.isEmpty()) - { - components->setComponentVersion("net.neoforged", neoForgeVersion); - } - if(!quiltVersion.isEmpty()) - { - components->setComponentVersion("org.quiltmc.quilt-loader", quiltVersion); - } - if (m_instIcon != "default") - { - instance.setIconKey(m_instIcon); - } - else + + // Handle Forge "recommended" version mapping + auto &forgeMapping = loaderMappings[0]; + if(forgeMapping.version == "recommended") { - if(pack.name.contains("Direwolf20")) + if(forgemap.contains(mcVersion)) { - instance.setIconKey("steve"); + forgeMapping.version = forgemap[mcVersion]; } - else if(pack.name.contains("FTB") || pack.name.contains("Feed The Beast")) + else { - instance.setIconKey("ftb_logo"); + logWarning(tr("Could not map recommended forge version for Minecraft %1").arg(mcVersion)); } - else + } + + for (const auto &mapping : loaderMappings) + { + if (!mapping.version.isEmpty()) { - // default to something other than the MeshMC default to distinguish these - instance.setIconKey("flame"); + components->setComponentVersion(mapping.componentId, mapping.version); } } + + instance.setIconKey(selectFlameIcon(m_instIcon, pack)); + QString jarmodsPath = FS::PathCombine(m_stagingPath, "minecraft", "jarmods"); QFileInfo jarmodsInfo(jarmodsPath); if(jarmodsInfo.isDir()) @@ -379,86 +413,218 @@ void InstanceImportTask::processFlame() FS::deletePath(jarmodsPath); } instance.setName(m_instName); - m_modIdResolver = new Flame::FileResolvingTask(APPLICATION->network(), pack); - connect(m_modIdResolver.get(), &Flame::FileResolvingTask::succeeded, [&]() +} + +void InstanceImportTask::onFlameFileResolutionSucceeded() +{ + auto results = m_modIdResolver->getResults(); + m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); + for(auto result: results.files) { - auto results = m_modIdResolver->getResults(); - m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network()); - for(auto result: results.files) + QString filename = result.fileName; + if(!result.required) { - QString filename = result.fileName; - if(!result.required) - { - filename += ".disabled"; - } + filename += ".disabled"; + } - auto relpath = FS::PathCombine("minecraft", result.targetFolder, filename); - auto path = FS::PathCombine(m_stagingPath , relpath); + auto relpath = FS::PathCombine("minecraft", result.targetFolder, filename); + auto path = FS::PathCombine(m_stagingPath , relpath); - switch(result.type) + switch(result.type) + { + case Flame::File::Type::Folder: { - case Flame::File::Type::Folder: - { - logWarning(tr("This 'Folder' may need extracting: %1").arg(relpath)); - // fall-through intentional, we treat these as plain old mods and dump them wherever. - } - case Flame::File::Type::SingleFile: - case Flame::File::Type::Mod: + logWarning(tr("This 'Folder' may need extracting: %1").arg(relpath)); + [[fallthrough]]; + } + case Flame::File::Type::SingleFile: + [[fallthrough]]; + case Flame::File::Type::Mod: + { + if(!result.resolved || !result.url.isValid() || result.url.isEmpty()) { - if(!result.resolved || !result.url.isValid() || result.url.isEmpty()) - { - logWarning(tr("Skipping %1 - no download URL available (mod may have restricted downloads)").arg(result.fileName)); - break; - } - qDebug() << "Will download" << result.url << "to" << path; - auto dl = Net::Download::makeFile(result.url, path); - m_filesNetJob->addNetAction(dl); + logWarning(tr("Skipping %1 - no download URL available (mod may have restricted downloads)").arg(result.fileName)); break; } - case Flame::File::Type::Modpack: - logWarning(tr("Nesting modpacks in modpacks is not implemented, nothing was downloaded: %1").arg(relpath)); - break; - case Flame::File::Type::Cmod2: - case Flame::File::Type::Ctoc: - case Flame::File::Type::Unknown: - logWarning(tr("Unrecognized/unhandled PackageType for: %1").arg(relpath)); - break; + qDebug() << "Will download" << result.url << "to" << path; + auto dl = Net::Download::makeFile(result.url, path); + m_filesNetJob->addNetAction(dl); + break; } + case Flame::File::Type::Modpack: + logWarning(tr("Nesting modpacks in modpacks is not implemented, nothing was downloaded: %1").arg(relpath)); + break; + case Flame::File::Type::Cmod2: + [[fallthrough]]; + case Flame::File::Type::Ctoc: + [[fallthrough]]; + case Flame::File::Type::Unknown: + logWarning(tr("Unrecognized/unhandled PackageType for: %1").arg(relpath)); + break; } - m_modIdResolver.reset(); - connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() + } + m_modIdResolver.reset(); + connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() + { + m_filesNetJob.reset(); + emitSucceeded(); + } + ); + connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) + { + m_filesNetJob.reset(); + emitFailed(reason); + }); + connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) + { + setProgress(current, total); + }); + setStatus(tr("Downloading mods...")); + m_filesNetJob->start(); +} + +static void applyModrinthOverrides(const QString &stagingPath, const QString &mcPath) +{ + QString overridePath = FS::PathCombine(stagingPath, "overrides"); + QString clientOverridePath = FS::PathCombine(stagingPath, "client-overrides"); + + if (QFile::exists(overridePath)) + { + if (!FS::copy(overridePath, mcPath)()) { - m_filesNetJob.reset(); - emitSucceeded(); + qWarning() << "Could not apply overrides from the modpack."; } - ); - connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) + FS::deletePath(overridePath); + } + + if (QFile::exists(clientOverridePath)) + { + if (!FS::copy(clientOverridePath, mcPath)()) { - m_filesNetJob.reset(); - emitFailed(reason); - }); - connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) + qWarning() << "Could not apply client-overrides from the modpack."; + } + FS::deletePath(clientOverridePath); + } +} + +void InstanceImportTask::processModrinth() +{ + Modrinth::Manifest pack; + try + { + QString configPath = FS::PathCombine(m_stagingPath, "modrinth.index.json"); + Modrinth::loadManifest(pack, configPath); + if (!QFile::remove(configPath)) { - setProgress(current, total); - }); - setStatus(tr("Downloading mods...")); - m_filesNetJob->start(); + qWarning() << "Could not remove modrinth.index.json from staging"; + } } - ); - connect(m_modIdResolver.get(), &Flame::FileResolvingTask::failed, [&](QString reason) + catch (const JSONValidationError &e) { - m_modIdResolver.reset(); - emitFailed(tr("Unable to resolve mod IDs:\n") + reason); + emitFailed(tr("Could not understand Modrinth modpack manifest:\n") + e.cause()); + return; + } + + // Move overrides folder contents to minecraft directory + QString mcPath = FS::PathCombine(m_stagingPath, "minecraft"); + + QDir mcDir(mcPath); + if (!mcDir.exists()) + { + mcDir.mkpath("."); + } + + applyModrinthOverrides(m_stagingPath, mcPath); + + // Create instance config + QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg"); + auto instanceSettings = std::make_shared<INISettingsObject>(configPath); + instanceSettings->registerSetting("InstanceType", "Legacy"); + instanceSettings->set("InstanceType", "OneSix"); + MinecraftInstance instance(m_globalSettings, instanceSettings, m_stagingPath); + + auto components = instance.getPackProfile(); + components->buildingFromScratch(); + + struct ModLoaderMapping { + const QString &version; + const char *componentId; + }; + const ModLoaderMapping loaders[] = { + {pack.forgeVersion, "net.minecraftforge"}, + {pack.fabricVersion, "net.fabricmc.fabric-loader"}, + {pack.quiltVersion, "org.quiltmc.quilt-loader"}, + {pack.neoForgeVersion, "net.neoforged"}, + }; + + if (!pack.minecraftVersion.isEmpty()) + { + components->setComponentVersion("net.minecraft", pack.minecraftVersion, true); + } + for (const auto &loader : loaders) + { + if (!loader.version.isEmpty()) + { + components->setComponentVersion(loader.componentId, loader.version); + } + } + + if (m_instIcon != "default") + { + instance.setIconKey(m_instIcon); + } + else + { + instance.setIconKey("modrinth"); + } + + instance.setName(m_instName); + + // Download all mod files + m_filesNetJob = new NetJob(tr("Modrinth mod download"), APPLICATION->network()); + auto minecraftDir = FS::PathCombine(m_stagingPath, "minecraft"); + auto canonicalBase = QDir(minecraftDir).canonicalPath(); + for (auto &file : pack.files) + { + if (file.path.contains("..") || QDir::isAbsolutePath(file.path)) + { + qWarning() << "Skipping potentially malicious file path:" << file.path; + continue; + } + auto path = FS::PathCombine(minecraftDir, file.path); + auto canonicalDir = QFileInfo(path).absolutePath(); + if (!canonicalDir.startsWith(canonicalBase)) + { + qWarning() << "Skipping file path that escapes staging directory:" << file.path; + continue; + } + if (!file.downloadUrl.isValid() || file.downloadUrl.isEmpty()) + { + logWarning(tr("Skipping file with no download URL: %1").arg(file.path)); + continue; + } + qDebug() << "Will download" << file.downloadUrl << "to" << path; + auto dl = Net::Download::makeFile(file.downloadUrl, path); + m_filesNetJob->addNetAction(dl); + } + + connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]() + { + m_filesNetJob.reset(); + emitSucceeded(); }); - connect(m_modIdResolver.get(), &Flame::FileResolvingTask::progress, [&](qint64 current, qint64 total) + connect(m_filesNetJob.get(), &NetJob::failed, [&](QString reason) { - setProgress(current, total); + m_filesNetJob.reset(); + emitFailed(reason); }); - connect(m_modIdResolver.get(), &Flame::FileResolvingTask::status, [&](QString status) + connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total) { - setStatus(status); + setProgress(current, total); }); - m_modIdResolver->start(); + + setStatus(tr("Downloading mods...")); + m_filesNetJob->start(); } void InstanceImportTask::processTechnic() @@ -480,10 +646,10 @@ void InstanceImportTask::processMeshMC() // reset time played on import... because packs. instance.resetTimePlayed(); - // set a new nice name + // Set a new name for the imported instance instance.setName(m_instName); - // if the icon was specified by user, use that. otherwise pull icon from the pack + // Use user-specified icon if available, otherwise import from the pack if (m_instIcon != "default") { instance.setIconKey(m_instIcon); diff --git a/launcher/InstanceImportTask.h b/launcher/InstanceImportTask.h index 0e8f59cac1..9748c6dbea 100644 --- a/launcher/InstanceImportTask.h +++ b/launcher/InstanceImportTask.h @@ -17,7 +17,7 @@ * * 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: * @@ -52,6 +52,7 @@ class QuaZip; namespace Flame { class FileResolvingTask; + struct Manifest; } class InstanceImportTask : public InstanceTask @@ -68,6 +69,9 @@ private: void processZipPack(); void processMeshMC(); void processFlame(); + void configureFlameInstance(Flame::Manifest &pack); + void onFlameFileResolutionSucceeded(); + void processModrinth(); void processTechnic(); private slots: @@ -90,6 +94,7 @@ private: /* data */ Unknown, MeshMC, Flame, + Modrinth, Technic } m_modpackType = ModpackType::Unknown; }; diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.cpp b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp new file mode 100644 index 0000000000..06296e971a --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.cpp @@ -0,0 +1,89 @@ +/* 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 "ModrinthPackIndex.h" + +#include "Json.h" + +void Modrinth::loadIndexedPack(Modrinth::IndexedPack &pack, QJsonObject &obj) +{ + pack.projectId = Json::ensureString(obj, "project_id", ""); + if (pack.projectId.isEmpty()) + { + pack.projectId = Json::requireString(obj, "id"); + } + pack.slug = Json::ensureString(obj, "slug", ""); + pack.name = Json::requireString(obj, "title"); + pack.description = Json::ensureString(obj, "description", ""); + pack.author = Json::ensureString(obj, "author", ""); + pack.downloads = Json::ensureInteger(obj, "downloads", 0); + + pack.iconUrl = Json::ensureString(obj, "icon_url", ""); +} + +void Modrinth::loadIndexedPackVersions(Modrinth::IndexedPack &pack, QJsonArray &arr) +{ + pack.versions.clear(); + for (auto versionRaw : arr) + { + auto obj = versionRaw.toObject(); + Modrinth::IndexedVersion version; + version.id = Json::requireString(obj, "id"); + version.projectId = Json::ensureString(obj, "project_id", pack.projectId); + version.name = Json::ensureString(obj, "name", ""); + version.versionNumber = Json::requireString(obj, "version_number"); + + auto gameVersions = Json::ensureArray(obj, "game_versions"); + if (!gameVersions.isEmpty()) + { + version.mcVersion = gameVersions.first().toString(); + } + + auto loaders = Json::ensureArray(obj, "loaders"); + QStringList loaderList; + for (auto loader : loaders) + { + loaderList.append(loader.toString()); + } + version.loaders = loaderList.join(", "); + + auto files = Json::ensureArray(obj, "files"); + for (auto fileRaw : files) + { + auto fileObj = fileRaw.toObject(); + bool primary = Json::ensureBoolean(fileObj, "primary", false); + if (primary || files.size() == 1) + { + version.downloadUrl = Json::ensureString(fileObj, "url", ""); + version.downloadSize = Json::ensureInteger(fileObj, "size", 0); + auto hashes = Json::ensureObject(fileObj, "hashes"); + version.sha1 = Json::ensureString(hashes, "sha1", ""); + break; + } + } + + if (!version.downloadUrl.isEmpty()) + { + pack.versions.append(version); + } + } + pack.versionsLoaded = true; +} diff --git a/launcher/modplatform/modrinth/ModrinthPackIndex.h b/launcher/modplatform/modrinth/ModrinthPackIndex.h new file mode 100644 index 0000000000..d42547f16e --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackIndex.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 <QList> +#include <QMetaType> +#include <QString> +#include <QVector> + +namespace Modrinth { + +struct IndexedVersion { + QString id; + QString projectId; + QString name; + QString versionNumber; + QString mcVersion; + QString downloadUrl; + int downloadSize = 0; + QString sha1; + QString loaders; +}; + +struct IndexedPack { + QString projectId; + QString slug; + QString name; + QString description; + QString author; + QString iconUrl; + int downloads = 0; + + bool versionsLoaded = false; + QVector<IndexedVersion> versions; +}; + +void loadIndexedPack(IndexedPack &pack, QJsonObject &obj); +void loadIndexedPackVersions(IndexedPack &pack, QJsonArray &arr); + +} + +Q_DECLARE_METATYPE(Modrinth::IndexedPack) diff --git a/launcher/modplatform/modrinth/ModrinthPackManifest.cpp b/launcher/modplatform/modrinth/ModrinthPackManifest.cpp new file mode 100644 index 0000000000..477c020008 --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackManifest.cpp @@ -0,0 +1,90 @@ +/* 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 "ModrinthPackManifest.h" + +#include "Json.h" + +static void loadFile(Modrinth::File &f, QJsonObject &fileObj) +{ + f.path = Json::requireString(fileObj, "path"); + + auto downloads = Json::requireArray(fileObj, "downloads"); + if (!downloads.isEmpty()) + { + f.downloadUrl = QUrl(downloads.first().toString()); + } + + auto hashes = Json::ensureObject(fileObj, "hashes"); + f.sha1 = Json::ensureString(hashes, "sha1", ""); + f.sha512 = Json::ensureString(hashes, "sha512", ""); + + f.fileSize = Json::ensureInteger(fileObj, "fileSize", 0); +} + +static void loadDependencies(Modrinth::Manifest &m, QJsonObject &deps) +{ + m.minecraftVersion = Json::ensureString(deps, "minecraft", ""); + m.forgeVersion = Json::ensureString(deps, "forge", ""); + m.fabricVersion = Json::ensureString(deps, "fabric-loader", ""); + m.quiltVersion = Json::ensureString(deps, "quilt-loader", ""); + m.neoForgeVersion = Json::ensureString(deps, "neoforge", ""); +} + +void Modrinth::loadManifest(Modrinth::Manifest &m, const QString &filepath) +{ + auto doc = Json::requireDocument(filepath); + auto obj = Json::requireObject(doc); + + m.formatVersion = Json::requireInteger(obj, "formatVersion"); + if (m.formatVersion != 1) + { + throw JSONValidationError( + QString("Unsupported Modrinth modpack format version: %1") + .arg(m.formatVersion)); + } + + m.game = Json::requireString(obj, "game"); + if (m.game != "minecraft") + { + throw JSONValidationError( + QString("Unsupported game in Modrinth modpack: %1").arg(m.game)); + } + + m.versionId = Json::ensureString(obj, "versionId", ""); + m.name = Json::ensureString(obj, "name", "Unnamed"); + m.summary = Json::ensureString(obj, "summary", ""); + + auto files = Json::requireArray(obj, "files"); + for (auto fileRaw : files) + { + auto fileObj = Json::requireObject(fileRaw); + Modrinth::File file; + loadFile(file, fileObj); + m.files.append(file); + } + + auto deps = Json::ensureObject(obj, "dependencies"); + if (!deps.isEmpty()) + { + loadDependencies(m, deps); + } +} diff --git a/launcher/modplatform/modrinth/ModrinthPackManifest.h b/launcher/modplatform/modrinth/ModrinthPackManifest.h new file mode 100644 index 0000000000..d2fa02054f --- /dev/null +++ b/launcher/modplatform/modrinth/ModrinthPackManifest.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 <QString> +#include <QUrl> +#include <QVector> + +namespace Modrinth { + +struct File { + QString path; + QUrl downloadUrl; + QString sha1; + QString sha512; + int fileSize = 0; +}; + +struct Dependency { + QString versionId; + QString projectId; + QString fileName; +}; + +struct Manifest { + int formatVersion = 0; + QString game; + QString versionId; + QString name; + QString summary; + QVector<Modrinth::File> files; + + QString minecraftVersion; + QString forgeVersion; + QString fabricVersion; + QString quiltVersion; + QString neoForgeVersion; +}; + +void loadManifest(Modrinth::Manifest &m, const QString &filepath); + +} diff --git a/launcher/mojang/PackageManifest.cpp b/launcher/mojang/PackageManifest.cpp index 7a6d6fa3db..11b6ef0c51 100644 --- a/launcher/mojang/PackageManifest.cpp +++ b/launcher/mojang/PackageManifest.cpp @@ -94,6 +94,36 @@ void Package::addSource(const FileSource& source) { namespace { + +FileSource parseFileDownloads( + const QJsonObject &fileObject, File &file) +{ + FileSource bestSource; + auto downloads = Json::requireObject(fileObject, "downloads"); + for (auto iter2 = downloads.begin(); + iter2 != downloads.end(); iter2++) { + FileSource source; + + auto downloadObject = Json::requireObject(iter2.value()); + source.hash = Json::requireString(downloadObject, "sha1"); + source.size = Json::requireInteger(downloadObject, "size"); + source.url = Json::requireString(downloadObject, "url"); + + auto compression = iter2.key(); + if (compression == "raw") { + file.hash = source.hash; + file.size = source.size; + source.compression = Compression::Raw; + } else if (compression == "lzma") { + source.compression = Compression::Lzma; + } else { + continue; + } + bestSource.upgrade(source); + } + return bestSource; +} + void fromJson(QJsonDocument & doc, Package & out) { std::set<Path> seen_paths; if (!doc.isObject()) @@ -125,32 +155,9 @@ void fromJson(QJsonDocument & doc, Package & out) { continue; } else if(type == "file") { - FileSource bestSource; File file; file.executable = Json::ensureBoolean(fileObject, QString("executable"), false); - auto downloads = Json::requireObject(fileObject, "downloads"); - for(auto iter2 = downloads.begin(); iter2 != downloads.end(); iter2++) { - FileSource source; - - auto downloadObject = Json::requireObject(iter2.value()); - source.hash = Json::requireString(downloadObject, "sha1"); - source.size = Json::requireInteger(downloadObject, "size"); - source.url = Json::requireString(downloadObject, "url"); - - auto compression = iter2.key(); - if(compression == "raw") { - file.hash = source.hash; - file.size = source.size; - source.compression = Compression::Raw; - } - else if (compression == "lzma") { - source.compression = Compression::Lzma; - } - else { - continue; - } - bestSource.upgrade(source); - } + auto bestSource = parseFileDownloads(fileObject, file); if(bestSource.isBad()) { throw JSONValidationError("No valid compression method for file " + iter.key()); } @@ -211,7 +218,8 @@ Package Package::fromManifestFile(const QString & filename) { #include <sys/stat.h> namespace { -// FIXME: Qt obscures symlink targets by making them absolute. that is useless. this is the workaround - we do it ourselves +// FIXME: Qt obscures symlink targets by making them absolute. +// This is the workaround - we do it ourselves bool actually_read_symlink_target(const QString & filepath, Path & out) { struct ::stat st; @@ -225,20 +233,22 @@ bool actually_read_symlink_target(const QString & filepath, Path & out) } auto size = st.st_size ? st.st_size + 1 : PATH_MAX; - std::string temp(size, '\0'); - // because we don't realiably know how long the damn thing actually is, we loop and expand. POSIX is naff + QByteArray temp(size, '\0'); + // because we don't reliably know how long the link target is, + // we loop and expand. POSIX is naff do { - auto link_length = ::readlink(filepath_cstr, &temp[0], temp.size()); + auto link_length = ::readlink( + filepath_cstr, temp.data(), temp.size()); if(link_length == -1) { return false; } - if(std::string::size_type(link_length) < temp.size()) + if(link_length < temp.size()) { - // buffer was long enough and we managed to read the link target. RETURN here. - temp.resize(link_length); - out = Path(QString::fromUtf8(temp.c_str())); + // buffer was long enough + out = Path(QString::fromUtf8( + temp.constData(), link_length)); return true; } temp.resize(temp.size() * 2); @@ -254,7 +264,11 @@ Package Package::fromInspectedFolder(const QString& folderPath) QDir root(folderPath); Package out; - QDirIterator iterator(folderPath, QDir::NoDotAndDotDot | QDir::AllEntries | QDir::System | QDir::Hidden, QDirIterator::Subdirectories); + QDirIterator iterator( + folderPath, + QDir::NoDotAndDotDot | QDir::AllEntries + | QDir::System | QDir::Hidden, + QDirIterator::Subdirectories); while(iterator.hasNext()) { iterator.next(); @@ -305,7 +319,7 @@ Package Package::fromInspectedFolder(const QString& folderPath) } namespace { -struct shallow_first_sort +struct ShallowFirstSort { bool operator()(const Path &lhs, const Path &rhs) const { @@ -326,7 +340,7 @@ struct shallow_first_sort } }; -struct deep_first_sort +struct DeepFirstSort { bool operator()(const Path &lhs, const Path &rhs) const { @@ -348,24 +362,18 @@ struct deep_first_sort }; } -UpdateOperations UpdateOperations::resolve(const Package& from, const Package& to) +static void resolveFiles( + const Package &from, const Package &to, + UpdateOperations &out) { - UpdateOperations out; - - if(!from.valid || !to.valid) { - out.valid = false; - return out; - } - - // Files - for(auto iter = from.files.begin(); iter != from.files.end(); iter++) { + for (auto iter = from.files.begin(); + iter != from.files.end(); iter++) { const auto ¤t_hash = iter->second.hash; const auto ¤t_executable = iter->second.executable; const auto &path = iter->first; auto iter2 = to.files.find(path); - if(iter2 == to.files.end()) { - // removed + if (iter2 == to.files.end()) { out.deletes.push_back(path); continue; } @@ -373,74 +381,105 @@ UpdateOperations UpdateOperations::resolve(const Package& from, const Package& t auto new_executable = iter2->second.executable; if (current_hash != new_hash) { out.deletes.push_back(path); + auto sourceIt = to.sources.find(iter2->second.hash); + if (sourceIt == to.sources.end()) { + continue; + } out.downloads.emplace( std::pair<Path, FileDownload>{ path, - FileDownload(to.sources.at(iter2->second.hash), iter2->second.executable) + FileDownload(sourceIt->second, + iter2->second.executable) } ); - } - else if (current_executable != new_executable) { + } else if (current_executable != new_executable) { out.executable_fixes[path] = new_executable; } } - for(auto iter = to.files.begin(); iter != to.files.end(); iter++) { + for (auto iter = to.files.begin(); + iter != to.files.end(); iter++) { auto path = iter->first; - if(!from.files.count(path)) { + if (!from.files.count(path)) { + auto sourceIt = to.sources.find(iter->second.hash); + if (sourceIt == to.sources.end()) { + continue; + } out.downloads.emplace( std::pair<Path, FileDownload>{ path, - FileDownload(to.sources.at(iter->second.hash), iter->second.executable) + FileDownload(sourceIt->second, + iter->second.executable) } ); } } +} - // Folders - std::set<Path, deep_first_sort> remove_folders; - std::set<Path, shallow_first_sort> make_folders; - for(auto from_path: from.folders) { - auto iter = to.folders.find(from_path); - if(iter == to.folders.end()) { +static void resolveFolders( + const Package &from, const Package &to, + UpdateOperations &out) +{ + std::set<Path, DeepFirstSort> remove_folders; + std::set<Path, ShallowFirstSort> make_folders; + for (auto from_path : from.folders) { + if (to.folders.find(from_path) == to.folders.end()) { remove_folders.insert(from_path); } } - for(auto & rmdir: remove_folders) { + for (auto &rmdir : remove_folders) { out.rmdirs.push_back(rmdir); } - for(auto to_path: to.folders) { - auto iter = from.folders.find(to_path); - if(iter == from.folders.end()) { + for (auto to_path : to.folders) { + if (from.folders.find(to_path) == from.folders.end()) { make_folders.insert(to_path); } } - for(auto & mkdir: make_folders) { + for (auto &mkdir : make_folders) { out.mkdirs.push_back(mkdir); } +} - // Symlinks - for(auto iter = from.symlinks.begin(); iter != from.symlinks.end(); iter++) { +static void resolveSymlinks( + const Package &from, const Package &to, + UpdateOperations &out) +{ + for (auto iter = from.symlinks.begin(); + iter != from.symlinks.end(); iter++) { const auto ¤t_target = iter->second; const auto &path = iter->first; auto iter2 = to.symlinks.find(path); - if(iter2 == to.symlinks.end()) { - // removed + if (iter2 == to.symlinks.end()) { out.deletes.push_back(path); continue; } - const auto &new_target = iter2->second; - if (current_target != new_target) { + if (current_target != iter2->second) { out.deletes.push_back(path); out.mklinks[path] = iter2->second; } } - for(auto iter = to.symlinks.begin(); iter != to.symlinks.end(); iter++) { - auto path = iter->first; - if(!from.symlinks.count(path)) { - out.mklinks[path] = iter->second; + for (auto iter = to.symlinks.begin(); + iter != to.symlinks.end(); iter++) { + if (!from.symlinks.count(iter->first)) { + out.mklinks[iter->first] = iter->second; } } +} + +UpdateOperations UpdateOperations::resolve( + const Package& from, const Package& to) +{ + UpdateOperations out; + + if (!from.valid || !to.valid) { + out.valid = false; + return out; + } + + resolveFiles(from, to, out); + resolveFolders(from, to, out); + resolveSymlinks(from, to, out); + out.valid = true; return out; } diff --git a/launcher/mojang/PackageManifest.h b/launcher/mojang/PackageManifest.h index 5d1c80d859..397d2ff4d9 100644 --- a/launcher/mojang/PackageManifest.h +++ b/launcher/mojang/PackageManifest.h @@ -39,7 +39,7 @@ public: using parts_type = QStringList; Path() = default; - Path(QString string) { + Path(QString string) { // NOLINT(IMPLICIT_CONSTRUCTOR) auto parts_in = string.split('/'); for(auto & part: parts_in) { if(part.isEmpty() || part == ".") { diff --git a/launcher/qtlogging.ini b/launcher/qtlogging.ini new file mode 100644 index 0000000000..d71565a247 --- /dev/null +++ b/launcher/qtlogging.ini @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 Project Tick +# SPDX-FileContributor: Project Tick +# SPDX-License-Identifier: GPL-3.0-or-later +[Rules] +*.debug=true +# prevent log spam and strange bugs +# qt.qpa.drawing in particular causes theme artifacts on MacOS +qt.*.debug=false +# supress image format noise +kf.imageformats.plugins.hdr=false +kf.imageformats.plugins.xcf=false +# don't log credentials by default +launcher.auth.credentials.debug=false +# remove the debug lines, other log levels still get through +launcher.task.net.download.debug=false +# enable or disable whole catageries +launcher.task.net=true +launcher.task=false +launcher.task.net.upload=true +launcher.task.net.metacache=false +launcher.task.net.metacache.http=true + diff --git a/launcher/resources/multimc/multimc.qrc b/launcher/resources/multimc/multimc.qrc index 3762a48f03..a486dc8598 100644 --- a/launcher/resources/multimc/multimc.qrc +++ b/launcher/resources/multimc/multimc.qrc @@ -16,6 +16,9 @@ <!-- technic logo icon --> <file>scalable/technic.svg</file> + <!-- Modrinth logo icon --> + <file>scalable/instances/modrinth.svg</file> + <!-- ATLauncher logo icon (and related bits) --> <file>scalable/atlauncher.svg</file> <file>scalable/atlauncher-placeholder.png</file> diff --git a/launcher/ui/dialogs/NewInstanceDialog.cpp b/launcher/ui/dialogs/NewInstanceDialog.cpp index 373484433a..2f95c97d1f 100644 --- a/launcher/ui/dialogs/NewInstanceDialog.cpp +++ b/launcher/ui/dialogs/NewInstanceDialog.cpp @@ -17,7 +17,7 @@ * * 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: * @@ -61,11 +61,10 @@ #include "ui/pages/modplatform/ftb/FtbPage.h" #include "ui/pages/modplatform/legacy_ftb/Page.h" #include "ui/pages/modplatform/flame/FlamePage.h" +#include "ui/pages/modplatform/modrinth/ModrinthPage.h" #include "ui/pages/modplatform/ImportPage.h" #include "ui/pages/modplatform/technic/TechnicPage.h" - - NewInstanceDialog::NewInstanceDialog(const QString & initialGroup, const QString & url, QWidget *parent) : QDialog(parent), ui(new Ui::NewInstanceDialog) { @@ -92,8 +91,12 @@ NewInstanceDialog::NewInstanceDialog(const QString & initialGroup, const QString ui->groupBox->lineEdit()->setPlaceholderText(tr("No group")); - // NOTE: m_buttons must be initialized before PageContainer, because it indirectly accesses m_buttons through setSuggestedPack! Do not move this below. - m_buttons = new QDialogButtonBox(QDialogButtonBox::Help | QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + // NOTE: m_buttons must be initialized before PageContainer, because it + // indirectly accesses m_buttons through setSuggestedPack! Do not move + // this below. + m_buttons = new QDialogButtonBox( + QDialogButtonBox::Help | QDialogButtonBox::Ok | + QDialogButtonBox::Cancel); m_container = new PageContainer(this); m_container->setSizePolicy(QSizePolicy::Policy::Preferred, QSizePolicy::Policy::Expanding); @@ -155,6 +158,7 @@ QList<BasePage *> NewInstanceDialog::getPages() importPage, new AtlPage(this), flamePage, + new ModrinthPage(this), new FtbPage(this), new LegacyFTB::Page(this), technicPage diff --git a/launcher/ui/pages/instance/ServersPage.cpp b/launcher/ui/pages/instance/ServersPage.cpp index d24de188ae..15cbcf0027 100644 --- a/launcher/ui/pages/instance/ServersPage.cpp +++ b/launcher/ui/pages/instance/ServersPage.cpp @@ -449,12 +449,15 @@ public: auto serversDat = parseServersDat(serversPath()); if(serversDat) { - auto &serversList = serversDat->at("servers").as<nbt::tag_list>(); - for(auto iter = serversList.begin(); iter != serversList.end(); iter++) + if(serversDat->has_key("servers", nbt::tag_type::List)) { - auto & serverTag = (*iter).as<nbt::tag_compound>(); - Server s(serverTag); - servers.append(s); + auto &serversList = serversDat->at("servers").as<nbt::tag_list>(); + for(auto iter = serversList.begin(); iter != serversList.end(); iter++) + { + auto & serverTag = (*iter).as<nbt::tag_compound>(); + Server s(serverTag); + servers.append(s); + } } } m_servers.swap(servers); diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp new file mode 100644 index 0000000000..20876f73b5 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp @@ -0,0 +1,294 @@ +/* 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 "ModrinthModel.h" + +#include "Application.h" +#include "Json.h" + +#include <QtMath> + +namespace Modrinth { + +ListModel::ListModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +ListModel::~ListModel() +{ +} + +int ListModel::rowCount(const QModelIndex &parent) const +{ + return modpacks.size(); +} + +int ListModel::columnCount(const QModelIndex &parent) const +{ + return 1; +} + +QVariant ListModel::data(const QModelIndex &index, int role) const +{ + int pos = index.row(); + if (pos >= modpacks.size() || pos < 0 || !index.isValid()) + { + return QString("INVALID INDEX %1").arg(pos); + } + + IndexedPack pack = modpacks.at(pos); + if (role == Qt::DisplayRole) + { + return pack.name; + } + else if (role == Qt::ToolTipRole) + { + if (pack.description.length() > 100) + { + QString edit = pack.description.left(97); + edit = edit.left(edit.lastIndexOf(" ")).append("..."); + return edit; + } + return pack.description; + } + else if (role == Qt::DecorationRole) + { + if (m_logoMap.contains(pack.slug)) + { + return (m_logoMap.value(pack.slug)); + } + QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder"); + ((ListModel *)this)->requestLogo(pack.slug, pack.iconUrl); + return icon; + } + else if (role == Qt::UserRole) + { + QVariant v; + v.setValue(pack); + return v; + } + + return QVariant(); +} + +void ListModel::logoLoaded(QString logo, QIcon out) +{ + m_loadingLogos.removeAll(logo); + m_logoMap.insert(logo, out); + for (int i = 0; i < modpacks.size(); i++) + { + if (modpacks[i].slug == logo) + { + emit dataChanged(createIndex(i, 0), createIndex(i, 0), {Qt::DecorationRole}); + } + } +} + +void ListModel::logoFailed(QString logo) +{ + m_failedLogos.append(logo); + m_loadingLogos.removeAll(logo); +} + +void ListModel::requestLogo(QString logo, QString url) +{ + if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) + { + return; + } + + MetaEntryPtr entry = APPLICATION->metacache()->resolveEntry("ModrinthPacks", QString("logos/%1").arg(logo)); + NetJob *job = new NetJob(QString("Modrinth Icon Download %1").arg(logo), APPLICATION->network()); + job->addNetAction(Net::Download::makeCached(QUrl(url), entry)); + + auto fullPath = entry->getFullPath(); + QObject::connect(job, &NetJob::succeeded, this, [this, job, logo, fullPath] + { + job->deleteLater(); + emit logoLoaded(logo, QIcon(fullPath)); + if (waitingCallbacks.contains(logo)) + { + waitingCallbacks.value(logo)(fullPath); + } + }); + + QObject::connect(job, &NetJob::failed, this, [this, job, logo] + { + job->deleteLater(); + emit logoFailed(logo); + }); + + job->start(); + + m_loadingLogos.append(logo); +} + +void ListModel::getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback) +{ + if (m_logoMap.contains(logo)) + { + callback(APPLICATION->metacache()->resolveEntry("ModrinthPacks", QString("logos/%1").arg(logo))->getFullPath()); + } + else + { + requestLogo(logo, logoUrl); + } +} + +Qt::ItemFlags ListModel::flags(const QModelIndex &index) const +{ + return QAbstractListModel::flags(index); +} + +bool ListModel::canFetchMore(const QModelIndex &parent) const +{ + return searchState == CanPossiblyFetchMore; +} + +void ListModel::fetchMore(const QModelIndex &parent) +{ + if (parent.isValid()) + { + return; + } + if (nextSearchOffset == 0) + { + qWarning() << "fetchMore with 0 offset is wrong..."; + return; + } + performPaginatedSearch(); +} + +void ListModel::performPaginatedSearch() +{ + static const char *sortFields[] = { "relevance", "downloads", "updated", "newest", "follows" }; + int sortIndex = (currentSort >= 0 && currentSort < 5) ? currentSort : 0; + + NetJob *netJob = new NetJob("Modrinth::Search", APPLICATION->network()); + auto searchUrl = QString( + "https://api.modrinth.com/v2/search?" + "query=%1&" + "facets=[[\"project_type:modpack\"]]&" + "index=%2&" + "offset=%3&" + "limit=20" + ).arg(currentSearchTerm, sortFields[sortIndex]).arg(nextSearchOffset); + netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchUrl), &response)); + jobPtr = netJob; + jobPtr->start(); + QObject::connect(netJob, &NetJob::succeeded, this, &ListModel::searchRequestFinished); + QObject::connect(netJob, &NetJob::failed, this, &ListModel::searchRequestFailed); +} + +void ListModel::searchWithTerm(const QString &term, int sort) +{ + if (currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort) + { + return; + } + currentSearchTerm = term; + currentSort = sort; + if (jobPtr) + { + jobPtr->abort(); + searchState = ResetRequested; + return; + } + else + { + beginResetModel(); + modpacks.clear(); + endResetModel(); + searchState = None; + } + nextSearchOffset = 0; + performPaginatedSearch(); +} + +void ListModel::searchRequestFinished() +{ + jobPtr.reset(); + + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(response, &parse_error); + if (parse_error.error != QJsonParseError::NoError) + { + qWarning() << "Error while parsing JSON response from Modrinth at " << parse_error.offset + << " reason: " << parse_error.errorString(); + qWarning() << response; + return; + } + + QList<Modrinth::IndexedPack> newList; + auto obj = doc.object(); + auto hits = Json::ensureArray(obj, "hits"); + for (auto packRaw : hits) + { + auto packObj = packRaw.toObject(); + Modrinth::IndexedPack pack; + try + { + Modrinth::loadIndexedPack(pack, packObj); + newList.append(pack); + } + catch (const JSONValidationError &e) + { + qWarning() << "Error while loading pack from Modrinth: " << e.cause(); + continue; + } + } + + int totalHits = Json::ensureInteger(obj, "total_hits", 0); + if (newList.size() < 20 || (nextSearchOffset + newList.size()) >= totalHits) + { + searchState = Finished; + } + else + { + nextSearchOffset += 20; + searchState = CanPossiblyFetchMore; + } + + beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1); + modpacks.append(newList); + endInsertRows(); +} + +void ListModel::searchRequestFailed(QString reason) +{ + jobPtr.reset(); + + if (searchState == ResetRequested) + { + beginResetModel(); + modpacks.clear(); + endResetModel(); + + nextSearchOffset = 0; + performPaginatedSearch(); + } + else + { + searchState = Finished; + } +} + +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.h new file mode 100644 index 0000000000..bfa2e2f643 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthModel.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 <RWStorage.h> + +#include <QAbstractListModel> +#include <QIcon> +#include <QList> +#include <QMetaType> +#include <QString> +#include <QStringList> + +#include <functional> +#include <net/NetJob.h> + +#include <modplatform/modrinth/ModrinthPackIndex.h> + +namespace Modrinth { + +typedef QMap<QString, QIcon> LogoMap; +typedef std::function<void(QString)> LogoCallback; + +class ListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + explicit ListModel(QObject *parent); + virtual ~ListModel() override; + + int rowCount(const QModelIndex &parent) const override; + int columnCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; + + void getLogo(const QString &logo, const QString &logoUrl, LogoCallback callback); + void searchWithTerm(const QString &term, const int sort); + +private slots: + void performPaginatedSearch(); + + void logoFailed(QString logo); + void logoLoaded(QString logo, QIcon out); + + void searchRequestFinished(); + void searchRequestFailed(QString reason); + +private: + void requestLogo(QString file, QString url); + +private: + QList<IndexedPack> modpacks; + QStringList m_failedLogos; + QStringList m_loadingLogos; + LogoMap m_logoMap; + QMap<QString, LogoCallback> waitingCallbacks; + + QString currentSearchTerm; + int currentSort = 0; + int nextSearchOffset = 0; + enum SearchState { + None, + CanPossiblyFetchMore, + ResetRequested, + Finished + } searchState = None; + NetJob::Ptr jobPtr; + QByteArray response; +}; + +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp new file mode 100644 index 0000000000..dd5d3176f4 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.cpp @@ -0,0 +1,244 @@ +/* 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 "ModrinthPage.h" +#include "ui_ModrinthPage.h" + +#include <QKeyEvent> + +#include "Application.h" +#include "Json.h" +#include "ui/dialogs/NewInstanceDialog.h" +#include "InstanceImportTask.h" +#include "ModrinthModel.h" + +ModrinthPage::ModrinthPage(NewInstanceDialog *dialog, QWidget *parent) + : QWidget(parent), ui(new Ui::ModrinthPage), dialog(dialog) +{ + ui->setupUi(this); + connect(ui->searchButton, &QPushButton::clicked, this, &ModrinthPage::triggerSearch); + ui->searchEdit->installEventFilter(this); + listModel = new Modrinth::ListModel(this); + ui->packView->setModel(listModel); + + ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300); + + ui->sortByBox->addItem(tr("Sort by relevance")); + ui->sortByBox->addItem(tr("Sort by downloads")); + ui->sortByBox->addItem(tr("Sort by last updated")); + ui->sortByBox->addItem(tr("Sort by newest")); + ui->sortByBox->addItem(tr("Sort by follows")); + + connect(ui->sortByBox, QOverload<int>::of(&QComboBox::currentIndexChanged), + this, &ModrinthPage::triggerSearch); + connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, + this, &ModrinthPage::onSelectionChanged); + connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, + this, &ModrinthPage::onVersionSelectionChanged); +} + +ModrinthPage::~ModrinthPage() +{ + delete ui; +} + +bool ModrinthPage::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) + { + QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event); + if (keyEvent->key() == Qt::Key_Return) + { + triggerSearch(); + keyEvent->accept(); + return true; + } + } + return QWidget::eventFilter(watched, event); +} + +bool ModrinthPage::shouldDisplay() const +{ + return true; +} + +void ModrinthPage::openedImpl() +{ + suggestCurrent(); + triggerSearch(); +} + +void ModrinthPage::triggerSearch() +{ + listModel->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex()); +} + +void ModrinthPage::onSelectionChanged(QModelIndex first, QModelIndex second) +{ + ui->versionSelectionBox->clear(); + + if (!first.isValid()) + { + if (isOpened) + { + dialog->setSuggestedPack(); + } + return; + } + + current = listModel->data(first, Qt::UserRole).value<Modrinth::IndexedPack>(); + QString text = ""; + QString name = current.name; + + if (current.slug.isEmpty()) + { + text = name; + } + else + { + text = "<a href=\"https://modrinth.com/modpack/" + current.slug + "\">" + name + "</a>"; + } + + if (!current.author.isEmpty()) + { + text += "<br>" + tr(" by ") + current.author; + } + text += "<br><br>"; + + ui->packDescription->setHtml(text + current.description); + + if (isOpened) + { + dialog->setSuggestedPack(current.name); + } + + if (!current.versionsLoaded) + { + qDebug() << "Loading Modrinth modpack versions"; + NetJob *netJob = new NetJob( + QString("Modrinth::PackVersions(%1)").arg(current.name), + APPLICATION->network()); + std::shared_ptr<QByteArray> versionResponse = std::make_shared<QByteArray>(); + QString projectId = current.projectId; + netJob->addNetAction(Net::Download::makeByteArray( + QString("https://api.modrinth.com/v2/project/%1/version?" + "loaders=[\"forge\",\"fabric\",\"quilt\",\"neoforge\"]") + .arg(projectId), + versionResponse.get())); + + QObject::connect(netJob, &NetJob::succeeded, this, [this, netJob, versionResponse] + { + netJob->deleteLater(); + QJsonParseError parse_error; + QJsonDocument doc = QJsonDocument::fromJson(*versionResponse, &parse_error); + if (parse_error.error != QJsonParseError::NoError) + { + qWarning() << "Error while parsing JSON response from Modrinth at " + << parse_error.offset << " reason: " << parse_error.errorString(); + qWarning() << *versionResponse; + return; + } + QJsonArray arr = doc.array(); + try + { + Modrinth::loadIndexedPackVersions(current, arr); + } + catch (const JSONValidationError &e) + { + qDebug() << *versionResponse; + qWarning() << "Error while reading Modrinth modpack version: " << e.cause(); + } + + for (auto version : current.versions) + { + QString label = version.versionNumber; + if (!version.mcVersion.isEmpty()) + { + label += " [" + version.mcVersion + "]"; + } + if (!version.loaders.isEmpty()) + { + label += " (" + version.loaders + ")"; + } + ui->versionSelectionBox->addItem(label, QVariant(version.downloadUrl)); + } + + suggestCurrent(); + }); + QObject::connect(netJob, &NetJob::failed, this, [netJob] + { + netJob->deleteLater(); + }); + netJob->start(); + } + else + { + for (auto version : current.versions) + { + QString label = version.versionNumber; + if (!version.mcVersion.isEmpty()) + { + label += " [" + version.mcVersion + "]"; + } + if (!version.loaders.isEmpty()) + { + label += " (" + version.loaders + ")"; + } + ui->versionSelectionBox->addItem(label, QVariant(version.downloadUrl)); + } + + suggestCurrent(); + } +} + +void ModrinthPage::suggestCurrent() +{ + if (!isOpened) + { + return; + } + + if (selectedVersion.isEmpty()) + { + dialog->setSuggestedPack(); + return; + } + + dialog->setSuggestedPack(current.name, new InstanceImportTask(selectedVersion)); + QString editedLogoName; + editedLogoName = "modrinth_" + current.slug; + listModel->getLogo(current.slug, current.iconUrl, [this, editedLogoName](QString logo) + { + dialog->setSuggestedIconFromFile(logo, editedLogoName); + }); +} + +void ModrinthPage::onVersionSelectionChanged(QString data) +{ + if (data.isNull() || data.isEmpty()) + { + selectedVersion = ""; + return; + } + selectedVersion = ui->versionSelectionBox->currentData().toString(); + suggestCurrent(); +} diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h new file mode 100644 index 0000000000..57b456a6e1 --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.h @@ -0,0 +1,86 @@ +/* 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 <QWidget> + +#include "ui/pages/BasePage.h" +#include <Application.h> +#include "tasks/Task.h" +#include <modplatform/modrinth/ModrinthPackIndex.h> + +namespace Ui +{ +class ModrinthPage; +} + +class NewInstanceDialog; + +namespace Modrinth { + class ListModel; +} + +class ModrinthPage : public QWidget, public BasePage +{ + Q_OBJECT + +public: + explicit ModrinthPage(NewInstanceDialog *dialog, QWidget *parent = 0); + virtual ~ModrinthPage() override; + virtual QString displayName() const override + { + return tr("Modrinth"); + } + virtual QIcon icon() const override + { + return APPLICATION->getThemedIcon("modrinth"); + } + virtual QString id() const override + { + return "modrinth"; + } + virtual QString helpPage() const override + { + return "Modrinth-platform"; + } + virtual bool shouldDisplay() const override; + + void openedImpl() override; + + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void suggestCurrent(); + +private slots: + void triggerSearch(); + void onSelectionChanged(QModelIndex first, QModelIndex second); + void onVersionSelectionChanged(QString data); + +private: + Ui::ModrinthPage *ui = nullptr; + NewInstanceDialog *dialog = nullptr; + Modrinth::ListModel *listModel = nullptr; + Modrinth::IndexedPack current; + + QString selectedVersion; +}; diff --git a/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui new file mode 100644 index 0000000000..6d183de50c --- /dev/null +++ b/launcher/ui/pages/modplatform/modrinth/ModrinthPage.ui @@ -0,0 +1,90 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ModrinthPage</class> + <widget class="QWidget" name="ModrinthPage"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>837</width> + <height>685</height> + </rect> + </property> + <layout class="QGridLayout" name="gridLayout"> + <item row="1" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_3"> + <item row="1" column="0"> + <widget class="QListView" name="packView"> + <property name="iconSize"> + <size> + <width>48</width> + <height>48</height> + </size> + </property> + <property name="horizontalScrollBarPolicy"> + <enum>Qt::ScrollBarAlwaysOff</enum> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + </widget> + </item> + <item row="1" column="1"> + <widget class="QTextBrowser" name="packDescription"> + <property name="openExternalLinks"> + <bool>true</bool> + </property> + <property name="openLinks"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </item> + <item row="2" column="0" colspan="2"> + <layout class="QGridLayout" name="gridLayout_4" columnstretch="0,0,0" rowminimumheight="0" columnminimumwidth="0,0,0"> + <item row="0" column="2"> + <widget class="QComboBox" name="versionSelectionBox"/> + </item> + <item row="0" column="1"> + <widget class="QLabel" name="label"> + <property name="text"> + <string>Version selected:</string> + </property> + <property name="alignment"> + <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QComboBox" name="sortByBox"/> + </item> + </layout> + </item> + <item row="0" column="1"> + <widget class="QPushButton" name="searchButton"> + <property name="text"> + <string>Search</string> + </property> + </widget> + </item> + <item row="0" column="0"> + <widget class="QLineEdit" name="searchEdit"> + <property name="placeholderText"> + <string>Search and filter ...</string> + </property> + </widget> + </item> + </layout> + </widget> + <tabstops> + <tabstop>searchEdit</tabstop> + <tabstop>searchButton</tabstop> + <tabstop>packView</tabstop> + <tabstop>packDescription</tabstop> + <tabstop>sortByBox</tabstop> + <tabstop>versionSelectionBox</tabstop> + </tabstops> + <resources/> + <connections/> +</ui> diff --git a/scripts/checkpatch.pl b/scripts/checkpatch.pl index 7f279f62d9..deb950b0f7 100755 --- a/scripts/checkpatch.pl +++ b/scripts/checkpatch.pl @@ -96,6 +96,7 @@ my $g_warn_count = 0; my $g_info_count = 0; my $g_file_count = 0; my $g_line_count = 0; +my @g_current_lines = (); # Options my $opt_diff = 0; @@ -433,6 +434,17 @@ sub severity_label { sub report { my ($file, $line, $severity, $rule, $message) = @_; + # Support NOLINT suppression comments in source lines + if ($line > 0 && $line <= scalar @g_current_lines) { + my $src = $g_current_lines[$line - 1]; + if ($src =~ m{//\s*NOLINT\b(?:\(([^)]+)\))?}) { + my $nolint_rules = $1; + if (!defined $nolint_rules || $nolint_rules =~ /\b\Q$rule\E\b/i) { + return; # Suppressed by NOLINT + } + } + } + # Always track counts and store issues regardless of display mode if ($severity == SEV_ERROR) { $g_error_count++; @@ -1009,6 +1021,7 @@ sub process_diff_content { # Pass the full file content but only mark diff-changed lines my %changed = %{$file_changes{$file}}; + @g_current_lines = @lines; check_file_content($filepath, \@lines, \%changed); } } @@ -1046,6 +1059,9 @@ sub process_file { $g_file_count++; $g_line_count += scalar @lines; + # Store current file lines for NOLINT support in report() + @g_current_lines = @lines; + # All lines are considered "changed" when checking a file directly my %all_lines = map { ($_ + 1) => 1 } (0 .. $#lines); check_file_content($filepath, \@lines, \%all_lines); @@ -2345,7 +2361,7 @@ sub check_constructor_patterns { next if $line =~ /^\s*\*/; # Check for single-parameter constructors without explicit - if ($line =~ /^\s*(\w+)\s*\(\s*(?:const\s+)?(?:\w+(?:::\w+)*)\s*[&*]?\s*\w+\s*\)\s*[;{]/ && + if ($line =~ /^\s*(\w+)\s*\(\s*(?:const\s+)?(?:\w+(?:::\w+)*)(?:\s+[&*]?\s*\w+|\s*[&*]+\s*\w+)\s*\)\s*[;{]/ && $line !~ /\bexplicit\b/ && $line !~ /\bvirtual\b/ && $line !~ /\boverride\b/) { my $class = $1; # Skip destructors, operators @@ -2387,6 +2403,7 @@ sub check_function_length { my $function_start = -1; my $function_name = ''; my $in_function = 0; + my $brace_counted_line = -1; for (my $i = 0; $i < scalar @$lines_ref; $i++) { my $line = $lines_ref->[$i]; @@ -2397,37 +2414,41 @@ sub check_function_length { # Detect function definition start if (!$in_function && $line =~ /^(?:\w[\w:*&<> ,]*\s+)?(\w+(?:::\w+)?)\s*\([^;]*$/) { my $name = $1; - # Check if next lines contain the opening brace if ($line =~ /{/) { $function_start = $i; $function_name = $name; $in_function = 1; $brace_depth = count_braces($line); + $brace_counted_line = $i; } elsif ($i + 1 < scalar @$lines_ref && $lines_ref->[$i + 1] =~ /^\s*{/) { $function_start = $i; $function_name = $name; } } + # Handle deferred function start (opening brace on next line) if ($function_start >= 0 && !$in_function && $line =~ /{/) { $in_function = 1; - $brace_depth = 0; + $brace_depth = count_braces($line); + $brace_counted_line = $i; } - if ($in_function) { + # Count braces for subsequent lines (skip already-counted start line) + if ($in_function && $i != $brace_counted_line) { $brace_depth += count_braces($line); + } - if ($brace_depth <= 0 && $line =~ /}/) { - my $length = $i - $function_start + 1; - if ($length > $MAX_FUNCTION_LENGTH) { - report($filepath, $function_start + 1, SEV_WARNING, 'FUNCTION_TOO_LONG', - "Function '$function_name' is $length lines (recommended maximum: $MAX_FUNCTION_LENGTH). Consider refactoring."); - } - $in_function = 0; - $function_start = -1; - $function_name = ''; - $brace_depth = 0; + if ($in_function && $brace_depth <= 0 && $line =~ /}/) { + my $length = $i - $function_start + 1; + if ($length > $MAX_FUNCTION_LENGTH) { + report($filepath, $function_start + 1, SEV_WARNING, 'FUNCTION_TOO_LONG', + "Function '$function_name' is $length lines (recommended maximum: $MAX_FUNCTION_LENGTH). Consider refactoring."); } + $in_function = 0; + $function_start = -1; + $function_name = ''; + $brace_depth = 0; + $brace_counted_line = -1; } } } |
