Merge branch 'PrismLauncher:develop' into instance-accounts
This commit is contained in:
		
							
								
								
									
										3
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -546,11 +546,10 @@ jobs: | ||||
|           submodules: 'true' | ||||
|       - name: Build Flatpak (Linux) | ||||
|         if: inputs.build_type == 'Debug' | ||||
|         uses: flatpak/flatpak-github-actions/flatpak-builder@v4 | ||||
|         uses: flatpak/flatpak-github-actions/flatpak-builder@v5 | ||||
|         with: | ||||
|           bundle: "Prism Launcher.flatpak" | ||||
|           manifest-path: flatpak/org.prismlauncher.PrismLauncher.yml  | ||||
|           cache-key: flatpak-${{ github.sha }}-x86_64 | ||||
|  | ||||
|   nix: | ||||
|     runs-on: ubuntu-latest | ||||
|   | ||||
| @@ -268,6 +268,8 @@ if(NOT Launcher_FORCE_BUNDLED_LIBS) | ||||
|     find_package(ghc_filesystem QUIET) | ||||
| endif() | ||||
|  | ||||
| include(ECMQtDeclareLoggingCategory) | ||||
|  | ||||
| ####################################### Program Info ####################################### | ||||
|  | ||||
| set(Launcher_APP_BINARY_NAME "prismlauncher" CACHE STRING "Name of the Launcher binary") | ||||
|   | ||||
| @@ -551,6 +551,24 @@ set(ATLAUNCHER_SOURCES | ||||
|     modplatform/atlauncher/ATLShareCode.h | ||||
| ) | ||||
|  | ||||
| ######## Logging categories ######## | ||||
|  | ||||
| ecm_qt_declare_logging_category(CORE_SOURCES | ||||
|     HEADER Logging.h | ||||
|     IDENTIFIER authCredentials | ||||
|     CATEGORY_NAME "launcher.auth.credentials" | ||||
|     DEFAULT_SEVERITY Warning | ||||
|     DESCRIPTION "Secrets and credentials for debugging purposes" | ||||
|     EXPORT "${Launcher_Name}" | ||||
| ) | ||||
|  | ||||
| if(KDE_INSTALL_LOGGINGCATEGORIESDIR)  # only install if there is a standard path for this | ||||
|     ecm_qt_install_logging_categories( | ||||
|         EXPORT "${Launcher_Name}" | ||||
|         DESTINATION "${KDE_INSTALL_LOGGINGCATEGORIESDIR}" | ||||
|     ) | ||||
| endif() | ||||
|  | ||||
| ################################ COMPILE ################################ | ||||
|  | ||||
| set(LOGIC_SOURCES | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| #include "Parsers.h" | ||||
| #include "Json.h" | ||||
| #include "Logging.h" | ||||
|  | ||||
| #include <QJsonDocument> | ||||
| #include <QJsonArray> | ||||
| @@ -75,9 +76,7 @@ bool getBool(QJsonValue value, bool & out) { | ||||
|  | ||||
| bool parseXTokenResponse(QByteArray & data, Katabasis::Token &output, QString name) { | ||||
|     qDebug() << "Parsing" << name <<":"; | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|     QJsonParseError jsonError; | ||||
|     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); | ||||
|     if(jsonError.error) { | ||||
| @@ -137,9 +136,7 @@ bool parseXTokenResponse(QByteArray & data, Katabasis::Token &output, QString na | ||||
|  | ||||
| bool parseMinecraftProfile(QByteArray & data, MinecraftProfile &output) { | ||||
|     qDebug() << "Parsing Minecraft profile..."; | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|  | ||||
|     QJsonParseError jsonError; | ||||
|     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); | ||||
| @@ -275,9 +272,7 @@ decoded base64 "value": | ||||
|  | ||||
| bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { | ||||
|     qDebug() << "Parsing Minecraft profile..."; | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|  | ||||
|     QJsonParseError jsonError; | ||||
|     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); | ||||
| @@ -389,9 +384,7 @@ bool parseMinecraftProfileMojang(QByteArray & data, MinecraftProfile &output) { | ||||
|  | ||||
| bool parseMinecraftEntitlements(QByteArray & data, MinecraftEntitlement &output) { | ||||
|     qDebug() << "Parsing Minecraft entitlements..."; | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|  | ||||
|     QJsonParseError jsonError; | ||||
|     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); | ||||
| @@ -424,9 +417,7 @@ bool parseMinecraftEntitlements(QByteArray & data, MinecraftEntitlement &output) | ||||
|  | ||||
| bool parseRolloutResponse(QByteArray & data, bool& result) { | ||||
|     qDebug() << "Parsing Rollout response..."; | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|  | ||||
|     QJsonParseError jsonError; | ||||
|     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); | ||||
| @@ -455,9 +446,7 @@ bool parseRolloutResponse(QByteArray & data, bool& result) { | ||||
| bool parseMojangResponse(QByteArray & data, Katabasis::Token &output) { | ||||
|     QJsonParseError jsonError; | ||||
|     qDebug() << "Parsing Mojang response..."; | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); | ||||
|     if(jsonError.error) { | ||||
|         qWarning() << "Failed to parse response from api.minecraftservices.com/launcher/login as JSON: " << jsonError.errorString(); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
| #include <QNetworkRequest> | ||||
| #include <QUuid> | ||||
|  | ||||
| #include "Logging.h" | ||||
| #include "minecraft/auth/AuthRequest.h" | ||||
| #include "minecraft/auth/Parsers.h" | ||||
|  | ||||
| @@ -41,9 +42,7 @@ void EntitlementsStep::onRequestDone( | ||||
|     auto requestor = qobject_cast<AuthRequest *>(QObject::sender()); | ||||
|     requestor->deleteLater(); | ||||
|  | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|  | ||||
|     // TODO: check presence of same entitlementsRequestId? | ||||
|     // TODO: validate JWTs? | ||||
|   | ||||
| @@ -2,9 +2,10 @@ | ||||
|  | ||||
| #include <QNetworkRequest> | ||||
|  | ||||
| #include "Logging.h" | ||||
| #include "minecraft/auth/AccountTask.h" | ||||
| #include "minecraft/auth/AuthRequest.h" | ||||
| #include "minecraft/auth/Parsers.h" | ||||
| #include "minecraft/auth/AccountTask.h" | ||||
| #include "net/NetUtils.h" | ||||
|  | ||||
| LauncherLoginStep::LauncherLoginStep(AccountData* data) : AuthStep(data) { | ||||
| @@ -51,14 +52,10 @@ void LauncherLoginStep::onRequestDone( | ||||
|     auto requestor = qobject_cast<AuthRequest *>(QObject::sender()); | ||||
|     requestor->deleteLater(); | ||||
|  | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|     if (error != QNetworkReply::NoError) { | ||||
|         qWarning() << "Reply error:" << error; | ||||
| #ifndef NDEBUG | ||||
|         qDebug() << data; | ||||
| #endif | ||||
|         qCDebug(authCredentials()) << data; | ||||
|         if (Net::isApplicationError(error)) { | ||||
|             emit finished( | ||||
|                 AccountTaskState::STATE_FAILED_SOFT, | ||||
| @@ -76,9 +73,7 @@ void LauncherLoginStep::onRequestDone( | ||||
|  | ||||
|     if(!Parsers::parseMojangResponse(data, m_data->yggdrasilToken)) { | ||||
|         qWarning() << "Could not parse login_with_xbox response..."; | ||||
| #ifndef NDEBUG | ||||
|         qDebug() << data; | ||||
| #endif | ||||
|         qCDebug(authCredentials()) << data; | ||||
|         emit finished( | ||||
|             AccountTaskState::STATE_FAILED_SOFT, | ||||
|             tr("Failed to parse the Minecraft access token response.") | ||||
|   | ||||
| @@ -42,6 +42,7 @@ | ||||
| #include "minecraft/auth/Parsers.h" | ||||
|  | ||||
| #include "Application.h" | ||||
| #include "Logging.h" | ||||
|  | ||||
| using OAuth2 = Katabasis::DeviceFlow; | ||||
| using Activity = Katabasis::Activity; | ||||
| @@ -117,14 +118,12 @@ void MSAStep::onOAuthActivityChanged(Katabasis::Activity activity) { | ||||
|             // Succeeded or did not invalidate tokens | ||||
|             emit hideVerificationUriAndCode(); | ||||
|             QVariantMap extraTokens = m_oauth2->extraTokens(); | ||||
| #ifndef NDEBUG | ||||
|             if (!extraTokens.isEmpty()) { | ||||
|                 qDebug() << "Extra tokens in response:"; | ||||
|                 qCDebug(authCredentials()) << "Extra tokens in response:"; | ||||
|                 foreach (QString key, extraTokens.keys()) { | ||||
|                     qDebug() << "\t" << key << ":" << extraTokens.value(key); | ||||
|                     qCDebug(authCredentials()) << "\t" << key << ":" << extraTokens.value(key); | ||||
|                 } | ||||
|             } | ||||
| #endif | ||||
|             emit finished(AccountTaskState::STATE_WORKING, tr("Got ")); | ||||
|             return; | ||||
|         } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|  | ||||
| #include <QNetworkRequest> | ||||
|  | ||||
| #include "Logging.h" | ||||
| #include "minecraft/auth/AuthRequest.h" | ||||
| #include "minecraft/auth/Parsers.h" | ||||
| #include "net/NetUtils.h" | ||||
| @@ -40,9 +41,7 @@ void MinecraftProfileStep::onRequestDone( | ||||
|     auto requestor = qobject_cast<AuthRequest *>(QObject::sender()); | ||||
|     requestor->deleteLater(); | ||||
|  | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|     if (error == QNetworkReply::ContentNotFoundError) { | ||||
|         // NOTE: Succeed even if we do not have a profile. This is a valid account state. | ||||
|         if(m_data->type == AccountType::Mojang) { | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|  | ||||
| #include <QNetworkRequest> | ||||
|  | ||||
| #include "Logging.h" | ||||
| #include "minecraft/auth/AuthRequest.h" | ||||
| #include "minecraft/auth/Parsers.h" | ||||
| #include "net/NetUtils.h" | ||||
| @@ -43,9 +44,7 @@ void MinecraftProfileStepMojang::onRequestDone( | ||||
|     auto requestor = qobject_cast<AuthRequest *>(QObject::sender()); | ||||
|     requestor->deleteLater(); | ||||
|  | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|     if (error == QNetworkReply::ContentNotFoundError) { | ||||
|         // NOTE: Succeed even if we do not have a profile. This is a valid account state. | ||||
|         if(m_data->type == AccountType::Mojang) { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| #include <QJsonParseError> | ||||
| #include <QJsonDocument> | ||||
|  | ||||
| #include "Logging.h" | ||||
| #include "minecraft/auth/AuthRequest.h" | ||||
| #include "minecraft/auth/Parsers.h" | ||||
| #include "net/NetUtils.h" | ||||
| @@ -58,9 +59,7 @@ void XboxAuthorizationStep::onRequestDone( | ||||
|     auto requestor = qobject_cast<AuthRequest *>(QObject::sender()); | ||||
|     requestor->deleteLater(); | ||||
|  | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << data; | ||||
|     if (error != QNetworkReply::NoError) { | ||||
|         qWarning() << "Reply error:" << error; | ||||
|         if (Net::isApplicationError(error)) { | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| #include <QNetworkRequest> | ||||
| #include <QUrlQuery> | ||||
|  | ||||
|  | ||||
| #include "Logging.h" | ||||
| #include "minecraft/auth/AuthRequest.h" | ||||
| #include "minecraft/auth/Parsers.h" | ||||
| #include "net/NetUtils.h" | ||||
| @@ -56,9 +56,7 @@ void XboxProfileStep::onRequestDone( | ||||
|  | ||||
|     if (error != QNetworkReply::NoError) { | ||||
|         qWarning() << "Reply error:" << error; | ||||
| #ifndef NDEBUG | ||||
|         qDebug() << data; | ||||
| #endif | ||||
|         qCDebug(authCredentials()) << data; | ||||
|         if (Net::isApplicationError(error)) { | ||||
|             emit finished( | ||||
|                 AccountTaskState::STATE_FAILED_SOFT, | ||||
| @@ -74,9 +72,7 @@ void XboxProfileStep::onRequestDone( | ||||
|         return; | ||||
|     } | ||||
|  | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << "XBox profile: " << data; | ||||
| #endif | ||||
|     qCDebug(authCredentials()) << "XBox profile: " << data; | ||||
|  | ||||
|     emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox profile")); | ||||
| } | ||||
|   | ||||
| @@ -44,6 +44,8 @@ | ||||
| #include "MetadataHandler.h" | ||||
| #include "Version.h" | ||||
|  | ||||
| static ModPlatform::ProviderCapabilities ProviderCaps; | ||||
|  | ||||
| Mod::Mod(const QFileInfo& file) : Resource(file), m_local_details() | ||||
| { | ||||
|     m_enabled = (file.suffix() != "disabled"); | ||||
| @@ -91,6 +93,11 @@ std::pair<int, bool> Mod::compare(const Resource& other, SortType type) const | ||||
|             if (this_ver < other_ver) | ||||
|                 return { -1, type == SortType::VERSION }; | ||||
|         } | ||||
|         case SortType::PROVIDER: { | ||||
|             auto compare_result = QString::compare(provider().value_or("Unknown"), cast_other->provider().value_or("Unknown"), Qt::CaseInsensitive); | ||||
|             if (compare_result != 0) | ||||
|                 return { compare_result, type == SortType::PROVIDER }; | ||||
|         } | ||||
|     } | ||||
|     return { 0, false }; | ||||
| } | ||||
| @@ -189,4 +196,11 @@ void Mod::finishResolvingWithDetails(ModDetails&& details) | ||||
|     m_local_details = std::move(details); | ||||
|     if (metadata) | ||||
|         setMetadata(std::move(metadata)); | ||||
| }; | ||||
|  | ||||
| auto Mod::provider() const -> std::optional<QString> | ||||
| { | ||||
|     if (metadata()) | ||||
|         return ProviderCaps.readableName(metadata()->provider); | ||||
|     return {}; | ||||
| } | ||||
|   | ||||
| @@ -39,6 +39,8 @@ | ||||
| #include <QFileInfo> | ||||
| #include <QList> | ||||
|  | ||||
| #include <optional> | ||||
|  | ||||
| #include "Resource.h" | ||||
| #include "ModDetails.h" | ||||
|  | ||||
| @@ -61,6 +63,7 @@ public: | ||||
|     auto description() const -> QString; | ||||
|     auto authors()     const -> QStringList; | ||||
|     auto status()      const -> ModStatus; | ||||
|     auto provider()    const -> std::optional<QString>; | ||||
|  | ||||
|     auto metadata() -> std::shared_ptr<Metadata::ModStruct>; | ||||
|     auto metadata() const -> const std::shared_ptr<Metadata::ModStruct>; | ||||
|   | ||||
| @@ -48,10 +48,11 @@ | ||||
|  | ||||
| #include "minecraft/mod/tasks/LocalModParseTask.h" | ||||
| #include "minecraft/mod/tasks/ModFolderLoadTask.h" | ||||
| #include "modplatform/ModIndex.h" | ||||
|  | ||||
| ModFolderModel::ModFolderModel(const QString &dir, bool is_indexed) : ResourceFolderModel(QDir(dir)), m_is_indexed(is_indexed) | ||||
| { | ||||
|     m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::VERSION, SortType::DATE }; | ||||
|     m_column_sort_keys = { SortType::ENABLED, SortType::NAME, SortType::VERSION, SortType::DATE, SortType::PROVIDER }; | ||||
| } | ||||
|  | ||||
| QVariant ModFolderModel::data(const QModelIndex &index, int role) const | ||||
| @@ -82,7 +83,15 @@ QVariant ModFolderModel::data(const QModelIndex &index, int role) const | ||||
|         } | ||||
|         case DateColumn: | ||||
|             return m_resources[row]->dateTimeChanged(); | ||||
|         case ProviderColumn: { | ||||
|             auto provider = at(row)->provider(); | ||||
|             if (!provider.has_value()) { | ||||
| 	            //: Unknown mod provider (i.e. not Modrinth, CurseForge, etc...) | ||||
|                 return tr("Unknown"); | ||||
|             } | ||||
|  | ||||
|             return provider.value(); | ||||
|         } | ||||
|         default: | ||||
|             return QVariant(); | ||||
|         } | ||||
| @@ -118,6 +127,8 @@ QVariant ModFolderModel::headerData(int section, Qt::Orientation orientation, in | ||||
|             return tr("Version"); | ||||
|         case DateColumn: | ||||
|             return tr("Last changed"); | ||||
|         case ProviderColumn: | ||||
|             return tr("Provider"); | ||||
|         default: | ||||
|             return QVariant(); | ||||
|         } | ||||
| @@ -133,6 +144,8 @@ QVariant ModFolderModel::headerData(int section, Qt::Orientation orientation, in | ||||
|             return tr("The version of the mod."); | ||||
|         case DateColumn: | ||||
|             return tr("The date and time this mod was last changed (or added)."); | ||||
|         case ProviderColumn: | ||||
|             return tr("Where the mod was downloaded from."); | ||||
|         default: | ||||
|             return QVariant(); | ||||
|         } | ||||
|   | ||||
| @@ -67,6 +67,7 @@ public: | ||||
|         NameColumn, | ||||
|         VersionColumn, | ||||
|         DateColumn, | ||||
|         ProviderColumn, | ||||
|         NUM_COLUMNS | ||||
|     }; | ||||
|     enum ModStatusAction { | ||||
|   | ||||
| @@ -20,7 +20,8 @@ enum class SortType { | ||||
|     DATE, | ||||
|     VERSION, | ||||
|     ENABLED, | ||||
|     PACK_FORMAT | ||||
|     PACK_FORMAT, | ||||
|     PROVIDER | ||||
| }; | ||||
|  | ||||
| enum class EnableAction { | ||||
|   | ||||
| @@ -14,6 +14,9 @@ | ||||
|  */ | ||||
|  | ||||
| #include "ModListView.h" | ||||
|  | ||||
| #include "minecraft/mod/ModFolderModel.h" | ||||
|  | ||||
| #include <QHeaderView> | ||||
| #include <QMouseEvent> | ||||
| #include <QPainter> | ||||
| @@ -62,4 +65,17 @@ void ModListView::setModel ( QAbstractItemModel* model ) | ||||
|         for(int i = 1; i < head->count(); i++) | ||||
|             head->setSectionResizeMode(i, QHeaderView::ResizeToContents); | ||||
|     } | ||||
|  | ||||
|     auto real_model = model; | ||||
|     if (auto proxy_model = dynamic_cast<QSortFilterProxyModel*>(model); proxy_model) | ||||
|         real_model = proxy_model->sourceModel(); | ||||
|  | ||||
|     if (auto mod_model = dynamic_cast<ModFolderModel*>(real_model); mod_model) { | ||||
|         connect(mod_model, &ModFolderModel::updateFinished, this, [this, mod_model]{ | ||||
|             auto mods = mod_model->allMods(); | ||||
|             // Hide the 'Provider' column if no mod has a defined provider! | ||||
|             setColumnHidden(ModFolderModel::Columns::ProviderColumn, | ||||
|                     std::none_of(mods.constBegin(), mods.constEnd(), [](auto const mod){ return mod->provider().has_value(); })); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -38,6 +38,15 @@ set( katabasis_PUBLIC | ||||
|     include/katabasis/RequestParameter.h | ||||
| ) | ||||
|  | ||||
| ecm_qt_declare_logging_category(katabasis_PRIVATE | ||||
|     HEADER KatabasisLogging.h  # NOTE: this won't be in src/, but CMAKE_BINARY_DIR/src isn't included by default so this should be fine | ||||
|     IDENTIFIER katabasisCredentials | ||||
|     CATEGORY_NAME "katabasis.credentials" | ||||
|     DEFAULT_SEVERITY Warning | ||||
|     DESCRIPTION "Secrets and credentials from Katabasis" | ||||
|     EXPORT "Katabasis" | ||||
| ) | ||||
|  | ||||
| add_library( Katabasis STATIC ${katabasis_PRIVATE} ${katabasis_PUBLIC} ) | ||||
| target_link_libraries(Katabasis Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Network) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include <QLoggingCategory> | ||||
| #include <QNetworkAccessManager> | ||||
| #include <QNetworkRequest> | ||||
| #include <QNetworkReply> | ||||
|   | ||||
| @@ -19,9 +19,11 @@ | ||||
| #include "katabasis/PollServer.h" | ||||
| #include "katabasis/Globals.h" | ||||
|  | ||||
| #include "KatabasisLogging.h" | ||||
| #include "JsonResponse.h" | ||||
|  | ||||
| namespace { | ||||
|  | ||||
| // ref: https://tools.ietf.org/html/rfc8628#section-3.2 | ||||
| // Exception: Google sign-in uses "verification_url" instead of "*_uri" - we'll accept both. | ||||
| bool hasMandatoryDeviceAuthParams(const QVariantMap& params) | ||||
| @@ -333,9 +335,7 @@ QString DeviceFlow::refreshToken() { | ||||
| } | ||||
|  | ||||
| void DeviceFlow::setRefreshToken(const QString &v) { | ||||
| #ifndef NDEBUG | ||||
|     qDebug() << "DeviceFlow::setRefreshToken" << v << "..."; | ||||
| #endif | ||||
|     qCDebug(katabasisCredentials) << "new refresh token:" << v; | ||||
|     token_.refresh_token = v; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -19,38 +19,49 @@ | ||||
|     <p>Features:</p> | ||||
|     <ul> | ||||
|       <li>Easily install game modifications, such as Fabric, Forge and Quilt</li> | ||||
|       <li>Control your Java settings</li> | ||||
|       <li>Easily install and update modpacks from the Launcher</li> | ||||
|       <li>Control your Java settings, and enable Mangohud or Gamemode with a toggle</li> | ||||
|       <li>Manage worlds and resource packs from the launcher</li> | ||||
|       <li>See logs and other details easily</li> | ||||
|       <li>See logs and other details easily through a dashboard</li> | ||||
|       <li>Kill Minecraft in case of a crash/freeze</li> | ||||
|       <li>Isolate Minecraft instances to keep everything clean</li> | ||||
|       <li>Install and update mods directly from the launcher</li> | ||||
|       <li>Customize the launcher with themes, and more</li> | ||||
|       <li>And cat :3</li> | ||||
|     </ul> | ||||
|   </description> | ||||
|   <screenshots> | ||||
|     <screenshot type="default"> | ||||
|       <caption>The main Prism Launcher window</caption> | ||||
|       <image type="source" width="976" height="764">https://prismlauncher.org/img/screenshots/LauncherDark.png</image> | ||||
|       <image type="source" width="1030" height="764">https://prismlauncher.org/img/screenshots/LauncherDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Modpack installation</caption> | ||||
|       <image type="source" width="1103" height="954">https://prismlauncher.org/img/screenshots/ModpackInstallDark.png</image> | ||||
|       <image type="source" width="1126" height="850">https://prismlauncher.org/img/screenshots/ModpackInstallDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Modpack updating</caption> | ||||
|       <image type="source" width="930" height="677">https://prismlauncher.org/img/screenshots/ModpackUpdateDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Mod installation</caption> | ||||
|       <image type="source" width="1036" height="700">https://prismlauncher.org/img/screenshots/ModInstallDark.png</image> | ||||
|       <image type="source" width="848" height="558">https://prismlauncher.org/img/screenshots/ModInstallDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Mod updating</caption> | ||||
|       <image type="source" width="930" height="858">https://prismlauncher.org/img/screenshots/ModUpdateDark.png</image> | ||||
|       <image type="source" width="860" height="748">https://prismlauncher.org/img/screenshots/ModUpdateDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Instance management</caption> | ||||
|       <image type="source" width="1083" height="735">https://prismlauncher.org/img/screenshots/PropertiesDark.png</image> | ||||
|       <image type="source" width="960" height="659">https://prismlauncher.org/img/screenshots/PropertiesDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Cat :)</caption> | ||||
|       <image type="source" width="931" height="759">https://prismlauncher.org/img/screenshots/LauncherCatDark.png</image> | ||||
|       <caption>Cat :3</caption> | ||||
|       <image type="source" width="1042" height="754">https://prismlauncher.org/img/screenshots/LauncherCatDark.png</image> | ||||
|     </screenshot> | ||||
|     <screenshot> | ||||
|       <caption>Customization</caption> | ||||
|       <image type="source" width="1040" height="752">https://prismlauncher.org/img/screenshots/CustomizeDark.png</image> | ||||
|     </screenshot> | ||||
|   </screenshots> | ||||
|   <releases> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Aaron Sonin
					Aaron Sonin