Merge pull request #4 from flowln/modrinth_pack
This commit is contained in:
commit
8e764fc8fb
@ -778,6 +778,8 @@ SET(LAUNCHER_SOURCES
|
|||||||
|
|
||||||
ui/pages/modplatform/modrinth/ModrinthPage.cpp
|
ui/pages/modplatform/modrinth/ModrinthPage.cpp
|
||||||
ui/pages/modplatform/modrinth/ModrinthPage.h
|
ui/pages/modplatform/modrinth/ModrinthPage.h
|
||||||
|
ui/pages/modplatform/modrinth/ModrinthModel.cpp
|
||||||
|
ui/pages/modplatform/modrinth/ModrinthModel.h
|
||||||
|
|
||||||
ui/pages/modplatform/technic/TechnicModel.cpp
|
ui/pages/modplatform/technic/TechnicModel.cpp
|
||||||
ui/pages/modplatform/technic/TechnicModel.h
|
ui/pages/modplatform/technic/TechnicModel.h
|
||||||
|
@ -504,16 +504,16 @@ void InstanceImportTask::processModrinth() {
|
|||||||
QJsonObject hashes = Json::requireObject(obj, "hashes");
|
QJsonObject hashes = Json::requireObject(obj, "hashes");
|
||||||
QString hash;
|
QString hash;
|
||||||
QCryptographicHash::Algorithm hashAlgorithm;
|
QCryptographicHash::Algorithm hashAlgorithm;
|
||||||
hash = Json::ensureString(hashes, "sha256");
|
hash = Json::ensureString(hashes, "sha1");
|
||||||
hashAlgorithm = QCryptographicHash::Sha256;
|
hashAlgorithm = QCryptographicHash::Sha1;
|
||||||
if (hash.isEmpty())
|
if (hash.isEmpty())
|
||||||
{
|
{
|
||||||
hash = Json::ensureString(hashes, "sha512");
|
hash = Json::ensureString(hashes, "sha512");
|
||||||
hashAlgorithm = QCryptographicHash::Sha512;
|
hashAlgorithm = QCryptographicHash::Sha512;
|
||||||
if (hash.isEmpty())
|
if (hash.isEmpty())
|
||||||
{
|
{
|
||||||
hash = Json::ensureString(hashes, "sha1");
|
hash = Json::ensureString(hashes, "sha256");
|
||||||
hashAlgorithm = QCryptographicHash::Sha1;
|
hashAlgorithm = QCryptographicHash::Sha256;
|
||||||
if (hash.isEmpty())
|
if (hash.isEmpty())
|
||||||
{
|
{
|
||||||
throw JSONValidationError("No hash found for: " + file.path);
|
throw JSONValidationError("No hash found for: " + file.path);
|
||||||
|
@ -14,3 +14,96 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include "ModrinthPackManifest.h"
|
#include "ModrinthPackManifest.h"
|
||||||
|
#include "Json.h"
|
||||||
|
|
||||||
|
#include "minecraft/MinecraftInstance.h"
|
||||||
|
#include "minecraft/PackProfile.h"
|
||||||
|
|
||||||
|
namespace Modrinth {
|
||||||
|
|
||||||
|
void loadIndexedPack(Modpack& pack, QJsonObject& obj)
|
||||||
|
{
|
||||||
|
pack.id = Json::ensureString(obj, "project_id");
|
||||||
|
|
||||||
|
pack.name = Json::ensureString(obj, "title");
|
||||||
|
pack.description = Json::ensureString(obj, "description");
|
||||||
|
pack.authors << Json::ensureString(obj, "author");
|
||||||
|
pack.iconName = QString("modrinth_%1").arg(Json::ensureString(obj, "slug"));
|
||||||
|
pack.iconUrl = Json::ensureString(obj, "icon_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadIndexedInfo(Modpack& pack, QJsonObject& obj)
|
||||||
|
{
|
||||||
|
pack.extra.body = Json::ensureString(obj, "body");
|
||||||
|
pack.extra.projectUrl = QString("https://modrinth.com/modpack/%1").arg(Json::ensureString(obj, "slug"));
|
||||||
|
pack.extra.sourceUrl = Json::ensureString(obj, "source_url");
|
||||||
|
pack.extra.wikiUrl = Json::ensureString(obj, "wiki_url");
|
||||||
|
|
||||||
|
pack.extraInfoLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadIndexedVersions(Modpack& pack, QJsonDocument& doc)
|
||||||
|
{
|
||||||
|
QVector<ModpackVersion> unsortedVersions;
|
||||||
|
|
||||||
|
auto arr = Json::requireArray(doc);
|
||||||
|
|
||||||
|
for (auto versionIter : arr) {
|
||||||
|
auto obj = Json::requireObject(versionIter);
|
||||||
|
auto file = loadIndexedVersion(obj);
|
||||||
|
|
||||||
|
if(!file.id.isEmpty()) // Heuristic to check if the returned value is valid
|
||||||
|
unsortedVersions.append(file);
|
||||||
|
}
|
||||||
|
auto orderSortPredicate = [](const ModpackVersion& a, const ModpackVersion& b) -> bool {
|
||||||
|
// dates are in RFC 3339 format
|
||||||
|
return a.date > b.date;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::sort(unsortedVersions.begin(), unsortedVersions.end(), orderSortPredicate);
|
||||||
|
|
||||||
|
pack.versions.swap(unsortedVersions);
|
||||||
|
|
||||||
|
pack.versionsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto loadIndexedVersion(QJsonObject &obj) -> ModpackVersion
|
||||||
|
{
|
||||||
|
ModpackVersion file;
|
||||||
|
|
||||||
|
file.name = Json::requireString(obj, "name");
|
||||||
|
file.version = Json::requireString(obj, "version_number");
|
||||||
|
|
||||||
|
file.id = Json::requireString(obj, "id");
|
||||||
|
file.project_id = Json::requireString(obj, "project_id");
|
||||||
|
|
||||||
|
file.date = Json::requireString(obj, "date_published");
|
||||||
|
|
||||||
|
auto files = Json::requireArray(obj, "files");
|
||||||
|
|
||||||
|
qWarning() << files;
|
||||||
|
|
||||||
|
for (auto file_iter : files) {
|
||||||
|
File indexed_file;
|
||||||
|
auto parent = Json::requireObject(file_iter);
|
||||||
|
auto is_primary = Json::ensureBoolean(parent, "primary", false);
|
||||||
|
if (!is_primary) {
|
||||||
|
auto filename = Json::ensureString(parent, "filename");
|
||||||
|
// Checking suffix here is fine because it's the response from Modrinth,
|
||||||
|
// so one would assume it will always be in English.
|
||||||
|
if(!filename.endsWith("mrpack") && !filename.endsWith("zip"))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.download_url = Json::requireString(parent, "url");
|
||||||
|
if(is_primary)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(file.download_url.isEmpty())
|
||||||
|
return {};
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Modrinth
|
||||||
|
@ -15,18 +15,69 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMetaType>
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
|
class MinecraftInstance;
|
||||||
|
|
||||||
namespace Modrinth {
|
namespace Modrinth {
|
||||||
|
|
||||||
struct File
|
struct File
|
||||||
{
|
{
|
||||||
QString path;
|
QString path;
|
||||||
|
|
||||||
QCryptographicHash::Algorithm hashAlgorithm;
|
QCryptographicHash::Algorithm hashAlgorithm;
|
||||||
QByteArray hash;
|
QByteArray hash;
|
||||||
// TODO: should this support multiple download URLs, like the JSON does?
|
// TODO: should this support multiple download URLs, like the JSON does?
|
||||||
QUrl download;
|
QUrl download;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct ModpackExtra {
|
||||||
|
QString body;
|
||||||
|
|
||||||
|
QString projectUrl;
|
||||||
|
QString sourceUrl;
|
||||||
|
QString wikiUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ModpackVersion {
|
||||||
|
QString name;
|
||||||
|
QString version;
|
||||||
|
|
||||||
|
QString id;
|
||||||
|
QString project_id;
|
||||||
|
|
||||||
|
QString date;
|
||||||
|
|
||||||
|
QString download_url;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Modpack {
|
||||||
|
QString id;
|
||||||
|
|
||||||
|
QString name;
|
||||||
|
QString description;
|
||||||
|
QStringList authors;
|
||||||
|
QString iconName;
|
||||||
|
QUrl iconUrl;
|
||||||
|
|
||||||
|
bool versionsLoaded = false;
|
||||||
|
bool extraInfoLoaded = false;
|
||||||
|
|
||||||
|
ModpackExtra extra;
|
||||||
|
QVector<ModpackVersion> versions;
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadIndexedPack(Modpack&, QJsonObject&);
|
||||||
|
void loadIndexedInfo(Modpack&, QJsonObject&);
|
||||||
|
void loadIndexedVersions(Modpack&, QJsonDocument&);
|
||||||
|
auto loadIndexedVersion(QJsonObject&) -> ModpackVersion;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Q_DECLARE_METATYPE(Modrinth::Modpack)
|
||||||
|
Q_DECLARE_METATYPE(Modrinth::ModpackVersion)
|
||||||
|
@ -110,7 +110,10 @@ void ImportPage::updateState()
|
|||||||
// FIXME: actually do some validation of what's inside here... this is fake AF
|
// FIXME: actually do some validation of what's inside here... this is fake AF
|
||||||
QFileInfo fi(input);
|
QFileInfo fi(input);
|
||||||
// mrpack is a modrinth pack
|
// mrpack is a modrinth pack
|
||||||
if(fi.exists() && (fi.suffix() == "zip" || fi.suffix() == "mrpack"))
|
|
||||||
|
// Allow non-latin people to use ZIP files!
|
||||||
|
auto zip = QMimeDatabase().mimeTypeForUrl(url).suffixes().contains("zip");
|
||||||
|
if(fi.exists() && (zip || fi.suffix() == "mrpack"))
|
||||||
{
|
{
|
||||||
QFileInfo fi(url.fileName());
|
QFileInfo fi(url.fileName());
|
||||||
dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url));
|
dialog->setSuggestedPack(fi.completeBaseName(), new InstanceImportTask(url));
|
||||||
|
@ -11,28 +11,75 @@
|
|||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QGridLayout" name="gridLayout">
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
<item row="1" column="1">
|
<item row="2" column="0">
|
||||||
<widget class="QPushButton" name="modpackBtn">
|
|
||||||
<property name="text">
|
|
||||||
<string>Browse</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item row="1" column="0">
|
|
||||||
<widget class="QLineEdit" name="modpackEdit">
|
<widget class="QLineEdit" name="modpackEdit">
|
||||||
<property name="placeholderText">
|
<property name="placeholderText">
|
||||||
<string notr="true">http://</string>
|
<string notr="true">http://</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="0" column="0" colspan="2">
|
<item row="2" column="1">
|
||||||
<widget class="QLabel" name="modpackLabel">
|
<widget class="QPushButton" name="modpackBtn">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Local file or link to a direct download:</string>
|
<string>Browse</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="2" column="0" colspan="2">
|
<item row="3" column="0" rowspan="2" colspan="2">
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="text">
|
||||||
|
<string>The following file types are implemented (both for local files and URLs):</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label_5">
|
||||||
|
<property name="text">
|
||||||
|
<string>- Curseforge modpacks (ZIP)</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label_3">
|
||||||
|
<property name="text">
|
||||||
|
<string>- Modrinth modpacks (ZIP and mrpack)</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label_2">
|
||||||
|
<property name="text">
|
||||||
|
<string>- PolyMC / MultiMC exported instances (ZIP)</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label_4">
|
||||||
|
<property name="text">
|
||||||
|
<string>- Technic modpacks (ZIP)</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item row="5" column="0" colspan="2">
|
||||||
<spacer name="verticalSpacer">
|
<spacer name="verticalSpacer">
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
<enum>Qt::Vertical</enum>
|
<enum>Qt::Vertical</enum>
|
||||||
@ -45,6 +92,13 @@
|
|||||||
</property>
|
</property>
|
||||||
</spacer>
|
</spacer>
|
||||||
</item>
|
</item>
|
||||||
|
<item row="0" column="0" colspan="2">
|
||||||
|
<widget class="QLabel" name="modpackLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>Local file or link to a direct download:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
<resources/>
|
<resources/>
|
||||||
|
267
launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp
Normal file
267
launcher/ui/pages/modplatform/modrinth/ModrinthModel.cpp
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
#include "ModrinthModel.h"
|
||||||
|
|
||||||
|
#include "BuildConfig.h"
|
||||||
|
#include "Json.h"
|
||||||
|
#include "minecraft/MinecraftInstance.h"
|
||||||
|
#include "minecraft/PackProfile.h"
|
||||||
|
#include "ui/dialogs/ModDownloadDialog.h"
|
||||||
|
|
||||||
|
#include <QMessageBox>
|
||||||
|
|
||||||
|
namespace Modrinth {
|
||||||
|
|
||||||
|
ModpackListModel::ModpackListModel(ModrinthPage* parent) : QAbstractListModel(parent), m_parent(parent) {}
|
||||||
|
|
||||||
|
auto ModpackListModel::debugName() const -> QString
|
||||||
|
{
|
||||||
|
return m_parent->debugName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/******** Make data requests ********/
|
||||||
|
|
||||||
|
void ModpackListModel::fetchMore(const QModelIndex& parent)
|
||||||
|
{
|
||||||
|
if (parent.isValid())
|
||||||
|
return;
|
||||||
|
if (nextSearchOffset == 0) {
|
||||||
|
qWarning() << "fetchMore with 0 offset is wrong...";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performPaginatedSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto ModpackListModel::data(const QModelIndex& index, int role) const -> QVariant
|
||||||
|
{
|
||||||
|
int pos = index.row();
|
||||||
|
if (pos >= modpacks.size() || pos < 0 || !index.isValid()) {
|
||||||
|
return QString("INVALID INDEX %1").arg(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
Modrinth::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.iconName)) {
|
||||||
|
return (m_logoMap.value(pack.iconName)
|
||||||
|
.pixmap(48, 48)
|
||||||
|
.scaled(48, 48, Qt::IgnoreAspectRatio, Qt::TransformationMode::SmoothTransformation));
|
||||||
|
}
|
||||||
|
QIcon icon = APPLICATION->getThemedIcon("screenshot-placeholder");
|
||||||
|
((ModpackListModel*)this)->requestLogo(pack.iconName, pack.iconUrl.toString());
|
||||||
|
return icon;
|
||||||
|
} else if (role == Qt::UserRole) {
|
||||||
|
QVariant v;
|
||||||
|
v.setValue(pack);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::performPaginatedSearch()
|
||||||
|
{
|
||||||
|
// TODO: Move to standalone API
|
||||||
|
NetJob* netJob = new NetJob("Modrinth::SearchModpack", APPLICATION->network());
|
||||||
|
auto searchAllUrl = QString(
|
||||||
|
"https://staging-api.modrinth.com/v2/search?"
|
||||||
|
"query=%1&"
|
||||||
|
"facets=[[\"project_type:modpack\"]]")
|
||||||
|
.arg(currentSearchTerm);
|
||||||
|
|
||||||
|
netJob->addNetAction(Net::Download::makeByteArray(QUrl(searchAllUrl), &m_all_response));
|
||||||
|
|
||||||
|
QObject::connect(netJob, &NetJob::succeeded, this, [this] {
|
||||||
|
QJsonParseError parse_error_all{};
|
||||||
|
|
||||||
|
QJsonDocument doc_all = QJsonDocument::fromJson(m_all_response, &parse_error_all);
|
||||||
|
if (parse_error_all.error != QJsonParseError::NoError) {
|
||||||
|
qWarning() << "Error while parsing JSON response from " << debugName() << " at " << parse_error_all.offset
|
||||||
|
<< " reason: " << parse_error_all.errorString();
|
||||||
|
qWarning() << m_all_response;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchRequestFinished(doc_all);
|
||||||
|
});
|
||||||
|
QObject::connect(netJob, &NetJob::failed, this, &ModpackListModel::searchRequestFailed);
|
||||||
|
|
||||||
|
jobPtr = netJob;
|
||||||
|
jobPtr->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::refresh()
|
||||||
|
{
|
||||||
|
if (jobPtr) {
|
||||||
|
jobPtr->abort();
|
||||||
|
searchState = ResetRequested;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
beginResetModel();
|
||||||
|
modpacks.clear();
|
||||||
|
endResetModel();
|
||||||
|
searchState = None;
|
||||||
|
}
|
||||||
|
nextSearchOffset = 0;
|
||||||
|
performPaginatedSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::searchWithTerm(const QString& term, const int sort)
|
||||||
|
{
|
||||||
|
if (currentSearchTerm == term && currentSearchTerm.isNull() == term.isNull() && currentSort == sort) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSearchTerm = term;
|
||||||
|
currentSort = sort;
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback)
|
||||||
|
{
|
||||||
|
if (m_logoMap.contains(logo)) {
|
||||||
|
callback(APPLICATION->metacache()
|
||||||
|
->resolveEntry(m_parent->metaEntryBase(), QString("logos/%1").arg(logo.section(".", 0, 0)))
|
||||||
|
->getFullPath());
|
||||||
|
} else {
|
||||||
|
requestLogo(logo, logoUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::requestLogo(QString logo, QString url)
|
||||||
|
{
|
||||||
|
if (m_loadingLogos.contains(logo) || m_failedLogos.contains(logo)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MetaEntryPtr entry =
|
||||||
|
APPLICATION->metacache()->resolveEntry(m_parent->metaEntryBase(), QString("logos/%1").arg(logo.section(".", 0, 0)));
|
||||||
|
auto job = new NetJob(QString("%1 Icon Download %2").arg(m_parent->debugName()).arg(logo), APPLICATION->network());
|
||||||
|
job->addNetAction(Net::Download::makeCached(QUrl(url), entry));
|
||||||
|
|
||||||
|
auto fullPath = entry->getFullPath();
|
||||||
|
QObject::connect(job, &NetJob::succeeded, this, [this, logo, fullPath, job] {
|
||||||
|
job->deleteLater();
|
||||||
|
emit logoLoaded(logo, QIcon(fullPath));
|
||||||
|
if (waitingCallbacks.contains(logo)) {
|
||||||
|
waitingCallbacks.value(logo)(fullPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
QObject::connect(job, &NetJob::failed, this, [this, logo, job] {
|
||||||
|
job->deleteLater();
|
||||||
|
emit logoFailed(logo);
|
||||||
|
});
|
||||||
|
|
||||||
|
job->start();
|
||||||
|
m_loadingLogos.append(logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/******** Request callbacks ********/
|
||||||
|
|
||||||
|
void ModpackListModel::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].iconName == logo) {
|
||||||
|
emit dataChanged(createIndex(i, 0), createIndex(i, 0), { Qt::DecorationRole });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::logoFailed(QString logo)
|
||||||
|
{
|
||||||
|
m_failedLogos.append(logo);
|
||||||
|
m_loadingLogos.removeAll(logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::searchRequestFinished(QJsonDocument& doc_all)
|
||||||
|
{
|
||||||
|
jobPtr.reset();
|
||||||
|
|
||||||
|
QList<Modrinth::Modpack> newList;
|
||||||
|
|
||||||
|
auto packs_all = doc_all.object().value("hits").toArray();
|
||||||
|
for (auto packRaw : packs_all) {
|
||||||
|
auto packObj = packRaw.toObject();
|
||||||
|
|
||||||
|
Modrinth::Modpack pack;
|
||||||
|
try {
|
||||||
|
Modrinth::loadIndexedPack(pack, packObj);
|
||||||
|
newList.append(pack);
|
||||||
|
} catch (const JSONValidationError& e) {
|
||||||
|
qWarning() << "Error while loading mod from " << m_parent->debugName() << ": " << e.cause();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packs_all.size() < 25) {
|
||||||
|
searchState = Finished;
|
||||||
|
} else {
|
||||||
|
nextSearchOffset += 25;
|
||||||
|
searchState = CanPossiblyFetchMore;
|
||||||
|
}
|
||||||
|
|
||||||
|
beginInsertRows(QModelIndex(), modpacks.size(), modpacks.size() + newList.size() - 1);
|
||||||
|
modpacks.append(newList);
|
||||||
|
endInsertRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::searchRequestFailed(QString reason)
|
||||||
|
{
|
||||||
|
if (!jobPtr->first()->m_reply) {
|
||||||
|
// Network error
|
||||||
|
QMessageBox::critical(nullptr, tr("Error"), tr("A network error occurred. Could not load mods."));
|
||||||
|
} else if (jobPtr->first()->m_reply && jobPtr->first()->m_reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 409) {
|
||||||
|
// 409 Gone, notify user to update
|
||||||
|
QMessageBox::critical(nullptr, tr("Error"),
|
||||||
|
//: %1 refers to the launcher itself
|
||||||
|
QString("%1 %2")
|
||||||
|
.arg(m_parent->displayName())
|
||||||
|
.arg(tr("API version too old!\nPlease update %1!").arg(BuildConfig.LAUNCHER_NAME)));
|
||||||
|
}
|
||||||
|
jobPtr.reset();
|
||||||
|
|
||||||
|
if (searchState == ResetRequested) {
|
||||||
|
beginResetModel();
|
||||||
|
modpacks.clear();
|
||||||
|
endResetModel();
|
||||||
|
|
||||||
|
nextSearchOffset = 0;
|
||||||
|
performPaginatedSearch();
|
||||||
|
} else {
|
||||||
|
searchState = Finished;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModpackListModel::versionRequestSucceeded(QJsonDocument doc, QString id)
|
||||||
|
{
|
||||||
|
auto& current = m_parent->getCurrent();
|
||||||
|
if (id != current.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto arr = doc.isObject() ? Json::ensureArray(doc.object(), "data") : doc.array();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// loadIndexedPackVersions(current, arr);
|
||||||
|
} catch (const JSONValidationError& e) {
|
||||||
|
qDebug() << doc;
|
||||||
|
qWarning() << "Error while reading " << debugName() << " mod version: " << e.cause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// m_parent->updateModVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace Modrinth
|
||||||
|
|
||||||
|
/******** Helpers ********/
|
81
launcher/ui/pages/modplatform/modrinth/ModrinthModel.h
Normal file
81
launcher/ui/pages/modplatform/modrinth/ModrinthModel.h
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
|
||||||
|
#include "modplatform/modrinth/ModrinthPackManifest.h"
|
||||||
|
#include "ui/pages/modplatform/modrinth/ModrinthPage.h"
|
||||||
|
|
||||||
|
class ModPage;
|
||||||
|
class Version;
|
||||||
|
|
||||||
|
namespace Modrinth {
|
||||||
|
|
||||||
|
using LogoMap = QMap<QString, QIcon>;
|
||||||
|
using LogoCallback = std::function<void (QString)>;
|
||||||
|
|
||||||
|
class ModpackListModel : public QAbstractListModel {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
ModpackListModel(ModrinthPage* parent);
|
||||||
|
~ModpackListModel() override = default;
|
||||||
|
|
||||||
|
inline auto rowCount(const QModelIndex& parent) const -> int override { return modpacks.size(); };
|
||||||
|
inline auto columnCount(const QModelIndex& parent) const -> int override { return 1; };
|
||||||
|
inline auto flags(const QModelIndex& index) const -> Qt::ItemFlags override { return QAbstractListModel::flags(index); };
|
||||||
|
|
||||||
|
auto debugName() const -> QString;
|
||||||
|
|
||||||
|
/* Retrieve information from the model at a given index with the given role */
|
||||||
|
auto data(const QModelIndex& index, int role) const -> QVariant override;
|
||||||
|
|
||||||
|
inline void setActiveJob(NetJob::Ptr ptr) { jobPtr = ptr; }
|
||||||
|
|
||||||
|
/* Ask the API for more information */
|
||||||
|
void fetchMore(const QModelIndex& parent) override;
|
||||||
|
void refresh();
|
||||||
|
void searchWithTerm(const QString& term, const int sort);
|
||||||
|
|
||||||
|
void getLogo(const QString& logo, const QString& logoUrl, LogoCallback callback);
|
||||||
|
|
||||||
|
inline auto canFetchMore(const QModelIndex& parent) const -> bool override { return searchState == CanPossiblyFetchMore; };
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void searchRequestFinished(QJsonDocument& doc_all);
|
||||||
|
void searchRequestFailed(QString reason);
|
||||||
|
|
||||||
|
void versionRequestSucceeded(QJsonDocument doc, QString addonId);
|
||||||
|
|
||||||
|
protected slots:
|
||||||
|
|
||||||
|
void logoFailed(QString logo);
|
||||||
|
void logoLoaded(QString logo, QIcon out);
|
||||||
|
|
||||||
|
void performPaginatedSearch();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void requestLogo(QString file, QString url);
|
||||||
|
|
||||||
|
inline auto getMineVersions() const -> std::list<Version>;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
ModrinthPage* m_parent;
|
||||||
|
|
||||||
|
QList<Modrinth::Modpack> modpacks;
|
||||||
|
|
||||||
|
LogoMap m_logoMap;
|
||||||
|
QMap<QString, LogoCallback> waitingCallbacks;
|
||||||
|
QStringList m_failedLogos;
|
||||||
|
QStringList m_loadingLogos;
|
||||||
|
|
||||||
|
QString currentSearchTerm;
|
||||||
|
int currentSort = 0;
|
||||||
|
int nextSearchOffset = 0;
|
||||||
|
enum SearchState { None, CanPossiblyFetchMore, ResetRequested, Finished } searchState = None;
|
||||||
|
|
||||||
|
NetJob::Ptr jobPtr;
|
||||||
|
|
||||||
|
QByteArray m_all_response;
|
||||||
|
QByteArray m_specific_response;
|
||||||
|
};
|
||||||
|
} // namespace ModPlatform
|
@ -34,14 +34,41 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include "ModrinthPage.h"
|
#include "ModrinthPage.h"
|
||||||
|
|
||||||
#include "ui_ModrinthPage.h"
|
#include "ui_ModrinthPage.h"
|
||||||
|
|
||||||
#include <QKeyEvent>
|
#include "ModrinthModel.h"
|
||||||
|
|
||||||
ModrinthPage::ModrinthPage(NewInstanceDialog *dialog, QWidget *parent) : QWidget(parent), ui(new Ui::ModrinthPage), dialog(dialog)
|
#include "InstanceImportTask.h"
|
||||||
|
#include "Json.h"
|
||||||
|
|
||||||
|
#include <HoeDown.h>
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QPushButton>
|
||||||
|
|
||||||
|
ModrinthPage::ModrinthPage(NewInstanceDialog* dialog, QWidget* parent) : QWidget(parent), ui(new Ui::ModrinthPage), dialog(dialog)
|
||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
|
||||||
|
connect(ui->searchButton, &QPushButton::clicked, this, &ModrinthPage::triggerSearch);
|
||||||
|
ui->searchEdit->installEventFilter(this);
|
||||||
|
m_model = new Modrinth::ModpackListModel(this);
|
||||||
|
ui->packView->setModel(m_model);
|
||||||
|
|
||||||
|
ui->versionSelectionBox->view()->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
||||||
|
ui->versionSelectionBox->view()->parentWidget()->setMaximumHeight(300);
|
||||||
|
|
||||||
|
ui->sortByBox->addItem(tr("Sort by Featured"));
|
||||||
|
ui->sortByBox->addItem(tr("Sort by Popularity"));
|
||||||
|
ui->sortByBox->addItem(tr("Sort by Last Updated"));
|
||||||
|
ui->sortByBox->addItem(tr("Sort by Name"));
|
||||||
|
ui->sortByBox->addItem(tr("Sort by Author"));
|
||||||
|
ui->sortByBox->addItem(tr("Sort by Total Downloads"));
|
||||||
|
|
||||||
|
connect(ui->sortByBox, SIGNAL(currentIndexChanged(int)), this, SLOT(triggerSearch()));
|
||||||
|
connect(ui->packView->selectionModel(), &QItemSelectionModel::currentChanged, this, &ModrinthPage::onSelectionChanged);
|
||||||
|
connect(ui->versionSelectionBox, &QComboBox::currentTextChanged, this, &ModrinthPage::onVersionSelectionChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
ModrinthPage::~ModrinthPage()
|
ModrinthPage::~ModrinthPage()
|
||||||
@ -60,10 +87,10 @@ void ModrinthPage::openedImpl()
|
|||||||
triggerSearch();
|
triggerSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ModrinthPage::eventFilter(QObject *watched, QEvent *event)
|
bool ModrinthPage::eventFilter(QObject* watched, QEvent* event)
|
||||||
{
|
{
|
||||||
if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) {
|
if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) {
|
||||||
auto *keyEvent = reinterpret_cast<QKeyEvent *>(event);
|
auto* keyEvent = reinterpret_cast<QKeyEvent*>(event);
|
||||||
if (keyEvent->key() == Qt::Key_Return) {
|
if (keyEvent->key() == Qt::Key_Return) {
|
||||||
this->triggerSearch();
|
this->triggerSearch();
|
||||||
keyEvent->accept();
|
keyEvent->accept();
|
||||||
@ -73,6 +100,179 @@ bool ModrinthPage::eventFilter(QObject *watched, QEvent *event)
|
|||||||
return QObject::eventFilter(watched, event);
|
return QObject::eventFilter(watched, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ModrinthPage::triggerSearch() {
|
void ModrinthPage::onSelectionChanged(QModelIndex first, QModelIndex second)
|
||||||
|
{
|
||||||
|
ui->versionSelectionBox->clear();
|
||||||
|
|
||||||
|
if (!first.isValid()) {
|
||||||
|
if (isOpened) {
|
||||||
|
dialog->setSuggestedPack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = m_model->data(first, Qt::UserRole).value<Modrinth::Modpack>();
|
||||||
|
auto name = current.name;
|
||||||
|
|
||||||
|
if (!current.extraInfoLoaded) {
|
||||||
|
qDebug() << "Loading modrinth modpack information";
|
||||||
|
|
||||||
|
auto netJob = new NetJob(QString("Modrinth::PackInformation(%1)").arg(current.name), APPLICATION->network());
|
||||||
|
auto response = new QByteArray();
|
||||||
|
|
||||||
|
QString id = current.id;
|
||||||
|
|
||||||
|
netJob->addNetAction(Net::Download::makeByteArray(QString("https://staging-api.modrinth.com/v2/project/%1").arg(id), response));
|
||||||
|
|
||||||
|
QObject::connect(netJob, &NetJob::succeeded, this, [this, response, id] {
|
||||||
|
if (id != current.id) {
|
||||||
|
return; // wrong request?
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto obj = Json::requireObject(doc);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Modrinth::loadIndexedInfo(current, obj);
|
||||||
|
} catch (const JSONValidationError& e) {
|
||||||
|
qDebug() << *response;
|
||||||
|
qWarning() << "Error while reading modrinth modpack version: " << e.cause();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
suggestCurrent();
|
||||||
|
});
|
||||||
|
QObject::connect(netJob, &NetJob::finished, this, [response, netJob] {
|
||||||
|
netJob->deleteLater();
|
||||||
|
delete response;
|
||||||
|
});
|
||||||
|
netJob->start();
|
||||||
|
} else
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
if (!current.versionsLoaded) {
|
||||||
|
qDebug() << "Loading modrinth modpack versions";
|
||||||
|
|
||||||
|
auto netJob = new NetJob(QString("Modrinth::PackVersions(%1)").arg(current.name), APPLICATION->network());
|
||||||
|
auto response = new QByteArray();
|
||||||
|
|
||||||
|
QString id = current.id;
|
||||||
|
|
||||||
|
netJob->addNetAction(
|
||||||
|
Net::Download::makeByteArray(QString("https://staging-api.modrinth.com/v2/project/%1/version").arg(id), response));
|
||||||
|
|
||||||
|
QObject::connect(netJob, &NetJob::succeeded, this, [this, response, id] {
|
||||||
|
if (id != current.id) {
|
||||||
|
return; // wrong request?
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Modrinth::loadIndexedVersions(current, doc);
|
||||||
|
} catch (const JSONValidationError& e) {
|
||||||
|
qDebug() << *response;
|
||||||
|
qWarning() << "Error while reading modrinth modpack version: " << e.cause();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto version : current.versions) {
|
||||||
|
ui->versionSelectionBox->addItem(version.version, QVariant(version.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVersionsUI();
|
||||||
|
suggestCurrent();
|
||||||
|
});
|
||||||
|
QObject::connect(netJob, &NetJob::finished, this, [response, netJob] {
|
||||||
|
netJob->deleteLater();
|
||||||
|
delete response;
|
||||||
|
});
|
||||||
|
netJob->start();
|
||||||
|
|
||||||
|
} else {
|
||||||
|
for (auto version : current.versions) {
|
||||||
|
ui->versionSelectionBox->addItem(QString("%1 - %2").arg(version.name, version.version), QVariant(version.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModrinthPage::updateUI()
|
||||||
|
{
|
||||||
|
QString text = "";
|
||||||
|
|
||||||
|
if (current.extra.sourceUrl.isEmpty())
|
||||||
|
text = current.name;
|
||||||
|
else
|
||||||
|
text = "<a href=\"" + current.extra.projectUrl + "\">" + current.name + "</a>";
|
||||||
|
|
||||||
|
if (!current.authors.empty()) {
|
||||||
|
// TODO: Implement multiple authors with links
|
||||||
|
text += "<br>" + tr(" by ") + current.authors.at(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
text += "<br>";
|
||||||
|
|
||||||
|
HoeDown h;
|
||||||
|
text += h.process(current.extra.body.toUtf8());
|
||||||
|
|
||||||
|
ui->packDescription->setHtml(text + current.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModrinthPage::updateVersionsUI()
|
||||||
|
{
|
||||||
|
// idk
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModrinthPage::suggestCurrent()
|
||||||
|
{
|
||||||
|
if (!isOpened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedVersion.isEmpty()) {
|
||||||
|
dialog->setSuggestedPack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& ver : current.versions) {
|
||||||
|
if (ver.id == selectedVersion) {
|
||||||
|
dialog->setSuggestedPack(current.name, new InstanceImportTask(ver.download_url));
|
||||||
|
auto iconName = current.iconName;
|
||||||
|
m_model->getLogo(iconName, current.iconUrl.toString(),
|
||||||
|
[this, iconName](QString logo) { dialog->setSuggestedIconFromFile(logo, iconName); });
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModrinthPage::triggerSearch()
|
||||||
|
{
|
||||||
|
m_model->searchWithTerm(ui->searchEdit->text(), ui->sortByBox->currentIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ModrinthPage::onVersionSelectionChanged(QString data)
|
||||||
|
{
|
||||||
|
if (data.isNull() || data.isEmpty()) {
|
||||||
|
selectedVersion = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedVersion = ui->versionSelectionBox->currentData().toString();
|
||||||
|
suggestCurrent();
|
||||||
}
|
}
|
||||||
|
@ -39,48 +39,53 @@
|
|||||||
#include "ui/dialogs/NewInstanceDialog.h"
|
#include "ui/dialogs/NewInstanceDialog.h"
|
||||||
#include "ui/pages/BasePage.h"
|
#include "ui/pages/BasePage.h"
|
||||||
|
|
||||||
|
#include "modplatform/modrinth/ModrinthPackManifest.h"
|
||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
namespace Ui
|
namespace Ui {
|
||||||
{
|
class ModrinthPage;
|
||||||
class ModrinthPage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ModrinthPage : public QWidget, public BasePage
|
namespace Modrinth {
|
||||||
{
|
class ModpackListModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModrinthPage : public QWidget, public BasePage {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ModrinthPage(NewInstanceDialog *dialog, QWidget *parent = nullptr);
|
explicit ModrinthPage(NewInstanceDialog* dialog, QWidget* parent = nullptr);
|
||||||
~ModrinthPage() override;
|
~ModrinthPage() override;
|
||||||
|
|
||||||
QString displayName() const override
|
QString displayName() const override { return tr("Modrinth"); }
|
||||||
{
|
QIcon icon() const override { return APPLICATION->getThemedIcon("modrinth"); }
|
||||||
return tr("Modrinth");
|
QString id() const override { return "modrinth"; }
|
||||||
}
|
QString helpPage() const override { return "Modrinth-platform"; }
|
||||||
QIcon icon() const override
|
|
||||||
{
|
inline auto debugName() const -> QString { return "Modrinth"; }
|
||||||
return APPLICATION->getThemedIcon("modrinth");
|
inline auto metaEntryBase() const -> QString { return "ModrinthModpacks"; };
|
||||||
}
|
|
||||||
QString id() const override
|
auto getCurrent() -> Modrinth::Modpack& { return current; }
|
||||||
{
|
void suggestCurrent();
|
||||||
return "modrinth";
|
|
||||||
}
|
void updateUI();
|
||||||
|
void updateVersionsUI();
|
||||||
|
|
||||||
virtual QString helpPage() const override
|
|
||||||
{
|
|
||||||
return "Modrinth-platform";
|
|
||||||
}
|
|
||||||
void retranslate() override;
|
void retranslate() override;
|
||||||
|
|
||||||
void openedImpl() override;
|
void openedImpl() override;
|
||||||
|
bool eventFilter(QObject* watched, QEvent* event) override;
|
||||||
|
|
||||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
private slots:
|
||||||
|
void onSelectionChanged(QModelIndex first, QModelIndex second);
|
||||||
private slots:
|
void onVersionSelectionChanged(QString data);
|
||||||
void triggerSearch();
|
void triggerSearch();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Ui::ModrinthPage *ui;
|
Ui::ModrinthPage* ui;
|
||||||
NewInstanceDialog *dialog;
|
NewInstanceDialog* dialog;
|
||||||
|
Modrinth::ModpackListModel* m_model;
|
||||||
|
|
||||||
|
Modrinth::Modpack current;
|
||||||
|
QString selectedVersion;
|
||||||
};
|
};
|
||||||
|
@ -45,6 +45,9 @@
|
|||||||
<height>48</height>
|
<height>48</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="uniformItemSizes">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
|
Loading…
Reference in New Issue
Block a user