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)
This commit is contained in:
Petr Mrázek 2024-12-26 03:54:11 +01:00
parent 469053439a
commit 1a673cf4dd
120 changed files with 5545 additions and 2916 deletions

View File

@ -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

View File

@ -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())

View File

@ -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;

View File

@ -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}
)

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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:

View File

@ -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 {

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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:

View File

@ -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;

View File

@ -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();

View File

@ -21,7 +21,6 @@ public:
public slots:
virtual void perform() = 0;
virtual void rehydrate() = 0;
signals:
void finished(AccountTaskState resultingState, QString message);

View File

@ -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();
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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));
}

View File

@ -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
);
};

View File

@ -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,

View File

@ -13,7 +13,6 @@ public:
virtual ~EntitlementsStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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"));
}
}

View File

@ -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>);
};

View File

@ -24,10 +24,6 @@ void GetSkinStep::perform() {
requestor->get(request);
}
void GetSkinStep::rehydrate() {
// NOOP, for now.
}
void GetSkinStep::onRequestDone(
QNetworkReply::NetworkError error,
QByteArray data,

View File

@ -13,7 +13,6 @@ public:
virtual ~GetSkinStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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,

View File

@ -13,7 +13,6 @@ public:
virtual ~LauncherLoginStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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: {

View File

@ -19,7 +19,6 @@ public:
virtual ~MSAStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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"));
}

View File

@ -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>);
};

View 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(""));
}
}

View 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;
};

View File

@ -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,

View File

@ -13,7 +13,6 @@ public:
virtual ~MinecraftProfileStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View 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(""));
}
}

View 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;
};

View 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(""));
}
}

View 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;
};

View File

@ -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(
{

View File

@ -13,7 +13,6 @@ public:
virtual ~XboxAuthorizationStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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;

View File

@ -13,7 +13,6 @@ public:
virtual ~XboxProfileStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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(
{

View File

@ -13,7 +13,6 @@ public:
virtual ~XboxUserStep() noexcept;
void perform() override;
void rehydrate() override;
QString describe() override;

View File

@ -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();
}

View File

@ -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;
};

View File

@ -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();
}

View File

@ -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();
};

View File

@ -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();
}

View File

@ -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();
};

View File

@ -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();
}

View File

@ -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();
};

View 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);
}

View 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);
}

View 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;
}
}

View 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;
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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;
}

View 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;
};

View 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;
}

View 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;
};

View 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();
}
}

View 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);
};
}

View 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
View 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;
};
}

View 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;
}
}

View 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);
}

View 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();
}

View 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;
};

View 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;
}

View 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;
};

View 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
};
}

View 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;
}

View File

@ -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()

Some files were not shown because too many files have changed in this diff Show More