NOISSUE Overhaul the account management
There's now a new Accounts UI with a 3d skin preview and fully functional skin and cape selection. Minimum Qt version on Linux has been raised to 5.6.3 (from 5.4.x)
@ -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
|
||||
|
@ -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<CustomCommandsPage>();
|
||||
m_globalSettingsProvider->addPage<ProxyPage>();
|
||||
m_globalSettingsProvider->addPage<ExternalToolsPage>();
|
||||
m_globalSettingsProvider->addPage<AccountListPage>();
|
||||
m_globalSettingsProvider->addPage<PasteEEPage>();
|
||||
}
|
||||
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<Meta::Index> Application::metadataIndex()
|
||||
return m_metadataIndex;
|
||||
}
|
||||
|
||||
shared_qobject_ptr<CapeCache> Application::capeCache()
|
||||
{
|
||||
if (!m_capeCache)
|
||||
{
|
||||
m_capeCache.reset(new CapeCache(this));
|
||||
}
|
||||
return m_capeCache;
|
||||
}
|
||||
|
||||
QString Application::getJarsPath()
|
||||
{
|
||||
if(m_jarsPath.isEmpty())
|
||||
|
@ -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> skinsModel() const {
|
||||
return m_skinsModel;
|
||||
}
|
||||
|
||||
MCEditTool *mcedit() const {
|
||||
return m_mcedit.get();
|
||||
}
|
||||
@ -117,6 +123,8 @@ public:
|
||||
|
||||
shared_qobject_ptr<Meta::Index> metadataIndex();
|
||||
|
||||
shared_qobject_ptr<CapeCache> 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<HttpMetaCache> m_metacache;
|
||||
shared_qobject_ptr<Meta::Index> m_metadataIndex;
|
||||
|
||||
shared_qobject_ptr<CapeCache> m_capeCache;
|
||||
shared_qobject_ptr<SkinsModel> m_skinsModel;
|
||||
|
||||
std::shared_ptr<SettingsObject> m_settings;
|
||||
std::shared_ptr<InstanceList> m_instances;
|
||||
std::shared_ptr<IconList> m_icons;
|
||||
|
@ -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}
|
||||
)
|
||||
|
||||
|
@ -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 <QLineEdit>
|
||||
@ -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;
|
||||
|
@ -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 <QFile>
|
||||
#include <QPainter>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
@ -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 <QPixmap>
|
||||
|
||||
namespace SkinUtils
|
||||
{
|
||||
QPixmap getFaceFromCache(QString id, int height = 64, int width = 64);
|
||||
}
|
@ -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:
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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 <QSaveFile>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include <Application.h>
|
||||
|
||||
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<MinecraftAccount *>(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<MinecraftAccount *>(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);
|
||||
}
|
||||
}
|
||||
|
@ -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 <QSharedPointer>
|
||||
|
||||
/*!
|
||||
* 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<MinecraftAccountPtr> m_accounts;
|
||||
QList<Entry> m_accounts;
|
||||
|
||||
MinecraftAccountPtr m_defaultAccount;
|
||||
|
||||
|
@ -24,6 +24,7 @@
|
||||
#include <QByteArray>
|
||||
|
||||
#include <QDebug>
|
||||
#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;
|
||||
}
|
||||
|
@ -19,12 +19,13 @@
|
||||
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QTimer>
|
||||
#include <qsslerror.h>
|
||||
|
||||
#include "MinecraftAccount.h"
|
||||
#include <QJsonParseError>
|
||||
|
||||
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:
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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<QNetworkReply::RawHeaderPair> headers);
|
||||
|
||||
@ -30,7 +31,6 @@ signals:
|
||||
void uploadProgress(qint64 bytesSent, qint64 bytesTotal);
|
||||
|
||||
protected slots:
|
||||
|
||||
/// Handle request finished.
|
||||
void onRequestFinished();
|
||||
|
||||
|
@ -21,7 +21,6 @@ public:
|
||||
|
||||
public slots:
|
||||
virtual void perform() = 0;
|
||||
virtual void rehydrate() = 0;
|
||||
|
||||
signals:
|
||||
void finished(AccountTaskState resultingState, QString message);
|
||||
|
@ -30,6 +30,9 @@
|
||||
|
||||
#include "flows/MSA.h"
|
||||
|
||||
#include "skins/CapeCache.h"
|
||||
#include <Application.h>
|
||||
|
||||
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<AccountTask> MinecraftAccount::loginMSA() {
|
||||
@ -94,6 +159,33 @@ shared_qobject_ptr<AccountTask> MinecraftAccount::refresh() {
|
||||
return m_currentTask;
|
||||
}
|
||||
|
||||
shared_qobject_ptr<AccountTask> 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<AccountTask> 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<AccountTask> MinecraftAccount::currentTask() {
|
||||
return m_currentTask;
|
||||
}
|
||||
@ -102,6 +194,7 @@ shared_qobject_ptr<AccountTask> 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();
|
||||
}
|
||||
|
@ -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<AccountTask> refresh();
|
||||
|
||||
shared_qobject_ptr<AccountTask> createMinecraftProfile(const QString& profileName);
|
||||
|
||||
shared_qobject_ptr<AccountTask> setSkin(Skins::Model model, QByteArray texture, const QString& capeUUID);
|
||||
|
||||
shared_qobject_ptr<AccountTask> 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
|
||||
|
@ -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 <QNetworkAccessManager>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
|
@ -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 <QObject>
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~EntitlementsStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -1,54 +0,0 @@
|
||||
#include "ForcedMigrationStep.h"
|
||||
|
||||
#include <QNetworkRequest>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair> headers
|
||||
) {
|
||||
auto requestor = qobject_cast<AuthRequest *>(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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair>);
|
||||
};
|
||||
|
@ -24,10 +24,6 @@ void GetSkinStep::perform() {
|
||||
requestor->get(request);
|
||||
}
|
||||
|
||||
void GetSkinStep::rehydrate() {
|
||||
// NOOP, for now.
|
||||
}
|
||||
|
||||
void GetSkinStep::onRequestDone(
|
||||
QNetworkReply::NetworkError error,
|
||||
QByteArray data,
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~GetSkinStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -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,
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~LauncherLoginStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -19,7 +19,6 @@ public:
|
||||
virtual ~MSAStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -1,47 +0,0 @@
|
||||
#include "MigrationEligibilityStep.h"
|
||||
|
||||
#include <QNetworkRequest>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair> headers
|
||||
) {
|
||||
auto requestor = qobject_cast<AuthRequest *>(QObject::sender());
|
||||
requestor->deleteLater();
|
||||
|
||||
if (error == QNetworkReply::NoError) {
|
||||
Parsers::parseRolloutResponse(data, m_data->canMigrateToMSA);
|
||||
}
|
||||
emit finished(AccountTaskState::STATE_WORKING, tr("Got migration flags"));
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair>);
|
||||
};
|
66
launcher/minecraft/auth/steps/MinecraftProfileCreateStep.cpp
Normal file
@ -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 <QNetworkRequest>
|
||||
|
||||
#include "minecraft/auth/AuthRequest.h"
|
||||
#include "minecraft/auth/Parsers.h"
|
||||
|
||||
#include "BuildConfig.h"
|
||||
#include <QJsonDocument>
|
||||
|
||||
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<QNetworkReply::RawHeaderPair> headers
|
||||
) {
|
||||
auto requestor = qobject_cast<AuthRequest *>(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(""));
|
||||
}
|
||||
}
|
34
launcher/minecraft/auth/steps/MinecraftProfileCreateStep.h
Normal file
@ -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 <QObject>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair>);
|
||||
|
||||
private:
|
||||
QString m_profileName;
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~MinecraftProfileStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
72
launcher/minecraft/auth/steps/SetCapeStep.cpp
Normal file
@ -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 <QNetworkRequest>
|
||||
|
||||
#include "minecraft/auth/AuthRequest.h"
|
||||
#include "minecraft/auth/Parsers.h"
|
||||
|
||||
#include "BuildConfig.h"
|
||||
#include <QJsonDocument>
|
||||
|
||||
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<QNetworkReply::RawHeaderPair> headers
|
||||
) {
|
||||
auto requestor = qobject_cast<AuthRequest *>(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(""));
|
||||
}
|
||||
}
|
||||
|
33
launcher/minecraft/auth/steps/SetCapeStep.h
Normal file
@ -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 <QObject>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair>);
|
||||
|
||||
private:
|
||||
QString m_capeId;
|
||||
};
|
102
launcher/minecraft/auth/steps/SetSkinStep.cpp
Normal file
@ -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 <QNetworkRequest>
|
||||
|
||||
#include "minecraft/auth/AuthRequest.h"
|
||||
#include "minecraft/auth/Parsers.h"
|
||||
|
||||
#include "BuildConfig.h"
|
||||
#include <QJsonDocument>
|
||||
#include <QHttpMultiPart>
|
||||
|
||||
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<QNetworkReply::RawHeaderPair> headers
|
||||
) {
|
||||
auto requestor = qobject_cast<AuthRequest *>(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(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
35
launcher/minecraft/auth/steps/SetSkinStep.h
Normal file
@ -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 <QObject>
|
||||
|
||||
#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<QNetworkReply::RawHeaderPair>);
|
||||
|
||||
private:
|
||||
Skins::Model m_model;
|
||||
QByteArray m_skinData;
|
||||
};
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~XboxAuthorizationStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~XboxProfileStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -13,7 +13,6 @@ public:
|
||||
virtual ~XboxUserStep() noexcept;
|
||||
|
||||
void perform() override;
|
||||
void rehydrate() override;
|
||||
|
||||
QString describe() override;
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -32,6 +32,8 @@ public:
|
||||
return false;
|
||||
}
|
||||
private:
|
||||
std::unique_ptr<UseLock> lock;
|
||||
std::unique_ptr<UseLock> m_lock;
|
||||
QString m_playerName;
|
||||
MinecraftAccountPtr m_account;
|
||||
bool online = false;
|
||||
};
|
||||
|
@ -1,70 +0,0 @@
|
||||
#include "CapeChange.h"
|
||||
|
||||
#include <QNetworkRequest>
|
||||
#include <QHttpMultiPart>
|
||||
|
||||
#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<QNetworkReply>(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<QNetworkReply>(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();
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QFile>
|
||||
#include <QtNetwork/QtNetwork>
|
||||
#include <memory>
|
||||
#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<QNetworkReply> m_reply;
|
||||
|
||||
protected:
|
||||
virtual void executeTask();
|
||||
|
||||
public slots:
|
||||
void downloadError(QNetworkReply::NetworkError);
|
||||
void downloadFinished();
|
||||
};
|
||||
|
@ -1,45 +0,0 @@
|
||||
#include "SkinDelete.h"
|
||||
|
||||
#include <QNetworkRequest>
|
||||
#include <QHttpMultiPart>
|
||||
|
||||
#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<QNetworkReply>(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();
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QFile>
|
||||
#include <QtNetwork/QtNetwork>
|
||||
#include "tasks/Task.h"
|
||||
|
||||
typedef shared_qobject_ptr<class SkinDelete> SkinDeletePtr;
|
||||
|
||||
class SkinDelete : public Task
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
SkinDelete(QObject *parent, QString token);
|
||||
virtual ~SkinDelete() = default;
|
||||
|
||||
private:
|
||||
QString m_token;
|
||||
shared_qobject_ptr<QNetworkReply> m_reply;
|
||||
|
||||
protected:
|
||||
virtual void executeTask();
|
||||
|
||||
public slots:
|
||||
void downloadError(QNetworkReply::NetworkError);
|
||||
void downloadFinished();
|
||||
};
|
@ -1,69 +0,0 @@
|
||||
#include "SkinUpload.h"
|
||||
|
||||
#include <QNetworkRequest>
|
||||
#include <QHttpMultiPart>
|
||||
|
||||
#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<QNetworkReply>(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();
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QFile>
|
||||
#include <QtNetwork/QtNetwork>
|
||||
#include <memory>
|
||||
#include "tasks/Task.h"
|
||||
|
||||
typedef shared_qobject_ptr<class SkinUpload> 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<QNetworkReply> m_reply;
|
||||
protected:
|
||||
virtual void executeTask();
|
||||
|
||||
public slots:
|
||||
|
||||
void downloadError(QNetworkReply::NetworkError);
|
||||
|
||||
void downloadFinished();
|
||||
};
|
22
launcher/resources/skins/shaders/bg.frag
Normal file
@ -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);
|
||||
}
|
14
launcher/resources/skins/shaders/bg.vert
Normal file
@ -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);
|
||||
}
|
||||
|
49
launcher/resources/skins/shaders/skin.frag
Normal file
@ -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;
|
||||
}
|
||||
}
|
27
launcher/resources/skins/shaders/skin.vert
Normal file
@ -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;
|
||||
}
|
34
launcher/resources/skins/skins.qrc
Normal file
@ -0,0 +1,34 @@
|
||||
<!DOCTYPE RCC>
|
||||
<RCC version="1.0">
|
||||
<qresource prefix="/skins">
|
||||
<file>textures/placeholder_skin.png</file>
|
||||
<file>textures/placeholder_cape.png</file>
|
||||
<file>textures/no_cape.png</file>
|
||||
|
||||
<!-- Mojang assets for default skins -->
|
||||
<file>textures/Zuri_Slim.png</file>
|
||||
<file>textures/Zuri_Classic.png</file>
|
||||
<file>textures/Sunny_Slim.png</file>
|
||||
<file>textures/Sunny_Classic.png</file>
|
||||
<file>textures/Steve_Slim.png</file>
|
||||
<file>textures/Steve_Classic.png</file>
|
||||
<file>textures/Noor_Slim.png</file>
|
||||
<file>textures/Noor_Classic.png</file>
|
||||
<file>textures/Makena_Slim.png</file>
|
||||
<file>textures/Makena_Classic.png</file>
|
||||
<file>textures/Kai_Slim.png</file>
|
||||
<file>textures/Kai_Classic.png</file>
|
||||
<file>textures/Efe_Slim.png</file>
|
||||
<file>textures/Efe_Classic.png</file>
|
||||
<file>textures/Ari_Slim.png</file>
|
||||
<file>textures/Ari_Classic.png</file>
|
||||
<file>textures/Alex_Slim.png</file>
|
||||
<file>textures/Alex_Classic.png</file>
|
||||
|
||||
<file>shaders/skin.frag</file>
|
||||
<file>shaders/skin.vert</file>
|
||||
|
||||
<file>shaders/bg.frag</file>
|
||||
<file>shaders/bg.vert</file>
|
||||
</qresource>
|
||||
</RCC>
|
BIN
launcher/resources/skins/textures/Alex_Classic.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
launcher/resources/skins/textures/Alex_Slim.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
launcher/resources/skins/textures/Ari_Classic.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
launcher/resources/skins/textures/Ari_Slim.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
launcher/resources/skins/textures/Efe_Classic.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
launcher/resources/skins/textures/Efe_Slim.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
launcher/resources/skins/textures/Kai_Classic.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
launcher/resources/skins/textures/Kai_Slim.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
launcher/resources/skins/textures/Makena_Classic.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
launcher/resources/skins/textures/Makena_Slim.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
launcher/resources/skins/textures/Noor_Classic.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
launcher/resources/skins/textures/Noor_Slim.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
launcher/resources/skins/textures/Steve_Classic.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
launcher/resources/skins/textures/Steve_Slim.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
launcher/resources/skins/textures/Sunny_Classic.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
launcher/resources/skins/textures/Sunny_Slim.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
launcher/resources/skins/textures/Zuri_Classic.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
launcher/resources/skins/textures/Zuri_Slim.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
launcher/resources/skins/textures/no_cape.png
Normal file
After Width: | Height: | Size: 572 B |
BIN
launcher/resources/skins/textures/placeholder_cape.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
launcher/resources/skins/textures/placeholder_skin.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
196
launcher/skins/CapeCache.cpp
Normal file
@ -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 <Application.h>
|
||||
#include <FileSystem.h>
|
||||
|
||||
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;
|
||||
}
|
60
launcher/skins/CapeCache.h
Normal file
@ -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 <QObject>
|
||||
#include <QMap>
|
||||
#include <QImage>
|
||||
#include <QQueue>
|
||||
#include <QTimer>
|
||||
|
||||
#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<QString, IndexEntry> m_index;
|
||||
QImage m_placeholder;
|
||||
|
||||
NetJob::Ptr m_downloadJob;
|
||||
QByteArray m_response;
|
||||
QQueue<CapeRequest> m_requests;
|
||||
QTimer m_saveTimer;
|
||||
};
|
101
launcher/skins/CapesModel.cpp
Normal file
@ -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 <QMap>
|
||||
#include <QDebug>
|
||||
|
||||
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;
|
||||
}
|
42
launcher/skins/CapesModel.h
Normal file
@ -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 <QMutex>
|
||||
#include <QMap>
|
||||
#include <QVector>
|
||||
#include <QAbstractListModel>
|
||||
|
||||
#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<QString, int> m_uuidIndex;
|
||||
QVector<Skins::CapeEntry> m_capes;
|
||||
};
|
494
launcher/skins/SkinRenderer.cpp
Normal file
@ -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 <array>
|
||||
|
||||
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<QVector3D, 8> 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<VertexBg, 4> backQuad = {
|
||||
VertexBg{-1.0, -1.0f},
|
||||
VertexBg{ 1.0, -1.0f},
|
||||
VertexBg{ 1.0, 1.0f},
|
||||
VertexBg{-1.0, 1.0f}
|
||||
};
|
||||
std::array<GLuint, 6> 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<Uniform>& 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();
|
||||
}
|
||||
|
||||
}
|
92
launcher/skins/SkinRenderer.h
Normal file
@ -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 <QtGui/QOpenGLFunctions>
|
||||
|
||||
#include <QVector3D>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
#include <QOpenGLTexture>
|
||||
#include <QOpenGLShaderProgram>
|
||||
#include <QOpenGLVertexArrayObject>
|
||||
#include <QOpenGLBuffer>
|
||||
|
||||
#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<Vertex> m_vertexBuffer;
|
||||
std::vector<GLuint> 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<Uniform> m_uniforms;
|
||||
|
||||
QOpenGLShaderProgram* m_bgShader = nullptr;
|
||||
QList<Uniform> m_bgUniforms;
|
||||
|
||||
QOpenGLFunctions& GL;
|
||||
private:
|
||||
bool initShader(QOpenGLShaderProgram* program, QList<Uniform>& uniforms, const QString& vertexShaderPath, const QString& fragmentShaderPath);
|
||||
};
|
||||
|
||||
}
|
287
launcher/skins/SkinTypes.cpp
Normal file
@ -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 <QFile>
|
||||
#include <QPainter>
|
||||
|
||||
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<Model> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
100
launcher/skins/SkinTypes.h
Normal file
@ -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 <QImage>
|
||||
#include <QString>
|
||||
#include <QByteArray>
|
||||
#include <nonstd/optional>
|
||||
|
||||
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<Model> 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<SkinData> slimVariant;
|
||||
nonstd::optional<SkinData> 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;
|
||||
};
|
||||
|
||||
}
|
102
launcher/skins/SkinUtils.cpp
Normal file
@ -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 <QFileInfo>
|
||||
#include <QCryptographicHash>
|
||||
#include <FileSystem.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
18
launcher/skins/SkinUtils.h
Normal file
@ -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 <QString>
|
||||
#include <QByteArray>
|
||||
#include <QImage>
|
||||
|
||||
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);
|
||||
}
|
254
launcher/skins/SkinWidget.cpp
Normal file
@ -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 <cmath>
|
||||
|
||||
#include <QtCore/QDebug>
|
||||
#include <QVector3D>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <rainbow.h>
|
||||
|
||||
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();
|
||||
}
|
82
launcher/skins/SkinWidget.h
Normal file
@ -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 <QOpenGLWindow>
|
||||
#include <QOpenGLFunctions>
|
||||
#include <QWidget>
|
||||
#include <QFrame>
|
||||
#include <QMouseEvent>
|
||||
#include <QPoint>
|
||||
#include <QMatrix4x4>
|
||||
#include <QVector3D>
|
||||
|
||||
#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;
|
||||
};
|
479
launcher/skins/SkinsModel.cpp
Normal file
@ -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 <FileSystem.h>
|
||||
#include <QMap>
|
||||
#include <QEventLoop>
|
||||
#include <QMimeData>
|
||||
#include <QUrl>
|
||||
#include <QFileSystemWatcher>
|
||||
#include <QSet>
|
||||
#include <QDebug>
|
||||
#include <QSaveFile>
|
||||
|
||||
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<QString> 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<QString> 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<QString> current_set = current_list.toSet();
|
||||
|
||||
QSet<QString> to_remove = current_set;
|
||||
to_remove -= new_set;
|
||||
|
||||
QSet<QString> 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;
|
||||
}
|
86
launcher/skins/SkinsModel.h
Normal file
@ -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 <QMutex>
|
||||
#include <QAbstractListModel>
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QtGui/QIcon>
|
||||
#include <memory>
|
||||
|
||||
#include "settings/Setting.h"
|
||||
|
||||
#include "QObjectPtr.h"
|
||||
#include <nonstd/optional>
|
||||
|
||||
#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<QFileSystemWatcher> m_watcher;
|
||||
bool m_isWatching = false;
|
||||
QMap<QString, int> m_nameIndex;
|
||||
QVector<Skins::SkinEntry> m_skins;
|
||||
QDir m_dir;
|
||||
QTimer m_removalTimer;
|
||||
QSet<QString> m_toRemove;
|
||||
QSet<QString> m_reservedNames;
|
||||
};
|
267
launcher/skins/TextureMappings.cpp
Normal file
@ -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
|
||||
};
|
||||
|
||||
}
|
||||
|
34
launcher/skins/TextureMappings.h
Normal file
@ -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;
|
||||
}
|
@ -54,7 +54,6 @@
|
||||
#include <java/JavaInstallList.h>
|
||||
#include <launch/LaunchTask.h>
|
||||
#include <minecraft/auth/AccountList.h>
|
||||
#include <SkinUtils.h>
|
||||
#include <BuildConfig.h>
|
||||
#include <net/NetJob.h>
|
||||
#include <net/Download.h>
|
||||
@ -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()
|
||||
|