diff --git a/COPYING.md b/COPYING.md index a4253ebf..7c22f069 100644 --- a/COPYING.md +++ b/COPYING.md @@ -76,6 +76,15 @@ Portions are licensed under MS-PL: laws, the contributors exclude the implied warranties of merchantability, fitness for a particular purpose and non-infringement. +# Mojang assets + + MultiMC contains some Minecraft assets owned by Mojang/Microsoft. + These are included, because MultiMC is, after all, a Minecraft launcher. + And without some creeper faces, what is even the point. + + If someone from Mojang finds this objectionable, we'll replace these + with suitable substitutes. Until then, enjoy the Minecraft vibes. + # MinGW runtime (Windows) Copyright (c) 2012 MinGW.org project diff --git a/launcher/Application.cpp b/launcher/Application.cpp index a8e5201e..00005c93 100644 --- a/launcher/Application.cpp +++ b/launcher/Application.cpp @@ -13,7 +13,6 @@ #include "ui/pages/global/LanguagePage.h" #include "ui/pages/global/ProxyPage.h" #include "ui/pages/global/ExternalToolsPage.h" -#include "ui/pages/global/AccountListPage.h" #include "ui/pages/global/PasteEEPage.h" #include "ui/pages/global/CustomCommandsPage.h" @@ -27,6 +26,7 @@ #include "ui/setupwizard/LanguageWizardPage.h" #include "ui/setupwizard/JavaWizardPage.h" +#include "ui/dialogs/AccountsDialog.h" #include "ui/dialogs/CustomMessageBox.h" #include "ui/pagedialog/PageDialog.h" @@ -52,6 +52,9 @@ #include "icons/IconList.h" #include "net/HttpMetaCache.h" +#include "skins/CapeCache.h" +#include "skins/SkinsModel.h" + #include "java/JavaUtils.h" #include "updater/UpdateChecker.h" @@ -686,6 +689,7 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) m_settings->registerSetting("InstanceDir", "instances"); m_settings->registerSetting({"CentralModsDir", "ModsDir"}, "mods"); m_settings->registerSetting("IconsDir", "icons"); + m_settings->registerSetting("SkinsDir", "skins"); // Editors m_settings->registerSetting("JsonEditor", QString()); @@ -782,7 +786,6 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) m_globalSettingsProvider->addPage(); m_globalSettingsProvider->addPage(); m_globalSettingsProvider->addPage(); - m_globalSettingsProvider->addPage(); m_globalSettingsProvider->addPage(); } qDebug() << "<> Settings loaded."; @@ -865,6 +868,17 @@ Application::Application(int &argc, char **argv) : QApplication(argc, argv) qDebug() << "<> Widget themes initialized."; } + // Skins + { + auto setting = APPLICATION->settings()->getSetting("SkinsDir"); + m_skinsModel.reset(new SkinsModel(setting->get().toString())); + connect(setting.get(), &Setting::SettingChanged,[&](const Setting &, QVariant value) + { + m_skinsModel->directoryChanged(value.toString()); + }); + qDebug() << "<> Skins intialized."; + } + // initialize and load all instances { auto InstDirSetting = m_settings->getSetting("InstanceDir"); @@ -1038,8 +1052,8 @@ void Application::performMainStartupAction() if(!m_profileToUse.isEmpty()) { - accountToUse = accounts()->getAccountByProfileName(m_profileToUse); - if(!accountToUse) { + int dummyRow; + if(!accounts()->getAccountByProfileName(m_profileToUse, accountToUse, dummyRow)) { return; } qDebug() << " Launching with account" << m_profileToUse; @@ -1144,8 +1158,8 @@ void Application::messageReceived(const QByteArray& message) MinecraftAccountPtr accountObject; if(!profile.isEmpty()) { - accountObject = accounts()->getAccountByProfileName(profile); - if(!accountObject) { + int dummyRow; + if(!accounts()->getAccountByProfileName(profile, accountObject, dummyRow)) { qWarning() << "Launch command requires the specified profile to be valid. " << profile << "does not resolve to any account."; return; } @@ -1410,6 +1424,13 @@ void Application::ShowGlobalSettings(class QWidget* parent, QString open_page) emit globalSettingsClosed(); } +void Application::ShowAccountsDialog(class QWidget* parent) +{ + AccountsDialog dialog(parent); + dialog.exec(); +} + + MainWindow* Application::showMainWindow(bool minimized) { if(m_mainWindow) @@ -1579,6 +1600,15 @@ shared_qobject_ptr Application::metadataIndex() return m_metadataIndex; } +shared_qobject_ptr Application::capeCache() +{ + if (!m_capeCache) + { + m_capeCache.reset(new CapeCache(this)); + } + return m_capeCache; +} + QString Application::getJarsPath() { if(m_jarsPath.isEmpty()) diff --git a/launcher/Application.h b/launcher/Application.h index 3d93b8b8..bbbdd8f8 100644 --- a/launcher/Application.h +++ b/launcher/Application.h @@ -33,6 +33,8 @@ class BaseDetachedToolFactory; class TranslationsModel; class ITheme; class MCEditTool; +class CapeCache; +class SkinsModel; namespace Meta { class Index; @@ -91,6 +93,10 @@ public: return m_icons; } + shared_qobject_ptr skinsModel() const { + return m_skinsModel; + } + MCEditTool *mcedit() const { return m_mcedit.get(); } @@ -117,6 +123,8 @@ public: shared_qobject_ptr metadataIndex(); + shared_qobject_ptr capeCache(); + QString getJarsPath(); /// this is the root of the 'installation'. Used for automatic updates @@ -133,6 +141,8 @@ public: InstanceWindow *showInstanceWindow(InstancePtr instance, QString page = QString()); MainWindow *showMainWindow(bool minimized = false); + void ShowAccountsDialog(class QWidget * parent); + void updateIsRunning(bool running); bool updatesAreAllowed(); @@ -184,6 +194,9 @@ private: shared_qobject_ptr m_metacache; shared_qobject_ptr m_metadataIndex; + shared_qobject_ptr m_capeCache; + shared_qobject_ptr m_skinsModel; + std::shared_ptr m_settings; std::shared_ptr m_instances; std::shared_ptr m_icons; diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 29193841..6dc2bb0f 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -218,16 +218,13 @@ set(MINECRAFT_SOURCES minecraft/auth/flows/MSA.cpp minecraft/auth/flows/MSA.h + # Auth steps minecraft/auth/steps/EntitlementsStep.cpp minecraft/auth/steps/EntitlementsStep.h - minecraft/auth/steps/ForcedMigrationStep.cpp - minecraft/auth/steps/ForcedMigrationStep.h minecraft/auth/steps/GetSkinStep.cpp minecraft/auth/steps/GetSkinStep.h minecraft/auth/steps/LauncherLoginStep.cpp minecraft/auth/steps/LauncherLoginStep.h - minecraft/auth/steps/MigrationEligibilityStep.cpp - minecraft/auth/steps/MigrationEligibilityStep.h minecraft/auth/steps/MinecraftProfileStep.cpp minecraft/auth/steps/MinecraftProfileStep.h minecraft/auth/steps/MSAStep.cpp @@ -239,6 +236,14 @@ set(MINECRAFT_SOURCES minecraft/auth/steps/XboxUserStep.cpp minecraft/auth/steps/XboxUserStep.h + # API call steps + minecraft/auth/steps/SetSkinStep.cpp + minecraft/auth/steps/SetSkinStep.h + minecraft/auth/steps/SetCapeStep.cpp + minecraft/auth/steps/SetCapeStep.h + minecraft/auth/steps/MinecraftProfileCreateStep.cpp + minecraft/auth/steps/MinecraftProfileCreateStep.h + minecraft/gameoptions/GameOptions.h minecraft/gameoptions/GameOptions.cpp @@ -338,14 +343,6 @@ set(MINECRAFT_SOURCES minecraft/AssetsUtils.h minecraft/AssetsUtils.cpp - # Minecraft services - minecraft/services/CapeChange.cpp - minecraft/services/CapeChange.h - minecraft/services/SkinUpload.cpp - minecraft/services/SkinUpload.h - minecraft/services/SkinDelete.cpp - minecraft/services/SkinDelete.h - mojang/PackageManifest.h mojang/PackageManifest.cpp ) @@ -581,25 +578,31 @@ SET(LAUNCHER_SOURCES KonamiCode.h KonamiCode.cpp - # Bundled resources - resources/backgrounds/backgrounds.qrc - resources/multimc/multimc.qrc - resources/pe_dark/pe_dark.qrc - resources/pe_light/pe_light.qrc - resources/pe_colored/pe_colored.qrc - resources/pe_blue/pe_blue.qrc - resources/OSX/OSX.qrc - resources/iOS/iOS.qrc - resources/flat/flat.qrc - resources/documents/documents.qrc - ${Launcher_Branding_LogoQRC} - # Icons icons/MMCIcon.h icons/MMCIcon.cpp icons/IconList.h icons/IconList.cpp + # Skins + skins/CapeCache.h + skins/CapeCache.cpp + skins/CapesModel.h + skins/CapesModel.cpp + skins/SkinsModel.h + skins/SkinsModel.cpp + skins/SkinRenderer.h + skins/SkinRenderer.cpp + skins/SkinWidget.h + skins/SkinWidget.cpp + skins/SkinTypes.h + skins/SkinTypes.cpp + skins/SkinUtils.h + skins/SkinUtils.cpp + skins/TextureMappings.h + skins/TextureMappings.cpp + + # GUI - windows ui/GuiUtil.h ui/GuiUtil.cpp @@ -610,10 +613,6 @@ SET(LAUNCHER_SOURCES ui/InstanceWindow.h ui/InstanceWindow.cpp - # FIXME: maybe find a better home for this. - SkinUtils.cpp - SkinUtils.h - # GUI - setup wizard ui/setupwizard/SetupWizard.h ui/setupwizard/SetupWizard.cpp @@ -681,8 +680,6 @@ SET(LAUNCHER_SOURCES ui/pages/instance/WorldListPage.h # GUI - global settings pages - ui/pages/global/AccountListPage.cpp - ui/pages/global/AccountListPage.h ui/pages/global/CustomCommandsPage.cpp ui/pages/global/CustomCommandsPage.h ui/pages/global/ExternalToolsPage.cpp @@ -748,8 +745,6 @@ SET(LAUNCHER_SOURCES ui/dialogs/AccountsDialog.h ui/dialogs/ProfileSelectDialog.cpp ui/dialogs/ProfileSelectDialog.h - ui/dialogs/ProfileSetupDialog.cpp - ui/dialogs/ProfileSetupDialog.h ui/dialogs/CopyInstanceDialog.cpp ui/dialogs/CopyInstanceDialog.h ui/dialogs/CustomMessageBox.cpp @@ -758,8 +753,6 @@ SET(LAUNCHER_SOURCES ui/dialogs/ExportInstanceDialog.h ui/dialogs/IconPickerDialog.cpp ui/dialogs/IconPickerDialog.h - ui/dialogs/MSALoginDialog.cpp - ui/dialogs/MSALoginDialog.h ui/dialogs/NewComponentDialog.cpp ui/dialogs/NewComponentDialog.h ui/dialogs/NewInstanceDialog.cpp @@ -776,8 +769,6 @@ SET(LAUNCHER_SOURCES ui/dialogs/UpdateDialog.h ui/dialogs/VersionSelectDialog.cpp ui/dialogs/VersionSelectDialog.h - ui/dialogs/SkinUploadDialog.cpp - ui/dialogs/SkinUploadDialog.h ui/dialogs/CreateShortcutDialog.cpp ui/dialogs/CreateShortcutDialog.h ui/dialogs/ModrinthExportDialog.cpp @@ -843,17 +834,13 @@ qt5_wrap_ui(LAUNCHER_UI ui/dialogs/CreateShortcutDialog.ui ui/dialogs/ExportInstanceDialog.ui ui/dialogs/IconPickerDialog.ui - ui/dialogs/MSALoginDialog.ui ui/dialogs/ModrinthExportDialog.ui ui/dialogs/NewComponentDialog.ui ui/dialogs/NewInstanceDialog.ui ui/dialogs/NotificationDialog.ui ui/dialogs/ProfileSelectDialog.ui - ui/dialogs/ProfileSetupDialog.ui ui/dialogs/ProgressDialog.ui - ui/dialogs/SkinUploadDialog.ui ui/dialogs/UpdateDialog.ui - ui/pages/global/AccountListPage.ui ui/pages/global/ExternalToolsPage.ui ui/pages/global/JavaPage.ui ui/pages/global/LauncherPage.ui @@ -898,6 +885,7 @@ qt5_add_resources(LAUNCHER_RESOURCES resources/iOS/iOS.qrc resources/flat/flat.qrc resources/documents/documents.qrc + resources/skins/skins.qrc ${Launcher_Branding_LogoQRC} ) diff --git a/launcher/LaunchController.cpp b/launcher/LaunchController.cpp index f343f1a7..c7313b66 100644 --- a/launcher/LaunchController.cpp +++ b/launcher/LaunchController.cpp @@ -8,8 +8,6 @@ #include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/ProfileSelectDialog.h" #include "ui/dialogs/ProgressDialog.h" -#include "ui/dialogs/ProfileSetupDialog.h" -#include "ui/dialogs/MSALoginDialog.h" #include "ui/dialogs/OfflineNameDialog.h" #include @@ -25,6 +23,7 @@ #include "tasks/Task.h" #include "minecraft/auth/AccountTask.h" #include "launch/steps/TextPrint.h" +#include "ui/dialogs/AccountsDialog.h" LaunchController::LaunchController(QObject *parent) : Task(parent) { @@ -162,16 +161,23 @@ void LaunchController::login() { } if(m_accountToUse->ownsMinecraft()) { if(!m_accountToUse->hasProfile()) { - // Now handle setting up a profile name here... - ProfileSetupDialog dialog(m_accountToUse, m_parentWidget); - if (dialog.exec() == QDialog::Accepted) - { + QMessageBox box(m_parentWidget); + box.setWindowTitle(tr("Account doesn't have a Minecraft profile")); + box.setText(tr("The account doesn't have a Minecraft profile yet.\nYou need to create a profile first to play.\n\nDo you want to open the Accounts dialog?")); + box.setIcon(QMessageBox::Warning); + auto accountsButton = box.addButton(tr("Open Accounts"), QMessageBox::ButtonRole::YesRole); + box.addButton(tr("Cancel"), QMessageBox::ButtonRole::NoRole); + box.setDefaultButton(accountsButton); + + box.exec(); + if(box.clickedButton() == accountsButton) { + AccountsDialog dialog(m_parentWidget, m_accountToUse->internalId()); + dialog.exec(); tryagain = true; continue; } - else - { - emitFailed(tr("Received undetermined session status during login.")); + else { + emitFailed(tr("Launch cancelled - account does not own Minecraft.")); return; } } @@ -225,7 +231,7 @@ void LaunchController::login() { } */ case AccountState::Expired: { - auto errorString = tr("The account has expired and needs to be logged into manually. Press OK to log in again."); + auto errorString = tr("The account has expired and needs to be logged into manually. Press OK to open the accounts window."); auto button = QMessageBox::warning( m_parentWidget, tr("Account refresh failed"), @@ -235,22 +241,11 @@ void LaunchController::login() { ); if (button == QMessageBox::StandardButton::Ok) { auto accounts = APPLICATION->accounts(); - bool isDefault = accounts->defaultAccount() == m_accountToUse; - accounts->removeAccount(accounts->index(accounts->findAccountByProfileId(m_accountToUse->profileId()))); - MinecraftAccountPtr newAccount = nullptr; - newAccount = MSALoginDialog::newAccount(m_parentWidget); - if (newAccount) { - accounts->addAccount(newAccount); - if (isDefault) { - accounts->setDefaultAccount(newAccount); - } - m_accountToUse = nullptr; - decideAccount(); - continue; - } else { - emitFailed(tr("Account expired and re-login attempt failed")); - return; - } + accounts->removeAccount(m_accountToUse->internalId()); + AccountsDialog accountsDialog; + accountsDialog.exec(); + emitFailed("The account has expired."); + return; } else { emitFailed(errorString); return; diff --git a/launcher/SkinUtils.cpp b/launcher/SkinUtils.cpp deleted file mode 100644 index 1fe0c896..00000000 --- a/launcher/SkinUtils.cpp +++ /dev/null @@ -1,50 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "SkinUtils.h" -#include "net/HttpMetaCache.h" -#include "Application.h" - -#include -#include -#include -#include -#include - -namespace SkinUtils -{ -/* - * Given a username, return a pixmap of the cached skin (if it exists), QPixmap() otherwise - */ -QPixmap getFaceFromCache(QString username, int height, int width) -{ - QFile fskin(APPLICATION->metacache()->resolveEntry("skins", username + ".png")->getFullPath()); - - if (fskin.exists()) - { - QPixmap skinTexture(fskin.fileName()); - if(!skinTexture.isNull()) - { - QPixmap skin = QPixmap(8, 8); - QPainter painter(&skin); - painter.drawPixmap(0, 0, skinTexture.copy(8, 8, 8, 8)); - painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8)); - return skin.scaled(height, width, Qt::KeepAspectRatio); - } - } - - return QPixmap(); -} -} diff --git a/launcher/SkinUtils.h b/launcher/SkinUtils.h deleted file mode 100644 index c1f437ab..00000000 --- a/launcher/SkinUtils.h +++ /dev/null @@ -1,23 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include - -namespace SkinUtils -{ -QPixmap getFaceFromCache(QString id, int height = 64, int width = 64); -} diff --git a/launcher/main.cpp b/launcher/main.cpp index aabb5a06..074fc52f 100644 --- a/launcher/main.cpp +++ b/launcher/main.cpp @@ -49,6 +49,8 @@ int main(int argc, char *argv[]) Q_INIT_RESOURCE(OSX); Q_INIT_RESOURCE(iOS); Q_INIT_RESOURCE(flat); + + Q_INIT_RESOURCE(skins); return app.exec(); } case Application::Failed: diff --git a/launcher/minecraft/auth/AccountData.cpp b/launcher/minecraft/auth/AccountData.cpp index 8af92d15..54086ad7 100644 --- a/launcher/minecraft/auth/AccountData.cpp +++ b/launcher/minecraft/auth/AccountData.cpp @@ -297,27 +297,16 @@ QString AccountData::profileId() const { return minecraftProfile.id; } -QString AccountData::profileName() const { - if(minecraftProfile.name.size() == 0) { - return QObject::tr("No profile (%1)").arg(accountDisplayString()); - } - else { - return minecraftProfile.name; - } +QString AccountData::xid() const { + return xboxApiToken.extra["xid"].toString(); } -QString AccountData::accountDisplayString() const { - switch(type) { - case AccountType::MSA: { - if(xboxApiToken.extra.contains("gtg")) { - return xboxApiToken.extra["gtg"].toString(); - } - return "Xbox profile missing"; - } - default: { - return "Invalid Account"; - } - } +QString AccountData::profileName() const { + return minecraftProfile.name; +} + +QString AccountData::gamerTag() const { + return xboxApiToken.extra["gtg"].toString(); } QString AccountData::lastError() const { diff --git a/launcher/minecraft/auth/AccountData.h b/launcher/minecraft/auth/AccountData.h index 2d88e34b..6926eaad 100644 --- a/launcher/minecraft/auth/AccountData.h +++ b/launcher/minecraft/auth/AccountData.h @@ -56,7 +56,7 @@ struct AccountData { bool resumeStateFromV3(QJsonObject data); //! gamertag for MSA - QString accountDisplayString() const; + QString gamerTag() const; //! Yggdrasil access token, as passed to the game. QString accessToken() const; @@ -64,6 +64,8 @@ struct AccountData { QString profileId() const; QString profileName() const; + QString xid() const; + QString lastError() const; AccountType type = AccountType::MSA; diff --git a/launcher/minecraft/auth/AccountList.cpp b/launcher/minecraft/auth/AccountList.cpp index f7ac8e6e..2ad59996 100644 --- a/launcher/minecraft/auth/AccountList.cpp +++ b/launcher/minecraft/auth/AccountList.cpp @@ -1,4 +1,4 @@ -/* Copyright 2013-2021 MultiMC Contributors +/* Copyright 2013-2024 MultiMC Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ #include #include - +#include enum AccountListVersion { MojangOnly = 2, @@ -53,36 +53,75 @@ AccountList::AccountList(QObject *parent) : QAbstractListModel(parent) { AccountList::~AccountList() noexcept {} -int AccountList::findAccountByProfileId(const QString& profileId) const { +bool AccountList::getAccountByXID(const QString& xid, MinecraftAccountPtr& pointer, int& index) const { for (int i = 0; i < count(); i++) { - MinecraftAccountPtr account = at(i); - if (account->profileId() == profileId) { - return i; + auto entry = at(i); + if(!entry.isAccount) + { + continue; + } + if (entry.account->xid() == xid) { + pointer = entry.account; + index = i; + return true; } } - return -1; + pointer = nullptr; + index = -1; + return false; } -MinecraftAccountPtr AccountList::getAccountByProfileName(const QString& profileName) const { +bool AccountList::getAccountByProfileName(const QString& profileName, MinecraftAccountPtr& pointer, int& index) const { for (int i = 0; i < count(); i++) { - MinecraftAccountPtr account = at(i); - if (account->profileName() == profileName) { - return account; + auto entry = at(i); + if(!entry.isAccount) + { + continue; + } + if (entry.account->profileName() == profileName) { + pointer = entry.account; + index = i; + return true; } } - return nullptr; + pointer = nullptr; + index = -1; + return false; } -const MinecraftAccountPtr AccountList::at(int i) const +bool AccountList::getAccountById(const QString& internalId, MinecraftAccountPtr& pointer, int& index) const { + for (int i = 0; i < count(); i++) { + auto entry = at(i); + if(!entry.isAccount) + { + continue; + } + if (entry.account->internalId() == internalId) { + pointer = entry.account; + index = i; + return true; + } + } + pointer = nullptr; + index = -1; + return false; +} + +const AccountList::Entry& AccountList::at(int i) const { - return MinecraftAccountPtr(m_accounts.at(i)); + return m_accounts.at(i); } QStringList AccountList::profileNames() const { QStringList out; - for(auto & account: m_accounts) { - auto profileName = account->profileName(); - if(profileName.isEmpty()) { + for(auto & entry: m_accounts) { + if(!entry.isAccount) + { + continue; + } + auto profileName = entry.account->profileName(); + if(profileName.isEmpty()) + { continue; } out.append(profileName); @@ -90,63 +129,86 @@ QStringList AccountList::profileNames() const { return out; } -void AccountList::addAccount(const MinecraftAccountPtr account) +QModelIndex AccountList::addAccount(const MinecraftAccountPtr account) { // NOTE: Do not allow adding something that's already there - if(m_accounts.contains(account)) { - return; + int i = 0; + for(const auto& entry: m_accounts) + { + if(entry.account == account) + { + return index(i); + } + } + + // override/replace existing account with the same XUID + auto xid = account->xid(); + MinecraftAccountPtr existingAccountPtr; + int existingIndex; + if(getAccountByXID(xid, existingAccountPtr, existingIndex)) { + existingAccountPtr->replaceDataWith(account); + auto modelIndex = index(existingIndex); + emit dataChanged(modelIndex, modelIndex); + onListChanged(); + return modelIndex; } // hook up notifications for changes in the account - connect(account.get(), &MinecraftAccount::changed, this, &AccountList::accountChanged); - connect(account.get(), &MinecraftAccount::activityChanged, this, &AccountList::accountActivityChanged); - - // override/replace existing account with the same profileId - auto profileId = account->profileId(); - if(profileId.size()) { - auto existingAccount = findAccountByProfileId(profileId); - if(existingAccount != -1) { - MinecraftAccountPtr existingAccountPtr = m_accounts[existingAccount]; - m_accounts[existingAccount] = account; - if(m_defaultAccount == existingAccountPtr) { - m_defaultAccount = account; - } - // disconnect notifications for changes in the account being replaced - existingAccountPtr->disconnect(this); - emit dataChanged(index(existingAccount), index(existingAccount, columnCount(QModelIndex()) - 1)); - onListChanged(); - return; - } - } + connect(account.get(), &MinecraftAccount::changed, this, &AccountList::onAccountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, &AccountList::onAccountActivityChanged); // if we don't have this profileId yet, add the account to the end int row = m_accounts.count(); beginInsertRows(QModelIndex(), row, row); - m_accounts.append(account); + m_accounts.append(Entry{true, account}); endInsertRows(); onListChanged(); + return index(row); +} + +void AccountList::removeAccount(const QString& internalId) +{ + int row; + MinecraftAccountPtr account; + if(!getAccountById(internalId, account, row)) + { + return; + } + + if(account == m_defaultAccount) + { + m_defaultAccount = nullptr; + onDefaultAccountChanged(); + } + account->disconnect(this); + + beginRemoveRows(QModelIndex(), row, row); + m_accounts.removeAt(row); + endRemoveRows(); + onListChanged(); } -void AccountList::removeAccount(QModelIndex index) +QModelIndex AccountList::defaultAccountIndex() const { - int row = index.row(); - if(index.isValid() && row >= 0 && row < m_accounts.size()) + if(!m_defaultAccount) { - auto & account = m_accounts[row]; - if(account == m_defaultAccount) - { - m_defaultAccount = nullptr; - onDefaultAccountChanged(); - } - account->disconnect(this); - - beginRemoveRows(QModelIndex(), row, row); - m_accounts.removeAt(index.row()); - endRemoveRows(); - onListChanged(); + return QModelIndex(); } + + for (int i = 0; i < count(); i++) { + auto entry = at(i); + if(!entry.isAccount) + { + continue; + } + if (entry.account == m_defaultAccount) { + return index(i); + } + } + return QModelIndex(); } + MinecraftAccountPtr AccountList::defaultAccount() const { return m_defaultAccount; @@ -159,11 +221,11 @@ void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount) int idx = 0; auto previousDefaultAccount = m_defaultAccount; m_defaultAccount = nullptr; - for (MinecraftAccountPtr account : m_accounts) + for (auto& entry : m_accounts) { - if (account == previousDefaultAccount) + if(entry.isAccount && entry.account == previousDefaultAccount) { - emit dataChanged(index(idx), index(idx, columnCount(QModelIndex()) - 1)); + emit dataChanged(index(idx), index(idx)); } idx ++; } @@ -176,14 +238,18 @@ void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount) auto newDefaultAccount = m_defaultAccount; int newDefaultAccountIdx = -1; int idx = 0; - for (MinecraftAccountPtr account : m_accounts) + for (auto& entry : m_accounts) { - if (account == newAccount) + if(!entry.isAccount) { - newDefaultAccount = account; + continue; + } + if (entry.account == newAccount) + { + newDefaultAccount = entry.account; newDefaultAccountIdx = idx; } - if(currentDefaultAccount == account) + if(currentDefaultAccount == entry.account) { currentDefaultAccountIdx = idx; } @@ -191,33 +257,54 @@ void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount) } if(currentDefaultAccount != newDefaultAccount) { - emit dataChanged(index(currentDefaultAccountIdx), index(currentDefaultAccountIdx, columnCount(QModelIndex()) - 1)); - emit dataChanged(index(newDefaultAccountIdx), index(newDefaultAccountIdx, columnCount(QModelIndex()) - 1)); + emit dataChanged(index(currentDefaultAccountIdx), index(currentDefaultAccountIdx)); + emit dataChanged(index(newDefaultAccountIdx), index(newDefaultAccountIdx)); m_defaultAccount = newDefaultAccount; onDefaultAccountChanged(); } } } -void AccountList::accountChanged() -{ - // the list changed. there is no doubt. - onListChanged(); -} - -void AccountList::accountActivityChanged(bool active) +void AccountList::onAccountChanged() { + // TODO: factor out MinecraftAccount *account = qobject_cast(sender()); bool found = false; for (int i = 0; i < count(); i++) { - if (at(i).get() == account) { - emit dataChanged(index(i), index(i, columnCount(QModelIndex()) - 1)); + auto entry = at(i); + if(!entry.isAccount) + continue; + if (entry.account.get() == account) { + emit dataChanged(index(i), index(i)); + found = true; + break; + } + } + if(found) + { + emit accountChanged(account); + // the list changed. there is no doubt. + onListChanged(); + } +} + +void AccountList::onAccountActivityChanged(bool active) +{ + // TODO: factor out + MinecraftAccount *account = qobject_cast(sender()); + bool found = false; + for (int i = 0; i < count(); i++) { + auto entry = at(i); + if(!entry.isAccount) + continue; + if (entry.account.get() == account) { + emit dataChanged(index(i), index(i)); found = true; break; } } if(found) { - emit listActivityChanged(); + emit accountActivityChanged(account, active); if(active) { beginActivity(); } @@ -258,122 +345,59 @@ QVariant AccountList::data(const QModelIndex &index, int role) const if (index.row() > count()) return QVariant(); - MinecraftAccountPtr account = at(index.row()); + auto entry = at(index.row()); + if(!entry.isAccount) + { + switch (role) + { + case Qt::DisplayRole: + return tr("Add New Account"); + case Qt::DecorationRole: + return APPLICATION->getThemedIcon("accounts"); + case PointerRole: + return QVariant::fromValue(MinecraftAccountPtr()); + } + return QVariant(); + } + auto account = entry.account; switch (role) { case Qt::DisplayRole: - switch (index.column()) + { + return account->profileName() + "\n" + account->gamerTag() + "\n" + account->accountStateText(); + } + case AccountNameRole: + return account->gamerTag(); + case ProfileNameRole: + return account->profileName(); + case AccountStatusRole: + return account->accountStateText(); + + case IconRole: + case Qt::DecorationRole: + { + QPixmap face = account->getFace(); + if(face.isNull()) { - case NameColumn: - return account->accountDisplayString(); - - case StatusColumn: { - switch(account->accountState()) { - case AccountState::Unchecked: { - return tr("Unchecked", "Account status"); - } - case AccountState::Offline: { - return tr("Offline", "Account status"); - } - case AccountState::Online: { - return tr("Online", "Account status"); - } - case AccountState::Working: { - return tr("Working", "Account status"); - } - case AccountState::Errored: { - return tr("Errored", "Account status"); - } - case AccountState::Expired: { - return tr("Expired", "Account status"); - } - case AccountState::Gone: { - return tr("Gone", "Account status"); - } - case AccountState::MustMigrate: { - return tr("Must Migrate", "Account status"); - } - } + return APPLICATION->getThemedIcon("noaccount"); } + return QIcon(face); + } - case ProfileNameColumn: { - return account->profileName(); - } - - default: - return QVariant(); - } - - case Qt::ToolTipRole: - return account->accountDisplayString(); case PointerRole: return QVariant::fromValue(account); case Qt::CheckStateRole: - switch (index.column()) - { - case NameColumn: - return account == m_defaultAccount ? Qt::Checked : Qt::Unchecked; - } - - case Qt::DecorationRole: - { - switch (index.column()) - { - case NameColumn: - { - QPixmap face = account->getFace(); - if(face.isNull()) - { - return APPLICATION->getThemedIcon("noaccount"); - } - return QIcon(face); - } - } - - } + return account == m_defaultAccount ? Qt::Checked : Qt::Unchecked; default: return QVariant(); } } -QVariant AccountList::headerData(int section, Qt::Orientation orientation, int role) const -{ - switch (role) - { - case Qt::DisplayRole: - switch (section) - { - case NameColumn: - return tr("Account"); - case StatusColumn: - return tr("Status"); - case ProfileNameColumn: - return tr("Profile"); - default: - return QVariant(); - } - case Qt::ToolTipRole: - switch (section) - { - case NameColumn: - return tr("User name of the account."); - case StatusColumn: - return tr("Current status of the account."); - case ProfileNameColumn: - return tr("Name of the Minecraft profile associated with the account."); - default: - return QVariant(); - } - - default: - return QVariant(); - } -} int AccountList::rowCount(const QModelIndex &) const { @@ -381,11 +405,6 @@ int AccountList::rowCount(const QModelIndex &) const return count(); } -int AccountList::columnCount(const QModelIndex &) const -{ - return NUM_COLUMNS; -} - Qt::ItemFlags AccountList::flags(const QModelIndex &index) const { if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) @@ -402,17 +421,25 @@ bool AccountList::setData(const QModelIndex &idx, const QVariant &value, int rol { return false; } + auto entry = at(idx.row()); + if(!entry.isAccount) + { + return false; + } if(role == Qt::CheckStateRole) { if(value == Qt::Checked) { - MinecraftAccountPtr account = at(idx.row()); - setDefaultAccount(account); + setDefaultAccount(entry.account); + } + else if(value == Qt::Unchecked) + { + setDefaultAccount(nullptr); } } - emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1)); + emit dataChanged(idx, idx); return true; } @@ -478,6 +505,7 @@ bool AccountList::loadList() bool AccountList::loadV3(QJsonObject& root) { beginResetModel(); + m_accounts.append(Entry{false, nullptr}); QJsonArray accounts = root.value("accounts").toArray(); for (QJsonValue accountVal : accounts) { @@ -485,15 +513,19 @@ bool AccountList::loadV3(QJsonObject& root) { MinecraftAccountPtr account = MinecraftAccount::loadFromJsonV3(accountObj); if (account.get() != nullptr) { - auto profileId = account->profileId(); - if(profileId.size()) { - if(findAccountByProfileId(profileId) != -1) { + // Note: protection against duplication in the file + auto xid = account->xid(); + if(xid.size()) { + MinecraftAccountPtr dummy; + int dummyRow; + if(getAccountByXID(xid, dummy, dummyRow)) { continue; } } - connect(account.get(), &MinecraftAccount::changed, this, &AccountList::accountChanged); - connect(account.get(), &MinecraftAccount::activityChanged, this, &AccountList::accountActivityChanged); - m_accounts.append(account); + connect(account.get(), &MinecraftAccount::changed, this, &AccountList::onAccountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, &AccountList::onAccountActivityChanged); + account->updateCapeCache(); + m_accounts.append(Entry{true, account}); if(accountObj.value("active").toBool(false)) { m_defaultAccount = account; } @@ -539,10 +571,14 @@ bool AccountList::saveList() // Build a list of accounts. qDebug() << "Building account array."; QJsonArray accounts; - for (MinecraftAccountPtr account : m_accounts) + for (auto& entry : m_accounts) { - QJsonObject accountObj = account->saveToJson(); - if(m_defaultAccount == account) { + if(!entry.isAccount) + { + continue; + } + QJsonObject accountObj = entry.account->saveToJson(); + if(m_defaultAccount == entry.account) { accountObj["active"] = true; } accounts.append(accountObj); @@ -587,9 +623,9 @@ void AccountList::setListFilePath(QString path, bool autosave) bool AccountList::anyAccountIsValid() { - for(auto account: m_accounts) + for(auto& entry: m_accounts) { - if(account->ownsMinecraft()) { + if(entry.account && entry.account->ownsMinecraft()) { return true; } } @@ -605,13 +641,18 @@ void AccountList::fillQueue() { } for(int i = 0; i < count(); i++) { - auto account = at(i); - if(account == m_defaultAccount) { + auto entry = at(i); + if(!entry.isAccount) + { + continue; + } + if(entry.account == m_defaultAccount) + { continue; } - if(account->shouldRefresh()) { - auto idToRefresh = account->internalId(); + if(entry.account->shouldRefresh()) { + auto idToRefresh = entry.account->internalId(); queueRefresh(idToRefresh); } } @@ -644,14 +685,18 @@ void AccountList::tryNext() { auto accountId = m_refreshQueue.front(); m_refreshQueue.pop_front(); for(int i = 0; i < count(); i++) { - auto account = at(i); - if(account->internalId() == accountId) { - m_currentTask = account->refresh(); + auto entry = at(i); + if(!entry.isAccount) + { + continue; + } + if(entry.account->internalId() == accountId) { + m_currentTask = entry.account->refresh(); if(m_currentTask) { connect(m_currentTask.get(), &AccountTask::succeeded, this, &AccountList::authSucceeded); connect(m_currentTask.get(), &AccountTask::failed, this, &AccountList::authFailed); m_currentTask->start(); - qDebug() << "RefreshSchedule: Processing account " << account->accountDisplayString() << " with internal ID " << accountId; + qDebug() << "RefreshSchedule: Processing account " << entry.account->gamerTag() << " with internal ID " << accountId; return; } } @@ -679,11 +724,7 @@ bool AccountList::isActive() const { } void AccountList::beginActivity() { - bool activating = m_activityCount == 0; m_activityCount++; - if(activating) { - emit activityChanged(true); - } } void AccountList::endActivity() { @@ -691,9 +732,5 @@ void AccountList::endActivity() { qWarning() << m_name << " - Activity count would become below zero"; return; } - bool deactivating = m_activityCount == 1; m_activityCount--; - if(deactivating) { - emit activityChanged(false); - } } diff --git a/launcher/minecraft/auth/AccountList.h b/launcher/minecraft/auth/AccountList.h index 0777ae1f..f3baffc0 100644 --- a/launcher/minecraft/auth/AccountList.h +++ b/launcher/minecraft/auth/AccountList.h @@ -1,4 +1,4 @@ -/* Copyright 2013-2021 MultiMC Contributors +/* Copyright 2013-2024 MultiMC Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ #include /*! - * List of available Mojang accounts. + * List of available Minecraft accounts. * This should be loaded in the background by MultiMC on startup. */ class AccountList : public QAbstractListModel @@ -32,36 +32,38 @@ class AccountList : public QAbstractListModel public: enum ModelRoles { - PointerRole = 0x34B1CB48 + PointerRole = Qt::UserRole, + AccountNameRole, + ProfileNameRole, + AccountStatusRole, + IconRole }; - enum VListColumns + struct Entry { - NameColumn = 0, - ProfileNameColumn, - StatusColumn, - - NUM_COLUMNS + bool isAccount; + MinecraftAccountPtr account; }; explicit AccountList(QObject *parent = 0); virtual ~AccountList() noexcept; - const MinecraftAccountPtr at(int i) const; + const AccountList::Entry& at(int i) const; int count() const; //////// List Model Functions //////// QVariant data(const QModelIndex &index, int role) const override; - virtual QVariant headerData(int section, Qt::Orientation orientation, int role) const override; virtual int rowCount(const QModelIndex &parent) const override; - virtual int columnCount(const QModelIndex &parent) const override; virtual Qt::ItemFlags flags(const QModelIndex &index) const override; virtual bool setData(const QModelIndex &index, const QVariant &value, int role) override; - void addAccount(const MinecraftAccountPtr account); - void removeAccount(QModelIndex index); - int findAccountByProfileId(const QString &profileId) const; - MinecraftAccountPtr getAccountByProfileName(const QString &profileName) const; + QModelIndex addAccount(const MinecraftAccountPtr account); + void removeAccount(const QString& accountId); + + bool getAccountByXID(const QString &xid, MinecraftAccountPtr& pointer, int& index) const; + bool getAccountByProfileName(const QString& profileName, MinecraftAccountPtr& pointer, int& index) const; + bool getAccountById(const QString& internalId, MinecraftAccountPtr& pointer, int& index) const; + QStringList profileNames() const; // requesting a refresh pushes it to the front of the queue @@ -83,6 +85,7 @@ public: bool saveList(); MinecraftAccountPtr defaultAccount() const; + QModelIndex defaultAccountIndex() const; void setDefaultAccount(MinecraftAccountPtr profileId); bool anyAccountIsValid(); @@ -95,22 +98,23 @@ protected: private: const char* m_name; uint32_t m_activityCount = 0; + signals: void listChanged(); - void listActivityChanged(); + void accountActivityChanged(MinecraftAccount *account, bool active); + void accountChanged(MinecraftAccount *account); void defaultAccountChanged(); - void activityChanged(bool active); public slots: /** * This is called when one of the accounts changes and the list needs to be updated */ - void accountChanged(); + void onAccountChanged(); /** * This is called when a (refresh/login) task involving the account starts or ends */ - void accountActivityChanged(bool active); + void onAccountActivityChanged(bool active); /** * This is initially to run background account refresh tasks, or on a hourly timer @@ -141,7 +145,7 @@ protected: */ void onDefaultAccountChanged(); - QList m_accounts; + QList m_accounts; MinecraftAccountPtr m_defaultAccount; diff --git a/launcher/minecraft/auth/AccountTask.cpp b/launcher/minecraft/auth/AccountTask.cpp index e6f1cd38..2cb79dbf 100644 --- a/launcher/minecraft/auth/AccountTask.cpp +++ b/launcher/minecraft/auth/AccountTask.cpp @@ -24,6 +24,7 @@ #include #include +#include "Parsers.h" AccountTask::AccountTask(AccountData *data, QObject *parent) : Task(parent), m_data(data) @@ -112,3 +113,65 @@ bool AccountTask::changeState(AccountTaskState newState, QString reason) } } } + +MojangError MojangError::fromJSON(QByteArray data, QNetworkReply::NetworkError networkError) +{ + MojangError out; + out.rawError = QString::fromUtf8(data); + out.networkError = networkError; + + auto doc = QJsonDocument::fromJson(data, &out.parseError); + if(out.parseError.error != QJsonParseError::NoError) + { + out.jsonParsed = false; + } + else + { + auto object = doc.object(); + Parsers::getString(object.value("path"), out.path); + QJsonValue details = object.value("details"); + if(details.isObject()) + { + QJsonObject detailsObj = details.toObject(); + Parsers::getString(detailsObj.value("status"), out.detailsStatus); + } + Parsers::getString(object.value("error"), out.error); + Parsers::getString(object.value("errorMessage"), out.errorMessage); + out.jsonParsed = true; + } + + + return out; +} + +QString MojangError::toString() const +{ + QString outString; + QTextStream out(&outString); + out << "Network error:" << networkError << "\n"; + if(jsonParsed) + { + if(!path.isNull()) + { + out << "path: " << path << "\n"; + } + if(!error.isNull()) + { + out << "error: " << error << "\n"; + } + if(!errorMessage.isNull()) + { + out << "errorMessage: " << errorMessage << "\n"; + } + if(!detailsStatus.isNull()) + { + out << "details.status: " << detailsStatus << "\n"; + } + } + else + { + out << "Mojang error failed to parse with error: " << parseError.errorString() << "\n"; + out << "Raw contents:\n" << rawError << "\n"; + } + return outString; +} diff --git a/launcher/minecraft/auth/AccountTask.h b/launcher/minecraft/auth/AccountTask.h index 3c1a398a..52de3b55 100644 --- a/launcher/minecraft/auth/AccountTask.h +++ b/launcher/minecraft/auth/AccountTask.h @@ -19,12 +19,13 @@ #include #include +#include #include #include #include "MinecraftAccount.h" +#include -class QNetworkReply; /** * Enum for describing the state of the current task. @@ -42,6 +43,22 @@ enum class AccountTaskState STATE_OFFLINE //!< soft failure. authentication failed in the first step in a 'soft' way }; +struct MojangError{ + static MojangError fromJSON(QByteArray data, QNetworkReply::NetworkError networkError); + QString toString() const; + + QNetworkReply::NetworkError networkError; + QString rawError; + + QJsonParseError parseError; + bool jsonParsed = false; + + QString path; + QString error; + QString errorMessage; + QString detailsStatus; +}; + class AccountTask : public Task { Q_OBJECT @@ -58,6 +75,7 @@ public: signals: void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn); void hideVerificationUriAndCode(); + void apiError(const MojangError& error); protected: diff --git a/launcher/minecraft/auth/AuthRequest.cpp b/launcher/minecraft/auth/AuthRequest.cpp index feface80..9eaa2778 100644 --- a/launcher/minecraft/auth/AuthRequest.cpp +++ b/launcher/minecraft/auth/AuthRequest.cpp @@ -37,6 +37,41 @@ void AuthRequest::post(const QNetworkRequest &req, const QByteArray &data, int t connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); } +void AuthRequest::post(const QNetworkRequest &req, QHttpMultiPart *multipart, int timeout/* = 60*1000*/) { + setup(req, QNetworkAccessManager::PostOperation); + status_ = Requesting; + reply_ = APPLICATION->network()->post(request_, multipart); + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); +} + +void AuthRequest::put(const QNetworkRequest &req, const QByteArray &data, int timeout/* = 60*1000*/) { + setup(req, QNetworkAccessManager::PutOperation); + data_ = data; + status_ = Requesting; + reply_ = APPLICATION->network()->put(request_, data_); + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); +} + +void AuthRequest::deleteResource(const QNetworkRequest& req, int timeout) +{ + setup(req, QNetworkAccessManager::DeleteOperation); + status_ = Requesting; + reply_ = APPLICATION->network()->deleteResource(request_); + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); + connect(reply_, SIGNAL(uploadProgress(qint64,qint64)), this, SLOT(onUploadProgress(qint64,qint64))); +} + void AuthRequest::onRequestFinished() { if (status_ == Idle) { return; diff --git a/launcher/minecraft/auth/AuthRequest.h b/launcher/minecraft/auth/AuthRequest.h index 89f7a123..c4ba530e 100644 --- a/launcher/minecraft/auth/AuthRequest.h +++ b/launcher/minecraft/auth/AuthRequest.h @@ -19,10 +19,11 @@ public: public slots: void get(const QNetworkRequest &req, int timeout = 60*1000); void post(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000); - + void post(const QNetworkRequest &req, class QHttpMultiPart *multipart, int timeout = 60*1000); + void put(const QNetworkRequest &req, const QByteArray &data, int timeout = 60*1000); + void deleteResource(const QNetworkRequest &req, int timeout = 60*1000); signals: - /// Emitted when a request has been completed or failed. void finished(QNetworkReply::NetworkError error, QByteArray data, QList headers); @@ -30,7 +31,6 @@ signals: void uploadProgress(qint64 bytesSent, qint64 bytesTotal); protected slots: - /// Handle request finished. void onRequestFinished(); diff --git a/launcher/minecraft/auth/AuthStep.h b/launcher/minecraft/auth/AuthStep.h index 2a8dc2ca..5b954e71 100644 --- a/launcher/minecraft/auth/AuthStep.h +++ b/launcher/minecraft/auth/AuthStep.h @@ -21,7 +21,6 @@ public: public slots: virtual void perform() = 0; - virtual void rehydrate() = 0; signals: void finished(AccountTaskState resultingState, QString message); diff --git a/launcher/minecraft/auth/MinecraftAccount.cpp b/launcher/minecraft/auth/MinecraftAccount.cpp index 702ac5e4..2a461517 100644 --- a/launcher/minecraft/auth/MinecraftAccount.cpp +++ b/launcher/minecraft/auth/MinecraftAccount.cpp @@ -30,6 +30,9 @@ #include "flows/MSA.h" +#include "skins/CapeCache.h" +#include + MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent) { data.internalId = QUuid::createUuid().toString().remove(QRegExp("[{}-]")); } @@ -59,16 +62,78 @@ AccountState MinecraftAccount::accountState() const { return data.accountState; } +QString MinecraftAccount::accountStateText() const +{ + switch(data.accountState) + { + case AccountState::Unchecked: { + return tr("Unchecked", "Account status"); + } + case AccountState::Offline: { + return tr("Offline", "Account status"); + } + case AccountState::Online: { + return tr("Online", "Account status"); + } + case AccountState::Working: { + return tr("Working", "Account status"); + } + case AccountState::Errored: { + return tr("Errored", "Account status"); + } + case AccountState::Expired: { + return tr("Expired", "Account status"); + } + case AccountState::Gone: { + return tr("Gone", "Account status"); + } + case AccountState::MustMigrate: { + return tr("Must Migrate", "Account status"); + } + default: { + return tr("Unknown", "Account status"); + } + } +} + + +void MinecraftAccount::updateCapeCache() const +{ + auto capeCache = APPLICATION->capeCache(); + for(const auto& cape: data.minecraftProfile.capes) + { + capeCache->addCapeImage(cape.id, cape.url); + } +} + +QString MinecraftAccount::getCurrentCape() const +{ + return data.minecraftProfile.currentCape; +} + +QByteArray MinecraftAccount::getSkin() const +{ + return data.minecraftProfile.skin.data; +} + +Skins::Model MinecraftAccount::getSkinModel() const +{ + if(data.minecraftProfile.skin.variant == "CLASSIC") + return Skins::Model::Classic; + return Skins::Model::Slim; +} + QPixmap MinecraftAccount::getFace() const { QPixmap skinTexture; if(!skinTexture.loadFromData(data.minecraftProfile.skin.data, "PNG")) { return QPixmap(); } - QPixmap skin = QPixmap(8, 8); + QPixmap skin = QPixmap(72, 72); + skin.fill(Qt::transparent); QPainter painter(&skin); - painter.drawPixmap(0, 0, skinTexture.copy(8, 8, 8, 8)); - painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8)); - return skin.scaled(64, 64, Qt::KeepAspectRatio); + painter.drawPixmap(4, 4, skinTexture.copy(8, 8, 8, 8).scaled(64, 64)); + painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8).scaled(72, 72)); + return skin; } shared_qobject_ptr MinecraftAccount::loginMSA() { @@ -94,6 +159,33 @@ shared_qobject_ptr MinecraftAccount::refresh() { return m_currentTask; } +shared_qobject_ptr MinecraftAccount::createMinecraftProfile(const QString& profileName) { + if(m_currentTask) { + return nullptr; + } + + m_currentTask.reset(new MSACreateProfile(&data, profileName)); + + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr MinecraftAccount::setSkin(Skins::Model model, QByteArray texture, const QString& capeUUID) { + if(m_currentTask) { + return nullptr; + } + + m_currentTask.reset(new MSASetSkin(&data, texture, model, capeUUID)); + + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + + shared_qobject_ptr MinecraftAccount::currentTask() { return m_currentTask; } @@ -102,6 +194,7 @@ shared_qobject_ptr MinecraftAccount::currentTask() { void MinecraftAccount::authSucceeded() { m_currentTask.reset(); + updateCapeCache(); emit changed(); emit activityChanged(false); } @@ -234,3 +327,9 @@ void MinecraftAccount::incrementUses() qWarning() << "Profile" << data.profileId() << "is now in use."; } } + +void MinecraftAccount::replaceDataWith(MinecraftAccountPtr other) +{ + data = other->data; + emit changed(); +} diff --git a/launcher/minecraft/auth/MinecraftAccount.h b/launcher/minecraft/auth/MinecraftAccount.h index 0b871e46..464d1aa2 100644 --- a/launcher/minecraft/auth/MinecraftAccount.h +++ b/launcher/minecraft/auth/MinecraftAccount.h @@ -29,6 +29,7 @@ #include "Usable.h" #include "AccountData.h" #include "QObjectPtr.h" +#include "skins/SkinTypes.h" class Task; class AccountTask; @@ -82,6 +83,10 @@ public: /* manipulation */ shared_qobject_ptr refresh(); + shared_qobject_ptr createMinecraftProfile(const QString& profileName); + + shared_qobject_ptr setSkin(Skins::Model model, QByteArray texture, const QString& capeUUID); + shared_qobject_ptr currentTask(); public: /* queries */ @@ -89,8 +94,8 @@ public: /* queries */ return data.internalId; } - QString accountDisplayString() const { - return data.accountDisplayString(); + QString gamerTag() const { + return data.gamerTag(); } QString accessToken() const { @@ -105,6 +110,10 @@ public: /* queries */ return data.profileName(); } + QString xid() const { + return data.xid(); + } + bool isActive() const; bool canMigrate() const { @@ -133,8 +142,14 @@ public: /* queries */ QPixmap getFace() const; + QByteArray getSkin() const; + QString getCurrentCape() const; + Skins::Model getSkinModel() const; + + //! Returns the current state of the account AccountState accountState() const; + QString accountStateText() const; AccountData * accountData() { return &data; @@ -148,6 +163,9 @@ public: /* queries */ return data.lastError(); } + void updateCapeCache() const; + + void replaceDataWith(MinecraftAccountPtr other); signals: /** * This signal is emitted when the account changes diff --git a/launcher/minecraft/auth/flows/AuthFlow.cpp b/launcher/minecraft/auth/flows/AuthFlow.cpp index 4f78e8c3..640cefc2 100644 --- a/launcher/minecraft/auth/flows/AuthFlow.cpp +++ b/launcher/minecraft/auth/flows/AuthFlow.cpp @@ -1,3 +1,9 @@ +/* Copyright 2021-2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + #include #include #include diff --git a/launcher/minecraft/auth/flows/AuthFlow.h b/launcher/minecraft/auth/flows/AuthFlow.h index 564b81d9..5d5ddf7d 100644 --- a/launcher/minecraft/auth/flows/AuthFlow.h +++ b/launcher/minecraft/auth/flows/AuthFlow.h @@ -1,3 +1,9 @@ +/* Copyright 2021-2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + #pragma once #include diff --git a/launcher/minecraft/auth/flows/MSA.cpp b/launcher/minecraft/auth/flows/MSA.cpp index 416b8f2c..935f6955 100644 --- a/launcher/minecraft/auth/flows/MSA.cpp +++ b/launcher/minecraft/auth/flows/MSA.cpp @@ -7,7 +7,10 @@ #include "minecraft/auth/steps/XboxProfileStep.h" #include "minecraft/auth/steps/EntitlementsStep.h" #include "minecraft/auth/steps/MinecraftProfileStep.h" +#include "minecraft/auth/steps/MinecraftProfileCreateStep.h" #include "minecraft/auth/steps/GetSkinStep.h" +#include "minecraft/auth/steps/SetSkinStep.h" +#include "minecraft/auth/steps/SetCapeStep.h" MSASilent::MSASilent(AccountData* data, QObject* parent) : AuthFlow(data, parent) { m_steps.append(new MSAStep(m_data, MSAStep::Action::Refresh)); @@ -35,3 +38,42 @@ MSAInteractive::MSAInteractive( m_steps.append(new MinecraftProfileStep(m_data)); m_steps.append(new GetSkinStep(m_data)); } + +MSACreateProfile::MSACreateProfile(AccountData* data, const QString& profileName, QObject* parent) : AuthFlow(data, parent) { + m_steps.append(new MSAStep(m_data, MSAStep::Action::Refresh)); + m_steps.append(new XboxUserStep(m_data)); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken, "http://xboxlive.com", "Xbox")); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken, "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(new LauncherLoginStep(m_data)); + m_steps.append(new XboxProfileStep(m_data)); + m_steps.append(new EntitlementsStep(m_data)); + auto apiCall = new MinecraftProfileCreateStep(m_data, profileName); + m_steps.append(apiCall); + connect(apiCall, &MinecraftProfileCreateStep::apiError, this, &AccountTask::apiError); + m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new GetSkinStep(m_data)); +} + +MSASetSkin::MSASetSkin(AccountData* data, const QByteArray& skinData, Skins::Model model, const QString& uuid, QObject* parent) : AuthFlow(data, parent) { + m_steps.append(new MSAStep(m_data, MSAStep::Action::Refresh)); + m_steps.append(new XboxUserStep(m_data)); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken, "http://xboxlive.com", "Xbox")); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken, "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(new LauncherLoginStep(m_data)); + m_steps.append(new XboxProfileStep(m_data)); + m_steps.append(new EntitlementsStep(m_data)); + + { + auto apiCall = new SetSkinStep(m_data, model, skinData); + m_steps.append(apiCall); + connect(apiCall, &SetSkinStep::apiError, this, &AccountTask::apiError); + } + if(uuid != data->minecraftProfile.currentCape) + { + auto apiCall = new SetCapeStep(m_data, uuid); + m_steps.append(apiCall); + connect(apiCall, &SetCapeStep::apiError, this, &AccountTask::apiError); + } + m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new GetSkinStep(m_data)); +} diff --git a/launcher/minecraft/auth/flows/MSA.h b/launcher/minecraft/auth/flows/MSA.h index 14a4ff43..30f99912 100644 --- a/launcher/minecraft/auth/flows/MSA.h +++ b/launcher/minecraft/auth/flows/MSA.h @@ -1,5 +1,6 @@ #pragma once #include "AuthFlow.h" +#include "skins/SkinTypes.h" class MSAInteractive : public AuthFlow { @@ -20,3 +21,27 @@ public: QObject *parent = 0 ); }; + +class MSACreateProfile : public AuthFlow +{ + Q_OBJECT +public: + explicit MSACreateProfile( + AccountData * data, + const QString& profileName, + QObject *parent = 0 + ); +}; + +class MSASetSkin : public AuthFlow +{ + Q_OBJECT +public: + explicit MSASetSkin( + AccountData * data, + const QByteArray& skinData, + Skins::Model model, + const QString& uuid, + QObject *parent = 0 + ); +}; diff --git a/launcher/minecraft/auth/steps/EntitlementsStep.cpp b/launcher/minecraft/auth/steps/EntitlementsStep.cpp index 6a0bb359..b49a282a 100644 --- a/launcher/minecraft/auth/steps/EntitlementsStep.cpp +++ b/launcher/minecraft/auth/steps/EntitlementsStep.cpp @@ -31,10 +31,6 @@ void EntitlementsStep::perform() { qDebug() << "Getting entitlements..."; } -void EntitlementsStep::rehydrate() { - // NOOP, for now. We only save bools and there's nothing to check. -} - void EntitlementsStep::onRequestDone( QNetworkReply::NetworkError error, QByteArray data, diff --git a/launcher/minecraft/auth/steps/EntitlementsStep.h b/launcher/minecraft/auth/steps/EntitlementsStep.h index 9412ae79..badd1675 100644 --- a/launcher/minecraft/auth/steps/EntitlementsStep.h +++ b/launcher/minecraft/auth/steps/EntitlementsStep.h @@ -13,7 +13,6 @@ public: virtual ~EntitlementsStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/ForcedMigrationStep.cpp b/launcher/minecraft/auth/steps/ForcedMigrationStep.cpp deleted file mode 100644 index 20ff4653..00000000 --- a/launcher/minecraft/auth/steps/ForcedMigrationStep.cpp +++ /dev/null @@ -1,54 +0,0 @@ -#include "ForcedMigrationStep.h" - -#include - -#include "minecraft/auth/AuthRequest.h" -#include "minecraft/auth/Parsers.h" - -#include "BuildConfig.h" - -ForcedMigrationStep::ForcedMigrationStep(AccountData* data) : AuthStep(data) { - -} - -ForcedMigrationStep::~ForcedMigrationStep() noexcept = default; - -QString ForcedMigrationStep::describe() { - return tr("Checking for migration eligibility."); -} - -void ForcedMigrationStep::perform() { - auto url = QString("%1/rollout/v1/msamigrationforced").arg(BuildConfig.API_BASE); - QNetworkRequest request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); - - AuthRequest *requestor = new AuthRequest(this); - connect(requestor, &AuthRequest::finished, this, &ForcedMigrationStep::onRequestDone); - requestor->get(request); -} - -void ForcedMigrationStep::rehydrate() { - // NOOP, for now. We only save bools and there's nothing to check. -} - -void ForcedMigrationStep::onRequestDone( - QNetworkReply::NetworkError error, - QByteArray data, - QList headers -) { - auto requestor = qobject_cast(QObject::sender()); - requestor->deleteLater(); - - if (error == QNetworkReply::NoError) { - Parsers::parseForcedMigrationResponse(data, m_data->mustMigrateToMSA); - } - if(m_data->mustMigrateToMSA) { - emit finished(AccountTaskState::STATE_FAILED_MUST_MIGRATE, tr("The account must be migrated to a Microsoft account.")); - } - else { - emit finished(AccountTaskState::STATE_WORKING, tr("Got forced migration flags")); - } - -} - diff --git a/launcher/minecraft/auth/steps/ForcedMigrationStep.h b/launcher/minecraft/auth/steps/ForcedMigrationStep.h deleted file mode 100644 index 8b9cbbbc..00000000 --- a/launcher/minecraft/auth/steps/ForcedMigrationStep.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once -#include - -#include "QObjectPtr.h" -#include "minecraft/auth/AuthStep.h" - - -class ForcedMigrationStep : public AuthStep { - Q_OBJECT - -public: - explicit ForcedMigrationStep(AccountData *data); - virtual ~ForcedMigrationStep() noexcept; - - void perform() override; - void rehydrate() override; - - QString describe() override; - -private slots: - void onRequestDone(QNetworkReply::NetworkError, QByteArray, QList); -}; - diff --git a/launcher/minecraft/auth/steps/GetSkinStep.cpp b/launcher/minecraft/auth/steps/GetSkinStep.cpp index 3521f8dc..1f488f20 100644 --- a/launcher/minecraft/auth/steps/GetSkinStep.cpp +++ b/launcher/minecraft/auth/steps/GetSkinStep.cpp @@ -24,10 +24,6 @@ void GetSkinStep::perform() { requestor->get(request); } -void GetSkinStep::rehydrate() { - // NOOP, for now. -} - void GetSkinStep::onRequestDone( QNetworkReply::NetworkError error, QByteArray data, diff --git a/launcher/minecraft/auth/steps/GetSkinStep.h b/launcher/minecraft/auth/steps/GetSkinStep.h index 6b97371e..9d0232db 100644 --- a/launcher/minecraft/auth/steps/GetSkinStep.h +++ b/launcher/minecraft/auth/steps/GetSkinStep.h @@ -13,7 +13,6 @@ public: virtual ~GetSkinStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/LauncherLoginStep.cpp b/launcher/minecraft/auth/steps/LauncherLoginStep.cpp index 800a6c55..07200383 100644 --- a/launcher/minecraft/auth/steps/LauncherLoginStep.cpp +++ b/launcher/minecraft/auth/steps/LauncherLoginStep.cpp @@ -40,10 +40,6 @@ void LauncherLoginStep::perform() { qDebug() << "Getting Minecraft access token..."; } -void LauncherLoginStep::rehydrate() { - // TODO: check the token validity -} - void LauncherLoginStep::onRequestDone( QNetworkReply::NetworkError error, QByteArray data, diff --git a/launcher/minecraft/auth/steps/LauncherLoginStep.h b/launcher/minecraft/auth/steps/LauncherLoginStep.h index e06a306f..a0ae7976 100644 --- a/launcher/minecraft/auth/steps/LauncherLoginStep.h +++ b/launcher/minecraft/auth/steps/LauncherLoginStep.h @@ -13,7 +13,6 @@ public: virtual ~LauncherLoginStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/MSAStep.cpp b/launcher/minecraft/auth/steps/MSAStep.cpp index be711f7e..26bee6ea 100644 --- a/launcher/minecraft/auth/steps/MSAStep.cpp +++ b/launcher/minecraft/auth/steps/MSAStep.cpp @@ -31,19 +31,6 @@ QString MSAStep::describe() { } -void MSAStep::rehydrate() { - switch(m_action) { - case Refresh: { - // TODO: check the tokens and see if they are old (older than a day) - return; - } - case Login: { - // NOOP - return; - } - } -} - void MSAStep::perform() { switch(m_action) { case Refresh: { diff --git a/launcher/minecraft/auth/steps/MSAStep.h b/launcher/minecraft/auth/steps/MSAStep.h index 49ba3542..dff70a35 100644 --- a/launcher/minecraft/auth/steps/MSAStep.h +++ b/launcher/minecraft/auth/steps/MSAStep.h @@ -19,7 +19,6 @@ public: virtual ~MSAStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/MigrationEligibilityStep.cpp b/launcher/minecraft/auth/steps/MigrationEligibilityStep.cpp deleted file mode 100644 index 9ab0c73a..00000000 --- a/launcher/minecraft/auth/steps/MigrationEligibilityStep.cpp +++ /dev/null @@ -1,47 +0,0 @@ -#include "MigrationEligibilityStep.h" - -#include - -#include "minecraft/auth/AuthRequest.h" -#include "minecraft/auth/Parsers.h" - -#include "BuildConfig.h" - -MigrationEligibilityStep::MigrationEligibilityStep(AccountData* data) : AuthStep(data) { - -} - -MigrationEligibilityStep::~MigrationEligibilityStep() noexcept = default; - -QString MigrationEligibilityStep::describe() { - return tr("Checking for migration eligibility."); -} - -void MigrationEligibilityStep::perform() { - auto url = QString("%1/rollout/v1/msamigration").arg(BuildConfig.API_BASE); - QNetworkRequest request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); - - AuthRequest *requestor = new AuthRequest(this); - connect(requestor, &AuthRequest::finished, this, &MigrationEligibilityStep::onRequestDone); - requestor->get(request); -} - -void MigrationEligibilityStep::rehydrate() { - // NOOP, for now. We only save bools and there's nothing to check. -} - -void MigrationEligibilityStep::onRequestDone( - QNetworkReply::NetworkError error, - QByteArray data, - QList headers -) { - auto requestor = qobject_cast(QObject::sender()); - requestor->deleteLater(); - - if (error == QNetworkReply::NoError) { - Parsers::parseRolloutResponse(data, m_data->canMigrateToMSA); - } - emit finished(AccountTaskState::STATE_WORKING, tr("Got migration flags")); -} diff --git a/launcher/minecraft/auth/steps/MigrationEligibilityStep.h b/launcher/minecraft/auth/steps/MigrationEligibilityStep.h deleted file mode 100644 index b1bf9cbf..00000000 --- a/launcher/minecraft/auth/steps/MigrationEligibilityStep.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once -#include - -#include "QObjectPtr.h" -#include "minecraft/auth/AuthStep.h" - - -class MigrationEligibilityStep : public AuthStep { - Q_OBJECT - -public: - explicit MigrationEligibilityStep(AccountData *data); - virtual ~MigrationEligibilityStep() noexcept; - - void perform() override; - void rehydrate() override; - - QString describe() override; - -private slots: - void onRequestDone(QNetworkReply::NetworkError, QByteArray, QList); -}; diff --git a/launcher/minecraft/auth/steps/MinecraftProfileCreateStep.cpp b/launcher/minecraft/auth/steps/MinecraftProfileCreateStep.cpp new file mode 100644 index 00000000..4d234de2 --- /dev/null +++ b/launcher/minecraft/auth/steps/MinecraftProfileCreateStep.cpp @@ -0,0 +1,66 @@ +/* Copyright 2024 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "MinecraftProfileCreateStep.h" + +#include + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +#include "BuildConfig.h" +#include + +MinecraftProfileCreateStep::MinecraftProfileCreateStep(AccountData* data, const QString& profileName) : AuthStep(data), m_profileName(profileName) { +} + +MinecraftProfileCreateStep::~MinecraftProfileCreateStep() noexcept = default; + +QString MinecraftProfileCreateStep::describe() { + return tr("Creating the Minecraft profile '%1'.").arg(m_profileName); +} + + +void MinecraftProfileCreateStep::perform() { + auto url = QString("%1/minecraft/profile").arg(BuildConfig.API_BASE); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + + QString payloadTemplate("{\"profileName\":\"%1\"}"); + auto data = payloadTemplate.arg(m_profileName).toUtf8(); + + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &MinecraftProfileCreateStep::onRequestDone); + requestor->post(request, data); +} + +void MinecraftProfileCreateStep::onRequestDone( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + auto requestor = qobject_cast(QObject::sender()); + requestor->deleteLater(); + + if(error == QNetworkReply::NoError) { + emit finished(AccountTaskState::STATE_WORKING, tr("")); + return; + } + else { + auto parsedError = MojangError::fromJSON(data, error); + if(parsedError.detailsStatus == "ALREADY_REGISTERED") + { + emit finished(AccountTaskState::STATE_WORKING, tr("")); + return; + } + QString errorString = parsedError.toString(); + emit apiError(parsedError); + qWarning() << "Failed to set up player profile: " << errorString; + emit finished(AccountTaskState::STATE_SUCCEEDED, tr("")); + } +} diff --git a/launcher/minecraft/auth/steps/MinecraftProfileCreateStep.h b/launcher/minecraft/auth/steps/MinecraftProfileCreateStep.h new file mode 100644 index 00000000..d2b67580 --- /dev/null +++ b/launcher/minecraft/auth/steps/MinecraftProfileCreateStep.h @@ -0,0 +1,34 @@ +/* Copyright 2024 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + + +class MinecraftProfileCreateStep : public AuthStep { + Q_OBJECT + +public: + explicit MinecraftProfileCreateStep(AccountData *data, const QString& profileName); + virtual ~MinecraftProfileCreateStep() noexcept; + + void perform() override; + + QString describe() override; + +signals: + void apiError(const MojangError& error); + +private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, QList); + +private: + QString m_profileName; +}; + diff --git a/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp b/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp index 2afdac90..eee53c2b 100644 --- a/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp +++ b/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp @@ -29,10 +29,6 @@ void MinecraftProfileStep::perform() { requestor->get(request); } -void MinecraftProfileStep::rehydrate() { - // NOOP, for now. We only save bools and there's nothing to check. -} - void MinecraftProfileStep::onRequestDone( QNetworkReply::NetworkError error, QByteArray data, diff --git a/launcher/minecraft/auth/steps/MinecraftProfileStep.h b/launcher/minecraft/auth/steps/MinecraftProfileStep.h index 8ef3395c..99f38126 100644 --- a/launcher/minecraft/auth/steps/MinecraftProfileStep.h +++ b/launcher/minecraft/auth/steps/MinecraftProfileStep.h @@ -13,7 +13,6 @@ public: virtual ~MinecraftProfileStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/SetCapeStep.cpp b/launcher/minecraft/auth/steps/SetCapeStep.cpp new file mode 100644 index 00000000..8dad12c2 --- /dev/null +++ b/launcher/minecraft/auth/steps/SetCapeStep.cpp @@ -0,0 +1,72 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "SetCapeStep.h" + +#include + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +#include "BuildConfig.h" +#include + +SetCapeStep::SetCapeStep(AccountData* data, const QString& capeId) : AuthStep(data), m_capeId(capeId) { +} + +SetCapeStep::~SetCapeStep() noexcept = default; + +QString SetCapeStep::describe() { + return tr("Setting cape."); +} + + +void SetCapeStep::perform() { + auto url = QString("%1/minecraft/profile/capes/active").arg(BuildConfig.API_BASE); + QNetworkRequest request = QNetworkRequest(url); + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + + if(m_capeId.isEmpty()) + { + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &SetCapeStep::onRequestDone); + requestor->deleteResource(request); + } + else + { + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + + QString payloadTemplate("{\"capeId\":\"%1\"}"); + auto data = payloadTemplate.arg(m_capeId).toUtf8(); + + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &SetCapeStep::onRequestDone); + requestor->put(request, data); + } +} + +void SetCapeStep::onRequestDone( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + auto requestor = qobject_cast(QObject::sender()); + requestor->deleteLater(); + + if(error == QNetworkReply::NoError) { + emit finished(AccountTaskState::STATE_WORKING, tr("")); + return; + } + else { + auto parsedError = MojangError::fromJSON(data, error); + QString errorString = parsedError.toString(); + emit apiError(parsedError); + qWarning() << "Failed to set player cape: " << errorString; + emit finished(AccountTaskState::STATE_SUCCEEDED, tr("")); + } +} + diff --git a/launcher/minecraft/auth/steps/SetCapeStep.h b/launcher/minecraft/auth/steps/SetCapeStep.h new file mode 100644 index 00000000..31ef80a1 --- /dev/null +++ b/launcher/minecraft/auth/steps/SetCapeStep.h @@ -0,0 +1,33 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + + +class SetCapeStep : public AuthStep { + Q_OBJECT + +public: + explicit SetCapeStep(AccountData *data, const QString& capeId); + virtual ~SetCapeStep() noexcept; + + void perform() override; + + QString describe() override; + +signals: + void apiError(const MojangError& error); + +private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, QList); + +private: + QString m_capeId; +}; diff --git a/launcher/minecraft/auth/steps/SetSkinStep.cpp b/launcher/minecraft/auth/steps/SetSkinStep.cpp new file mode 100644 index 00000000..93d39182 --- /dev/null +++ b/launcher/minecraft/auth/steps/SetSkinStep.cpp @@ -0,0 +1,102 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "SetSkinStep.h" + +#include + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +#include "BuildConfig.h" +#include +#include + +namespace { +QByteArray getVariant(Skins::Model model) { + switch (model) { + default: + qWarning() << "Unknown skin type!"; + case Skins::Model::Classic: + return "CLASSIC"; + case Skins::Model::Slim: + return "SLIM"; + } +} +} + +SetSkinStep::SetSkinStep(AccountData* data, Skins::Model model, QByteArray skinData) : AuthStep(data), m_model(model), m_skinData(skinData) { +} + +SetSkinStep::~SetSkinStep() noexcept = default; + +QString SetSkinStep::describe() { + if(m_skinData.isEmpty()) + { + return tr("Clearing skin"); + } + else + { + return tr("Uploading skin"); + } +} + +void SetSkinStep::perform() { + auto url = QString("%1/minecraft/profile/skins").arg(BuildConfig.API_BASE); + QNetworkRequest request = QNetworkRequest(url); + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + + if(m_skinData.isEmpty()) + { + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &SetSkinStep::onRequestDone); + requestor->deleteResource(request); + } + else + { + QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + + QHttpPart skin; + skin.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/png")); + skin.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"file\"; filename=\"skin.png\"")); + skin.setBody(m_skinData); + + QHttpPart model; + model.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"variant\"")); + model.setBody(getVariant(m_model)); + + multiPart->append(skin); + multiPart->append(model); + + AuthRequest *requestor = new AuthRequest(this); + multiPart->setParent(requestor); + connect(requestor, &AuthRequest::finished, this, &SetSkinStep::onRequestDone); + requestor->post(request, multiPart); + } +} + +void SetSkinStep::onRequestDone( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + auto requestor = qobject_cast(QObject::sender()); + requestor->deleteLater(); + + if(error == QNetworkReply::NoError) { + emit finished(AccountTaskState::STATE_WORKING, tr("")); + return; + } + else { + auto parsedError = MojangError::fromJSON(data, error); + QString errorString = parsedError.toString(); + emit apiError(parsedError); + qWarning() << "Failed to set player skin: " << errorString; + emit finished(AccountTaskState::STATE_SUCCEEDED, tr("")); + } +} + + diff --git a/launcher/minecraft/auth/steps/SetSkinStep.h b/launcher/minecraft/auth/steps/SetSkinStep.h new file mode 100644 index 00000000..ecd19d11 --- /dev/null +++ b/launcher/minecraft/auth/steps/SetSkinStep.h @@ -0,0 +1,35 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once +#include + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" +#include "skins/SkinTypes.h" + +class SetSkinStep : public AuthStep { + Q_OBJECT + +public: + explicit SetSkinStep(AccountData *data, Skins::Model model, QByteArray skinData); + virtual ~SetSkinStep() noexcept; + + void perform() override; + + QString describe() override; + +signals: + void apiError(const MojangError& error); + +private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, QList); + +private: + Skins::Model m_model; + QByteArray m_skinData; +}; + diff --git a/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp b/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp index b4147f1b..6fd2b300 100644 --- a/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp +++ b/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp @@ -20,10 +20,6 @@ QString XboxAuthorizationStep::describe() { return tr("Getting authorization to access %1 services.").arg(m_authorizationKind); } -void XboxAuthorizationStep::rehydrate() { - // FIXME: check if the tokens are good? -} - void XboxAuthorizationStep::perform() { QString xbox_auth_template = R"XXX( { diff --git a/launcher/minecraft/auth/steps/XboxAuthorizationStep.h b/launcher/minecraft/auth/steps/XboxAuthorizationStep.h index 31e43bf0..871c8449 100644 --- a/launcher/minecraft/auth/steps/XboxAuthorizationStep.h +++ b/launcher/minecraft/auth/steps/XboxAuthorizationStep.h @@ -13,7 +13,6 @@ public: virtual ~XboxAuthorizationStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/XboxProfileStep.cpp b/launcher/minecraft/auth/steps/XboxProfileStep.cpp index 9f50138e..72c2b6c7 100644 --- a/launcher/minecraft/auth/steps/XboxProfileStep.cpp +++ b/launcher/minecraft/auth/steps/XboxProfileStep.cpp @@ -17,10 +17,6 @@ QString XboxProfileStep::describe() { return tr("Fetching Xbox profile."); } -void XboxProfileStep::rehydrate() { - // NOOP, for now. We only save bools and there's nothing to check. -} - void XboxProfileStep::perform() { auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings"); QUrlQuery q; diff --git a/launcher/minecraft/auth/steps/XboxProfileStep.h b/launcher/minecraft/auth/steps/XboxProfileStep.h index 7a0c5873..cae215fc 100644 --- a/launcher/minecraft/auth/steps/XboxProfileStep.h +++ b/launcher/minecraft/auth/steps/XboxProfileStep.h @@ -13,7 +13,6 @@ public: virtual ~XboxProfileStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/auth/steps/XboxUserStep.cpp b/launcher/minecraft/auth/steps/XboxUserStep.cpp index a38a28e4..2e354203 100644 --- a/launcher/minecraft/auth/steps/XboxUserStep.cpp +++ b/launcher/minecraft/auth/steps/XboxUserStep.cpp @@ -16,10 +16,6 @@ QString XboxUserStep::describe() { } -void XboxUserStep::rehydrate() { - // NOOP, for now. We only save bools and there's nothing to check. -} - void XboxUserStep::perform() { QString xbox_auth_template = R"XXX( { diff --git a/launcher/minecraft/auth/steps/XboxUserStep.h b/launcher/minecraft/auth/steps/XboxUserStep.h index 83e9405f..af14f1b5 100644 --- a/launcher/minecraft/auth/steps/XboxUserStep.h +++ b/launcher/minecraft/auth/steps/XboxUserStep.h @@ -13,7 +13,6 @@ public: virtual ~XboxUserStep() noexcept; void perform() override; - void rehydrate() override; QString describe() override; diff --git a/launcher/minecraft/launch/ClaimAccount.cpp b/launcher/minecraft/launch/ClaimAccount.cpp index 1cd7c0da..9d97499e 100644 --- a/launcher/minecraft/launch/ClaimAccount.cpp +++ b/launcher/minecraft/launch/ClaimAccount.cpp @@ -6,23 +6,37 @@ ClaimAccount::ClaimAccount(LaunchTask* parent, AuthSessionPtr session): LaunchStep(parent) { + m_playerName = session->player_name; if(session->status == AuthSession::Status::PlayableOnline && !session->demo) { + online = true; auto accounts = APPLICATION->accounts(); - m_account = accounts->getAccountByProfileName(session->player_name); + int index; + accounts->getAccountByProfileName(m_playerName, m_account, index); } } void ClaimAccount::executeTask() { - if(m_account) + if(online) + { + if(m_account) + { + m_lock.reset(new UseLock(m_account)); + emitSucceeded(); + } + else + { + emitFailed(tr("Failed to claim account by profile: %1 was not found.").arg(m_playerName)); + } + } + else { - lock.reset(new UseLock(m_account)); emitSucceeded(); } } void ClaimAccount::finalize() { - lock.reset(); + m_lock.reset(); } diff --git a/launcher/minecraft/launch/ClaimAccount.h b/launcher/minecraft/launch/ClaimAccount.h index cb4de23f..2205b680 100644 --- a/launcher/minecraft/launch/ClaimAccount.h +++ b/launcher/minecraft/launch/ClaimAccount.h @@ -32,6 +32,8 @@ public: return false; } private: - std::unique_ptr lock; + std::unique_ptr m_lock; + QString m_playerName; MinecraftAccountPtr m_account; + bool online = false; }; diff --git a/launcher/minecraft/services/CapeChange.cpp b/launcher/minecraft/services/CapeChange.cpp deleted file mode 100644 index 70d68fea..00000000 --- a/launcher/minecraft/services/CapeChange.cpp +++ /dev/null @@ -1,70 +0,0 @@ -#include "CapeChange.h" - -#include -#include - -#include "Application.h" -#include "BuildConfig.h" - -CapeChange::CapeChange(QObject *parent, QString token, QString cape) - : Task(parent), m_capeId(cape), m_token(token) -{ -} - -void CapeChange::setCape(QString& cape) { - QNetworkRequest request(QString("%1/minecraft/profile/capes/active").arg(BuildConfig.API_BASE)); - auto requestString = QString("{\"capeId\":\"%1\"}").arg(m_capeId); - request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply *rep = APPLICATION->network()->put(request, requestString.toUtf8()); - - setStatus(tr("Equipping cape")); - - m_reply = shared_qobject_ptr(rep); - connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); - connect(rep, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(downloadError(QNetworkReply::NetworkError))); - connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); -} - -void CapeChange::clearCape() { - QNetworkRequest request(QString("%1/minecraft/profile/capes/active").arg(BuildConfig.API_BASE)); - auto requestString = QString("{\"capeId\":\"%1\"}").arg(m_capeId); - request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply *rep = APPLICATION->network()->deleteResource(request); - - setStatus(tr("Removing cape")); - - m_reply = shared_qobject_ptr(rep); - connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); - connect(rep, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(downloadError(QNetworkReply::NetworkError))); - connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); -} - - -void CapeChange::executeTask() -{ - if(m_capeId.isEmpty()) { - clearCape(); - } - else { - setCape(m_capeId); - } -} - -void CapeChange::downloadError(QNetworkReply::NetworkError error) -{ - // error happened during download. - qCritical() << "Network error: " << error; - emitFailed(m_reply->errorString()); -} - -void CapeChange::downloadFinished() -{ - // if the download failed - if (m_reply->error() != QNetworkReply::NetworkError::NoError) - { - emitFailed(QString("Network error: %1").arg(m_reply->errorString())); - m_reply.reset(); - return; - } - emitSucceeded(); -} diff --git a/launcher/minecraft/services/CapeChange.h b/launcher/minecraft/services/CapeChange.h deleted file mode 100644 index 185d69b6..00000000 --- a/launcher/minecraft/services/CapeChange.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include -#include -#include -#include "tasks/Task.h" -#include "QObjectPtr.h" - -class CapeChange : public Task -{ - Q_OBJECT -public: - CapeChange(QObject *parent, QString token, QString capeId); - virtual ~CapeChange() {} - -private: - void setCape(QString & cape); - void clearCape(); - -private: - QString m_capeId; - QString m_token; - shared_qobject_ptr m_reply; - -protected: - virtual void executeTask(); - -public slots: - void downloadError(QNetworkReply::NetworkError); - void downloadFinished(); -}; - diff --git a/launcher/minecraft/services/SkinDelete.cpp b/launcher/minecraft/services/SkinDelete.cpp deleted file mode 100644 index 971a7c3f..00000000 --- a/launcher/minecraft/services/SkinDelete.cpp +++ /dev/null @@ -1,45 +0,0 @@ -#include "SkinDelete.h" - -#include -#include - -#include "Application.h" -#include "BuildConfig.h" - -SkinDelete::SkinDelete(QObject *parent, QString token) - : Task(parent), m_token(token) -{ -} - -void SkinDelete::executeTask() -{ - QNetworkRequest request(QString("%1/minecraft/profile/skins/active").arg(BuildConfig.API_BASE)); - request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QNetworkReply *rep = APPLICATION->network()->deleteResource(request); - m_reply = shared_qobject_ptr(rep); - - setStatus(tr("Deleting skin")); - connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); - connect(rep, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(downloadError(QNetworkReply::NetworkError))); - connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); -} - -void SkinDelete::downloadError(QNetworkReply::NetworkError error) -{ - // error happened during download. - qCritical() << "Network error: " << error; - emitFailed(m_reply->errorString()); -} - -void SkinDelete::downloadFinished() -{ - // if the download failed - if (m_reply->error() != QNetworkReply::NetworkError::NoError) - { - emitFailed(QString("Network error: %1").arg(m_reply->errorString())); - m_reply.reset(); - return; - } - emitSucceeded(); -} - diff --git a/launcher/minecraft/services/SkinDelete.h b/launcher/minecraft/services/SkinDelete.h deleted file mode 100644 index 83a84685..00000000 --- a/launcher/minecraft/services/SkinDelete.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include "tasks/Task.h" - -typedef shared_qobject_ptr SkinDeletePtr; - -class SkinDelete : public Task -{ - Q_OBJECT -public: - SkinDelete(QObject *parent, QString token); - virtual ~SkinDelete() = default; - -private: - QString m_token; - shared_qobject_ptr m_reply; - -protected: - virtual void executeTask(); - -public slots: - void downloadError(QNetworkReply::NetworkError); - void downloadFinished(); -}; diff --git a/launcher/minecraft/services/SkinUpload.cpp b/launcher/minecraft/services/SkinUpload.cpp deleted file mode 100644 index 11c3c074..00000000 --- a/launcher/minecraft/services/SkinUpload.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "SkinUpload.h" - -#include -#include - -#include "Application.h" -#include "BuildConfig.h" - -QByteArray getVariant(SkinUpload::Model model) { - switch (model) { - default: - qDebug() << "Unknown skin type!"; - case SkinUpload::STEVE: - return "CLASSIC"; - case SkinUpload::ALEX: - return "SLIM"; - } -} - -SkinUpload::SkinUpload(QObject *parent, QString token, QByteArray skin, SkinUpload::Model model) - : Task(parent), m_model(model), m_skin(skin), m_token(token) -{ -} - -void SkinUpload::executeTask() -{ - QNetworkRequest request(QString("%1/minecraft/profile/skins").arg(BuildConfig.API_BASE)); - request.setRawHeader("Authorization", QString("Bearer %1").arg(m_token).toLocal8Bit()); - QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - QHttpPart skin; - skin.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/png")); - skin.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"file\"; filename=\"skin.png\"")); - skin.setBody(m_skin); - - QHttpPart model; - model.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"variant\"")); - model.setBody(getVariant(m_model)); - - multiPart->append(skin); - multiPart->append(model); - - QNetworkReply *rep = APPLICATION->network()->post(request, multiPart); - m_reply = shared_qobject_ptr(rep); - - setStatus(tr("Uploading skin")); - connect(rep, &QNetworkReply::uploadProgress, this, &Task::setProgress); - connect(rep, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(downloadError(QNetworkReply::NetworkError))); - connect(rep, SIGNAL(finished()), this, SLOT(downloadFinished())); -} - -void SkinUpload::downloadError(QNetworkReply::NetworkError error) -{ - // error happened during download. - qCritical() << "Network error: " << error; - emitFailed(m_reply->errorString()); -} - -void SkinUpload::downloadFinished() -{ - // if the download failed - if (m_reply->error() != QNetworkReply::NetworkError::NoError) - { - emitFailed(QString("Network error: %1").arg(m_reply->errorString())); - m_reply.reset(); - return; - } - emitSucceeded(); -} diff --git a/launcher/minecraft/services/SkinUpload.h b/launcher/minecraft/services/SkinUpload.h deleted file mode 100644 index 2c1f0a2e..00000000 --- a/launcher/minecraft/services/SkinUpload.h +++ /dev/null @@ -1,37 +0,0 @@ -#pragma once - -#include -#include -#include -#include "tasks/Task.h" - -typedef shared_qobject_ptr SkinUploadPtr; - -class SkinUpload : public Task -{ - Q_OBJECT -public: - enum Model - { - STEVE, - ALEX - }; - - // Note this class takes ownership of the file. - SkinUpload(QObject *parent, QString token, QByteArray skin, Model model = STEVE); - virtual ~SkinUpload() {} - -private: - Model m_model; - QByteArray m_skin; - QString m_token; - shared_qobject_ptr m_reply; -protected: - virtual void executeTask(); - -public slots: - - void downloadError(QNetworkReply::NetworkError); - - void downloadFinished(); -}; diff --git a/launcher/resources/skins/shaders/bg.frag b/launcher/resources/skins/shaders/bg.frag new file mode 100644 index 00000000..0ee35331 --- /dev/null +++ b/launcher/resources/skins/shaders/bg.frag @@ -0,0 +1,22 @@ +#version 330 core + +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +out vec4 finalColor; +layout(origin_upper_left) in vec4 gl_FragCoord; + +uniform vec3 baseColor; +uniform vec3 alternateColor; +uniform float fDevicePixelSize; + +void main() { + float fieldSize = fDevicePixelSize * 25.0; + if ((int(floor(gl_FragCoord.x / fieldSize) + floor(gl_FragCoord.y / fieldSize)) & 1) == 0) + finalColor = vec4(baseColor, 1.0); + else + finalColor = vec4(alternateColor, 1.0); +} diff --git a/launcher/resources/skins/shaders/bg.vert b/launcher/resources/skins/shaders/bg.vert new file mode 100644 index 00000000..438981da --- /dev/null +++ b/launcher/resources/skins/shaders/bg.vert @@ -0,0 +1,14 @@ +#version 330 core + +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +layout(location = 0) in vec2 position; + +void main() { + gl_Position = vec4(position, 0.0, 1.0); +} + diff --git a/launcher/resources/skins/shaders/skin.frag b/launcher/resources/skins/shaders/skin.frag new file mode 100644 index 00000000..16169101 --- /dev/null +++ b/launcher/resources/skins/shaders/skin.frag @@ -0,0 +1,49 @@ +#version 330 core + +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +in vec2 texCoord; +flat in int texID; +flat in int texTransparency; + +out vec4 finalColor; + +uniform sampler2D skinTexture; +uniform sampler2D capeTexture; + +void main() { + vec4 color; + if (texID == 0) + { + color = texture(skinTexture, texCoord); + } + else + { + color = texture(capeTexture, texCoord); + } + if(texTransparency == 0) + { + // replace transparency with black. TODO: verify this is correct + if(color.w == 0.0) + { + finalColor = vec4(0.0, 0.0, 0.0, 1.0); + } + else + { + finalColor = color; + finalColor.w = 1.0; + } + } + else + { + if(color.w == 0.0) + { + discard; + } + finalColor = color; + } +} diff --git a/launcher/resources/skins/shaders/skin.vert b/launcher/resources/skins/shaders/skin.vert new file mode 100644 index 00000000..bacaf423 --- /dev/null +++ b/launcher/resources/skins/shaders/skin.vert @@ -0,0 +1,27 @@ +#version 330 core + +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +// vertex shader + +layout(location = 0) in vec3 position; +layout(location = 1) in vec2 texcoords; +layout(location = 2) in int texnr; +layout(location = 3) in int transparency; + +out vec2 texCoord; +flat out int texID; +flat out int texTransparency; + +uniform mat4 worldToView; + +void main() { + gl_Position = worldToView * vec4(position, 1.0); + texCoord = texcoords; + texID = texnr; + texTransparency = transparency; +} diff --git a/launcher/resources/skins/skins.qrc b/launcher/resources/skins/skins.qrc new file mode 100644 index 00000000..2fe76b51 --- /dev/null +++ b/launcher/resources/skins/skins.qrc @@ -0,0 +1,34 @@ + + + + textures/placeholder_skin.png + textures/placeholder_cape.png + textures/no_cape.png + + + textures/Zuri_Slim.png + textures/Zuri_Classic.png + textures/Sunny_Slim.png + textures/Sunny_Classic.png + textures/Steve_Slim.png + textures/Steve_Classic.png + textures/Noor_Slim.png + textures/Noor_Classic.png + textures/Makena_Slim.png + textures/Makena_Classic.png + textures/Kai_Slim.png + textures/Kai_Classic.png + textures/Efe_Slim.png + textures/Efe_Classic.png + textures/Ari_Slim.png + textures/Ari_Classic.png + textures/Alex_Slim.png + textures/Alex_Classic.png + + shaders/skin.frag + shaders/skin.vert + + shaders/bg.frag + shaders/bg.vert + + diff --git a/launcher/resources/skins/textures/Alex_Classic.png b/launcher/resources/skins/textures/Alex_Classic.png new file mode 100644 index 00000000..3b46b8bd Binary files /dev/null and b/launcher/resources/skins/textures/Alex_Classic.png differ diff --git a/launcher/resources/skins/textures/Alex_Slim.png b/launcher/resources/skins/textures/Alex_Slim.png new file mode 100644 index 00000000..bae1bb32 Binary files /dev/null and b/launcher/resources/skins/textures/Alex_Slim.png differ diff --git a/launcher/resources/skins/textures/Ari_Classic.png b/launcher/resources/skins/textures/Ari_Classic.png new file mode 100644 index 00000000..63496af4 Binary files /dev/null and b/launcher/resources/skins/textures/Ari_Classic.png differ diff --git a/launcher/resources/skins/textures/Ari_Slim.png b/launcher/resources/skins/textures/Ari_Slim.png new file mode 100644 index 00000000..0a90560d Binary files /dev/null and b/launcher/resources/skins/textures/Ari_Slim.png differ diff --git a/launcher/resources/skins/textures/Efe_Classic.png b/launcher/resources/skins/textures/Efe_Classic.png new file mode 100644 index 00000000..a0629d04 Binary files /dev/null and b/launcher/resources/skins/textures/Efe_Classic.png differ diff --git a/launcher/resources/skins/textures/Efe_Slim.png b/launcher/resources/skins/textures/Efe_Slim.png new file mode 100644 index 00000000..395dc5b7 Binary files /dev/null and b/launcher/resources/skins/textures/Efe_Slim.png differ diff --git a/launcher/resources/skins/textures/Kai_Classic.png b/launcher/resources/skins/textures/Kai_Classic.png new file mode 100644 index 00000000..539f3643 Binary files /dev/null and b/launcher/resources/skins/textures/Kai_Classic.png differ diff --git a/launcher/resources/skins/textures/Kai_Slim.png b/launcher/resources/skins/textures/Kai_Slim.png new file mode 100644 index 00000000..02fe2d77 Binary files /dev/null and b/launcher/resources/skins/textures/Kai_Slim.png differ diff --git a/launcher/resources/skins/textures/Makena_Classic.png b/launcher/resources/skins/textures/Makena_Classic.png new file mode 100644 index 00000000..dae365d9 Binary files /dev/null and b/launcher/resources/skins/textures/Makena_Classic.png differ diff --git a/launcher/resources/skins/textures/Makena_Slim.png b/launcher/resources/skins/textures/Makena_Slim.png new file mode 100644 index 00000000..06360a02 Binary files /dev/null and b/launcher/resources/skins/textures/Makena_Slim.png differ diff --git a/launcher/resources/skins/textures/Noor_Classic.png b/launcher/resources/skins/textures/Noor_Classic.png new file mode 100644 index 00000000..a1073314 Binary files /dev/null and b/launcher/resources/skins/textures/Noor_Classic.png differ diff --git a/launcher/resources/skins/textures/Noor_Slim.png b/launcher/resources/skins/textures/Noor_Slim.png new file mode 100644 index 00000000..189aa1f8 Binary files /dev/null and b/launcher/resources/skins/textures/Noor_Slim.png differ diff --git a/launcher/resources/skins/textures/Steve_Classic.png b/launcher/resources/skins/textures/Steve_Classic.png new file mode 100644 index 00000000..0e0652cb Binary files /dev/null and b/launcher/resources/skins/textures/Steve_Classic.png differ diff --git a/launcher/resources/skins/textures/Steve_Slim.png b/launcher/resources/skins/textures/Steve_Slim.png new file mode 100644 index 00000000..160621b8 Binary files /dev/null and b/launcher/resources/skins/textures/Steve_Slim.png differ diff --git a/launcher/resources/skins/textures/Sunny_Classic.png b/launcher/resources/skins/textures/Sunny_Classic.png new file mode 100644 index 00000000..3f0c1777 Binary files /dev/null and b/launcher/resources/skins/textures/Sunny_Classic.png differ diff --git a/launcher/resources/skins/textures/Sunny_Slim.png b/launcher/resources/skins/textures/Sunny_Slim.png new file mode 100644 index 00000000..117b71c5 Binary files /dev/null and b/launcher/resources/skins/textures/Sunny_Slim.png differ diff --git a/launcher/resources/skins/textures/Zuri_Classic.png b/launcher/resources/skins/textures/Zuri_Classic.png new file mode 100644 index 00000000..2c62d697 Binary files /dev/null and b/launcher/resources/skins/textures/Zuri_Classic.png differ diff --git a/launcher/resources/skins/textures/Zuri_Slim.png b/launcher/resources/skins/textures/Zuri_Slim.png new file mode 100644 index 00000000..a3eab85f Binary files /dev/null and b/launcher/resources/skins/textures/Zuri_Slim.png differ diff --git a/launcher/resources/skins/textures/no_cape.png b/launcher/resources/skins/textures/no_cape.png new file mode 100644 index 00000000..5559597e Binary files /dev/null and b/launcher/resources/skins/textures/no_cape.png differ diff --git a/launcher/resources/skins/textures/placeholder_cape.png b/launcher/resources/skins/textures/placeholder_cape.png new file mode 100644 index 00000000..7b0b83b2 Binary files /dev/null and b/launcher/resources/skins/textures/placeholder_cape.png differ diff --git a/launcher/resources/skins/textures/placeholder_skin.png b/launcher/resources/skins/textures/placeholder_skin.png new file mode 100644 index 00000000..9b6440de Binary files /dev/null and b/launcher/resources/skins/textures/placeholder_skin.png differ diff --git a/launcher/skins/CapeCache.cpp b/launcher/skins/CapeCache.cpp new file mode 100644 index 00000000..b4dfcb8a --- /dev/null +++ b/launcher/skins/CapeCache.cpp @@ -0,0 +1,196 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "CapeCache.h" +#include +#include + +namespace { +QString fileName = "cache/capes.dat"; +} + +CapeCache::CapeCache(QObject* parent) : QObject(parent) +{ + connect(&m_saveTimer, &QTimer::timeout, this, &CapeCache::saveNow); + load(); +} + +CapeCache::~CapeCache() noexcept +{ + if(m_saveTimer.isActive()) + { + m_saveTimer.stop(); + saveNow(); + } +} + +void CapeCache::load() +{ + if(!QFile::exists(fileName)) + { + qDebug() << "No cape cache file to load"; + return; + } + + QFile capesData(fileName); + if(!capesData.open(QIODevice::ReadOnly)) + { + qWarning() << "Could not open " << fileName << " for reading"; + return; + } + QDataStream in(&capesData); + in.setVersion(QDataStream::Qt_5_4); + int32_t count; + in >> count; + if(in.status() != QDataStream::Ok) + { + qWarning() << "Could not read count of capes from " << fileName; + return; + } + for(int i = 0; i < count; i++) + { + QString uuid; + QByteArray data; + in >> uuid >> data; + if(in.status() != QDataStream::Ok) + { + qWarning() << "Could not read cape " << i << " out of " << count << " from " << fileName; + return; + } + QPixmap cape; + if(!cape.loadFromData(data, "PNG")) + { + qWarning() << "Failed to load cape id " << uuid << " from " << fileName << " : Could not read the PNG data."; + } + m_index[uuid] = {cape.toImage(), data}; + } +} + +void CapeCache::saveLater() +{ + m_saveTimer.stop(); + m_saveTimer.setSingleShot(true); + m_saveTimer.setInterval(5000); + m_saveTimer.start(); +} + +void CapeCache::saveNow() +{ + if(!FS::ensureFilePathExists(fileName)) + { + qWarning() << "Could not create directory for cape cache"; + return; + } + QSaveFile capesData(fileName); + if(!capesData.open(QIODevice::WriteOnly)) + { + qWarning() << "Could not open " << fileName << " for writing"; + return; + } + QDataStream out(&capesData); // we will serialize the data into the file + out.setVersion(QDataStream::Qt_5_4); + out << int32_t(m_index.size()); + for (auto it = m_index.begin(); it != m_index.end(); ++it) { + out << it.key() << it.value().rawData; + } + if(!capesData.commit()) + { + qWarning() << "Could not write into " << fileName; + return; + } +} + +void CapeCache::addCapeImage(const QString& uuid, const QString& url) +{ + // If we already have it, we skip it + if(m_index.contains(uuid)) + { + return; + } + + // Queue up retrieval of this cape if we do not have it + m_requests.enqueue({uuid, url}); + getNext(); +} + +void CapeCache::getNext() +{ + // if we are downloading already, don't start another one + if(m_downloadJob) + { + return; + } + + // make sure we do not duplicate work in case the queue is filled with duplicate cape requests + // pull out of the queue until we have a valid request to process +GO_AGAIN: + if(m_requests.isEmpty()) + { + return; + } + CapeRequest& request = m_requests.head(); + if(m_index.contains(request.uuid)) + { + m_requests.dequeue(); + goto GO_AGAIN; + } + + auto *netJob = new NetJob("CapeCache::Request", APPLICATION->network()); + netJob->addNetAction(Net::Download::makeByteArray(QUrl(request.url), &m_response)); + m_downloadJob = netJob; + m_downloadJob->start(); + + QObject::connect(netJob, &NetJob::succeeded, this, &CapeCache::requestFinished); + QObject::connect(netJob, &NetJob::failed, this, &CapeCache::requestFailed); +} + +void CapeCache::requestFinished() +{ + disposeDownloadJob(); + + CapeRequest request = m_requests.dequeue(); + QPixmap cape; + if(!cape.loadFromData(m_response, "PNG")) + { + qWarning() << "Failed to load cape id " << request.uuid << " from " << request.url << " : Could not read the PNG data."; + } + m_index[request.uuid] = {cape.toImage(), m_response}; + m_response.clear(); + + saveLater(); + emit capeReady(request.uuid); + + getNext(); +} + +void CapeCache::requestFailed(QString reason) +{ + CapeRequest request = m_requests.dequeue(); + + disposeDownloadJob(); + + qWarning() << "Failed to download cape id " << request.uuid << " from " << request.url << " : " << reason; + + getNext(); +} + +void CapeCache::disposeDownloadJob() +{ + disconnect(m_downloadJob.get()); + m_downloadJob->deleteLater(); + m_downloadJob = nullptr; +} + + +const QImage& CapeCache::getCapeImage(const QString& uuid) const +{ + auto iter = m_index.constFind(uuid); + if(iter == m_index.constEnd()) + { + return m_placeholder; + } + return iter.value().image; +} diff --git a/launcher/skins/CapeCache.h b/launcher/skins/CapeCache.h new file mode 100644 index 00000000..2531951d --- /dev/null +++ b/launcher/skins/CapeCache.h @@ -0,0 +1,60 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "net/NetJob.h" + +class CapeCache: public QObject +{ + struct CapeRequest + { + QString uuid; + QString url; + }; + struct IndexEntry + { + QImage image; + QByteArray rawData; + }; + Q_OBJECT +public: + explicit CapeCache(QObject* parent = nullptr); + virtual ~CapeCache() noexcept; + + void load(); + + const QImage& getCapeImage(const QString& uuid) const; + void addCapeImage(const QString& uuid, const QString& url); + +signals: + void capeReady(QString uuid); + +private: + void saveLater(); + void getNext(); + void disposeDownloadJob(); + +private slots: + void requestFinished(); + void requestFailed(QString reason); + void saveNow(); + +private: + QMap m_index; + QImage m_placeholder; + + NetJob::Ptr m_downloadJob; + QByteArray m_response; + QQueue m_requests; + QTimer m_saveTimer; +}; diff --git a/launcher/skins/CapesModel.cpp b/launcher/skins/CapesModel.cpp new file mode 100644 index 00000000..214935ce --- /dev/null +++ b/launcher/skins/CapesModel.cpp @@ -0,0 +1,101 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "CapesModel.h" +#include "CapeCache.h" +#include "Application.h" +#include "minecraft/auth/AccountTask.h" + +#include +#include + +CapesModel::CapesModel(QObject *parent) : QAbstractListModel(parent) +{ + auto capeCache = APPLICATION->capeCache(); + connect(capeCache.get(), &CapeCache::capeReady, this, &CapesModel::capeImageUpdated); +} + +void CapesModel::setAccount(MinecraftAccountPtr account) +{ + if(account == m_account) + { + return; + } + // beginResetModel(); + { + m_capes.clear(); + m_uuidIndex.clear(); + + m_account = account; + auto capeCache = APPLICATION->capeCache(); + m_capes.push_back(Skins::CapeEntry{"", tr("Nothing"), QImage(":/skins/textures/no_cape.png").scaled(64, 64, Qt::AspectRatioMode::KeepAspectRatio)}); + m_uuidIndex[""] = 0; + if(m_account) + { + for(auto& cape: m_account->accountData()->minecraftProfile.capes) + { + Skins::CapeEntry entry; + entry.alias = cape.alias; + entry.preview = capeCache->getCapeImage(cape.id).copy(1, 1, 10, 16).scaled(64, 64, Qt::AspectRatioMode::KeepAspectRatio); + entry.uuid = cape.id; + m_uuidIndex[cape.id] = m_capes.size(); + m_capes.push_back(entry); + } + } + } + endResetModel(); +} + +void CapesModel::capeImageUpdated(const QString& uuid) +{ + auto iter = m_uuidIndex.constFind(uuid); + if(iter == m_uuidIndex.constEnd()) + return; + + auto capeCache = APPLICATION->capeCache(); + int row = iter.value(); + auto idx = index(row); + m_capes[row].preview = capeCache->getCapeImage(uuid).copy(1, 1, 10, 16).scaled(64, 64, Qt::AspectRatioMode::KeepAspectRatio); + emit dataChanged(idx, idx, {Qt::DecorationRole}); +} + + +QVariant CapesModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + + if (row < 0 || row >= m_capes.size()) + return QVariant(); + + switch (role) + { + case Qt::DecorationRole: + return m_capes[row].preview; + case Qt::DisplayRole: + return m_capes[row].alias; + case Qt::UserRole: + return m_capes[row].uuid; + default: + return QVariant(); + } +} + +int CapesModel::rowCount(const QModelIndex& parent) const +{ + return m_capes.count(); +} + +QString CapesModel::at(int row) const +{ + if(row < 0 || row >= m_capes.size()) + { + return QString(); + } + return m_capes[row].uuid; +} diff --git a/launcher/skins/CapesModel.h b/launcher/skins/CapesModel.h new file mode 100644 index 00000000..748da295 --- /dev/null +++ b/launcher/skins/CapesModel.h @@ -0,0 +1,42 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include +#include +#include +#include + +#include "minecraft/auth/MinecraftAccount.h" + +#include "SkinTypes.h" + +class CapesModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit CapesModel(QObject *parent = 0); + virtual ~CapesModel() noexcept = default; + + virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + virtual int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + void setAccount(MinecraftAccountPtr account); + MinecraftAccountPtr account() const { + return m_account; + } + + QString at(int row) const; + +private slots: + void capeImageUpdated(const QString& uuid); + +private: + MinecraftAccountPtr m_account; + QMap m_uuidIndex; + QVector m_capes; +}; diff --git a/launcher/skins/SkinRenderer.cpp b/launcher/skins/SkinRenderer.cpp new file mode 100644 index 00000000..cad399f2 --- /dev/null +++ b/launcher/skins/SkinRenderer.cpp @@ -0,0 +1,494 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "SkinRenderer.h" +#include "SkinTypes.h" +#include "TextureMappings.h" +#include + +namespace Skins { + +struct VertexBg { + GLfloat m_position[2]; +}; + +static void RenderQuad( + RenderContext& context, + const QVector3D& p0, + const QVector3D& p1, + const QVector3D& p2, + const QVector3D& p3, + const Rectangle& mapping, + const Texture texture, + const bool transparency, + const float wPixel, + const float hPixel +) { + GLint textureId = GLint(texture); + + float left, right, bottom, top; + if(mapping.flipX) + { + right = mapping.x * wPixel; + left = (mapping.x + mapping.w) * wPixel; + } + else + { + left = mapping.x * wPixel; + right = (mapping.x + mapping.w) * wPixel; + } + if(mapping.flipY) + { + bottom = mapping.y * hPixel; + top = (mapping.y + mapping.h) * hPixel; + } + else + { + top = mapping.y * hPixel; + bottom = (mapping.y + mapping.h) * hPixel; + } + + context.m_vertexBuffer.push_back(Vertex(p0, left, bottom, textureId, transparency)); + context.m_vertexBuffer.push_back(Vertex(p1, right, bottom, textureId, transparency)); + context.m_vertexBuffer.push_back(Vertex(p2, right, top, textureId, transparency)); + context.m_vertexBuffer.push_back(Vertex(p3, left, top, textureId, transparency)); + + context.m_elementBuffer.push_back(context.m_elementStartIndex + 0); + context.m_elementBuffer.push_back(context.m_elementStartIndex + 1); + context.m_elementBuffer.push_back(context.m_elementStartIndex + 3); + + context.m_elementBuffer.push_back(context.m_elementStartIndex + 1); + context.m_elementBuffer.push_back(context.m_elementStartIndex + 2); + context.m_elementBuffer.push_back(context.m_elementStartIndex + 3); + + context.m_elementStartIndex += 4; +} + +void RenderBox(RenderContext& context, float width, float height, float depth, const TextureMapping& mapping, QMatrix4x4 transform) +{ + std::array vertices = { + transform * QVector3D(-0.5f*width, -0.5f*height, 0.5f*depth), + transform * QVector3D( 0.5f*width, -0.5f*height, 0.5f*depth), + transform * QVector3D( 0.5f*width, 0.5f*height, 0.5f*depth), + transform * QVector3D(-0.5f*width, 0.5f*height, 0.5f*depth), + + transform * QVector3D(-0.5f*width, -0.5f*height, -0.5f*depth), + transform * QVector3D( 0.5f*width, -0.5f*height, -0.5f*depth), + transform * QVector3D( 0.5f*width, 0.5f*height, -0.5f*depth), + transform * QVector3D(-0.5f*width, 0.5f*height, -0.5f*depth), + }; + + float wPixel; + float hPixel; + + if(mapping.material == Texture::Skin) + { + wPixel = 1.0 / float(context.m_skinTexture->width()); + hPixel = 1.0 / float(context.m_skinTexture->height()); + } + else + { + if(context.m_capeTexture) + { + wPixel = 1.0 / float(context.m_capeTexture->width()); + hPixel = 1.0 / float(context.m_capeTexture->height()); + } + else + { + wPixel = 0.1; + hPixel = 0.1; + } + } + + RenderQuad( + context, + vertices[0], + vertices[1], + vertices[2], + vertices[3], + mapping.front, + mapping.material, + mapping.transparent, + wPixel, + hPixel + ); + + RenderQuad( + context, + vertices[1], + vertices[5], + vertices[6], + vertices[2], + mapping.left, + mapping.material, + mapping.transparent, + wPixel, + hPixel + ); + + RenderQuad( + context, + vertices[5], + vertices[4], + vertices[7], + vertices[6], + mapping.back, + mapping.material, + mapping.transparent, + wPixel, + hPixel + ); + + RenderQuad( + context, + vertices[4], + vertices[0], + vertices[3], + vertices[7], + mapping.right, + mapping.material, + mapping.transparent, + wPixel, + hPixel + ); + + RenderQuad( + context, + vertices[4], + vertices[5], + vertices[1], + vertices[0], + mapping.bottom, + mapping.material, + mapping.transparent, + wPixel, + hPixel + ); + + RenderQuad( + context, + vertices[3], + vertices[2], + vertices[6], + vertices[7], + mapping.top, + mapping.material, + mapping.transparent, + wPixel, + hPixel + ); +} + +void RenderSkin(RenderContext& context, int version, Model model) +{ + // TODO: deadmau5 ears + QMatrix4x4 torsoPosition; + QMatrix4x4 headPosition; + QMatrix4x4 leftArmPosition; + QMatrix4x4 rightArmPosition; + QMatrix4x4 leftLegPosition; + QMatrix4x4 rightLegPosition; + torsoPosition.translate(0, 2, 0); + headPosition.translate(0, 12, 0); + leftLegPosition.translate(2, -10, 0); + rightLegPosition.translate(-2, -10, 0); + + QMatrix4x4 capeTransform; + capeTransform.translate(0, 8, -2); + capeTransform.rotate(10, 1, 0, 0); + capeTransform.translate(0, -8, -0.5); + + if(context.m_capeTexture) + { + auto capeScale = context.m_capeTexture->height() >= 34 ? 0 : 1; + RenderBox(context, 10, 16, 1, capeLayout[capeScale], capeTransform); + } + + if(model == Model::Classic) + { + leftArmPosition.translate(6, 2, 0); + rightArmPosition.translate(-6, 2, 0); + + RenderBox(context, 8, 8, 8, head, headPosition); + RenderBox(context, 8, 12, 4, torso, torsoPosition); + if(version == 1) + { + RenderBox(context, 4, 12, 4, left_leg, leftLegPosition); + RenderBox(context, 4, 12, 4, right_leg, rightLegPosition); + RenderBox(context, 4, 12, 4, left_arm_classic, leftArmPosition); + RenderBox(context, 4, 12, 4, right_arm_classic, rightArmPosition); + } + else + { + RenderBox(context, 4, 12, 4, left_leg_old, leftLegPosition); + RenderBox(context, 4, 12, 4, right_leg, rightLegPosition); + RenderBox(context, 4, 12, 4, left_arm_old_classic, leftArmPosition); + RenderBox(context, 4, 12, 4, right_arm_old_classic, rightArmPosition); + } + + RenderBox(context, 9, 9, 9, head_cover, headPosition); + if(version == 1) + { + RenderBox(context, 9, 13, 5, torso_cover, torsoPosition); + RenderBox(context, 5, 13, 5.01, left_arm_cover_classic, leftArmPosition); + RenderBox(context, 5, 13, 5.01, right_arm_cover_classic, rightArmPosition); + RenderBox(context, 5, 13, 5.01, left_leg_cover, leftLegPosition); + RenderBox(context, 5, 13, 5.01, right_leg_cover, rightLegPosition); + } + } + else + { + leftArmPosition.translate(5.5, 2, 0); + rightArmPosition.translate(-5.5, 2, 0); + + RenderBox(context, 8, 8, 8, head, headPosition); + RenderBox(context, 8, 12, 4, torso, torsoPosition); + if(version == 1) + { + RenderBox(context, 4, 12, 4, left_leg, leftLegPosition); + RenderBox(context, 4, 12, 4, right_leg, rightLegPosition); + RenderBox(context, 3, 12, 4, left_arm_slim, leftArmPosition); + RenderBox(context, 3, 12, 4, right_arm_slim, rightArmPosition); + } + else + { + RenderBox(context, 4, 12, 4, left_leg_old, leftLegPosition); + RenderBox(context, 4, 12, 4, right_leg, rightLegPosition); + RenderBox(context, 3, 12, 4, left_arm_old_slim, leftArmPosition); + RenderBox(context, 3, 12, 4, right_arm_old_slim, rightArmPosition); + } + + RenderBox(context, 9, 9, 9, head_cover, headPosition); + if(version == 1) + { + RenderBox(context, 9, 13, 5, torso_cover, torsoPosition); + RenderBox(context, 4, 13, 5.01, left_arm_cover_slim, leftArmPosition); + RenderBox(context, 4, 13, 5.01, right_arm_cover_slim, rightArmPosition); + RenderBox(context, 5, 13, 5.01, left_leg_cover, leftLegPosition); + RenderBox(context, 5, 13, 5.01, right_leg_cover, rightLegPosition); + } + } +} + + +RenderContext::RenderContext(QOpenGLFunctions& GL, QObject* parent) : QObject(parent), GL(GL) +{ + m_skinShader = new QOpenGLShaderProgram(this); + m_vao = new QOpenGLVertexArrayObject(this); + m_ebo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); + m_vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + + m_bgShader = new QOpenGLShaderProgram(this); + m_bgvao = new QOpenGLVertexArrayObject(this); + m_bgebo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); + m_bgvbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); +} + +void RenderContext::init() +{ + GL.glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + + m_uniforms.push_back(Skins::Uniform("worldToView")); + m_uniforms.push_back(Skins::Uniform("skinTexture")); + m_uniforms.push_back(Skins::Uniform("capeTexture")); + initShader(m_skinShader, m_uniforms, ":/skins/shaders/skin.vert", ":/skins/shaders/skin.frag"); + + m_bgUniforms.push_back(Skins::Uniform("baseColor")); + m_bgUniforms.push_back(Skins::Uniform("alternateColor")); + m_bgUniforms.push_back(Skins::Uniform("fDevicePixelSize")); + initShader(m_bgShader, m_bgUniforms, ":/skins/shaders/bg.vert", ":/skins/shaders/bg.frag"); + + GL.glEnable(GL_BLEND); + GL.glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + m_vao->create(); + m_vao->bind(); + { + m_vbo->create(); + m_vbo->bind(); + m_vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); + + m_ebo->create(); + m_ebo->bind(); + m_ebo->setUsagePattern(QOpenGLBuffer::StaticDraw); + + m_skinShader->setAttributeBuffer(0, GL_FLOAT, offsetof(Vertex, m_position), 3, sizeof(Vertex)); + m_skinShader->enableAttributeArray(0); + m_skinShader->setAttributeBuffer(1, GL_FLOAT, offsetof(Vertex, m_uv), 2, sizeof(Vertex)); + m_skinShader->enableAttributeArray(1); + m_skinShader->setAttributeBuffer(2, GL_INT, offsetof(Vertex, m_texture), 1, sizeof(Vertex)); + m_skinShader->enableAttributeArray(2); + m_skinShader->setAttributeBuffer(3, GL_INT, offsetof(Vertex, m_transparency), 1, sizeof(Vertex)); + m_skinShader->enableAttributeArray(3); + + // NOTE: this crashes + //m_ebo->release(); + m_vbo->release(); + } + m_vao->release(); + + m_bgvao->create(); + m_bgvao->bind(); + { + m_bgvbo->create(); + m_bgvbo->bind(); + m_bgvbo->setUsagePattern(QOpenGLBuffer::StaticDraw); + + m_bgebo->create(); + m_bgebo->bind(); + m_bgebo->setUsagePattern(QOpenGLBuffer::StaticDraw); + + m_bgShader->setAttributeBuffer(0, GL_FLOAT, 0, 2, sizeof(VertexBg)); + m_bgShader->enableAttributeArray(0); + + // NOTE: this crashes + //m_bgebo->release(); + m_bgvbo->release(); + } + m_bgvao->release(); + + std::array backQuad = { + VertexBg{-1.0, -1.0f}, + VertexBg{ 1.0, -1.0f}, + VertexBg{ 1.0, 1.0f}, + VertexBg{-1.0, 1.0f} + }; + std::array backQuadElements = { 0, 1, 3, 1, 2, 3 }; + + m_bgvbo->bind(); + m_bgvbo->allocate(backQuad.data(), backQuad.size()*sizeof(VertexBg)); + m_bgvbo->release(); + + m_bgebo->bind(); + m_bgebo->allocate(backQuadElements.data(), backQuadElements.size()*sizeof(GLuint)); + m_bgebo->release(); +} + +void RenderContext::deinit() +{ + // TODO: do something here. +} + +bool RenderContext::initShader(QOpenGLShaderProgram* program, QList& uniforms, const QString& vertexShaderPath, const QString& fragmentShaderPath) +{ + // read the shader programs from the resource + if (!program->addShaderFromSourceFile(QOpenGLShader::Vertex, vertexShaderPath)) + { + qCritical() << "Error compiling vertex shader " << vertexShaderPath << ": " << program->log(); + return false; + } + + if (!program->addShaderFromSourceFile(QOpenGLShader::Fragment, fragmentShaderPath)) + { + qCritical() << "Error compiling fragment shader " << fragmentShaderPath << ": " << program->log(); + return false; + } + + if (!program->link()) + { + qCritical() << "Shader linker error: " << program->log(); + return false; + } + + for (Uniform& uniform : uniforms) { + uniform.id = program->uniformLocation(uniform.name); + if (uniform.id == -1) + { + qCritical() << "Error retrieving uniform ID for uniform " << uniform.name; + return false; + } + } + return true; +} + +void Skins::RenderContext::setTextures(const QImage& skin, const QImage& cape) +{ + m_skinShader->bind(); + if(m_skinTexture) + { + m_skinTexture->destroy(); + delete m_skinTexture; + m_skinTexture = nullptr; + } + if(m_capeTexture) + { + m_capeTexture->destroy(); + delete m_capeTexture; + m_capeTexture = nullptr; + } + + m_skinTexture = new QOpenGLTexture(skin); + m_skinTexture->setMinificationFilter(QOpenGLTexture::NearestMipMapNearest); + m_skinTexture->setMagnificationFilter(QOpenGLTexture::Nearest); + m_skinShader->setUniformValue(m_uniforms[1].id, 0); + + if(!cape.isNull()) + { + m_capeTexture = new QOpenGLTexture(cape); + m_capeTexture->setMinificationFilter(QOpenGLTexture::NearestMipMapNearest); + m_capeTexture->setMagnificationFilter(QOpenGLTexture::Nearest); + m_skinShader->setUniformValue(m_uniforms[2].id, 1); + } + m_skinShader->release(); +} + +void RenderContext::regenerateGeometry(int skinVersion, Model skinModel) +{ + m_vertexBuffer.clear(); + m_elementBuffer.clear(); + m_elementStartIndex = 0; + + RenderSkin(*this, skinVersion, skinModel); + + m_vbo->bind(); + m_vbo->allocate(m_vertexBuffer.data(), m_vertexBuffer.size()*sizeof(Vertex)); + m_vbo->release(); + + m_ebo->bind(); + m_ebo->allocate(m_elementBuffer.data(), m_elementBuffer.size()*sizeof(GLuint)); + m_ebo->release(); +} + +void Skins::RenderContext::render(QMatrix4x4 viewMatrix, QVector3D baseColor, QVector3D alternateColor, float fDevicePixelSize) +{ + GL.glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + // Checkerboard background + GL.glDisable(GL_DEPTH_TEST); + GL.glDepthMask(GL_FALSE); + m_bgShader->bind(); + m_bgShader->setUniformValue(m_bgUniforms[0].id, baseColor); + m_bgShader->setUniformValue(m_bgUniforms[1].id, alternateColor); + m_bgShader->setUniformValue(m_bgUniforms[2].id, fDevicePixelSize); + m_bgvao->bind(); + GL.glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr); + m_bgvao->release(); + m_bgShader->release(); + + /** TODO: + * - render pure black version of the model (all color except alpha converted to black) to texture + * - render this texture over the background with a blur effect + * - render the textured model over that + */ + + // Textured model + GL.glDepthMask(GL_TRUE); + GL.glEnable(GL_DEPTH_TEST); + m_skinShader->bind(); + m_skinShader->setUniformValue(m_uniforms[0].id, viewMatrix); + m_skinTexture->bind(0); + if(m_capeTexture) + { + m_capeTexture->bind(1); + } + m_vao->bind(); + GL.glDrawElements(GL_TRIANGLES, m_elementBuffer.size(), GL_UNSIGNED_INT, nullptr); + m_vao->release(); + m_skinShader->release(); + GL.glFinish(); +} + +} diff --git a/launcher/skins/SkinRenderer.h b/launcher/skins/SkinRenderer.h new file mode 100644 index 00000000..ab72647e --- /dev/null +++ b/launcher/skins/SkinRenderer.h @@ -0,0 +1,92 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include "SkinTypes.h" + +namespace Skins { + +#pragma pack(push, 0) + +struct Vertex { + Vertex(const QVector3D & position, float u, float v, GLuint texture, bool transparency) + { + m_position[0] = position.x(); + m_position[1] = position.y(); + m_position[2] = position.z(); + m_uv[0] = u; + m_uv[1] = v; + m_texture = texture; + m_transparency = transparency; + } + + GLfloat m_position[3]; + GLfloat m_uv[2]; + GLint m_texture; // 0 = skin, 1 = cape + GLint m_transparency; // 0 = opaque, 1 = transparent +}; +#pragma pack(pop) + +class Uniform +{ +public: + Uniform(QString name): name(name) {} + QString name; + int id = -1; +}; + +class RenderContext : public QObject +{ + Q_OBJECT +public: + explicit RenderContext(QOpenGLFunctions& GL, QObject* parent); + void init(); + void deinit(); + + void setTextures(const QImage& skin, const QImage& cape); + + void regenerateGeometry(int skinVersion, Model skinModel); + void render(QMatrix4x4 viewMatrix, QVector3D baseColor, QVector3D alternateColor, float devicePixelRatio); + + QOpenGLTexture* m_skinTexture = nullptr; + QOpenGLTexture* m_capeTexture = nullptr; + + std::vector m_vertexBuffer; + std::vector m_elementBuffer; + unsigned int m_elementStartIndex; + + QOpenGLVertexArrayObject *m_vao = nullptr; + QOpenGLBuffer* m_vbo = nullptr; + QOpenGLBuffer* m_ebo = nullptr; + + QOpenGLVertexArrayObject *m_bgvao = nullptr; + QOpenGLBuffer* m_bgvbo = nullptr; + QOpenGLBuffer* m_bgebo = nullptr; + + QOpenGLShaderProgram* m_skinShader = nullptr; + QList m_uniforms; + + QOpenGLShaderProgram* m_bgShader = nullptr; + QList m_bgUniforms; + + QOpenGLFunctions& GL; +private: + bool initShader(QOpenGLShaderProgram* program, QList& uniforms, const QString& vertexShaderPath, const QString& fragmentShaderPath); +}; + +} diff --git a/launcher/skins/SkinTypes.cpp b/launcher/skins/SkinTypes.cpp new file mode 100644 index 00000000..52305fb8 --- /dev/null +++ b/launcher/skins/SkinTypes.cpp @@ -0,0 +1,287 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ +#include "SkinTypes.h" +#include "SkinUtils.h" +#include "TextureMappings.h" + +#include +#include + +namespace{ +const QImage nullImage; +} + +namespace Skins { +SkinEntry::SkinEntry(const QString& name, const QString& path, const QImage& image, const QString& textureID, const QByteArray data) : name(name), filename(path) +{ + internal = false; + if (qAlpha(image.pixel(54, 20)) == 0) + { + // slim-only textures will have this pixel fully transparent + slimVariant = SkinData{data, image, textureID}; + } + else + { + classicVariant = SkinData{data, image, textureID}; + } +} + +SkinEntry::SkinEntry(const QString& name, const QString& pathSlim, const QString& pathClassic): name(name) +{ + internal = true; + { + QFile file(pathSlim); + file.open(QIODevice::ReadOnly); + auto data = file.readAll(); + file.close(); + QImage image; + QString textureID; + readSkinFromData(data, image, textureID); + slimVariant = SkinData(data, image, textureID); + } + { + QFile file(pathClassic); + file.open(QIODevice::ReadOnly); + auto data = file.readAll(); + file.close(); + QImage image; + QString textureID; + readSkinFromData(data, image, textureID); + classicVariant = SkinData(data, image, textureID); + } +} + +const QImage& SkinEntry::getTextureFor(Model model) const +{ + switch(model) + { + case Model::Slim: + { + if(slimVariant) + { + return slimVariant->texture; + } + } + break; + case Model::Classic: + { + if(classicVariant) + { + return classicVariant->texture; + } + } + break; + } + return nullImage; +} + +QString Skins::SkinEntry::getTextureIDFor(Model model) const +{ + switch(model) + { + case Model::Slim: + { + if(slimVariant) + { + return slimVariant->textureID; + } + } + break; + case Model::Classic: + { + if(classicVariant) + { + return classicVariant->textureID; + } + } + break; + } + return QString(); +} + +QByteArray Skins::SkinEntry::getTextureDataFor(Model model) const +{ + switch(model) + { + case Model::Slim: + { + if(slimVariant) + { + return slimVariant->data; + } + } + break; + case Model::Classic: + { + if(classicVariant) + { + return classicVariant->data; + } + } + break; + } + return QByteArray(); +} + +const QImage & SkinData::getListTexture(Model model) const +{ + if(preview.isNull()) + { + QImage temp(36, 36, QImage::Format::Format_ARGB32); + temp.fill(Qt::transparent); + QPainter painter(&temp); + int version = texture.height() == 64 ? 1: 0; + + auto paintFront = [&](int x, int y, const Skins::TextureMapping& part) + { + auto partTexture = texture.copy(part.front.x, part.front.y, part.front.w, part.front.h); + painter.drawImage(x, y, partTexture.mirrored(part.front.flipX, part.front.flipY)); + }; + auto paintBack = [&](int x, int y, const Skins::TextureMapping& part) + { + auto partTexture = texture.copy(part.back.x, part.back.y, part.back.w, part.back.h); + painter.drawImage(x, y, partTexture.mirrored(part.back.flipX, part.back.flipY)); + }; + paintFront(4, 2, head); + paintFront(4, 2, head_cover); + paintFront(4, 10, torso); + paintFront(4, 22, right_leg); + + if(version == 0) + { + if(model == Model::Classic) + { + paintFront(12, 10, left_arm_old_classic); + paintFront(0, 10, right_arm_old_classic); + } + else + { + paintFront(12, 10, left_arm_old_slim); + paintFront(1, 10, right_arm_old_slim); + } + + paintFront(8, 22, left_leg_old); + } + else if(version == 1) + { + paintFront(4, 10, torso_cover); + + if(model == Model::Classic) + { + paintFront(12, 10, left_arm_classic); + paintFront(12, 10, left_arm_cover_classic); + + paintFront(0, 10, right_arm_classic); + paintFront(0, 10, right_arm_cover_classic); + } + else + { + paintFront(12, 10, left_arm_slim); + paintFront(12, 10, left_arm_cover_slim); + + paintFront(1, 10, right_arm_slim); + paintFront(1, 10, right_arm_cover_slim); + } + + paintFront(8, 22, left_leg); + paintFront(8, 22, left_leg_cover); + } + + paintBack(24, 2, head); + paintBack(24, 2, head_cover); + paintBack(24, 10, torso); + paintBack(28, 22, right_leg); + + if(version == 0) + { + if(model == Model::Classic) + { + paintBack(20, 10, left_arm_old_classic); + paintBack(32, 10, right_arm_old_classic); + } + else + { + paintBack(21, 10, left_arm_old_slim); + paintBack(32, 10, right_arm_old_slim); + } + + paintBack(24, 22, left_leg_old); + } + else if(version == 1) + { + paintBack(24, 10, torso_cover); + + if(model == Model::Classic) + { + paintBack(20, 10, left_arm_classic); + paintBack(20, 10, left_arm_cover_classic); + + paintBack(32, 10, right_arm_classic); + paintBack(32, 10, right_arm_cover_classic); + } + else + { + paintBack(21, 10, left_arm_slim); + paintBack(21, 10, left_arm_cover_slim); + + paintBack(32, 10, right_arm_slim); + paintBack(32, 10, right_arm_cover_slim); + } + + paintBack(24, 22, left_leg); + paintBack(24, 22, left_leg_cover); + } + preview = temp.scaled(72, 72); + } + return preview; +} + +const QImage & SkinEntry::getListTexture() const +{ + if(slimVariant) + { + return slimVariant->getListTexture(Model::Slim); + } + else if(classicVariant) + { + return classicVariant->getListTexture(Model::Classic); + } + return nullImage; +} + +bool SkinEntry::hasModel(Model model) const +{ + switch(model) + { + case Model::Slim: + { + return !!slimVariant; + } + break; + case Model::Classic: + { + return !!classicVariant; + } + break; + } + return false; +} + +nonstd::optional SkinEntry::matchesId(const QString& textureID) const +{ + if(slimVariant && slimVariant->textureID == textureID) + { + return Model::Slim; + } + if(classicVariant && classicVariant->textureID == textureID) + { + return Model::Classic; + } + return nonstd::nullopt; +} + +} + diff --git a/launcher/skins/SkinTypes.h b/launcher/skins/SkinTypes.h new file mode 100644 index 00000000..7b125f50 --- /dev/null +++ b/launcher/skins/SkinTypes.h @@ -0,0 +1,100 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ +#pragma once + +#include +#include +#include +#include + +namespace Skins { + +enum class Model +{ + Classic, + Slim, +}; + +class SkinData +{ +public: + SkinData() = default; + SkinData(const QByteArray& data, const QImage& texture, const QString& textureID): data(data), texture(texture), textureID(textureID) {} + + QByteArray data; + QImage texture; + QString textureID; + const QImage& getListTexture(Model model) const; +private: + mutable QImage preview; +}; + +struct SkinEntry +{ + // From a free-standing texture + SkinEntry(const QString& name, const QString& path, const QImage& image, const QString& textureID, const QByteArray data); + // From internal resources + SkinEntry(const QString& name, const QString& pathSlim, const QString& pathClassic); + SkinEntry() {}; + + nonstd::optional matchesId(const QString& textureID) const; + bool isNull() const + { + return !slimVariant && !classicVariant; + } + + const QImage& getListTexture() const; + const QImage& getTextureFor(Model model) const; + QString getTextureIDFor(Model model) const; + QByteArray getTextureDataFor(Model model) const; + bool hasModel(Model model) const; + + bool internal = false; + QString name; + QString filename; + nonstd::optional slimVariant; + nonstd::optional classicVariant; +}; + +struct CapeEntry +{ + QString uuid; + QString alias; + QImage preview; +}; + +class RenderContext; + +enum class Texture: int +{ + Skin = 0, + Cape = 1, +}; + +struct Rectangle { + Rectangle(float x, float y, float w, float h, bool flipY = false, bool flipX = false) : x(x), y(y), w(w), h(h), flipY(flipY), flipX(flipX) {}; + + float x; + float y; + float w; + float h; + bool flipY = false; + bool flipX = false; +}; + +struct TextureMapping { + // texture mappings + Rectangle left; + Rectangle right; + Rectangle top; + Rectangle bottom; + Rectangle front; + Rectangle back; + Texture material; + bool transparent; +}; + +} diff --git a/launcher/skins/SkinUtils.cpp b/launcher/skins/SkinUtils.cpp new file mode 100644 index 00000000..305d792a --- /dev/null +++ b/launcher/skins/SkinUtils.cpp @@ -0,0 +1,102 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "SkinUtils.h" +#include +#include +#include + +namespace Skins { + +// We will refuse to load 'skins' larger than what a raw bitmap of a skin would take up to prevent abuse of the system +constexpr qint64 maxFileSize = 64 * 64 * 4; + +QString hashSkin(const QImage& image) +{ + QCryptographicHash checksum(QCryptographicHash::Algorithm::Sha256); + char nothing[5] = "\0\0\0\0"; + for(int x = 0; x < image.width(); x++) + { + for(int y = 0; y < image.height(); y++) + { + QRgb pixel = image.pixel(x, y); + char color[4]; + color[0] = qRed(pixel); + color[1] = qGreen(pixel); + color[2] = qBlue(pixel); + color[3] = qAlpha(pixel); + if(color[3] == 0) + { + checksum.addData(nothing, 4); + } + else + { + checksum.addData(color, 4); + } + } + } + return checksum.result().toHex(); +} +bool readSkinFromFile(const QString& path, QByteArray& dataOut, QImage& imageOut, QString& keyOut, QString& textureIDOut) +{ + QFileInfo info(path); + keyOut = info.baseName(); + if(!info.isFile()) + { + return false; + } + if(info.suffix().toLower() != "png") + { + return false; + } + if(info.size() >= maxFileSize) + { + return false; + } + try + { + dataOut = FS::read(path); + } + catch (const Exception& e) + { + qWarning() << "Failed to read skin file:" << path << "Error:" << e.cause(); + return false; + } + return readSkinFromData(dataOut, imageOut, textureIDOut); +} + +bool readSkinFromData(const QByteArray& data, QImage& imageOut, QString& textureIDOut) +{ + if(data.size() >= maxFileSize) + { + return false; + } + QImage img = QImage::fromData(data, "PNG"); + if(img.width() != 64) + { + return false; + } + int height = img.height(); + if(height != 32 && height != 64) + { + return false; + } + + imageOut = img; + if(img.hasAlphaChannel()) + { + textureIDOut = hashSkin(imageOut); + return true; + } + // No alpha channel -> take top left pixel and replace all matching pixels with transparency + auto alphaChannel = imageOut.createMaskFromColor(imageOut.pixel(0,0), Qt::MaskMode::MaskOutColor); + imageOut.setAlphaChannel(alphaChannel); + + textureIDOut = hashSkin(imageOut); + return true; +} + +} diff --git a/launcher/skins/SkinUtils.h b/launcher/skins/SkinUtils.h new file mode 100644 index 00000000..6ff48496 --- /dev/null +++ b/launcher/skins/SkinUtils.h @@ -0,0 +1,18 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include +#include +#include + +namespace Skins { +bool readSkinFromFile(const QString& path, QByteArray& dataOut, QImage& imageOut, QString& keyOut, QString& textureIDOut); +bool readSkinFromData(const QByteArray& data, QImage& imageOut, QString& textureIDOut); + +QString hashSkin(const QImage& image); +} diff --git a/launcher/skins/SkinWidget.cpp b/launcher/skins/SkinWidget.cpp new file mode 100644 index 00000000..401af9b7 --- /dev/null +++ b/launcher/skins/SkinWidget.cpp @@ -0,0 +1,254 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "SkinWidget.h" +#include "SkinRenderer.h" + +#include + +#include +#include +#include + +#include + +SkinWidget::SkinWidget(QWidget* parent): QFrame(parent) +{ + // TODO: if OpenGL init fails in the Window, this should provide a fallback with no 3D rendering + m_window = new SkinWindow(); + m_window->setBackgroundColor(palette().color(QPalette::Normal, QPalette::Base)); + auto mainWidget = QWidget::createWindowContainer(m_window, this); + + QVBoxLayout *mainLayout = new QVBoxLayout; + mainLayout->addWidget(mainWidget); + mainLayout->setSpacing(0); + mainLayout->setContentsMargins(0, 0, 0, 0); + setLayout(mainLayout); +} + +bool SkinWidget::event(QEvent* event) +{ + if(event->type() == QEvent::Type::PaletteChange) + { + m_window->setBackgroundColor(palette().color(QPalette::Normal, QPalette::Base)); + } + return QFrame::event(event); +} + + +void SkinWidget::setAll(Skins::Model model, const QImage& skinImage, const QImage& capeImage) +{ + m_window->setAll(model, skinImage, capeImage); +} + +void SkinWidget::setCapeImage(const QImage& capeImage) +{ + m_window->setCapeImage(capeImage); +} + +void SkinWidget::setModel(Skins::Model model) +{ + m_window->setModel(model); +} + +void SkinWidget::setSkinImage(const QImage& skinImage) +{ + m_window->setSkinImage(skinImage); +} +void SkinWidget::setBackgroundColor(QColor color) +{ + m_window->setBackgroundColor(color); +} + + +SkinWindow::SkinWindow(): QOpenGLWindow() +{ + QSurfaceFormat format = QSurfaceFormat::defaultFormat(); + format.setMajorVersion(3); + format.setMinorVersion(2); + format.setProfile(QSurfaceFormat::CoreProfile); + format.setDepthBufferSize(24); + setFormat(format); + + m_context = new Skins::RenderContext(GL, this); + m_spinAngle = 0.0f; + m_liftAngle = 0.0f; + m_distance = 48.0f; + + setAll(Skins::Model::Classic, QImage(":/skins/textures/placeholder_skin.png"), QImage(":/skins/textures/placeholder_cape.png")); +} + +SkinWindow::~SkinWindow() noexcept +{ + m_context->deinit(); +} + + +void SkinWindow::initializeGL() +{ + GL.initializeOpenGLFunctions(); + m_context->init(); +} + +void SkinWindow::resizeGL(int w, int h) +{ + const qreal retinaScale = devicePixelRatioF(); + GL.glViewport(0, 0, w * retinaScale, h * retinaScale); + m_height = h; + m_width = w; + + m_projection.setToIdentity(); + float aspect = float(w) / float(h); + m_projection.perspective(45.0f, aspect, 0.1f, 100.0f); + updateMatrix(); +} + +void SkinWindow::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + m_lastPos = event->pos(); + } +} + +void SkinWindow::mouseMoveEvent(QMouseEvent *event) +{ + auto position = event->pos(); + if (event->buttons() & Qt::LeftButton) { + int dx = position.x() - m_lastPos.x(); + int dy = position.y() - m_lastPos.y(); + + m_spinAngle -= dx * 0.5 * 0.01; + m_liftAngle += dy * 0.5 * 0.01; + m_lastPos = event->pos(); + updateMatrix(); + update(); + } +} + +void SkinWindow::wheelEvent(QWheelEvent *event) +{ + float numDegrees = float(event->angleDelta().y()) / 8.0; + float numSteps = numDegrees / 15.0; + m_distance -= numSteps; + updateMatrix(); + update(); +} + + +// OK LAB color space conversions for decent determination of lighter/darker alternate color +// NOTE: code adapted from https://bottosson.github.io/posts/oklab/ +namespace { +QVector3D linear_srgb_to_oklab(QVector3D c) +{ + float l = 0.4122214708f * c.x() + 0.5363325363f * c.y() + 0.0514459929f * c.z(); + float m = 0.2119034982f * c.x() + 0.6806995451f * c.y() + 0.1073969566f * c.z(); + float s = 0.0883024619f * c.x() + 0.2817188376f * c.y() + 0.6299787005f * c.z(); + + float l_ = cbrtf(l); + float m_ = cbrtf(m); + float s_ = cbrtf(s); + + return { + 0.2104542553f*l_ + 0.7936177850f*m_ - 0.0040720468f*s_, + 1.9779984951f*l_ - 2.4285922050f*m_ + 0.4505937099f*s_, + 0.0259040371f*l_ + 0.7827717662f*m_ - 0.8086757660f*s_, + }; +} + +QVector3D oklab_to_linear_srgb(QVector3D c) +{ + float l_ = c.x() + 0.3963377774f * c.y() + 0.2158037573f * c.z(); + float m_ = c.x() - 0.1055613458f * c.y() - 0.0638541728f * c.z(); + float s_ = c.x() - 0.0894841775f * c.y() - 1.2914855480f * c.z(); + + float l = l_*l_*l_; + float m = m_*m_*m_; + float s = s_*s_*s_; + + return { + +4.0767416621f * l - 3.3077115913f * m + 0.2309699292f * s, + -1.2684380046f * l + 2.6097574011f * m - 0.3413193965f * s, + -0.0041960863f * l - 0.7034186147f * m + 1.7076147010f * s, + }; +} + +QVector3D getContrastColor(QVector3D color) { + constexpr float contrast = 0.035; + auto lab = linear_srgb_to_oklab(color); + if(lab.x() < contrast) + { + lab.setX(lab.x() + contrast); + } + else + { + lab.setX(lab.x() - contrast); + } + return oklab_to_linear_srgb(lab); +} +} + +void SkinWindow::paintGL() +{ + if(m_skinDirty) + { + auto version = m_skinImage.height() >= 64 ? 1 : 0; + m_context->setTextures(m_skinImage, m_capeImage); + m_context->regenerateGeometry(version, m_model); + m_skinDirty = false; + } + + QVector3D alternateColor = getContrastColor(m_backgroundColor); + m_context->render(m_worldToView, m_backgroundColor, alternateColor, devicePixelRatioF()); +} + +void SkinWindow::updateMatrix() { + QMatrix4x4 camera; + QVector3D cameraPosition( + m_distance * sin(m_spinAngle) * cos(m_liftAngle), + m_distance * sin(m_liftAngle), + m_distance * cos(m_spinAngle) * cos(m_liftAngle) + ); + QVector3D targetPosition(0.0f, 0.0f, 0.0f); + QVector3D upVector(0.0f, 1.0f, 0.0f); + camera.lookAt(cameraPosition, targetPosition, upVector); + m_worldToView = m_projection * camera; +} + +void SkinWindow::setAll(Skins::Model model, const QImage& skinImage, const QImage& capeImage) +{ + m_model = model; + m_skinImage = skinImage; + m_capeImage = capeImage; + m_skinDirty = true; + update(); +} + +void SkinWindow::setCapeImage(const QImage& capeImage) +{ + m_capeImage = capeImage; + m_skinDirty = true; + update(); +} + +void SkinWindow::setSkinImage(const QImage& skinImage) +{ + m_skinImage = skinImage; + m_skinDirty = true; + update(); +} + +void SkinWindow::setModel(Skins::Model model) +{ + m_model = model; + m_skinDirty = true; + update(); +} + +void SkinWindow::setBackgroundColor(QColor color) +{ + m_backgroundColor = QVector3D(color.redF(), color.greenF(), color.blueF()); + update(); +} diff --git a/launcher/skins/SkinWidget.h b/launcher/skins/SkinWidget.h new file mode 100644 index 00000000..4773d075 --- /dev/null +++ b/launcher/skins/SkinWidget.h @@ -0,0 +1,82 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "SkinTypes.h" + +class SkinWindow: public QOpenGLWindow +{ +public: + SkinWindow(); + virtual ~SkinWindow() noexcept; + + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + + void setAll(Skins::Model model, const QImage& skinImage, const QImage& capeImage); + void setCapeImage(const QImage& capeImage); + void setSkinImage(const QImage& skinImage); + void setModel(Skins::Model model); + void setBackgroundColor(QColor color); + +protected: + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + +private: + void updateMatrix(); + +private: + QMatrix4x4 m_projection; + + QPoint m_lastPos; + double m_spinAngle; + double m_liftAngle; + double m_distance; + + QMatrix4x4 m_worldToView; + + QImage m_skinImage; + QImage m_capeImage; + Skins::Model m_model; + QVector3D m_backgroundColor; + bool m_skinDirty = false; + int m_width = 0; + int m_height = 0; + + Skins::RenderContext *m_context = nullptr; + QOpenGLFunctions GL; +}; + +class SkinWidget : public QFrame +{ + Q_OBJECT +public: + SkinWidget(QWidget *parent = nullptr); + virtual ~SkinWidget() = default; + + void setAll(Skins::Model model, const QImage& skinImage, const QImage& capeImage); + void setCapeImage(const QImage& capeImage); + void setSkinImage(const QImage& skinImage); + void setModel(Skins::Model model); + void setBackgroundColor(QColor color); + + bool event(QEvent * event) override; +private: + SkinWindow* m_window = nullptr; +}; diff --git a/launcher/skins/SkinsModel.cpp b/launcher/skins/SkinsModel.cpp new file mode 100644 index 00000000..91a48797 --- /dev/null +++ b/launcher/skins/SkinsModel.cpp @@ -0,0 +1,479 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "SkinsModel.h" + +#include "SkinUtils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace{ + Skins::SkinEntry placeholderEntry = Skins::SkinEntry(); +} + +SkinsModel::SkinsModel(QString path, QObject *parent) : QAbstractListModel(parent) +{ + m_watcher.reset(new QFileSystemWatcher()); + m_isWatching = false; + connect(m_watcher.get(), SIGNAL(directoryChanged(QString)), SLOT(directoryChanged(QString))); + connect(m_watcher.get(), SIGNAL(fileChanged(QString)), SLOT(fileChanged(QString))); + m_removalTimer.setSingleShot(true); + m_removalTimer.setInterval(100); + connect(&m_removalTimer, &QTimer::timeout, this, &SkinsModel::removalTimerTriggered); + + addDefaultSkin("Steve", ":/skins/textures/Steve_Slim.png", ":/skins/textures/Steve_Classic.png"); + addDefaultSkin("Alex", ":/skins/textures/Alex_Slim.png", ":/skins/textures/Alex_Classic.png"); + addDefaultSkin("Ari", ":/skins/textures/Ari_Slim.png", ":/skins/textures/Ari_Classic.png"); + addDefaultSkin("Efe", ":/skins/textures/Efe_Slim.png", ":/skins/textures/Efe_Classic.png"); + addDefaultSkin("Kai", ":/skins/textures/Kai_Slim.png", ":/skins/textures/Kai_Classic.png"); + addDefaultSkin("Makena", ":/skins/textures/Makena_Slim.png", ":/skins/textures/Makena_Classic.png"); + addDefaultSkin("Noor", ":/skins/textures/Noor_Slim.png", ":/skins/textures/Noor_Classic.png"); + addDefaultSkin("Sunny", ":/skins/textures/Sunny_Slim.png", ":/skins/textures/Sunny_Classic.png"); + addDefaultSkin("Zuri", ":/skins/textures/Zuri_Slim.png", ":/skins/textures/Zuri_Classic.png"); + + directoryChanged(path); +} + +QString SkinsModel::path() const +{ + return m_dir.path(); +} + +void SkinsModel::addDefaultSkin(const QString& name, const QString& slimPath, const QString& classicPath) +{ + int index = m_skins.size(); + m_skins.push_back(Skins::SkinEntry(name, slimPath, classicPath)); + m_reservedNames.insert(name); + m_nameIndex[name] = index; +} + + +const Skins::SkinEntry & SkinsModel::at(int row) const +{ + if(row < 0 || row >= m_skins.size()) + { + return placeholderEntry; + } + return m_skins[row]; +} + +void SkinsModel::directoryChanged(const QString &path) +{ + QDir new_dir (path); + if(m_dir.absolutePath() != new_dir.absolutePath()) + { + m_dir.setPath(path); + m_dir.refresh(); + if(m_isWatching) + stopWatching(); + startWatching(); + } + if(!m_dir.exists()) + { + if(!FS::ensureFolderPathExists(m_dir.absolutePath())) + { + return; + } + } + m_dir.refresh(); + + auto new_list = m_dir.entryList(QDir::Files, QDir::Name); + QSet new_set; + for (auto it = new_list.begin(); it != new_list.end(); it++) + { + QString &foo = (*it); + foo = m_dir.filePath(foo); + QFileInfo fooInfo(foo); + // Do not recognize files that match name with internal skins + if(m_reservedNames.contains(fooInfo.baseName())) + continue; + new_set.insert(foo); + } + + QList current_list; + for (auto &it : m_skins) + { + // Do not take internal skins into account + if(it.internal) + { + continue; + } + current_list.push_back(it.filename); + } + QSet current_set = current_list.toSet(); + + QSet to_remove = current_set; + to_remove -= new_set; + + QSet to_add = new_set; + to_add -= current_set; + + bool removed = false; + for (auto remove : to_remove) + { + m_watcher->removePath(remove); + QFileInfo rmfile(remove); + QString key = rmfile.baseName(); + int idx = getSkinIndex(key); + if (idx == -1) + continue; + beginRemoveRows(QModelIndex(), idx, idx); + m_skins.remove(idx); + endRemoveRows(); + removed = true; + } + if(removed) + { + reindex(); + } + + bool added = false; + for (auto add : to_add) + { + m_watcher->addPath(add); + QByteArray data; + QImage image; + QString key; + QString textureId; + if(Skins::readSkinFromFile(add, data, image, key, textureId)) + { + auto idx = m_skins.size(); + beginInsertRows(QModelIndex(), idx, idx); + m_skins.push_back(Skins::SkinEntry(key, add, image, textureId, data)); + m_nameIndex[key] = idx; + endInsertRows(); + added = true; + } + } + if(removed || added) + { + emit listUpdated(); + } +} + +void SkinsModel::fileChanged(const QString &path) +{ + QByteArray data; + QImage image; + QString key; + QString textureId; + if(Skins::readSkinFromFile(path, data, image, key, textureId)) + { + cancelRemoval(key); + // new file is valid + int row = getSkinIndex(key); + if(row != -1) + { + auto rowIndex = index(row); + m_skins[row] = Skins::SkinEntry(key, path, image, textureId, data); + emit dataChanged(rowIndex, rowIndex); + emit skinUpdated(key); + emit listUpdated(); + } + else + { + row = m_skins.size(); + beginInsertRows(QModelIndex(), row, row); + m_skins.push_back(Skins::SkinEntry(key, path, image, textureId, data)); + m_nameIndex[key] = row; + endInsertRows(); + emit listUpdated(); + } + } + else + { + // new file is not valid + int row = getSkinIndex(key); + if(row != -1) + { + // file became invalid. We remove it. + scheduleRemoval(key); + } + else + { + // it was invalid, it is still invalid -> do nothing. + } + } +} + +void SkinsModel::scheduleRemoval(const QString& key) +{ + m_toRemove.insert(key); + m_removalTimer.start(); +} + +bool SkinsModel::cancelRemoval(const QString& key) +{ + bool removed = m_toRemove.remove(key); + if(m_toRemove.isEmpty()) + { + m_removalTimer.stop(); + } + return removed; +} + +void SkinsModel::removalTimerTriggered() +{ + bool removed = false; + for(auto& name: m_toRemove) + { + int row = getSkinIndex(name); + if(row != -1) + { + removed = true; + beginRemoveRows(QModelIndex(), row, row); + m_skins.remove(row); + endRemoveRows(); + emit listUpdated(); + } + } + if(removed) + { + reindex(); + } +} + + +void SkinsModel::SettingChanged(const Setting &setting, QVariant value) +{ + if(setting.id() != "SkinsDir") + return; + + directoryChanged(value.toString()); +} + +void SkinsModel::startWatching() +{ + auto abs_path = m_dir.absolutePath(); + FS::ensureFolderPathExists(abs_path); + m_isWatching = m_watcher->addPath(abs_path); + if (m_isWatching) + { + qDebug() << "Started watching " << abs_path; + } + else + { + qDebug() << "Failed to start watching " << abs_path; + } +} + +void SkinsModel::stopWatching() +{ + m_watcher->removePaths(m_watcher->files()); + m_watcher->removePaths(m_watcher->directories()); + m_isWatching = false; +} + +QStringList SkinsModel::mimeTypes() const +{ + QStringList types; + types << "text/uri-list"; + return types; +} +Qt::DropActions SkinsModel::supportedDropActions() const +{ + return Qt::CopyAction; +} + +bool SkinsModel::dropMimeData(const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &parent) +{ + if (action == Qt::IgnoreAction) + return true; + + if (!data || !(action & supportedDropActions())) + return false; + + if (data->hasUrls()) + { + auto urls = data->urls(); + QStringList skinFiles; + for (auto url : urls) + { + if (!url.isLocalFile()) + continue; + skinFiles += url.toLocalFile(); + // TODO: add drag and drop from websites + } + installSkins(skinFiles); + return true; + } + return false; +} + +Qt::ItemFlags SkinsModel::flags(const QModelIndex &index) const +{ + Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index); + if (index.isValid()) + return Qt::ItemIsDropEnabled | defaultFlags; + else + return Qt::ItemIsDropEnabled | defaultFlags; +} + +QVariant SkinsModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + + if (row < 0 || row >= m_skins.size()) + return QVariant(); + + switch (role) + { + case Qt::DecorationRole: + return m_skins[row].getListTexture(); + case Qt::DisplayRole: + return m_skins[row].name; + default: + return QVariant(); + } +} + +int SkinsModel::rowCount(const QModelIndex &parent) const +{ + return m_skins.size(); +} + +void SkinsModel::installSkins(const QStringList &iconFiles) +{ + for (QString file : iconFiles) + { + QFileInfo fileinfo(file); + if (!fileinfo.isReadable() || !fileinfo.isFile()) + continue; + + QString suffix = fileinfo.suffix().toLower(); + if (suffix != "png") + continue; + + QString target = FS::PathCombine(m_dir.dirName(), fileinfo.fileName()); + if (!QFile::copy(file, target)) + continue; + } +} + +bool SkinsModel::installSkin(const QString &file) +{ + QFileInfo fileinfo(file); + if(!fileinfo.isReadable() || !fileinfo.isFile()) + { + return false; + } + QString target = FS::PathCombine(m_dir.dirName(), fileinfo.baseName()); + + return QFile::copy(file, target); +} + +QModelIndex SkinsModel::installSkin(const QByteArray& data, const QString& playerName) +{ + int num = 0; + QString path; + QString key; + bool found = false; + while(!found) + { + if(num == 0) + { + key = playerName; + } + else + { + key = playerName + "_" + QString::number(num); + } + + if (num >= 100) + return QModelIndex(); + num++; + path = FS::PathCombine(m_dir.path(), key + ".png"); + found = !QFileInfo(path).exists(); + }; + QImage image; + QString textureId; + if(Skins::readSkinFromData(data, image, textureId)) + { + QSaveFile out(path); + out.open(QIODevice::WriteOnly); + out.write(data); + if(out.commit()) + { + int row = m_skins.size(); + beginInsertRows(QModelIndex(), row, row); + m_skins.push_back(Skins::SkinEntry(key, path, image, textureId, data)); + m_nameIndex[key] = row; + endInsertRows(); + emit listUpdated(); + return index(row); + } + } + return QModelIndex(); +} + + +bool SkinsModel::skinFileExists(const QString &key) const +{ + return getSkinIndex(key) != -1; +} + +bool SkinsModel::deleteSkin(const QString &key) +{ + int idx = getSkinIndex(key); + if (idx == -1) + return false; + auto &entry = m_skins[idx]; + return QFile::remove(entry.filename); +} + +void SkinsModel::reindex() +{ + m_nameIndex.clear(); + int i = 0; + for (auto &iter : m_skins) + { + m_nameIndex[iter.name] = i; + i++; + } +} + +int SkinsModel::getSkinIndex(const QString &key) const +{ + auto iter = m_nameIndex.find(key); + if (iter != m_nameIndex.end()) + return *iter; + + return -1; +} + +const Skins::SkinEntry & SkinsModel::skinEntry(const QString& key) const +{ + auto iter = m_nameIndex.find(key); + if (iter != m_nameIndex.end()) + { + return m_skins[*iter]; + } + + return placeholderEntry; +} + +const Skins::SkinEntry & SkinsModel::skinEntryByTextureID(const QString& textureID) const +{ + for(const auto& entry: m_skins) + { + if(entry.matchesId(textureID) != nonstd::nullopt) + { + return entry; + } + } + return placeholderEntry; +} diff --git a/launcher/skins/SkinsModel.h b/launcher/skins/SkinsModel.h new file mode 100644 index 00000000..0ae6039b --- /dev/null +++ b/launcher/skins/SkinsModel.h @@ -0,0 +1,86 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "settings/Setting.h" + +#include "QObjectPtr.h" +#include + +#include "SkinTypes.h" + +class QFileSystemWatcher; + +class SkinsModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit SkinsModel(QString path, QObject *parent = 0); + virtual ~SkinsModel() noexcept {}; + + virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + virtual int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + virtual QStringList mimeTypes() const override; + virtual Qt::DropActions supportedDropActions() const override; + virtual bool dropMimeData(const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &parent) override; + virtual Qt::ItemFlags flags(const QModelIndex &index) const override; + + void installSkins(const QStringList &paths); + bool installSkin(const QString &path); + QModelIndex installSkin(const QByteArray& data, const QString &playerName); + + bool deleteSkin(const QString &key); + bool skinFileExists(const QString &key) const; + + const Skins::SkinEntry& at(int row) const; + const Skins::SkinEntry& skinEntry(const QString& key) const; + const Skins::SkinEntry& skinEntryByTextureID(const QString& textureID) const; + + QString path() const; + +signals: + void skinUpdated(const QString &key); + void listUpdated(); + +private: + void startWatching(); + void stopWatching(); + + int getSkinIndex(const QString &key) const; + void scheduleRemoval(const QString &key); + bool cancelRemoval(const QString &key); + void reindex(); + void addDefaultSkin(const QString& name, const QString& slimPath, const QString& classicPath); + +public slots: + void directoryChanged(const QString &path); + +private slots: + void fileChanged(const QString &path); + void SettingChanged(const Setting & setting, QVariant value); + void removalTimerTriggered(); + +private: + shared_qobject_ptr m_watcher; + bool m_isWatching = false; + QMap m_nameIndex; + QVector m_skins; + QDir m_dir; + QTimer m_removalTimer; + QSet m_toRemove; + QSet m_reservedNames; +}; diff --git a/launcher/skins/TextureMappings.cpp b/launcher/skins/TextureMappings.cpp new file mode 100644 index 00000000..768e63a5 --- /dev/null +++ b/launcher/skins/TextureMappings.cpp @@ -0,0 +1,267 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#include "TextureMappings.h" + +namespace Skins { + +const TextureMapping capeLayout[2] = { + { + { 22, 2, 2, 32 }, + { 0, 2, 2, 32 }, + { 2, 0, 20, 2 }, + { 22, 0, 20, 2, true }, + { 2, 2, 20, 32 }, + { 24, 2, 20, 32 }, + Texture::Cape, + true + }, + { + { 11, 1, 1, 16 }, + { 0, 1, 1, 16 }, + { 1, 0, 10, 1 }, + { 11, 0, 10, 1, true }, + { 1, 1, 10, 16 }, + { 12, 1, 10, 16 }, + Texture::Cape, + true + } +}; + +const TextureMapping head = { + { 16, 8, 8, 8 }, + { 0, 8, 8, 8 }, + { 8, 0, 8, 8 }, + { 16, 0, 8, 8, true }, + { 8, 8, 8, 8 }, + { 24, 8, 8, 8 }, + Texture::Skin, + false +}; + +const TextureMapping head_cover = { + { 48, 8, 8, 8 }, + { 32, 8, 8, 8 }, + { 40, 0, 8, 8 }, + { 48, 0, 8, 8, true }, + { 40, 8, 8, 8 }, + { 56, 8, 8, 8 }, + Texture::Skin, + true +}; + +const TextureMapping torso = { + { 28, 20, 4, 12 }, + { 16, 20, 4, 12 }, + { 20, 16, 8, 4 }, + { 28, 16, 8, 4, true }, + { 20, 20, 8, 12 }, + { 32, 20, 8, 12 }, + Texture::Skin, + false +}; + +const TextureMapping torso_cover = { + { 28, 36, 4, 12 }, + { 16, 36, 4, 12 }, + { 20, 32, 8, 4 }, + { 28, 32, 8, 4, true }, + { 20, 36, 8, 12 }, + { 32, 36, 8, 12 }, + Texture::Skin, + true +}; + +const TextureMapping left_arm_old_classic = { + { 40, 20, 4, 12, false, true }, + { 48, 20, 4, 12, false, true }, + { 44, 16, 4, 4, false, true}, + { 48, 16, 4, 4, true, true }, + { 44, 20, 4, 12, false, true }, + { 52, 20, 4, 12, false, true }, + Texture::Skin, + false +}; + +const TextureMapping left_arm_old_slim = { + { 40, 20, 4, 12, false, true }, + { 47, 20, 4, 12, false, true }, + { 44, 16, 3, 4, false, true }, + { 47, 16, 3, 4, true, true }, + { 44, 20, 3, 12, false, true }, + { 51, 20, 3, 12, false, true }, + Texture::Skin, + false +}; + +const TextureMapping left_arm_classic = { + { 40, 52, 4, 12 }, + { 32, 52, 4, 12 }, + { 36, 48, 4, 4 }, + { 40, 48, 4, 4, true }, + { 36, 52, 4, 12 }, + { 44, 52, 4, 12 }, + Texture::Skin, + false +}; + +const TextureMapping left_arm_slim = { + { 39, 52, 4, 12 }, + { 32, 52, 4, 12 }, + { 36, 48, 3, 4 }, + { 39, 48, 3, 4, true }, + { 36, 52, 3, 12 }, + { 43, 52, 3, 12 }, + Texture::Skin, + false +}; + +const TextureMapping left_arm_cover_classic = { + { 56, 52, 4, 12 }, + { 48, 52, 4, 12 }, + { 52, 48, 4, 4 }, + { 56, 48, 4, 4, true }, + { 52, 52, 4, 12 }, + { 60, 52, 4, 12 }, + Texture::Skin, + true +}; + +const TextureMapping left_arm_cover_slim = { + { 55, 52, 4, 12 }, + { 48, 52, 4, 12 }, + { 52, 48, 3, 4 }, + { 55, 48, 3, 4, true }, + { 52, 52, 3, 12 }, + { 59, 52, 3, 12 }, + Texture::Skin, + true +}; + +const TextureMapping right_arm_old_classic = { + { 48, 20, 4, 12 }, + { 40, 20, 4, 12 }, + { 44, 16, 4, 4 }, + { 48, 16, 4, 4, true }, + { 44, 20, 4, 12 }, + { 52, 20, 4, 12 }, + Texture::Skin, + false +}; + +const TextureMapping right_arm_old_slim = { + { 47, 20, 4, 12 }, + { 40, 20, 4, 12 }, + { 44, 16, 3, 4 }, + { 47, 16, 3, 4, true }, + { 44, 20, 3, 12 }, + { 51, 20, 3, 12 }, + Texture::Skin, + false +}; + +const TextureMapping right_arm_classic = { + { 48, 20, 4, 12 }, + { 40, 20, 4, 12 }, + { 44, 16, 4, 4 }, + { 48, 16, 4, 4, true }, + { 44, 20, 4, 12 }, + { 52, 20, 4, 12 }, + Texture::Skin, + false +}; + +const TextureMapping right_arm_slim = { + { 47, 20, 4, 12 }, + { 40, 20, 4, 12 }, + { 44, 16, 3, 4 }, + { 47, 16, 3, 4, true }, + { 44, 20, 3, 12 }, + { 51, 20, 3, 12 }, + Texture::Skin, + false +}; + +const TextureMapping right_arm_cover_classic = { + { 48, 36, 4, 12 }, + { 40, 36, 4, 12 }, + { 44, 32, 4, 4 }, + { 48, 32, 4, 4, true }, + { 44, 36, 4, 12 }, + { 52, 36, 4, 12 }, + Texture::Skin, + true +}; + +const TextureMapping right_arm_cover_slim = { + { 47, 36, 4, 12 }, + { 40, 36, 4, 12 }, + { 44, 32, 3, 4 }, + { 47, 32, 3, 4, true }, + { 44, 36, 3, 12 }, + { 51, 36, 3, 12 }, + Texture::Skin, + true +}; + + +const TextureMapping right_leg = { + { 8, 20, 4, 12 }, + { 0, 20, 4, 12 }, + { 4, 16, 4, 4 }, + { 8, 16, 4, 4, true }, + { 4, 20, 4, 12 }, + { 12, 20, 4, 12 }, + Texture::Skin, + false +}; + +const TextureMapping right_leg_cover = { + { 8, 36, 4, 12 }, + { 0, 36, 4, 12 }, + { 4, 32, 4, 4 }, + { 8, 32, 4, 4, true }, + { 4, 36, 4, 12 }, + { 12, 36, 4, 12 }, + Texture::Skin, + true +}; + +const TextureMapping left_leg_old = { + { 0, 20, 4, 12, false, true}, + { 8, 20, 4, 12, false, true}, + { 4, 16, 4, 4, false, true}, + { 8, 16, 4, 4, true, true}, + { 4, 20, 4, 12, false, true}, + { 12, 20, 4, 12, false, true}, + Texture::Skin, + false +}; + +const TextureMapping left_leg = { + { 24, 52, 4, 12 }, + { 16, 52, 4, 12 }, + { 20, 48, 4, 4 }, + { 24, 48, 4, 4, true }, + { 20, 52, 4, 12 }, + { 28, 52, 4, 12 }, + Texture::Skin, + false +}; + +const TextureMapping left_leg_cover = { + { 8, 52, 4, 12 }, + { 0, 52, 4, 12 }, + { 4, 48, 4, 4 }, + { 8, 48, 4, 4, true }, + { 4, 52, 4, 12 }, + { 12, 52, 4, 12 }, + Texture::Skin, + true +}; + +} + diff --git a/launcher/skins/TextureMappings.h b/launcher/skins/TextureMappings.h new file mode 100644 index 00000000..4ac53d76 --- /dev/null +++ b/launcher/skins/TextureMappings.h @@ -0,0 +1,34 @@ +/* Copyright 2025 Petr Mrázek + * + * This source is subject to the Microsoft Permissive License (MS-PL). + * Please see the COPYING.md file for more information. + */ + +#pragma once + +#include "SkinTypes.h" + +namespace Skins { +extern const TextureMapping capeLayout[2]; +extern const TextureMapping head; +extern const TextureMapping head_cover; +extern const TextureMapping torso; +extern const TextureMapping torso_cover; +extern const TextureMapping left_arm_old_classic; +extern const TextureMapping left_arm_old_slim; +extern const TextureMapping left_arm_classic; +extern const TextureMapping left_arm_slim; +extern const TextureMapping left_arm_cover_classic; +extern const TextureMapping left_arm_cover_slim; +extern const TextureMapping right_arm_old_classic; +extern const TextureMapping right_arm_old_slim; +extern const TextureMapping right_arm_classic; +extern const TextureMapping right_arm_slim; +extern const TextureMapping right_arm_cover_classic; +extern const TextureMapping right_arm_cover_slim; +extern const TextureMapping right_leg; +extern const TextureMapping right_leg_cover; +extern const TextureMapping left_leg_old; +extern const TextureMapping left_leg; +extern const TextureMapping left_leg_cover; +} diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index b4be66a3..778bde06 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -54,7 +54,6 @@ #include #include #include -#include #include #include #include @@ -95,15 +94,22 @@ #include "MMCTime.h" namespace { -QString profileInUseFilter(const QString & profile, bool used) +QString profileInUseFilter(const QString& profileName, const QString& accountName, bool used) { + QString displayString; + if(profileName.size() == 0) { + displayString = QObject::tr("No profile (%1)").arg(accountName); + } + else { + displayString = profileName; + } if(used) { - return QObject::tr("%1 (in use)").arg(profile); + return QObject::tr("%1 (in use)").arg(displayString); } else { - return profile; + return displayString; } } } @@ -806,30 +812,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new MainWindow ui->mainToolBar->addAction(accountMenuButtonAction); // Update the menu when the active account changes. - // Shouldn't have to use lambdas here like this, but if I don't, the compiler throws a fit. - // Template hell sucks... - connect( - APPLICATION->accounts().get(), - &AccountList::defaultAccountChanged, - [this] { - defaultAccountChanged(); - } - ); - connect( - APPLICATION->accounts().get(), - &AccountList::listChanged, - [this] - { - repopulateAccountsMenu(); - } - ); + connect(APPLICATION->accounts().get(), &AccountList::defaultAccountChanged, this, &MainWindow::defaultAccountChanged); + connect(APPLICATION->accounts().get(), &AccountList::listChanged, this, &MainWindow::repopulateAccountsMenu); // Show initial account defaultAccountChanged(); - // TODO: refresh accounts here? - // auto accounts = APPLICATION->accounts(); - // load the news { m_newsChecker->reloadNews(); @@ -879,11 +867,11 @@ void MainWindow::retranslateUi() auto accounts = APPLICATION->accounts(); MinecraftAccountPtr defaultAccount = accounts->defaultAccount(); if(defaultAccount) { - auto profileLabel = profileInUseFilter(defaultAccount->profileName(), defaultAccount->isInUse()); + auto profileLabel = profileInUseFilter(defaultAccount->profileName(), defaultAccount->gamerTag(), defaultAccount->isInUse()); accountMenuButton->setText(profileLabel); } else { - accountMenuButton->setText(tr("Profiles")); + accountMenuButton->setText(tr("Accounts")); } if (m_selectedInstance) { @@ -1091,7 +1079,7 @@ void MainWindow::repopulateAccountsMenu() // this can be called before accountMenuButton exists if (accountMenuButton) { - auto profileLabel = profileInUseFilter(defaultAccount->profileName(), defaultAccount->isInUse()); + auto profileLabel = profileInUseFilter(defaultAccount->profileName(), defaultAccount->gamerTag(), defaultAccount->isInUse()); accountMenuButton->setText(profileLabel); } } @@ -1107,8 +1095,14 @@ void MainWindow::repopulateAccountsMenu() // TODO: Nicer way to iterate? for (int i = 0; i < accounts->count(); i++) { - MinecraftAccountPtr account = accounts->at(i); - auto profileLabel = profileInUseFilter(account->profileName(), account->isInUse()); + auto entry = accounts->at(i); + if(!entry.isAccount) + { + continue; + } + auto account = entry.account; + + auto profileLabel = profileInUseFilter(account->profileName(), account->gamerTag(), account->isInUse()); QAction *action = new QAction(profileLabel, this); action->setData(i); action->setCheckable(true); @@ -1173,7 +1167,7 @@ void MainWindow::changeActiveAccount() index = -1; } auto accounts = APPLICATION->accounts(); - accounts->setDefaultAccount(index == -1 ? nullptr : accounts->at(index)); + accounts->setDefaultAccount(index == -1 ? nullptr : accounts->at(index).account); defaultAccountChanged(); } @@ -1183,24 +1177,22 @@ void MainWindow::defaultAccountChanged() MinecraftAccountPtr account = APPLICATION->accounts()->defaultAccount(); - // FIXME: this needs adjustment for MSA - if (account && account->profileName() != "") + if (!account) { - auto profileLabel = profileInUseFilter(account->profileName(), account->isInUse()); - accountMenuButton->setText(profileLabel); - auto face = account->getFace(); - if(face.isNull()) { - accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount")); - } - else { - accountMenuButton->setIcon(face); - } + accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount")); + accountMenuButton->setText(tr("Accounts")); return; } - // Set the icon to the "no account" icon. - accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount")); - accountMenuButton->setText(tr("Profiles")); + auto profileLabel = profileInUseFilter(account->profileName(), account->gamerTag(), account->isInUse()); + accountMenuButton->setText(profileLabel); + auto face = account->getFace(); + if(face.isNull()) { + accountMenuButton->setIcon(APPLICATION->getThemedIcon("noaccount")); + } + else { + accountMenuButton->setIcon(face); + } } bool MainWindow::eventFilter(QObject *obj, QEvent *ev) @@ -1723,7 +1715,7 @@ void MainWindow::on_actionScreenshots_triggered() void MainWindow::on_actionManageAccounts_triggered() { - APPLICATION->ShowGlobalSettings(this, "accounts"); + APPLICATION->ShowAccountsDialog(this); } void MainWindow::on_actionReportBug_triggered() diff --git a/launcher/ui/dialogs/AccountsDialog.cpp b/launcher/ui/dialogs/AccountsDialog.cpp index 2f61d47f..b6e2e6e9 100644 --- a/launcher/ui/dialogs/AccountsDialog.cpp +++ b/launcher/ui/dialogs/AccountsDialog.cpp @@ -1,36 +1,771 @@ -/* Copyright 2024 Petr Mrázek +/* Copyright 2025 Petr Mrázek * * This source is subject to the Microsoft Permissive License (MS-PL). * Please see the COPYING.md file for more information. */ +#include +#include #include "AccountsDialog.h" #include "ui_AccountsDialog.h" -#include -#include + #include "Application.h" #include "BuildConfig.h" -#include "CustomMessageBox.h" -#include "ProgressDialog.h" -#include -#include "SkinUploadDialog.h" -#include "MSALoginDialog.h" +#include "DesktopServices.h" +#include "minecraft/auth/AccountTask.h" +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" -AccountsDialog::AccountsDialog(QWidget *parent) : QDialog(parent), ui(new Ui::AccountsDialog) +#include "skins/CapeCache.h" +#include "skins/CapesModel.h" +#include "skins/SkinsModel.h" +#include "skins/SkinUtils.h" + +#include +#include + +constexpr auto selectionFlags = QItemSelectionModel::Clear | QItemSelectionModel::Select | QItemSelectionModel::Rows | QItemSelectionModel::Current; + +AccountsDialog::AccountsDialog(QWidget *parent, const QString& internalId) : QDialog(parent), ui(new Ui::AccountsDialog) { ui->setupUi(this); + ui->windowLayout->setWindowFlags(Qt::Widget); + m_statusBar = ui->windowLayout->statusBar(); + auto icon = APPLICATION->getThemedIcon("accounts"); if(icon.isNull()) { icon = APPLICATION->getThemedIcon("noaccount"); } + m_accounts = APPLICATION->accounts(); + ui->accountListView->setModel(m_accounts.get()); + ui->accountListView->setIconSize(QSize(48, 48)); + m_capesModel = new CapesModel(this); + ui->capesView->setModel(m_capesModel); + m_skinsModel = APPLICATION->skinsModel().get(); + connect(m_skinsModel, &SkinsModel::skinUpdated, this, &AccountsDialog::onSkinUpdated); + connect(m_skinsModel, &SkinsModel::listUpdated, this, &AccountsDialog::onSkinModelUpdated); + ui->skinsView->setModel(m_skinsModel); setWindowIcon(icon); setWindowTitle(tr("Minecraft Accounts")); + QItemSelectionModel *skinSelectionModel = ui->skinsView->selectionModel(); + connect(skinSelectionModel, &QItemSelectionModel::selectionChanged, this, &AccountsDialog::onSkinSelectionChanged); + QItemSelectionModel *capeSelectionModel = ui->capesView->selectionModel(); + connect(capeSelectionModel, &QItemSelectionModel::selectionChanged, this, &AccountsDialog::onCapeSelectionChanged); + connect(ui->btnResetChanges, &QPushButton::clicked, this, &AccountsDialog::onRevertChangesClicked); + connect(ui->btnApplyChanges, &QPushButton::clicked, this, &AccountsDialog::onApplyChangesClicked); + connect(ui->saveSkinButton, &QPushButton::clicked, this, &AccountsDialog::onSaveSkinClicked); + connect(ui->openSkinsButton, &QPushButton::clicked, this, &AccountsDialog::onOpenSkinsFolderClicked); + + connect(ui->refreshButton, &QPushButton::clicked, this, &AccountsDialog::onRefreshButtonClicked); + connect(ui->signOutButton, &QPushButton::clicked, this, &AccountsDialog::onSignOutButtonClicked); + connect(ui->refreshButton_Setup, &QPushButton::clicked, this, &AccountsDialog::onRefreshButtonClicked); + connect(ui->signOutButton_Setup, &QPushButton::clicked, this, &AccountsDialog::onSignOutButtonClicked); + connect(ui->getFreshCodeButton, &QPushButton::clicked, this, &AccountsDialog::onGetFreshCodeButtonClicked); + + QItemSelectionModel *selectionModel = ui->accountListView->selectionModel(); + bool foundAccount = false; + if(!internalId.isEmpty()) + { + MinecraftAccountPtr account; + int row; + if(m_accounts->getAccountById(internalId, account, row)) + { + selectionModel->select(m_accounts->index(row), selectionFlags); + foundAccount = true; + } + } + if(!foundAccount) + { + if(m_accounts->count() == 1) + { + selectionModel->select(m_accounts->index(0), selectionFlags); + } + else + { + if(m_accounts->defaultAccount()) + { + selectionModel->select(m_accounts->defaultAccountIndex(), selectionFlags); + } + else + { + selectionModel->select(m_accounts->index(1), selectionFlags); + } + } + } + updateStates(); + + connect(selectionModel, &QItemSelectionModel::selectionChanged, [this](const QItemSelection &sel, const QItemSelection &dsel) { + updateStates(); + }); + connect(m_accounts.get(), &AccountList::accountActivityChanged, this, &AccountsDialog::onAccountActivityChanged); + connect(m_accounts.get(), &AccountList::accountChanged, this, &AccountsDialog::onAccountChanged); + + connect(ui->linkButton, &QToolButton::clicked, this, &AccountsDialog::onQrButtonClicked); + connect(&m_externalLoginTimer, &QTimer::timeout, this, &AccountsDialog::externalLoginTick); + + ui->createProfileErrorLabel->setVisible(false); + + connect(ui->modelButtonGroup, SIGNAL(buttonClicked(QAbstractButton*)), this, SLOT(onModelRadioClicked(QAbstractButton*))); + + // Profile creation elements + m_goodIcon = APPLICATION->getThemedIcon("status-good"); + m_yellowIcon = APPLICATION->getThemedIcon("status-yellow"); + m_badIcon = APPLICATION->getThemedIcon("status-bad"); + QRegExp permittedNames("[a-zA-Z0-9_]{3,16}"); + auto nameEdit = ui->createProfileNameEdit; + nameEdit->setValidator(new QRegExpValidator(permittedNames)); + nameEdit->setClearButtonEnabled(true); + + m_validityAction = nameEdit->addAction(m_yellowIcon, QLineEdit::LeadingPosition); + connect(nameEdit, &QLineEdit::textEdited, this, &AccountsDialog::onNameEdited); + + m_checkStartTimer.setSingleShot(true); + connect(&m_checkStartTimer, &QTimer::timeout, this, &AccountsDialog::onNameCheckTimerTriggered); + + connect(ui->createProfileButton, &QCommandLinkButton::clicked, this, &AccountsDialog::onCreateProfileButtonClicked); + + setNameStatus(NameStatus::NotSet, QString()); } AccountsDialog::~AccountsDialog() { delete ui; } + +void AccountsDialog::onRevertChangesClicked(bool) +{ + revertEdits(); +} + +void AccountsDialog::onOpenSkinsFolderClicked(bool) +{ + DesktopServices::openDirectory(m_skinsModel->path()); +} + + +void AccountsDialog::onSaveSkinClicked(bool) +{ + if(!m_currentAccount || !m_currentAccount->hasProfile()) + { + return; + } + auto skin = effectiveSkin().getTextureDataFor(effectiveModel()); + QModelIndex index = m_skinsModel->installSkin(skin, m_currentAccount->profileName()); + if(index.isValid()) + { + ui->saveSkinButton->setEnabled(false); + } +} + + +void AccountsDialog::onApplyChangesClicked(bool) +{ + if(!m_currentAccount || !m_currentAccount->hasProfile()) + { + return; + } + if(!m_skinEdit) + { + return; + } + auto task = m_currentAccount->setSkin(m_skinEdit->model, m_skinEdit->skinEntry.getTextureDataFor(m_skinEdit->model), m_skinEdit->cape); + if(task) + { + auto account = m_currentAccount; + connect(task.get(), &AccountTask::apiError, [this, account](const MojangError& error ) { + if(m_currentAccount == account) + { + QMessageBox::critical(this, tr("Failed to apply skin"), error.toString()); + } + }); + connect(task.get(), &AccountTask::succeeded, [this, account]() { + if(m_currentAccount == account) + { + revertEdits(); + } + }); + task->start(); + } +} + +void AccountsDialog::revertEdits() +{ + ui->skinsView->clearSelection(); + ui->capesView->clearSelection(); + + m_skinEdit = nonstd::nullopt; + ui->btnApplyChanges->setEnabled(false); + ui->btnResetChanges->setEnabled(false); + updateModelToMatchSkin(); + updateSkinDisplay(); +} + +bool AccountsDialog::startSkinEdit() +{ + if(m_skinEdit) + { + return true; + } + if(!m_currentAccount || !m_currentAccount->hasProfile()) + { + return false; + } + + ui->btnApplyChanges->setEnabled(true); + ui->btnResetChanges->setEnabled(true); + m_skinEdit = m_playerSkinState; + return true; +} + +Skins::Model AccountsDialog::effectiveModel() const +{ + if(m_skinEdit) + return m_skinEdit->model; + return m_playerSkinState.model; +} + +const QString & AccountsDialog::effectiveCape() const +{ + if(m_skinEdit) + return m_skinEdit->cape; + return m_playerSkinState.cape; +} + +const Skins::SkinEntry & AccountsDialog::effectiveSkin() const +{ + if(m_skinEdit) + return m_skinEdit->skinEntry; + return m_playerSkinState.skinEntry; +} + + +void AccountsDialog::editModel(Skins::Model model) +{ + if(!startSkinEdit()) + return; + m_skinEdit->model = model; + updateSkinDisplay(); +} + +void AccountsDialog::editCape(const QString& cape) +{ + if(!startSkinEdit()) + return; + m_skinEdit->cape = cape; + updateSkinDisplay(); +} + + +void AccountsDialog::editSkin(const Skins::SkinEntry& newEntry) +{ + if(!startSkinEdit()) + return; + + m_skinEdit->skinEntry = newEntry; + updateModelToMatchSkin(); + updateSkinDisplay(); +} + +void AccountsDialog::onSkinSelectionChanged(const QItemSelection& selected, const QItemSelection& deselected) +{ + if(!m_currentAccount || !m_currentAccount->hasProfile()) + { + return; + } + const auto& indexes = selected.indexes(); + if(indexes.size() == 0) + { + return; + } + const auto& index = indexes[0]; + int row = index.row(); + editSkin(m_skinsModel->at(row)); +} + +void AccountsDialog::onCapeSelectionChanged(const QItemSelection& selected, const QItemSelection& deselected) +{ + if(!m_currentAccount || !m_currentAccount->hasProfile()) + { + return; + } + const auto& indexes = selected.indexes(); + if(indexes.size() == 0) + { + return; + } + const auto& index = indexes[0]; + int row = index.row(); + editCape(m_capesModel->at(row)); +} + +void AccountsDialog::onSkinUpdated(const QString& key) +{ + if(effectiveSkin().name == key) + { + editSkin(m_skinsModel->skinEntry(key)); + } +} + +void AccountsDialog::onSkinModelUpdated() +{ + auto& skin = effectiveSkin(); + auto model = effectiveModel(); + + auto textureID = skin.getTextureIDFor(model); + ui->saveSkinButton->setEnabled(m_skinsModel->skinEntryByTextureID(textureID).isNull()); +} + + +void AccountsDialog::onAccountChanged(MinecraftAccount* account) +{ + if(m_currentAccount.get() == account) + { + updateStates(); + } +} + +void AccountsDialog::onAccountActivityChanged(MinecraftAccount* account, bool active) +{ + if(m_currentAccount.get() == account) + { + updateStates(); + } +} + +void AccountsDialog::updateStates() +{ + // If there is no selection, disable buttons that require something selected. + QModelIndexList selection = ui->accountListView->selectionModel()->selectedIndexes(); + bool hasSelection = selection.size() > 0; + bool accountIsReady = false; + auto prevAccount = m_currentAccount; + m_currentAccount = nullptr; + if (hasSelection) + { + QModelIndex selected = selection.first(); + m_currentAccount = selected.data(AccountList::PointerRole).value(); + accountIsReady = m_currentAccount && !m_currentAccount->isActive(); + } + + // New account page + if(!m_currentAccount) + { + ui->accountPageStack->setCurrentWidget(ui->loginPage); + + // Setup the login task and start it + if(!m_loginAccount) + { + startLogin(); + } + return; + } + + // Profile setup page + if(!m_currentAccount->hasProfile()) + { + ui->accountPageStack->setCurrentWidget(ui->setupProfilePage); + ui->setupProfilePage->setEnabled(accountIsReady); + ui->selectedAccountLabel_Setup->setText(m_currentAccount->gamerTag()); + ui->selectedAccountIconLabel_Setup->setIcon(APPLICATION->getThemedIcon("noaccount")); + return; + } + + ui->fullAccountPage->setEnabled(accountIsReady); + + // Full account page + if(prevAccount != m_currentAccount) + { + revertEdits(); + + m_capesModel->setAccount(m_currentAccount); + ui->accountPageStack->setCurrentWidget(ui->fullAccountPage); + ui->selectedAccountLabel->setText(m_currentAccount->profileName()); + ui->selectedAccountIconLabel->setIcon(m_currentAccount->getFace()); + + QByteArray playerSkinData = m_currentAccount->getSkin(); + QImage image; + QString textureID; + Skins::readSkinFromData(playerSkinData, image, textureID); + auto maybeEntry = m_skinsModel->skinEntryByTextureID(textureID); + m_playerSkinState = SkinState{ + m_currentAccount->getCurrentCape(), + m_currentAccount->getSkinModel(), + (!maybeEntry.isNull()) ? maybeEntry : Skins::SkinEntry("player", "", image, textureID, playerSkinData) + }; + + updateModelToMatchSkin(); + updateSkinDisplay(); + } +} + +void AccountsDialog::updateModelToMatchSkin() +{ + auto& skinEntry = effectiveSkin(); + auto model = effectiveModel(); + bool hasClassic = skinEntry.hasModel(Skins::Model::Classic); + bool hasSlim = skinEntry.hasModel(Skins::Model::Slim); + + if(m_skinEdit) + { + if(model == Skins::Model::Classic && !hasClassic) + { + m_skinEdit->model = Skins::Model::Slim; + ui->radioSlim->setChecked(true); + } + if(model == Skins::Model::Slim && !hasSlim) + { + m_skinEdit->model = Skins::Model::Classic; + ui->radioClassic->setChecked(true); + } + } + else + { + if(model == Skins::Model::Classic && !hasClassic) + { + ui->radioSlim->setChecked(true); + } + if(model == Skins::Model::Slim && !hasSlim) + { + ui->radioClassic->setChecked(true); + } + } +} + + +void AccountsDialog::updateSkinDisplay() +{ + QImage capeImage; + auto cape = effectiveCape(); + if(!cape.isEmpty()) + { + auto capeCache = APPLICATION->capeCache(); + capeImage = capeCache->getCapeImage(cape); + } + auto& skin = effectiveSkin(); + auto model = effectiveModel(); + + bool hasClassic = skin.hasModel(Skins::Model::Classic); + bool hasSlim = skin.hasModel(Skins::Model::Slim); + ui->radioClassic->setEnabled(hasClassic); + ui->radioSlim->setEnabled(hasSlim); + auto textureID = skin.getTextureIDFor(model); + ui->saveSkinButton->setEnabled(m_skinsModel->skinEntryByTextureID(textureID).isNull()); + + ui->skinPreviewWidget->setAll(model, skin.getTextureFor(model), capeImage); + { + const QSignalBlocker blocker(ui->modelButtonGroup); + switch(model) + { + case Skins::Model::Classic: + ui->radioClassic->setChecked(true); + break; + case Skins::Model::Slim: + ui->radioSlim->setChecked(true); + break; + } + } +} + + +void AccountsDialog::stopLogin() +{ + m_externalLoginTimer.stop(); + m_loginTask = nullptr; + m_loginAccount = nullptr; + ui->getFreshCodeButton->setEnabled(true); + ui->linkButton->setVisible(false); +} + +void AccountsDialog::startLogin() +{ + ui->getFreshCodeButton->setEnabled(false); + ui->linkButton->setVisible(false); + m_loginAccount = MinecraftAccount::createBlankMSA(); + m_loginTask = m_loginAccount->loginMSA(); + connect(m_loginTask.get(), &Task::failed, this, &AccountsDialog::onLoginTaskFailed); + connect(m_loginTask.get(), &Task::succeeded, this, &AccountsDialog::onLoginTaskSucceeded); + connect(m_loginTask.get(), &Task::status, this, &AccountsDialog::onLoginTaskStatus); + connect(m_loginTask.get(), &Task::progress, this, &AccountsDialog::onLoginTaskProgress); + connect(m_loginTask.get(), &AccountTask::showVerificationUriAndCode, this, &AccountsDialog::showVerificationUriAndCode); + connect(m_loginTask.get(), &AccountTask::hideVerificationUriAndCode, this, &AccountsDialog::hideVerificationUriAndCode); + m_loginTask->start(); +} + +void AccountsDialog::onGetFreshCodeButtonClicked(bool) +{ + stopLogin(); + startLogin(); +} + +void AccountsDialog::onQrButtonClicked(bool) +{ + DesktopServices::openUrl(m_codeUrl); +} + + +void AccountsDialog::externalLoginTick() { + m_externalLoginElapsed++; + ui->progressBar->setValue(m_externalLoginTimeout - m_externalLoginElapsed); + ui->progressBar->repaint(); + if(m_externalLoginElapsed == 5) + { + ui->getFreshCodeButton->setEnabled(true); + } + + if(m_externalLoginElapsed >= m_externalLoginTimeout) { + stopLogin(); + + } +} + +void AccountsDialog::onCapeUpdated(const QString& uuid) +{ + auto currentCape = effectiveCape(); + if(uuid != currentCape) + { + return; + } + // we need to update the cape image in the preview widget + auto capeCache = APPLICATION->capeCache(); + auto capeImage = capeCache->getCapeImage(currentCape); + ui->skinPreviewWidget->setCapeImage(capeImage); +} + +void AccountsDialog::onModelRadioClicked(QAbstractButton* radio) +{ + Skins::Model model = (radio == ui->radioClassic) ? Skins::Model::Classic : Skins::Model::Slim; + editModel(model); +} + + +void AccountsDialog::showVerificationUriAndCode(const QUrl& uri, const QString& code, int expiresIn) { + m_externalLoginElapsed = 0; + m_externalLoginTimeout = expiresIn; + + m_externalLoginTimer.setInterval(1000); + m_externalLoginTimer.setSingleShot(false); + m_externalLoginTimer.start(); + + ui->progressBar->setMaximum(expiresIn); + ui->progressBar->setValue(m_externalLoginTimeout - m_externalLoginElapsed); + ui->progressBar->setVisible(true); + + m_codeUrl = uri; + QUrlQuery query; + query.addQueryItem("otc", code); + m_codeUrl.setQuery(query); + QString codeUrlString = m_codeUrl.toString(); + + QImage qrcode = qrcode::generateQr(codeUrlString, 300); + ui->linkButton->setIcon(QPixmap::fromImage(qrcode)); + ui->linkButton->setText(codeUrlString); + ui->linkButton->setVisible(true); + + ui->label->setText(tr("You can scan the QR code and complete the login process on a separate device, or you can open the link and login on this machine.")); + m_code = code; +} + +void AccountsDialog::hideVerificationUriAndCode() { + ui->linkButton->setVisible(false); + ui->progressBar->setVisible(false); + m_externalLoginTimer.stop(); +} + +void AccountsDialog::onLoginTaskFailed(const QString &reason) +{ + // Set message + auto lines = reason.split('\n'); + QString processed; + for(auto line: lines) { + if(line.size()) { + processed += "" + line + "
"; + } + else { + processed += "
"; + } + } + ui->label->setText(processed); + + ui->progressBar->setVisible(false); +} + +void AccountsDialog::onLoginTaskSucceeded() +{ + m_loginTask = nullptr; + QModelIndex index = m_accounts->addAccount(m_loginAccount); + if (m_accounts->count() == 2) { + m_accounts->setDefaultAccount(m_loginAccount); + } + ui->accountListView->selectionModel()->select(index, selectionFlags); + m_loginAccount = nullptr; +} + +void AccountsDialog::onLoginTaskStatus(const QString &status) +{ + ui->label->setText(status); +} + +void AccountsDialog::onLoginTaskProgress(qint64 current, qint64 total) +{ + ui->progressBar->setMaximum(total); + ui->progressBar->setValue(current); +} + +void AccountsDialog::changeEvent(QEvent* event) +{ + if (event->type() == QEvent::LanguageChange) + { + ui->retranslateUi(this); + } + QDialog::changeEvent(event); +} + +void AccountsDialog::onRefreshButtonClicked(bool) +{ + if(m_currentAccount) + { + m_accounts->requestRefresh(m_currentAccount->internalId()); + } +} + +void AccountsDialog::onSignOutButtonClicked(bool) +{ + if(m_currentAccount) + { + m_accounts->removeAccount(m_currentAccount->internalId()); + } +} + +void AccountsDialog::setNameStatus(AccountsDialog::NameStatus status, QString errorString = QString()) +{ + nameStatus = status; + auto okButton = ui->createProfileButton; + switch(nameStatus) + { + case NameStatus::Available: { + m_validityAction->setIcon(m_goodIcon); + okButton->setEnabled(true); + } + break; + case NameStatus::NotSet: + case NameStatus::Pending: + m_validityAction->setIcon(m_yellowIcon); + okButton->setEnabled(false); + break; + case NameStatus::Exists: + case NameStatus::Error: + m_validityAction->setIcon(m_badIcon); + okButton->setEnabled(false); + break; + } + if(!errorString.isEmpty()) { + ui->createProfileErrorLabel->setText(errorString); + ui->createProfileErrorLabel->setVisible(true); + } + else { + ui->createProfileErrorLabel->setVisible(false); + } +} + +void AccountsDialog::onNameEdited(const QString& name) +{ + if(!ui->createProfileNameEdit->hasAcceptableInput()) { + setNameStatus(NameStatus::NotSet, tr("Name is too short - must be between 3 and 16 characters long.")); + return; + } + scheduleCheck(name); +} + +void AccountsDialog::scheduleCheck(const QString& name) { + m_queuedCheck = name; + setNameStatus(NameStatus::Pending); + m_checkStartTimer.start(1000); +} + +void AccountsDialog::onNameCheckTimerTriggered() { + if(m_isChecking) { + return; + } + if(m_queuedCheck.isNull()) { + return; + } + checkName(m_queuedCheck); +} + +void AccountsDialog::checkName(const QString &name) { + if(m_isChecking) { + return; + } + + m_currentCheck = name; + m_isChecking = true; + + auto token = m_currentAccount->accessToken(); + + auto url = QString("%1/minecraft/profile/name/%2/available").arg(BuildConfig.API_BASE).arg(name); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("Authorization", QString("Bearer %1").arg(token).toUtf8()); + + AuthRequest *requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, &AccountsDialog::onNameCheckFinished); + requestor->get(request); +} + +void AccountsDialog::onNameCheckFinished( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers +) { + auto requestor = qobject_cast(QObject::sender()); + requestor->deleteLater(); + + if(error == QNetworkReply::NoError) { + auto doc = QJsonDocument::fromJson(data); + auto root = doc.object(); + auto statusValue = root.value("status").toString("INVALID"); + if(statusValue == "AVAILABLE") { + setNameStatus(NameStatus::Available); + } + else if (statusValue == "DUPLICATE") { + setNameStatus(NameStatus::Exists, tr("Minecraft profile with name %1 already exists.").arg(m_currentCheck)); + } + else if (statusValue == "NOT_ALLOWED") { + setNameStatus(NameStatus::Exists, tr("The name %1 is not allowed.").arg(m_currentCheck)); + } + else { + setNameStatus(NameStatus::Error, tr("Unhandled profile name status: %1").arg(statusValue)); + } + } + else { + setNameStatus(NameStatus::Error, tr("Failed to check name availability.")); + } + m_isChecking = false; +} + +void AccountsDialog::onCreateProfileButtonClicked(bool) +{ + auto task = m_currentAccount->createMinecraftProfile(ui->createProfileNameEdit->text()); + if(task) + { + connect(task.get(), &AccountTask::apiError, this, &AccountsDialog::onProfileCreationError); + task->start(); + } +} + +void AccountsDialog::onProfileCreationError(const MojangError& error) +{ + ui->createProfileErrorLabel->setVisible(true); + if(error.jsonParsed) + { + ui->createProfileErrorLabel->setText(error.errorMessage); + } + else + { + ui->createProfileErrorLabel->setText(error.toString()); + } +} diff --git a/launcher/ui/dialogs/AccountsDialog.h b/launcher/ui/dialogs/AccountsDialog.h index 6a1d586a..551839e6 100644 --- a/launcher/ui/dialogs/AccountsDialog.h +++ b/launcher/ui/dialogs/AccountsDialog.h @@ -1,4 +1,4 @@ -/* Copyright 2024 Petr Mrázek +/* Copyright 2025 Petr Mrázek * * This source is subject to the Microsoft Permissive License (MS-PL). * Please see the COPYING.md file for more information. @@ -8,23 +8,162 @@ #include #include "minecraft/auth/AccountList.h" +#include "minecraft/auth/AccountTask.h" +#include +#include +#include +#include +#include +#include + +#include + +class QAbstractButton; class QMenu; +class QEvent; +class QAction; namespace Ui { class AccountsDialog; } +struct SkinState +{ + QString cape; + Skins::Model model; + Skins::SkinEntry skinEntry; +}; + class AccountsDialog : public QDialog { Q_OBJECT public: - explicit AccountsDialog(QWidget *parent = 0); + explicit AccountsDialog(QWidget *parent = 0, const QString& internalId = QString()); virtual ~AccountsDialog(); + enum class NameStatus + { + NotSet, + Pending, + Available, + Exists, + Error + } nameStatus = NameStatus::NotSet; + + // Skins stuff +private slots: + void onSkinSelectionChanged(const class QItemSelection &selected, const class QItemSelection &deselected); + void onCapeSelectionChanged(const class QItemSelection &selected, const class QItemSelection &deselected); + void onModelRadioClicked(QAbstractButton* radio); + void onRevertChangesClicked(bool); + void onApplyChangesClicked(bool); + void onSaveSkinClicked(bool); + void onOpenSkinsFolderClicked(bool); + + void onSkinUpdated(const QString& key); + void onSkinModelUpdated(); + void onCapeUpdated(const QString& uuid); + +private: + bool startSkinEdit(); + bool hasEdits() const + { + return m_skinEdit != nonstd::nullopt; + } + void revertEdits(); + + void editCape(const QString& cape); + const QString& effectiveCape() const; + + void editModel(Skins::Model model); + Skins::Model effectiveModel() const; + + void editSkin(const Skins::SkinEntry& skin); + const Skins::SkinEntry& effectiveSkin() const; + + void updateModelToMatchSkin(); + void updateSkinDisplay(); + +private: + SkinState m_playerSkinState; + nonstd::optional m_skinEdit; + +private slots: + // Account display page + void onRefreshButtonClicked(bool); + void onSignOutButtonClicked(bool); + void onAccountChanged(MinecraftAccount * account); + void onAccountActivityChanged(MinecraftAccount * account, bool active); + +private: + void updateStates(); + + // Login page +private: + void stopLogin(); + void startLogin(); + +private slots: + void onGetFreshCodeButtonClicked(bool); + void onQrButtonClicked(bool); + void onLoginTaskFailed(const QString &reason); + void onLoginTaskSucceeded(); + void onLoginTaskStatus(const QString &status); + void onLoginTaskProgress(qint64 current, qint64 total); + void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn); + void hideVerificationUriAndCode(); + void externalLoginTick(); + +// Profile setup stuff +private slots: + void onCreateProfileButtonClicked(bool); + + void onNameEdited(const QString &name); + void onNameCheckFinished( + QNetworkReply::NetworkError error, + QByteArray data, + QList headers + ); + void onNameCheckTimerTriggered(); + void onProfileCreationError(const MojangError& error); + +private: + void scheduleCheck(const QString &name); + void checkName(const QString &name); + void setNameStatus(NameStatus status, QString errorString); + +private: + QIcon m_goodIcon; + QIcon m_yellowIcon; + QIcon m_badIcon; + QAction * m_validityAction = nullptr; + + QString m_queuedCheck; + bool m_isChecking = false; + QString m_currentCheck; + QTimer m_checkStartTimer; + +// Other +private: + void changeEvent(QEvent * event) override; + private: Ui::AccountsDialog *ui; + class QStatusBar* m_statusBar = nullptr; shared_qobject_ptr m_accounts; + + MinecraftAccountPtr m_currentAccount; + class CapesModel* m_capesModel = nullptr; + class SkinsModel* m_skinsModel = nullptr; + + MinecraftAccountPtr m_loginAccount; + shared_qobject_ptr m_loginTask; + QTimer m_externalLoginTimer; + QString m_code; + QUrl m_codeUrl; + int m_externalLoginElapsed = 0; + int m_externalLoginTimeout = 0; }; diff --git a/launcher/ui/dialogs/AccountsDialog.ui b/launcher/ui/dialogs/AccountsDialog.ui index a12f8cf4..c02ceadd 100644 --- a/launcher/ui/dialogs/AccountsDialog.ui +++ b/launcher/ui/dialogs/AccountsDialog.ui @@ -6,14 +6,17 @@ 0 0 - 954 - 880 + 1085 + 1022 - - - 0 - + + + 0 + 0 + + + 0 @@ -27,479 +30,677 @@ 0 - - - - 0 - 0 - + + + Accounts - - - 250 - 0 - - - - - 250 - 16777215 - - - - true - - - - - - - - 0 - 0 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - 0 - - - - - - - - - - - - - - 15 - - - - Account / Profile Name Here - - - - - - - Rename - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Skins - - - true - - - - - - - false - - - Capes - - - true - - - true - - - false - - - - - - - Qt::Vertical - - - - - - - Sign Out - - - - - - - - - - - - - 300 - 400 - - - - - 16777215 - 400 - - - - - - - - Reset Skin - - - - - - - Save Skin - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - QFrame::Panel - - - QFrame::Sunken - - - 1 - - - - - - - - - - - - - - - - - - - - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + - + 0 0 - - - 500 - 0 - - - QFrame::StyledPanel + QFrame::NoFrame QFrame::Raised - - - - - - 0 - 0 - - - - You just need to take one more step to be able to play Minecraft on this account. + + 0 + + + + + 0 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + 32 + 32 + + + + + + + + + 15 + + + + Account / Profile Name Here + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh + + + + + + + Sign Out + + + + + + + + + + + 0 + 0 + + + + Qt::Vertical + + + false + + + + + 0 + 1 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Open Skins Folder + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Model + + + + + + Classic + + + true + + + modelButtonGroup + + + + + + + Slim + + + modelButtonGroup + + + + + + + + + + Reset Changes + + + + + + + Apply Changes + + + + + + + Save Skin To File + + + + + + + + 0 + 0 + + + + QFrame::Panel + + + QFrame::Sunken + + + 1 + + + + + + + + + 0 + 0 + + + + QTabWidget::East + + + 0 + + + + Skins + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ScrollBarAlwaysOff + + + QListView::Adjust + + + 5 + + + QListView::IconMode + + + + + + + + Capes + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::ScrollBarAlwaysOff + + + QListView::IconMode + + + + + + + + + + + + + + 0 + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 300 + 0 + + + + false + + + + + + + + 0 + 0 + + + + link here + + + + + + + 300 + 300 + + + + Qt::ToolButtonTextUnderIcon + + + + + + + + 0 + 0 + + + + Get Fresh Code + + + + + + + + + + false + + + true + + + + + + + + + + + + 0 + + + 6 + + + 6 + + + 6 + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 32 + 32 + + + + + 32 + 32 + + + + + + + + + 15 + + + + Account Name Here + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh + + + + + + + Sign Out + + + + + + + + + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 0 + 0 + + + + You just need to take one more step to be able to play Minecraft on this account. Choose your name carefully: - - - true - - - createProfileNameEdit - - - - - - - - - - true - - - - 0 - 0 - - - - Errors go here - - - true - - - true - - - Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse - - - - - - - Create Profile - - - - + + + true + + + createProfileNameEdit + + + + + + + + + + true + + + + 0 + 0 + + + + Errors go here + + + true + + + true + + + Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + Create Profile + + + + + + + + + + + - - - - - - - - 0 - 0 - - - - - 32 - 32 - - - - - 32 - 32 - - - - - - - - - 15 - - - - Account Name Here - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - false - - - Skins - - - true - - - - - - - false - - - Capes - - - true - - - true - - - false - - - - - - - Qt::Vertical - - - - - - - Sign Out - - - - + + + + + 0 + 0 + + + + QFrame::NoFrame + + + Qt::ScrollBarAlwaysOn + + + Qt::ScrollBarAlwaysOff + + + true + + + QListView::Adjust + + + true + - - - - Qt::Horizontal - - - - 87 - 809 - - - - - - - - Qt::Horizontal - - - - 87 - 809 - - - - - - - - Qt::Vertical - - - - 497 - 296 - - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 497 - 297 - - - - + + + WrapLabel + QTextEdit +
ui/widgets/WrapLabel.h
+
IconLabel32 QWidget
ui/widgets/IconLabel.h
1
+ + SkinWidget + QFrame +
skins/SkinWidget.h
+ 1 +
accountListView - skinsTabToggle - capesTabToggle capesView signOutButton - pushButton - pushButton_2 - renameButton - capesTabToggle_Setup - createProfileNameEdit - skinsTabToggle_Setup - createProfileButton skinsView signOutButton_Setup + + + diff --git a/launcher/ui/dialogs/CreateShortcutDialog.cpp b/launcher/ui/dialogs/CreateShortcutDialog.cpp index dc8415e6..af79084a 100644 --- a/launcher/ui/dialogs/CreateShortcutDialog.cpp +++ b/launcher/ui/dialogs/CreateShortcutDialog.cpp @@ -33,7 +33,12 @@ CreateShortcutDialog::CreateShortcutDialog(QWidget *parent, InstancePtr instance for (int i = 0; i < accounts->count(); i++) { - accountNameList.append(accounts->at(i)->profileName()); + auto entry = accounts->at(i); + if(!entry.isAccount) + { + continue; + } + accountNameList.append(entry.account->profileName()); } ui->profileComboBox->addItems(accountNameList); @@ -259,4 +264,4 @@ void CreateShortcutDialog::createWindowsLink(LPCSTR target, LPCSTR workingDir, L } CoUninitialize(); } -#endif \ No newline at end of file +#endif diff --git a/launcher/ui/dialogs/MSALoginDialog.cpp b/launcher/ui/dialogs/MSALoginDialog.cpp deleted file mode 100644 index 7f84d561..00000000 --- a/launcher/ui/dialogs/MSALoginDialog.cpp +++ /dev/null @@ -1,153 +0,0 @@ -/* Copyright 2013-2023 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "MSALoginDialog.h" -#include "ui_MSALoginDialog.h" - -#include "minecraft/auth/AccountTask.h" - -#include -#include -#include -#include -#include -#include - -MSALoginDialog::MSALoginDialog(QWidget *parent) : QDialog(parent), ui(new Ui::MSALoginDialog) -{ - ui->setupUi(this); - ui->progressBar->setVisible(false); -} - -int MSALoginDialog::exec() { - ui->linkButton->setVisible(false); - - // Setup the login task and start it - m_account = MinecraftAccount::createBlankMSA(); - m_loginTask = m_account->loginMSA(); - connect(ui->linkButton, &QToolButton::clicked, this, &MSALoginDialog::onButtonClicked); - connect(m_loginTask.get(), &Task::failed, this, &MSALoginDialog::onTaskFailed); - connect(m_loginTask.get(), &Task::succeeded, this, &MSALoginDialog::onTaskSucceeded); - connect(m_loginTask.get(), &Task::status, this, &MSALoginDialog::onTaskStatus); - connect(m_loginTask.get(), &Task::progress, this, &MSALoginDialog::onTaskProgress); - connect(m_loginTask.get(), &AccountTask::showVerificationUriAndCode, this, &MSALoginDialog::showVerificationUriAndCode); - connect(m_loginTask.get(), &AccountTask::hideVerificationUriAndCode, this, &MSALoginDialog::hideVerificationUriAndCode); - connect(&m_externalLoginTimer, &QTimer::timeout, this, &MSALoginDialog::externalLoginTick); - m_loginTask->start(); - - return QDialog::exec(); -} - - -MSALoginDialog::~MSALoginDialog() -{ - delete ui; -} - -void MSALoginDialog::onButtonClicked(bool) -{ - QDesktopServices::openUrl(m_codeUrl); -} - - -void MSALoginDialog::externalLoginTick() { - m_externalLoginElapsed++; - ui->progressBar->setValue(m_externalLoginTimeout - m_externalLoginElapsed); - ui->progressBar->repaint(); - - if(m_externalLoginElapsed >= m_externalLoginTimeout) { - m_externalLoginTimer.stop(); - close(); - } -} - - -void MSALoginDialog::showVerificationUriAndCode(const QUrl& uri, const QString& code, int expiresIn) { - m_externalLoginElapsed = 0; - m_externalLoginTimeout = expiresIn; - - m_externalLoginTimer.setInterval(1000); - m_externalLoginTimer.setSingleShot(false); - m_externalLoginTimer.start(); - - ui->progressBar->setMaximum(expiresIn); - ui->progressBar->setValue(m_externalLoginTimeout - m_externalLoginElapsed); - ui->progressBar->setVisible(true); - - m_codeUrl = uri; - QUrlQuery query; - query.addQueryItem("otc", code); - m_codeUrl.setQuery(query); - QString codeUrlString = m_codeUrl.toString(); - - QImage qrcode = qrcode::generateQr(codeUrlString, 300); - ui->linkButton->setIcon(QPixmap::fromImage(qrcode)); - ui->linkButton->setText(codeUrlString); - ui->linkButton->setVisible(true); - - ui->label->setText(tr("You can scan the QR code and complete the login process on a separate device, or you can open the link and login on this machine.")); - m_code = code; -} - -void MSALoginDialog::hideVerificationUriAndCode() { - ui->linkButton->setVisible(false); - ui->progressBar->setVisible(false); - m_externalLoginTimer.stop(); -} - -void MSALoginDialog::onTaskFailed(const QString &reason) -{ - // Set message - auto lines = reason.split('\n'); - QString processed; - for(auto line: lines) { - if(line.size()) { - processed += "" + line + "
"; - } - else { - processed += "
"; - } - } - ui->label->setText(processed); - - ui->progressBar->setVisible(false); -} - -void MSALoginDialog::onTaskSucceeded() -{ - QDialog::accept(); -} - -void MSALoginDialog::onTaskStatus(const QString &status) -{ - ui->label->setText(status); -} - -void MSALoginDialog::onTaskProgress(qint64 current, qint64 total) -{ - ui->progressBar->setMaximum(total); - ui->progressBar->setValue(current); -} - -// Public interface -MinecraftAccountPtr MSALoginDialog::newAccount(QWidget *parent) -{ - MSALoginDialog dlg(parent); - if (dlg.exec() == QDialog::Accepted) - { - return dlg.m_account; - } - return 0; -} diff --git a/launcher/ui/dialogs/MSALoginDialog.h b/launcher/ui/dialogs/MSALoginDialog.h deleted file mode 100644 index 6b3d26b0..00000000 --- a/launcher/ui/dialogs/MSALoginDialog.h +++ /dev/null @@ -1,66 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include -#include -#include - -#include "minecraft/auth/MinecraftAccount.h" - -namespace Ui -{ -class MSALoginDialog; -} - -class MSALoginDialog : public QDialog -{ - Q_OBJECT - -public: - ~MSALoginDialog(); - - static MinecraftAccountPtr newAccount(QWidget *parent); - int exec() override; - -private: - explicit MSALoginDialog(QWidget *parent = 0); - - void setUserInputsEnabled(bool enable); - -protected slots: - void onButtonClicked(bool); - void onTaskFailed(const QString &reason); - void onTaskSucceeded(); - void onTaskStatus(const QString &status); - void onTaskProgress(qint64 current, qint64 total); - void showVerificationUriAndCode(const QUrl &uri, const QString &code, int expiresIn); - void hideVerificationUriAndCode(); - - void externalLoginTick(); - -private: - Ui::MSALoginDialog *ui; - MinecraftAccountPtr m_account; - shared_qobject_ptr m_loginTask; - QTimer m_externalLoginTimer; - QString m_code; - QUrl m_codeUrl; - int m_externalLoginElapsed = 0; - int m_externalLoginTimeout = 0; -}; - diff --git a/launcher/ui/dialogs/MSALoginDialog.ui b/launcher/ui/dialogs/MSALoginDialog.ui deleted file mode 100644 index d8374e2c..00000000 --- a/launcher/ui/dialogs/MSALoginDialog.ui +++ /dev/null @@ -1,128 +0,0 @@ - - - MSALoginDialog - - - - 0 - 0 - 424 - 376 - - - - - 0 - 0 - - - - Add Microsoft Account - - - - - - Qt::Horizontal - - - - 112 - 20 - - - - - - - - Qt::Horizontal - - - - 111 - 20 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 0 - 0 - - - - link here - - - - - - - 300 - 300 - - - - Qt::ToolButtonTextUnderIcon - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - false - - - - - - - false - - - true - - - - - - - - WrapLabel - QTextEdit -
ui/widgets/WrapLabel.h
-
-
- - -
diff --git a/launcher/ui/dialogs/ProfileSelectDialog.cpp b/launcher/ui/dialogs/ProfileSelectDialog.cpp index 7882cf45..37bcfd2f 100644 --- a/launcher/ui/dialogs/ProfileSelectDialog.cpp +++ b/launcher/ui/dialogs/ProfileSelectDialog.cpp @@ -19,7 +19,6 @@ #include #include -#include "SkinUtils.h" #include "Application.h" #include "ui/dialogs/ProgressDialog.h" @@ -47,7 +46,12 @@ ProfileSelectDialog::ProfileSelectDialog(const QString &message, int flags, QWid QList items; for (int i = 0; i < m_accounts->count(); i++) { - MinecraftAccountPtr account = m_accounts->at(i); + auto entry = m_accounts->at(i); + if(!entry.isAccount) + { + continue; + } + MinecraftAccountPtr account = entry.account; QString profileLabel; if(account->isInUse()) { profileLabel = tr("%1 (in use)").arg(account->profileName()); diff --git a/launcher/ui/dialogs/ProfileSetupDialog.cpp b/launcher/ui/dialogs/ProfileSetupDialog.cpp deleted file mode 100644 index a3d66f61..00000000 --- a/launcher/ui/dialogs/ProfileSetupDialog.cpp +++ /dev/null @@ -1,314 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "ProfileSetupDialog.h" -#include "ui_ProfileSetupDialog.h" - -#include -#include -#include -#include -#include - -#include "ui/dialogs/ProgressDialog.h" - -#include -#include "minecraft/auth/AuthRequest.h" -#include "minecraft/auth/Parsers.h" - -#include "BuildConfig.h" - -ProfileSetupDialog::ProfileSetupDialog(MinecraftAccountPtr accountToSetup, QWidget *parent) - : QDialog(parent), m_accountToSetup(accountToSetup), ui(new Ui::ProfileSetupDialog) -{ - ui->setupUi(this); - ui->errorLabel->setVisible(false); - - goodIcon = APPLICATION->getThemedIcon("status-good"); - yellowIcon = APPLICATION->getThemedIcon("status-yellow"); - badIcon = APPLICATION->getThemedIcon("status-bad"); - - QRegExp permittedNames("[a-zA-Z0-9_]{3,16}"); - auto nameEdit = ui->nameEdit; - nameEdit->setValidator(new QRegExpValidator(permittedNames)); - nameEdit->setClearButtonEnabled(true); - validityAction = nameEdit->addAction(yellowIcon, QLineEdit::LeadingPosition); - connect(nameEdit, &QLineEdit::textEdited, this, &ProfileSetupDialog::nameEdited); - - checkStartTimer.setSingleShot(true); - connect(&checkStartTimer, &QTimer::timeout, this, &ProfileSetupDialog::startCheck); - - setNameStatus(NameStatus::NotSet, QString()); -} - -ProfileSetupDialog::~ProfileSetupDialog() -{ - delete ui; -} - -void ProfileSetupDialog::on_buttonBox_accepted() -{ - setupProfile(currentCheck); -} - -void ProfileSetupDialog::on_buttonBox_rejected() -{ - reject(); -} - -void ProfileSetupDialog::setNameStatus(ProfileSetupDialog::NameStatus status, QString errorString = QString()) -{ - nameStatus = status; - auto okButton = ui->buttonBox->button(QDialogButtonBox::Ok); - switch(nameStatus) - { - case NameStatus::Available: { - validityAction->setIcon(goodIcon); - okButton->setEnabled(true); - } - break; - case NameStatus::NotSet: - case NameStatus::Pending: - validityAction->setIcon(yellowIcon); - okButton->setEnabled(false); - break; - case NameStatus::Exists: - case NameStatus::Error: - validityAction->setIcon(badIcon); - okButton->setEnabled(false); - break; - } - if(!errorString.isEmpty()) { - ui->errorLabel->setText(errorString); - ui->errorLabel->setVisible(true); - } - else { - ui->errorLabel->setVisible(false); - } -} - -void ProfileSetupDialog::nameEdited(const QString& name) -{ - if(!ui->nameEdit->hasAcceptableInput()) { - setNameStatus(NameStatus::NotSet, tr("Name is too short - must be between 3 and 16 characters long.")); - return; - } - scheduleCheck(name); -} - -void ProfileSetupDialog::scheduleCheck(const QString& name) { - queuedCheck = name; - setNameStatus(NameStatus::Pending); - checkStartTimer.start(1000); -} - -void ProfileSetupDialog::startCheck() { - if(isChecking) { - return; - } - if(queuedCheck.isNull()) { - return; - } - checkName(queuedCheck); -} - - -void ProfileSetupDialog::checkName(const QString &name) { - if(isChecking) { - return; - } - - currentCheck = name; - isChecking = true; - - auto token = m_accountToSetup->accessToken(); - - auto url = QString("%1/minecraft/profile/name/%2/available").arg(BuildConfig.API_BASE).arg(name); - QNetworkRequest request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "application/json"); - request.setRawHeader("Authorization", QString("Bearer %1").arg(token).toUtf8()); - - AuthRequest *requestor = new AuthRequest(this); - connect(requestor, &AuthRequest::finished, this, &ProfileSetupDialog::checkFinished); - requestor->get(request); -} - -void ProfileSetupDialog::checkFinished( - QNetworkReply::NetworkError error, - QByteArray data, - QList headers -) { - auto requestor = qobject_cast(QObject::sender()); - requestor->deleteLater(); - - if(error == QNetworkReply::NoError) { - auto doc = QJsonDocument::fromJson(data); - auto root = doc.object(); - auto statusValue = root.value("status").toString("INVALID"); - if(statusValue == "AVAILABLE") { - setNameStatus(NameStatus::Available); - } - else if (statusValue == "DUPLICATE") { - setNameStatus(NameStatus::Exists, tr("Minecraft profile with name %1 already exists.").arg(currentCheck)); - } - else if (statusValue == "NOT_ALLOWED") { - setNameStatus(NameStatus::Exists, tr("The name %1 is not allowed.").arg(currentCheck)); - } - else { - setNameStatus(NameStatus::Error, tr("Unhandled profile name status: %1").arg(statusValue)); - } - } - else { - setNameStatus(NameStatus::Error, tr("Failed to check name availability.")); - } - isChecking = false; -} - -void ProfileSetupDialog::setupProfile(const QString &profileName) { - if(isWorking) { - return; - } - - auto token = m_accountToSetup->accessToken(); - - auto url = QString("%1/minecraft/profile").arg(BuildConfig.API_BASE); - QNetworkRequest request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "application/json"); - request.setRawHeader("Authorization", QString("Bearer %1").arg(token).toUtf8()); - - QString payloadTemplate("{\"profileName\":\"%1\"}"); - auto data = payloadTemplate.arg(profileName).toUtf8(); - - AuthRequest *requestor = new AuthRequest(this); - connect(requestor, &AuthRequest::finished, this, &ProfileSetupDialog::setupProfileFinished); - requestor->post(request, data); - isWorking = true; - - auto button = ui->buttonBox->button(QDialogButtonBox::Cancel); - button->setEnabled(false); -} - -namespace { - -struct MojangError{ - static MojangError fromJSON(QByteArray data, QNetworkReply::NetworkError networkError) { - MojangError out; - out.rawError = QString::fromUtf8(data); - out.networkError = networkError; - - auto doc = QJsonDocument::fromJson(data, &out.parseError); - if(out.parseError.error != QJsonParseError::NoError) - { - out.jsonParsed = false; - } - else - { - auto object = doc.object(); - Parsers::getString(object.value("path"), out.path); - QJsonValue details = object.value("details"); - if(details.isObject()) - { - QJsonObject detailsObj = details.toObject(); - Parsers::getString(detailsObj.value("status"), out.detailsStatus); - } - Parsers::getString(object.value("error"), out.error); - Parsers::getString(object.value("errorMessage"), out.errorMessage); - out.jsonParsed = true; - } - - - return out; - } - QString toString() const - { - QString outString; - QTextStream out(&outString); - out << "Network error:" << networkError << "\n"; - if(jsonParsed) - { - if(!path.isNull()) - { - out << "path: " << path << "\n"; - } - if(!error.isNull()) - { - out << "error: " << error << "\n"; - } - if(!errorMessage.isNull()) - { - out << "errorMessage: " << errorMessage << "\n"; - } - if(!detailsStatus.isNull()) - { - out << "details.status: " << detailsStatus << "\n"; - } - } - else - { - out << "Mojang error failed to parse with error: " << parseError.errorString() << "\n"; - out << "Raw contents:\n" << rawError << "\n"; - } - return outString; - } - - QNetworkReply::NetworkError networkError; - QString rawError; - - QJsonParseError parseError; - bool jsonParsed = false; - - QString path; - QString error; - QString errorMessage; - QString detailsStatus; -}; - -} - -void ProfileSetupDialog::setupProfileFinished( - QNetworkReply::NetworkError error, - QByteArray data, - QList headers -) { - auto requestor = qobject_cast(QObject::sender()); - requestor->deleteLater(); - - isWorking = false; - if(error == QNetworkReply::NoError) { - /* - * data contains the profile in the response - * ... we could parse it and update the account, but let's just return back to the normal login flow instead... - */ - accept(); - return; - } - else { - auto parsedError = MojangError::fromJSON(data, error); - // Apparently, this is something that can happen... - if(parsedError.detailsStatus == "ALREADY_REGISTERED") - { - accept(); - return; - } - ui->errorLabel->setVisible(true); - QString errorString = parsedError.toString(); - ui->errorLabel->setText(tr("The server returned the following error:") + "\n\n" + errorString); - qWarning() << "Failed to set up player profile: " << errorString; - auto button = ui->buttonBox->button(QDialogButtonBox::Cancel); - button->setEnabled(true); - } -} diff --git a/launcher/ui/dialogs/ProfileSetupDialog.h b/launcher/ui/dialogs/ProfileSetupDialog.h deleted file mode 100644 index 6f413ebd..00000000 --- a/launcher/ui/dialogs/ProfileSetupDialog.h +++ /dev/null @@ -1,88 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include -#include -#include - -#include -#include - -namespace Ui -{ -class ProfileSetupDialog; -} - -class ProfileSetupDialog : public QDialog -{ - Q_OBJECT -public: - - explicit ProfileSetupDialog(MinecraftAccountPtr accountToSetup, QWidget *parent = 0); - ~ProfileSetupDialog(); - - enum class NameStatus - { - NotSet, - Pending, - Available, - Exists, - Error - } nameStatus = NameStatus::NotSet; - -private slots: - void on_buttonBox_accepted(); - void on_buttonBox_rejected(); - - void nameEdited(const QString &name); - void checkFinished( - QNetworkReply::NetworkError error, - QByteArray data, - QList headers - ); - void startCheck(); - - void setupProfileFinished( - QNetworkReply::NetworkError error, - QByteArray data, - QList headers - ); -protected: - void scheduleCheck(const QString &name); - void checkName(const QString &name); - void setNameStatus(NameStatus status, QString errorString); - - void setupProfile(const QString & profileName); - -private: - MinecraftAccountPtr m_accountToSetup; - Ui::ProfileSetupDialog *ui; - QIcon goodIcon; - QIcon yellowIcon; - QIcon badIcon; - QAction * validityAction = nullptr; - - QString queuedCheck; - - bool isChecking = false; - bool isWorking = false; - QString currentCheck; - - QTimer checkStartTimer; -}; - diff --git a/launcher/ui/dialogs/ProfileSetupDialog.ui b/launcher/ui/dialogs/ProfileSetupDialog.ui deleted file mode 100644 index 9dbabb4b..00000000 --- a/launcher/ui/dialogs/ProfileSetupDialog.ui +++ /dev/null @@ -1,74 +0,0 @@ - - - ProfileSetupDialog - - - - 0 - 0 - 615 - 208 - - - - Choose Minecraft name - - - - - - - 0 - 0 - - - - You just need to take one more step to be able to play Minecraft on this account. - -Choose your name carefully: - - - true - - - nameEdit - - - - - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - true - - - Errors go here - - - true - - - true - - - Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse - - - - - - - nameEdit - - - - diff --git a/launcher/ui/dialogs/SkinUploadDialog.cpp b/launcher/ui/dialogs/SkinUploadDialog.cpp deleted file mode 100644 index 6a5a324f..00000000 --- a/launcher/ui/dialogs/SkinUploadDialog.cpp +++ /dev/null @@ -1,146 +0,0 @@ -#include -#include -#include - -#include - -#include -#include -#include - -#include "SkinUploadDialog.h" -#include "ui_SkinUploadDialog.h" -#include "ProgressDialog.h" -#include "CustomMessageBox.h" - -void SkinUploadDialog::on_buttonBox_rejected() -{ - close(); -} - -void SkinUploadDialog::on_buttonBox_accepted() -{ - QString fileName; - QString input = ui->skinPathTextBox->text(); - QRegExp urlPrefixMatcher("^([a-z]+)://.+$"); - bool isLocalFile = false; - // it has an URL prefix -> it is an URL - if(urlPrefixMatcher.exactMatch(input)) - { - QUrl fileURL = input; - if(fileURL.isValid()) - { - // local? - if(fileURL.isLocalFile()) - { - isLocalFile = true; - fileName = fileURL.toLocalFile(); - } - else - { - CustomMessageBox::selectable( - this, - tr("Skin Upload"), - tr("Using remote URLs for setting skins is not implemented yet."), - QMessageBox::Warning - )->exec(); - close(); - return; - } - } - else - { - CustomMessageBox::selectable( - this, - tr("Skin Upload"), - tr("You cannot use an invalid URL for uploading skins."), - QMessageBox::Warning - )->exec(); - close(); - return; - } - } - else - { - // just assume it's a path then - isLocalFile = true; - fileName = ui->skinPathTextBox->text(); - } - if (isLocalFile && !QFile::exists(fileName)) - { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Skin file does not exist!"), QMessageBox::Warning)->exec(); - close(); - return; - } - SkinUpload::Model model = SkinUpload::STEVE; - if (ui->steveBtn->isChecked()) - { - model = SkinUpload::STEVE; - } - else if (ui->alexBtn->isChecked()) - { - model = SkinUpload::ALEX; - } - ProgressDialog prog(this); - SequentialTask skinUpload; - skinUpload.addTask(shared_qobject_ptr(new SkinUpload(this, m_acct->accessToken(), FS::read(fileName), model))); - auto selectedCape = ui->capeCombo->currentData().toString(); - if(selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { - skinUpload.addTask(shared_qobject_ptr(new CapeChange(this, m_acct->accessToken(), selectedCape))); - } - if (prog.execWithTask(&skinUpload) != QDialog::Accepted) - { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Failed to upload skin!"), QMessageBox::Warning)->exec(); - close(); - return; - } - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Success"), QMessageBox::Information)->exec(); - close(); -} - -void SkinUploadDialog::on_skinBrowseBtn_clicked() -{ - QString raw_path = QFileDialog::getOpenFileName(this, tr("Select Skin Texture"), QString(), "*.png"); - if (raw_path.isEmpty() || !QFileInfo::exists(raw_path)) - { - return; - } - QString cooked_path = FS::NormalizePath(raw_path); - ui->skinPathTextBox->setText(cooked_path); -} - -SkinUploadDialog::SkinUploadDialog(MinecraftAccountPtr acct, QWidget *parent) - :QDialog(parent), m_acct(acct), ui(new Ui::SkinUploadDialog) -{ - ui->setupUi(this); - - // FIXME: add a model for this, download/refresh the capes on demand - auto &data = *acct->accountData(); - int index = 0; - ui->capeCombo->addItem(tr("No Cape"), QVariant()); - auto currentCape = data.minecraftProfile.currentCape; - if(currentCape.isEmpty()) { - ui->capeCombo->setCurrentIndex(index); - } - - for(auto & cape: data.minecraftProfile.capes) { - index++; - if(cape.data.size()) { - QPixmap capeImage; - if(capeImage.loadFromData(cape.data, "PNG")) { - QPixmap preview = QPixmap(10, 16); - QPainter painter(&preview); - painter.drawPixmap(0, 0, capeImage.copy(1, 1, 10, 16)); - ui->capeCombo->addItem(capeImage, cape.alias, cape.id); - if(currentCape == cape.id) { - ui->capeCombo->setCurrentIndex(index); - } - continue; - } - } - ui->capeCombo->addItem(cape.alias, cape.id); - if(currentCape == cape.id) { - ui->capeCombo->setCurrentIndex(index); - } - } -} diff --git a/launcher/ui/dialogs/SkinUploadDialog.h b/launcher/ui/dialogs/SkinUploadDialog.h deleted file mode 100644 index 84d17dc6..00000000 --- a/launcher/ui/dialogs/SkinUploadDialog.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include -#include - -namespace Ui -{ - class SkinUploadDialog; -} - -class SkinUploadDialog : public QDialog { - Q_OBJECT -public: - explicit SkinUploadDialog(MinecraftAccountPtr acct, QWidget *parent = 0); - virtual ~SkinUploadDialog() {}; - -public slots: - void on_buttonBox_accepted(); - - void on_buttonBox_rejected(); - - void on_skinBrowseBtn_clicked(); - -protected: - MinecraftAccountPtr m_acct; - -private: - Ui::SkinUploadDialog *ui; -}; diff --git a/launcher/ui/dialogs/SkinUploadDialog.ui b/launcher/ui/dialogs/SkinUploadDialog.ui deleted file mode 100644 index f4b0ed0a..00000000 --- a/launcher/ui/dialogs/SkinUploadDialog.ui +++ /dev/null @@ -1,97 +0,0 @@ - - - SkinUploadDialog - - - - 0 - 0 - 394 - 360 - - - - Skin Upload - - - - - - Skin File - - - - - - - - - - 0 - 0 - - - - - 28 - 16777215 - - - - ... - - - - - - - - - - Player Model - - - - - - Steve Model - - - true - - - - - - - Alex Model - - - - - - - - - - Cape - - - - - - - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - diff --git a/launcher/ui/pages/global/AccountListPage.cpp b/launcher/ui/pages/global/AccountListPage.cpp deleted file mode 100644 index 4bf9d33e..00000000 --- a/launcher/ui/pages/global/AccountListPage.cpp +++ /dev/null @@ -1,213 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "AccountListPage.h" -#include "ui_AccountListPage.h" - -#include -#include - -#include - -#include "net/NetJob.h" - -#include "ui/dialogs/ProgressDialog.h" -#include "ui/dialogs/MSALoginDialog.h" -#include "ui/dialogs/CustomMessageBox.h" -#include "ui/dialogs/SkinUploadDialog.h" - -#include "tasks/Task.h" -#include "minecraft/auth/AccountTask.h" -#include "minecraft/services/SkinDelete.h" - -#include "Application.h" - -#include "BuildConfig.h" - -AccountListPage::AccountListPage(QWidget *parent) - : QMainWindow(parent), ui(new Ui::AccountListPage) -{ - ui->setupUi(this); - ui->listView->setEmptyString(tr( - "Welcome!\n" - "If you're new here, you can click the \"Add\" button to add your Mojang or Minecraft account." - )); - ui->listView->setEmptyMode(VersionListView::String); - ui->listView->setContextMenuPolicy(Qt::CustomContextMenu); - - m_accounts = APPLICATION->accounts(); - - ui->listView->setModel(m_accounts.get()); - ui->listView->header()->setSectionResizeMode(0, QHeaderView::Stretch); - ui->listView->header()->setSectionResizeMode(1, QHeaderView::Stretch); - ui->listView->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); - ui->listView->setSelectionMode(QAbstractItemView::SingleSelection); - - // Expand the account column - - QItemSelectionModel *selectionModel = ui->listView->selectionModel(); - - connect(selectionModel, &QItemSelectionModel::selectionChanged, [this](const QItemSelection &sel, const QItemSelection &dsel) { - updateButtonStates(); - }); - connect(ui->listView, &VersionListView::customContextMenuRequested, this, &AccountListPage::ShowContextMenu); - - connect(m_accounts.get(), &AccountList::listChanged, this, &AccountListPage::listChanged); - connect(m_accounts.get(), &AccountList::listActivityChanged, this, &AccountListPage::listChanged); - connect(m_accounts.get(), &AccountList::defaultAccountChanged, this, &AccountListPage::listChanged); - - updateButtonStates(); - - // Xbox authentication won't work without a client identifier, so disable the button if it is missing - ui->actionAddMicrosoft->setVisible(!BuildConfig.MSA_CLIENT_ID.isEmpty()); -} - -AccountListPage::~AccountListPage() -{ - delete ui; -} - -void AccountListPage::ShowContextMenu(const QPoint& pos) -{ - auto menu = ui->toolBar->createContextMenu(this, tr("Context menu")); - menu->exec(ui->listView->mapToGlobal(pos)); - delete menu; -} - -void AccountListPage::changeEvent(QEvent* event) -{ - if (event->type() == QEvent::LanguageChange) - { - ui->retranslateUi(this); - } - QMainWindow::changeEvent(event); -} - -QMenu * AccountListPage::createPopupMenu() -{ - QMenu* filteredMenu = QMainWindow::createPopupMenu(); - filteredMenu->removeAction(ui->toolBar->toggleViewAction() ); - return filteredMenu; -} - - -void AccountListPage::listChanged() -{ - updateButtonStates(); -} - -void AccountListPage::on_actionAddMicrosoft_triggered() -{ - MinecraftAccountPtr account = MSALoginDialog::newAccount(this); - if (account) - { - m_accounts->addAccount(account); - if (m_accounts->count() == 1) { - m_accounts->setDefaultAccount(account); - } - } -} - -void AccountListPage::on_actionRemove_triggered() -{ - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - if (selection.size() > 0) - { - QModelIndex selected = selection.first(); - m_accounts->removeAccount(selected); - } -} - -void AccountListPage::on_actionRefresh_triggered() { - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - if (selection.size() > 0) { - QModelIndex selected = selection.first(); - MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - m_accounts->requestRefresh(account->internalId()); - } -} - - -void AccountListPage::on_actionSetDefault_triggered() -{ - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - if (selection.size() > 0) - { - QModelIndex selected = selection.first(); - MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - m_accounts->setDefaultAccount(account); - } -} - -void AccountListPage::on_actionNoDefault_triggered() -{ - m_accounts->setDefaultAccount(nullptr); -} - -void AccountListPage::updateButtonStates() -{ - // If there is no selection, disable buttons that require something selected. - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - bool hasSelection = selection.size() > 0; - bool accountIsReady = false; - if (hasSelection) - { - QModelIndex selected = selection.first(); - MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - accountIsReady = !account->isActive(); - } - ui->actionRemove->setEnabled(accountIsReady); - ui->actionSetDefault->setEnabled(accountIsReady); - ui->actionUploadSkin->setEnabled(accountIsReady); - ui->actionDeleteSkin->setEnabled(accountIsReady); - ui->actionRefresh->setEnabled(accountIsReady); - - if(m_accounts->defaultAccount().get() == nullptr) { - ui->actionNoDefault->setEnabled(false); - ui->actionNoDefault->setChecked(true); - } - else { - ui->actionNoDefault->setEnabled(true); - ui->actionNoDefault->setChecked(false); - } -} - -void AccountListPage::on_actionUploadSkin_triggered() -{ - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - if (selection.size() > 0) - { - QModelIndex selected = selection.first(); - MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - SkinUploadDialog dialog(account, this); - dialog.exec(); - } -} - -void AccountListPage::on_actionDeleteSkin_triggered() -{ - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - if (selection.size() <= 0) - return; - - QModelIndex selected = selection.first(); - MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - ProgressDialog prog(this); - auto deleteSkinTask = std::make_shared(this, account->accessToken()); - if (prog.execWithTask((Task*)deleteSkinTask.get()) != QDialog::Accepted) { - CustomMessageBox::selectable(this, tr("Skin Delete"), tr("Failed to delete current skin!"), QMessageBox::Warning)->exec(); - return; - } -} diff --git a/launcher/ui/pages/global/AccountListPage.h b/launcher/ui/pages/global/AccountListPage.h deleted file mode 100644 index 83105604..00000000 --- a/launcher/ui/pages/global/AccountListPage.h +++ /dev/null @@ -1,84 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include -#include - -#include "ui/pages/BasePage.h" - -#include "minecraft/auth/AccountList.h" -#include "Application.h" - -namespace Ui -{ -class AccountListPage; -} - -class AuthenticateTask; - -class AccountListPage : public QMainWindow, public BasePage -{ - Q_OBJECT -public: - explicit AccountListPage(QWidget *parent = 0); - ~AccountListPage(); - - QString displayName() const override - { - return tr("Accounts"); - } - QIcon icon() const override - { - auto icon = APPLICATION->getThemedIcon("accounts"); - if(icon.isNull()) - { - icon = APPLICATION->getThemedIcon("noaccount"); - } - return icon; - } - QString id() const override - { - return "accounts"; - } - QString helpPage() const override - { - return "Getting-Started#adding-an-account"; - } - -public slots: - void on_actionAddMicrosoft_triggered(); - void on_actionRemove_triggered(); - void on_actionRefresh_triggered(); - void on_actionSetDefault_triggered(); - void on_actionNoDefault_triggered(); - void on_actionUploadSkin_triggered(); - void on_actionDeleteSkin_triggered(); - - void listChanged(); - - //! Updates the states of the dialog's buttons. - void updateButtonStates(); - -protected slots: - void ShowContextMenu(const QPoint &pos); - -private: - void changeEvent(QEvent * event) override; - QMenu * createPopupMenu() override; - shared_qobject_ptr m_accounts; - Ui::AccountListPage *ui; -}; diff --git a/launcher/ui/pages/global/AccountListPage.ui b/launcher/ui/pages/global/AccountListPage.ui deleted file mode 100644 index 96d0dc75..00000000 --- a/launcher/ui/pages/global/AccountListPage.ui +++ /dev/null @@ -1,123 +0,0 @@ - - - AccountListPage - - - - 0 - 0 - 800 - 600 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - true - - - false - - - false - - - true - - - false - - - - - - - - RightToolBarArea - - - false - - - - - - - - - - - - - Remove - - - - - Set Default - - - - - true - - - No Default - - - - - Upload Skin - - - - - Delete Skin - - - Delete the currently active skin and go back to the default one - - - - - Add Microsoft - - - - - Refresh - - - Refresh the account tokens - - - - - - VersionListView - QTreeView -
ui/widgets/VersionListView.h
-
- - WideBar - QToolBar -
ui/widgets/WideBar.h
-
-
- - -
diff --git a/launcher/ui/pages/global/LauncherPage.cpp b/launcher/ui/pages/global/LauncherPage.cpp index 23435b5d..266aac25 100644 --- a/launcher/ui/pages/global/LauncherPage.cpp +++ b/launcher/ui/pages/global/LauncherPage.cpp @@ -139,6 +139,17 @@ void LauncherPage::on_modsDirBrowseBtn_clicked() ui->modsDirTextBox->setText(cooked_dir); } } +void LauncherPage::on_skinsDirBrowseBtn_clicked() +{ + QString raw_dir = QFileDialog::getExistingDirectory(this, tr("Skins Folder"), ui->skinsDirTextBox->text()); + + // do not allow current dir - it's dirty. Do not allow dirs that don't exist + if (!raw_dir.isEmpty() && QDir(raw_dir).exists()) + { + QString cooked_dir = FS::NormalizePath(raw_dir); + ui->skinsDirTextBox->setText(cooked_dir); + } +} void LauncherPage::on_migrateDataFolderMacBtn_clicked() { QFile file(QDir::current().absolutePath() + "/dontmovemacdata"); @@ -220,6 +231,7 @@ void LauncherPage::applySettings() s->set("InstanceDir", ui->instDirTextBox->text()); s->set("CentralModsDir", ui->modsDirTextBox->text()); s->set("IconsDir", ui->iconsDirTextBox->text()); + s->set("SkinsDir", ui->skinsDirTextBox->text()); auto sortMode = (InstSortMode)ui->sortingModeGroup->checkedId(); switch (sortMode) @@ -315,6 +327,7 @@ void LauncherPage::loadSettings() ui->instDirTextBox->setText(s->get("InstanceDir").toString()); ui->modsDirTextBox->setText(s->get("CentralModsDir").toString()); ui->iconsDirTextBox->setText(s->get("IconsDir").toString()); + ui->skinsDirTextBox->setText(s->get("SkinsDir").toString()); QString sortMode = s->get("InstSortMode").toString(); diff --git a/launcher/ui/pages/global/LauncherPage.h b/launcher/ui/pages/global/LauncherPage.h index d5ea2353..127b0ad7 100644 --- a/launcher/ui/pages/global/LauncherPage.h +++ b/launcher/ui/pages/global/LauncherPage.h @@ -67,6 +67,7 @@ slots: void on_instDirBrowseBtn_clicked(); void on_modsDirBrowseBtn_clicked(); void on_iconsDirBrowseBtn_clicked(); + void on_skinsDirBrowseBtn_clicked(); void on_migrateDataFolderMacBtn_clicked(); /*! diff --git a/launcher/ui/pages/global/LauncherPage.ui b/launcher/ui/pages/global/LauncherPage.ui index d63256eb..37e6a209 100644 --- a/launcher/ui/pages/global/LauncherPage.ui +++ b/launcher/ui/pages/global/LauncherPage.ui @@ -67,6 +67,16 @@ Folders + + + + &Mods: + + + modsDirTextBox + + + @@ -87,18 +97,8 @@ - - - - &Mods: - - - modsDirTextBox - - - - - + + @@ -107,8 +107,15 @@ - - + + + + ... + + + + + @@ -120,8 +127,21 @@ - - + + + + &Skins: + + + skinsDirTextBox + + + + + + + + ... @@ -471,6 +491,9 @@ modsDirBrowseBtn iconsDirTextBox iconsDirBrowseBtn + skinsDirTextBox + skinsDirBrowseBtn + migrateDataFolderMacBtn resetNotificationsBtn sortLastLaunchedBtn sortByNameBtn