summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt2
-rw-r--r--api/logic/Env.cpp1
-rw-r--r--api/logic/minecraft/LaunchProfile.cpp22
-rw-r--r--api/logic/minecraft/LaunchProfile.h7
-rw-r--r--api/logic/minecraft/OneSixVersionFormat.cpp25
-rw-r--r--api/logic/minecraft/VersionFile.cpp6
-rw-r--r--api/logic/minecraft/VersionFile.h3
-rw-r--r--api/logic/minecraft/update/LibrariesTask.cpp1
-rw-r--r--application/CMakeLists.txt6
-rw-r--r--application/dialogs/AboutDialog.cpp1
-rw-r--r--application/dialogs/NewInstanceDialog.cpp3
-rw-r--r--application/dialogs/NewInstanceDialog.h2
-rw-r--r--application/pages/instance/VersionPage.cpp2
-rw-r--r--application/pages/modplatform/twitch/TwitchData.h38
-rw-r--r--application/pages/modplatform/twitch/TwitchModel.cpp314
-rw-r--r--application/pages/modplatform/twitch/TwitchModel.h76
-rw-r--r--application/pages/modplatform/twitch/TwitchPage.cpp86
-rw-r--r--application/pages/modplatform/twitch/TwitchPage.h77
-rw-r--r--application/pages/modplatform/twitch/TwitchPage.ui63
-rw-r--r--changelog.md21
20 files changed, 744 insertions, 12 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b3b451e1..d02dfc8b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -46,7 +46,7 @@ set(MultiMC_NEWS_RSS_URL "https://multimc.org/rss.xml" CACHE STRING "URL to fetc
######## Set version numbers ########
set(MultiMC_VERSION_MAJOR 0)
set(MultiMC_VERSION_MINOR 6)
-set(MultiMC_VERSION_HOTFIX 9)
+set(MultiMC_VERSION_HOTFIX 12)
# Build number
set(MultiMC_VERSION_BUILD -1 CACHE STRING "Build number. -1 for no build number.")
diff --git a/api/logic/Env.cpp b/api/logic/Env.cpp
index 77546bbc..0d496d4e 100644
--- a/api/logic/Env.cpp
+++ b/api/logic/Env.cpp
@@ -97,6 +97,7 @@ void Env::initHttpMetaCache()
m_metacache->addBase("liteloader", QDir("mods/liteloader").absolutePath());
m_metacache->addBase("general", QDir("cache").absolutePath());
m_metacache->addBase("FTBPacks", QDir("cache/FTBPacks").absolutePath());
+ m_metacache->addBase("TwitchPacks", QDir("cache/TwitchPacks").absolutePath());
m_metacache->addBase("skins", QDir("accounts/skins").absolutePath());
m_metacache->addBase("root", QDir::currentPath());
m_metacache->addBase("translations", QDir("translations").absolutePath());
diff --git a/api/logic/minecraft/LaunchProfile.cpp b/api/logic/minecraft/LaunchProfile.cpp
index c39bdf04..41705187 100644
--- a/api/logic/minecraft/LaunchProfile.cpp
+++ b/api/logic/minecraft/LaunchProfile.cpp
@@ -11,6 +11,7 @@ void LaunchProfile::clear()
m_mainClass.clear();
m_appletClass.clear();
m_libraries.clear();
+ m_mavenFiles.clear();
m_traits.clear();
m_jarMods.clear();
m_mainJar.reset();
@@ -157,6 +158,22 @@ void LaunchProfile::applyLibrary(LibraryPtr library)
}
}
+void LaunchProfile::applyMavenFile(LibraryPtr mavenFile)
+{
+ if(!mavenFile->isActive())
+ {
+ return;
+ }
+
+ if(mavenFile->isNative())
+ {
+ return;
+ }
+
+ // unlike libraries, we do not keep only one version or try to dedupe them
+ m_mavenFiles.append(Library::limitedCopy(mavenFile));
+}
+
const LibraryPtr LaunchProfile::getMainJar() const
{
return m_mainJar;
@@ -253,6 +270,11 @@ const QList<LibraryPtr> & LaunchProfile::getNativeLibraries() const
return m_nativeLibraries;
}
+const QList<LibraryPtr> & LaunchProfile::getMavenFiles() const
+{
+ return m_mavenFiles;
+}
+
void LaunchProfile::getLibraryFiles(
const QString& architecture,
QStringList& jars,
diff --git a/api/logic/minecraft/LaunchProfile.h b/api/logic/minecraft/LaunchProfile.h
index 77174079..c1752531 100644
--- a/api/logic/minecraft/LaunchProfile.h
+++ b/api/logic/minecraft/LaunchProfile.h
@@ -20,6 +20,7 @@ public: /* application of profile variables from patches */
void applyJarMods(const QList<LibraryPtr> &jarMods);
void applyMods(const QList<LibraryPtr> &jarMods);
void applyLibrary(LibraryPtr library);
+ void applyMavenFile(LibraryPtr library);
void applyMainJar(LibraryPtr jar);
void applyProblemSeverity(ProblemSeverity severity);
/// clear the profile
@@ -37,6 +38,7 @@ public: /* getters for profile variables */
const QList<LibraryPtr> & getJarMods() const;
const QList<LibraryPtr> & getLibraries() const;
const QList<LibraryPtr> & getNativeLibraries() const;
+ const QList<LibraryPtr> & getMavenFiles() const;
const LibraryPtr getMainJar() const;
void getLibraryFiles(
const QString & architecture,
@@ -79,10 +81,13 @@ private:
/// the list of libraries
QList<LibraryPtr> m_libraries;
+ /// the list of maven files to be placed in the libraries folder, but not acted upon
+ QList<LibraryPtr> m_mavenFiles;
+
/// the main jar
LibraryPtr m_mainJar;
- /// the list of libraries
+ /// the list of native libraries
QList<LibraryPtr> m_nativeLibraries;
/// traits, collected from all the version files (version files can only add)
diff --git a/api/logic/minecraft/OneSixVersionFormat.cpp b/api/logic/minecraft/OneSixVersionFormat.cpp
index 6f3b926b..a0b6fd0e 100644
--- a/api/logic/minecraft/OneSixVersionFormat.cpp
+++ b/api/logic/minecraft/OneSixVersionFormat.cpp
@@ -144,14 +144,14 @@ VersionFilePtr OneSixVersionFormat::versionFileFromJson(const QJsonDocument &doc
}
}
- auto readLibs = [&](const char * which)
+ auto readLibs = [&](const char * which, QList<LibraryPtr> & out)
{
for (auto libVal : requireArray(root.value(which)))
{
QJsonObject libObj = requireObject(libVal);
// parse the library
auto lib = libraryFromJson(libObj, filename);
- out->libraries.append(lib);
+ out.append(lib);
}
};
bool hasPlusLibs = root.contains("+libraries");
@@ -160,16 +160,20 @@ VersionFilePtr OneSixVersionFormat::versionFileFromJson(const QJsonDocument &doc
{
out->addProblem(ProblemSeverity::Warning,
QObject::tr("Version file has both '+libraries' and 'libraries'. This is no longer supported."));
- readLibs("libraries");
- readLibs("+libraries");
+ readLibs("libraries", out->libraries);
+ readLibs("+libraries", out->libraries);
}
else if (hasLibs)
{
- readLibs("libraries");
+ readLibs("libraries", out->libraries);
}
else if(hasPlusLibs)
{
- readLibs("+libraries");
+ readLibs("+libraries", out->libraries);
+ }
+
+ if(root.contains("mavenFiles")) {
+ readLibs("mavenFiles", out->mavenFiles);
}
// if we have mainJar, just use it
@@ -276,6 +280,15 @@ QJsonDocument OneSixVersionFormat::versionFileToJson(const VersionFilePtr &patch
}
root.insert("libraries", array);
}
+ if (!patch->mavenFiles.isEmpty())
+ {
+ QJsonArray array;
+ for (auto value: patch->mavenFiles)
+ {
+ array.append(OneSixVersionFormat::libraryToJson(value.get()));
+ }
+ root.insert("mavenFiles", array);
+ }
if (!patch->jarMods.isEmpty())
{
QJsonArray array;
diff --git a/api/logic/minecraft/VersionFile.cpp b/api/logic/minecraft/VersionFile.cpp
index cfb9e504..5bad57e9 100644
--- a/api/logic/minecraft/VersionFile.cpp
+++ b/api/logic/minecraft/VersionFile.cpp
@@ -41,6 +41,10 @@ void VersionFile::applyTo(LaunchProfile *profile)
{
profile->applyLibrary(library);
}
+ for (auto mavenFile : mavenFiles)
+ {
+ profile->applyMavenFile(mavenFile);
+ }
profile->applyProblemSeverity(getProblemSeverity());
}
@@ -53,4 +57,4 @@ void VersionFile::applyTo(LaunchProfile *profile)
throw MinecraftVersionMismatch(uid, dependsOnMinecraftVersion, theirVersion);
}
}
-*/ \ No newline at end of file
+*/
diff --git a/api/logic/minecraft/VersionFile.h b/api/logic/minecraft/VersionFile.h
index 3dc9db96..29ddd0bc 100644
--- a/api/logic/minecraft/VersionFile.h
+++ b/api/logic/minecraft/VersionFile.h
@@ -75,6 +75,9 @@ public: /* data */
/// Mojang: list of libraries to add to the version
QList<LibraryPtr> libraries;
+ /// MultiMC: list of maven files to put in the libraries folder, but not in classpath
+ QList<LibraryPtr> mavenFiles;
+
/// The main jar (Minecraft version library, normally)
LibraryPtr mainJar;
diff --git a/api/logic/minecraft/update/LibrariesTask.cpp b/api/logic/minecraft/update/LibrariesTask.cpp
index 912f492b..a000f77f 100644
--- a/api/logic/minecraft/update/LibrariesTask.cpp
+++ b/api/logic/minecraft/update/LibrariesTask.cpp
@@ -45,6 +45,7 @@ void LibrariesTask::executeTask()
QList<LibraryPtr> libArtifactPool;
libArtifactPool.append(profile->getLibraries());
libArtifactPool.append(profile->getNativeLibraries());
+ libArtifactPool.append(profile->getMavenFiles());
libArtifactPool.append(profile->getMainJar());
processArtifactPool(libArtifactPool, failedLocalLibraries, inst->getLocalLibraryPath());
diff --git a/application/CMakeLists.txt b/application/CMakeLists.txt
index 94ba56d0..53c21866 100644
--- a/application/CMakeLists.txt
+++ b/application/CMakeLists.txt
@@ -133,6 +133,11 @@ SET(MULTIMC_SOURCES
pages/modplatform/legacy_ftb/Page.h
pages/modplatform/legacy_ftb/ListModel.h
pages/modplatform/legacy_ftb/ListModel.cpp
+ pages/modplatform/twitch/TwitchData.h
+ pages/modplatform/twitch/TwitchModel.cpp
+ pages/modplatform/twitch/TwitchModel.h
+ pages/modplatform/twitch/TwitchPage.cpp
+ pages/modplatform/twitch/TwitchPage.h
pages/modplatform/ImportPage.cpp
pages/modplatform/ImportPage.h
@@ -251,6 +256,7 @@ SET(MULTIMC_UIS
# Platform pages
pages/modplatform/VanillaPage.ui
pages/modplatform/legacy_ftb/Page.ui
+ pages/modplatform/twitch/TwitchPage.ui
pages/modplatform/ImportPage.ui
# Dialogs
diff --git a/application/dialogs/AboutDialog.cpp b/application/dialogs/AboutDialog.cpp
index a556ec05..c4e4ee24 100644
--- a/application/dialogs/AboutDialog.cpp
+++ b/application/dialogs/AboutDialog.cpp
@@ -46,6 +46,7 @@ QString getCreditsHtml(QStringList patrons)
stream << "<p>TakSuyu &lt;<a href='mailto:taksuyu@gmail.com'>taksuyu@gmail.com</a>&gt;</p>\n";
stream << "<p>Kilobyte &lt;<a href='mailto:stiepen22@gmx.de'>stiepen22@gmx.de</a>&gt;</p>\n";
stream << "<p>Rootbear75 &lt;<a href='https://twitter.com/rootbear75'>@rootbear75</a>&gt;</p>\n";
+ stream << "<p>Zeker Zhayard &lt;<a href='https://twitter.com/zeker_zhayard'>@Zeker_Zhayard</a>&gt;</p>\n";
stream << "<br />\n";
if(!patrons.isEmpty()) {
diff --git a/application/dialogs/NewInstanceDialog.cpp b/application/dialogs/NewInstanceDialog.cpp
index 804340bc..511f991e 100644
--- a/application/dialogs/NewInstanceDialog.cpp
+++ b/application/dialogs/NewInstanceDialog.cpp
@@ -35,6 +35,7 @@
#include "widgets/PageContainer.h"
#include <pages/modplatform/VanillaPage.h>
#include <pages/modplatform/legacy_ftb/Page.h>
+#include <pages/modplatform/twitch/TwitchPage.h>
#include <pages/modplatform/ImportPage.h>
@@ -119,11 +120,13 @@ void NewInstanceDialog::accept()
QList<BasePage *> NewInstanceDialog::getPages()
{
importPage = new ImportPage(this);
+ twitchPage = new TwitchPage(this);
return
{
new VanillaPage(this),
importPage,
new LegacyFTB::Page(this),
+ twitchPage
};
}
diff --git a/application/dialogs/NewInstanceDialog.h b/application/dialogs/NewInstanceDialog.h
index c86ab73f..0b8b2fb8 100644
--- a/application/dialogs/NewInstanceDialog.h
+++ b/application/dialogs/NewInstanceDialog.h
@@ -29,6 +29,7 @@ class NewInstanceDialog;
class PageContainer;
class QDialogButtonBox;
class ImportPage;
+class TwitchPage;
class NewInstanceDialog : public QDialog, public BasePageProvider
{
@@ -67,6 +68,7 @@ private:
QString InstIconKey;
ImportPage *importPage = nullptr;
+ TwitchPage *twitchPage = nullptr;
std::unique_ptr<InstanceTask> creationTask;
bool importIcon = false;
diff --git a/application/pages/instance/VersionPage.cpp b/application/pages/instance/VersionPage.cpp
index 20298117..60ff8301 100644
--- a/application/pages/instance/VersionPage.cpp
+++ b/application/pages/instance/VersionPage.cpp
@@ -206,7 +206,7 @@ void VersionPage::updateVersionControls()
bool newCraft = controlsEnabled && (minecraftVersion >= Version("1.14"));
bool oldCraft = controlsEnabled && (minecraftVersion <= Version("1.12.2"));
ui->actionInstall_Fabric->setEnabled(newCraft);
- ui->actionInstall_Forge->setEnabled(oldCraft);
+ ui->actionInstall_Forge->setEnabled(true);
ui->actionInstall_LiteLoader->setEnabled(oldCraft);
ui->actionReload->setEnabled(true);
updateButtons();
diff --git a/application/pages/modplatform/twitch/TwitchData.h b/application/pages/modplatform/twitch/TwitchData.h
new file mode 100644
index 00000000..dd000b84
--- /dev/null
+++ b/application/pages/modplatform/twitch/TwitchData.h
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <QString>
+#include <QList>
+
+namespace Twitch {
+
+struct ModpackAuthor {
+ QString name;
+ QString url;
+};
+
+struct ModpackFile {
+ int addonId;
+ int fileId;
+ QString version;
+ QString mcVersion;
+ QString downloadUrl;
+};
+
+struct Modpack
+{
+ bool broken = true;
+ int addonId = 0;
+
+ QString name;
+ QString description;
+ QList<ModpackAuthor> authors;
+ QString mcVersion;
+ QString logoName;
+ QString logoUrl;
+ QString websiteUrl;
+
+ ModpackFile latestFile;
+};
+}
+
+Q_DECLARE_METATYPE(Twitch::Modpack)
diff --git a/application/pages/modplatform/twitch/TwitchModel.cpp b/application/pages/modplatform/twitch/TwitchModel.cpp
new file mode 100644
index 00000000..d9358941
--- /dev/null
+++ b/application/pages/modplatform/twitch/TwitchModel.cpp
@@ -0,0 +1,314 @@
+#include "TwitchModel.h"
+#include "MultiMC.h"
+
+#include <MMCStrings.h>
+#include <Version.h>
+
+#include <QtMath>
+#include <QLabel>
+
+#include <RWStorage.h>
+#include <Env.h>
+
+#include "net/URLConstants.h"
+
+namespace Twitch {
+
+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);
+ }
+
+ Modpack pack = modpacks.at(pos);
+ if(role == Qt::DisplayRole)
+ {
+ return pack.name;
+ }
+ else if (role == Qt::ToolTipRole)
+ {
+ if(pack.description.length() > 100)
+ {
+ //some magic to prevent to long tooltips and replace html linebreaks
+ QString edit = pack.description.left(97);
+ edit = edit.left(edit.lastIndexOf("<br>")).left(edit.lastIndexOf(" ")).append("...");
+ return edit;
+
+ }
+ return pack.description;
+ }
+ else if(role == Qt::DecorationRole)
+ {
+ if(m_logoMap.contains(pack.logoName))
+ {
+ return (m_logoMap.value(pack.logoName));
+ }
+ QIcon icon = MMC->getThemedIcon("screenshot-placeholder");
+ ((ListModel *)this)->requestLogo(pack.logoName, pack.logoUrl);
+ 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].logoName == 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 = ENV.metacache()->resolveEntry("TwitchPacks", QString("logos/%1").arg(logo.section(".", 0, 0)));
+ NetJob *job = new NetJob(QString("Twitch Icon Download %1").arg(logo));
+ job->addNetAction(Net::Download::makeCached(QUrl(url), entry));
+
+ auto fullPath = entry->getFullPath();
+ QObject::connect(job, &NetJob::finished, this, [this, logo, fullPath]
+ {
+ emit logoLoaded(logo, QIcon(fullPath));
+ if(waitingCallbacks.contains(logo))
+ {
+ waitingCallbacks.value(logo)(fullPath);
+ }
+ });
+
+ QObject::connect(job, &NetJob::failed, this, [this, logo]
+ {
+ 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(ENV.metacache()->resolveEntry("TwitchPacks", QString("logos/%1").arg(logo.section(".", 0, 0)))->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()
+{
+ NetJob *netJob = new NetJob("Twitch::Search");
+ auto searchUrl = QString(
+ "https://addons-ecs.forgesvc.net/api/v2/addon/search?"
+ "categoryId=0&"
+ "gameId=432&"
+ //"gameVersion=1.12.2&"
+ "index=%1&"
+ "pageSize=25&"
+ "searchFilter=%2&"
+ "sectionId=4471&"
+ "sort=0"
+ ).arg(nextSearchOffset).arg(currentSearchTerm);
+ 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)
+{
+ if(currentSearchTerm == term) {
+ return;
+ }
+ currentSearchTerm = term;
+ if(jobPtr) {
+ jobPtr->abort();
+ searchState = ResetRequested;
+ return;
+ }
+ else {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+ searchState = None;
+ }
+ nextSearchOffset = 0;
+ performPaginatedSearch();
+}
+
+void Twitch::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 Twitch at " << parse_error.offset << " reason: " << parse_error.errorString();
+ qWarning() << response;
+ return;
+ }
+
+ QList<Modpack> newList;
+ auto objs = doc.array();
+ for(auto projectIter: objs) {
+ Modpack pack;
+ auto project = projectIter.toObject();
+ pack.addonId = project.value("id").toInt(0);
+ if (pack.addonId == 0) {
+ qWarning() << "Pack without an ID, skipping: " << pack.name;
+ continue;
+ }
+ pack.name = project.value("name").toString();
+ pack.websiteUrl = project.value("websiteUrl").toString();
+ pack.description = project.value("summary").toString();
+ bool thumbnailFound = false;
+ auto attachments = project.value("attachments").toArray();
+ for(auto attachmentIter: attachments) {
+ auto attachment = attachmentIter.toObject();
+ bool isDefault = attachment.value("isDefault").toBool(false);
+ if(isDefault) {
+ thumbnailFound = true;
+ pack.logoName = attachment.value("title").toString();
+ pack.logoUrl = attachment.value("thumbnailUrl").toString();
+ break;
+ }
+ }
+ if(!thumbnailFound) {
+ qWarning() << "Pack without an icon, skipping: " << pack.name;
+ continue;
+ }
+ auto authors = project.value("authors").toArray();
+ for(auto authorIter: authors) {
+ auto author = authorIter.toObject();
+ ModpackAuthor packAuthor;
+ packAuthor.name = author.value("name").toString();
+ packAuthor.url = author.value("url").toString();
+ pack.authors.append(packAuthor);
+ }
+ int defaultFileId = project.value("defaultFileId").toInt(0);
+ if(defaultFileId == 0) {
+ qWarning() << "Pack without default file, skipping: " << pack.name;
+ continue;
+ }
+ bool found = false;
+ auto files = project.value("latestFiles").toArray();
+ for(auto fileIter: files) {
+ auto file = fileIter.toObject();
+ int id = file.value("id").toInt(0);
+ // NOTE: for now, ignore everything that's not the default...
+ if(id != defaultFileId) {
+ continue;
+ }
+ pack.latestFile.addonId = pack.addonId;
+ pack.latestFile.fileId = id;
+ // FIXME: what to do when there's more than one, or there's no version?
+ auto versionArray = file.value("gameVersion").toArray();
+ if(versionArray.size() != 1) {
+ continue;
+ }
+ pack.latestFile.mcVersion = versionArray[0].toString();
+ pack.latestFile.version = file.value("displayName").toString();
+ pack.latestFile.downloadUrl = file.value("downloadUrl").toString();
+ found = true;
+ break;
+ }
+ if(!found) {
+ qWarning() << "Pack with no good file, skipping: " << pack.name;
+ continue;
+ }
+ pack.broken = false;
+ newList.append(pack);
+ }
+ if(objs.size() < 25) {
+ searchState = Finished;
+ } else {
+ nextSearchOffset += 25;
+ searchState = CanPossiblyFetchMore;
+ }
+ beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1);
+ modpacks.append(newList);
+ endInsertRows();
+}
+
+void Twitch::ListModel::searchRequestFailed(QString reason)
+{
+ jobPtr.reset();
+
+ if(searchState == ResetRequested) {
+ beginResetModel();
+ modpacks.clear();
+ endResetModel();
+
+ nextSearchOffset = 0;
+ performPaginatedSearch();
+ } else {
+ searchState = Finished;
+ }
+}
+
+}
+
diff --git a/application/pages/modplatform/twitch/TwitchModel.h b/application/pages/modplatform/twitch/TwitchModel.h
new file mode 100644
index 00000000..ad355c64
--- /dev/null
+++ b/application/pages/modplatform/twitch/TwitchModel.h
@@ -0,0 +1,76 @@
+#pragma once
+
+#include <modplatform/legacy_ftb/PackHelpers.h>
+#include <RWStorage.h>
+
+#include <QAbstractListModel>
+#include <QSortFilterProxyModel>
+#include <QThreadPool>
+#include <QIcon>
+#include <QStyledItemDelegate>
+#include <QList>
+#include <QString>
+#include <QStringList>
+#include <QMetaType>
+
+#include <functional>
+#include <net/NetJob.h>
+
+#include "TwitchData.h"
+
+namespace Twitch {
+
+
+typedef QMap<QString, QIcon> LogoMap;
+typedef std::function<void(QString)> LogoCallback;
+
+class ListModel : public QAbstractListModel
+{
+ Q_OBJECT
+
+public:
+ ListModel(QObject *parent);
+ virtual ~ListModel();
+
+ 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);
+
+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<Modpack> modpacks;
+ QStringList m_failedLogos;
+ QStringList m_loadingLogos;
+ LogoMap m_logoMap;
+ QMap<QString, LogoCallback> waitingCallbacks;
+
+ QString currentSearchTerm;
+ int nextSearchOffset = 0;
+ enum SearchState {
+ None,
+ CanPossiblyFetchMore,
+ ResetRequested,
+ Finished
+ } searchState = None;
+ NetJobPtr jobPtr;
+ QByteArray response;
+};
+
+}
diff --git a/application/pages/modplatform/twitch/TwitchPage.cpp b/application/pages/modplatform/twitch/TwitchPage.cpp
new file mode 100644
index 00000000..80d83133
--- /dev/null
+++ b/application/pages/modplatform/twitch/TwitchPage.cpp
@@ -0,0 +1,86 @@
+#include "TwitchPage.h"
+#include "ui_TwitchPage.h"
+
+#include "MultiMC.h"
+#include "dialogs/NewInstanceDialog.h"
+#include <InstanceImportTask.h>
+#include "TwitchModel.h"
+#include <QKeyEvent>
+
+TwitchPage::TwitchPage(NewInstanceDialog* dialog, QWidget *parent)
+ : QWidget(parent), ui(new Ui::TwitchPage), dialog(dialog)
+{
+ ui->setupUi(this);
+ connect(ui->searchButton, &QPushButton::clicked, this, &TwitchPage::triggerSearch);
+ ui->searchEdit->installEventFilter(this);
+ model = new Twitch::ListModel(this);
+ ui->packView->setModel(model);
+ connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &TwitchPage::onSelectionChanged);
+}
+
+TwitchPage::~TwitchPage()
+{
+ delete ui;
+}
+
+bool TwitchPage::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 TwitchPage::shouldDisplay() const
+{
+ return true;
+}
+
+void TwitchPage::openedImpl()
+{
+ suggestCurrent();
+}
+
+void TwitchPage::triggerSearch()
+{
+ model->searchWithTerm(ui->searchEdit->text());
+}
+
+void TwitchPage::onSelectionChanged(QModelIndex first, QModelIndex second)
+{
+ if(!first.isValid())
+ {
+ if(isOpened)
+ {
+ dialog->setSuggestedPack();
+ }
+ return;
+ }
+ current = model->data(first, Qt::UserRole).value<Twitch::Modpack>();
+ suggestCurrent();
+}
+
+void TwitchPage::suggestCurrent()
+{
+ if(!isOpened)
+ {
+ return;
+ }
+ if(current.broken)
+ {
+ dialog->setSuggestedPack();
+ }
+
+ dialog->setSuggestedPack(current.name, new InstanceImportTask(current.latestFile.downloadUrl));
+ QString editedLogoName;
+ editedLogoName = "twitch_" + current.logoName.section(".", 0, 0);
+ model->getLogo(current.logoName, current.logoUrl, [this, editedLogoName](QString logo)
+ {
+ dialog->setSuggestedIconFromFile(logo, editedLogoName);
+ });
+}
diff --git a/application/pages/modplatform/twitch/TwitchPage.h b/application/pages/modplatform/twitch/TwitchPage.h
new file mode 100644
index 00000000..04e3a1c6
--- /dev/null
+++ b/application/pages/modplatform/twitch/TwitchPage.h
@@ -0,0 +1,77 @@
+/* Copyright 2013-2019 MultiMC Contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <QWidget>
+
+#include "pages/BasePage.h"
+#include <MultiMC.h>
+#include "tasks/Task.h"
+#include "TwitchData.h"
+
+namespace Ui
+{
+class TwitchPage;
+}
+
+class NewInstanceDialog;
+
+namespace Twitch {
+ class ListModel;
+}
+
+class TwitchPage : public QWidget, public BasePage
+{
+ Q_OBJECT
+
+public:
+ explicit TwitchPage(NewInstanceDialog* dialog, QWidget *parent = 0);
+ virtual ~TwitchPage();
+ virtual QString displayName() const override
+ {
+ return tr("Twitch");
+ }
+ virtual QIcon icon() const override
+ {
+ return MMC->getThemedIcon("twitch");
+ }
+ virtual QString id() const override
+ {
+ return "twitch";
+ }
+ virtual QString helpPage() const override
+ {
+ return "Twitch-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);
+
+private:
+ Ui::TwitchPage *ui = nullptr;
+ NewInstanceDialog* dialog = nullptr;
+ Twitch::ListModel* model = nullptr;
+ Twitch::Modpack current;
+};
diff --git a/application/pages/modplatform/twitch/TwitchPage.ui b/application/pages/modplatform/twitch/TwitchPage.ui
new file mode 100644
index 00000000..7a8203b1
--- /dev/null
+++ b/application/pages/modplatform/twitch/TwitchPage.ui
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>TwitchPage</class>
+ <widget class="QWidget" name="TwitchPage">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>875</width>
+ <height>745</height>
+ </rect>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0">
+ <widget class="QLineEdit" name="searchEdit"/>
+ </item>
+ <item row="0" column="1">
+ <widget class="QPushButton" name="searchButton">
+ <property name="text">
+ <string>Search</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0" colspan="2">
+ <widget class="QTreeView" name="packView">
+ <property name="horizontalScrollBarPolicy">
+ <enum>Qt::ScrollBarAlwaysOff</enum>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>48</width>
+ <height>48</height>
+ </size>
+ </property>
+ <property name="rootIsDecorated">
+ <bool>false</bool>
+ </property>
+ <property name="uniformRowHeights">
+ <bool>true</bool>
+ </property>
+ <property name="itemsExpandable">
+ <bool>false</bool>
+ </property>
+ <property name="allColumnsShowFocus">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerVisible">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>searchEdit</tabstop>
+ <tabstop>searchButton</tabstop>
+ <tabstop>packView</tabstop>
+ </tabstops>
+ <connections/>
+</ui>
diff --git a/changelog.md b/changelog.md
index ef7c8dc6..e315950c 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,4 +1,22 @@
-# MultiMC 0.6.8
+# MultiMC 0.6.11
+
+This adds Forge 1.13+ support using [ForgeWrapper](https://github.com/ZekerZhayard/ForgeWrapper) by ZekerZhayard.
+
+### New or changed features
+
+- GH-2988: You can now import instances and curse modpacks from the command line with the `--import` option followed by either an URL or a local file path.
+
+- GH-2544: MultiMC now supports downloading library files without including them on the Java classpath.
+
+ This is done by adding them to the `mavenFiles` list instead of the `libraries` list.
+
+ Such downloads are not deduplicated or version upgraded like libraries are.
+
+ This enables ForgeWrapper to work - MultiMC downloads all the files, and ForgeWrapper runs the Forge installer during instance start when needed.
+
+# Previous releases
+
+## MultiMC 0.6.8
This is mostly about removal of the 'curse URL' related features, because they were of low quality and generally unreliable.
@@ -44,7 +62,6 @@ MultiMC also migrated to a new continuous deployment system, which makes everyth
- When a component is customized, the launcher will not try to update it in an infinite loop when something else requires a different version.
-# Previous releases
## MultiMC 0.6.7