Support authlib-injector skin upload

This commit is contained in:
Evan Goode 2025-03-25 16:16:40 -04:00 committed by Luna
parent b9ba1f1c65
commit 7050d01aab
11 changed files with 323 additions and 27 deletions

View File

@ -387,6 +387,10 @@ set(MINECRAFT_SOURCES
minecraft/AssetsUtils.cpp minecraft/AssetsUtils.cpp
# Minecraft skins # Minecraft skins
minecraft/skins/AuthlibInjectorTextureDelete.cpp
minecraft/skins/AuthlibInjectorTextureDelete.h
minecraft/skins/AuthlibInjectorTextureUpload.cpp
minecraft/skins/AuthlibInjectorTextureUpload.h
minecraft/skins/CapeChange.cpp minecraft/skins/CapeChange.cpp
minecraft/skins/CapeChange.h minecraft/skins/CapeChange.h
minecraft/skins/SkinUpload.cpp minecraft/skins/SkinUpload.cpp

View File

@ -133,6 +133,8 @@ void profileToJSONV3(QJsonObject& parent, MinecraftProfile p, const char* tokenN
out["skin"] = skinObj; out["skin"] = skinObj;
} }
out["canUploadSkins"] = p.canUploadSkins;
QJsonArray capesArray; QJsonArray capesArray;
for (auto& cape : p.capes) { for (auto& cape : p.capes) {
QJsonObject capeObj; QJsonObject capeObj;
@ -195,6 +197,11 @@ MinecraftProfile profileFromJSONV3(const QJsonObject& parent, const char* tokenN
} }
} }
out.canUploadSkins = true;
if (tokenObject.value("canUploadSkins").isBool()) {
out.canUploadSkins = tokenObject.value("canUploadskins").toBool();
}
{ {
auto capesV = tokenObject.value("capes"); auto capesV = tokenObject.value("capes");
if (!capesV.isArray()) { if (!capesV.isArray()) {
@ -383,9 +390,9 @@ bool AccountData::usesCustomApiServers() const
return type == AccountType::AuthlibInjector; return type == AccountType::AuthlibInjector;
} }
bool AccountData::supportsSkinManagement() const bool AccountData::canUploadSkins() const
{ {
return type == AccountType::MSA; return minecraftProfile.canUploadSkins;
} }
QString AccountData::authServerUrl() const QString AccountData::authServerUrl() const

View File

@ -82,6 +82,7 @@ struct MinecraftEntitlement {
struct MinecraftProfile { struct MinecraftProfile {
QString id; QString id;
QString name; QString name;
bool canUploadSkins = true;
Skin skin; Skin skin;
QString currentCape; QString currentCape;
QMap<QString, Cape> capes; QMap<QString, Cape> capes;
@ -96,7 +97,7 @@ struct AccountData {
QJsonObject saveState() const; QJsonObject saveState() const;
bool resumeStateFromV3(QJsonObject data); bool resumeStateFromV3(QJsonObject data);
bool supportsSkinManagement() const; bool canUploadSkins() const;
bool usesCustomApiServers() const; bool usesCustomApiServers() const;
QString authServerUrl() const; QString authServerUrl() const;
QString accountServerUrl() const; QString accountServerUrl() const;

View File

@ -119,7 +119,7 @@ class MinecraftAccount : public QObject, public Usable {
bool usesCustomApiServers() const { return data.usesCustomApiServers(); } bool usesCustomApiServers() const { return data.usesCustomApiServers(); }
bool supportsSkinManagement() const { return data.supportsSkinManagement(); } bool canUploadSkins() const { return data.canUploadSkins(); }
QString accountDisplayString() const { return data.accountDisplayString(); } QString accountDisplayString() const { return data.accountDisplayString(); }

View File

@ -188,6 +188,9 @@ bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output)
output.skin = skinOut; output.skin = skinOut;
break; break;
} }
output.canUploadSkins = true;
auto capesArray = obj.value("capes").toArray(); auto capesArray = obj.value("capes").toArray();
QString currentCape; QString currentCape;
@ -306,13 +309,16 @@ bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output)
auto propsArray = obj.value("properties").toArray(); auto propsArray = obj.value("properties").toArray();
QByteArray texturePayload; QByteArray texturePayload;
bool canUploadSkins = true;
for (auto p : propsArray) { for (auto p : propsArray) {
auto pObj = p.toObject(); auto pObj = p.toObject();
auto name = pObj.value("name"); auto name = pObj.value("name");
if (!name.isString() || name.toString() != "textures") { if (!name.isString()) {
continue; continue;
} }
const auto& nameString = name.toString();
if (nameString == "textures") {
auto value = pObj.value("value"); auto value = pObj.value("value");
if (value.isString()) { if (value.isString()) {
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
@ -321,11 +327,20 @@ bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output)
texturePayload = QByteArray::fromBase64(value.toString().toUtf8()); texturePayload = QByteArray::fromBase64(value.toString().toUtf8());
#endif #endif
} }
} else if (nameString == "uploadableTextures") {
if (!texturePayload.isEmpty()) { // https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83#uploadabletextures-%E5%8F%AF%E4%B8%8A%E4%BC%A0%E7%9A%84%E6%9D%90%E8%B4%A8%E7%B1%BB%E5%9E%8B
break; const auto& value = pObj.value("value");
if (value.isString()) {
canUploadSkins = false;
for (const auto& textureType : value.toString().split(",")) {
if (textureType == "skin") {
canUploadSkins = true;
} }
} }
}
}
}
output.canUploadSkins = canUploadSkins;
if (texturePayload.isNull()) { if (texturePayload.isNull()) {
qWarning() << "No texture payload data"; qWarning() << "No texture payload data";
@ -379,7 +394,6 @@ bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output)
// we don't know the cape ID as it is not returned from the session server // we don't know the cape ID as it is not returned from the session server
// so just fake it - changing capes is probably locked anyway :( // so just fake it - changing capes is probably locked anyway :(
capeOut.alias = "cape"; capeOut.alias = "cape";
capeOut.id = "00000000-0000-0000-0000-000000000000";
} }
} }
} }

View File

@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Fjord Launcher - Minecraft Launcher
* Copyright (C) 2024 Evan Goode <mail@evangoo.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This file incorporates work covered by the following copyright and
* permission notice:
*
* 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 "AuthlibInjectorTextureDelete.h"
#include "net/ByteArraySink.h"
#include "net/RawHeaderProxy.h"
AuthlibInjectorTextureDelete::AuthlibInjectorTextureDelete(QString textureType) : NetRequest(), m_textureType(textureType)
{
logCat = taskMCSkinsLogC;
}
QNetworkReply* AuthlibInjectorTextureDelete::getReply(QNetworkRequest& request)
{
setStatus(tr("Deleting texture"));
return m_network->deleteResource(request);
}
AuthlibInjectorTextureDelete::Ptr AuthlibInjectorTextureDelete::make(MinecraftAccountPtr account, QString textureType)
{
auto up = makeShared<AuthlibInjectorTextureDelete>(textureType);
QString token = account->accessToken();
up->m_url = QUrl(account->accountServerUrl() + "/user/profile/" + account->profileId() + "/" + textureType);
up->m_sink.reset(new Net::ByteArraySink(std::make_shared<QByteArray>()));
up->addHeaderProxy(new Net::RawHeaderProxy(QList<Net::HeaderPair>{
{ "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() },
}));
return up;
}

View File

@ -0,0 +1,38 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Fjord Launcher - Minecraft Launcher
* Copyright (C) 2024 Evan Goode <mail@evangoo.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <minecraft/auth/MinecraftAccount.h>
#include "net/NetRequest.h"
class AuthlibInjectorTextureDelete : public Net::NetRequest {
Q_OBJECT
public:
using Ptr = shared_qobject_ptr<AuthlibInjectorTextureDelete>;
AuthlibInjectorTextureDelete(QString textureType);
virtual ~AuthlibInjectorTextureDelete() = default;
static AuthlibInjectorTextureDelete::Ptr make(MinecraftAccountPtr account, QString textureType);
protected:
virtual QNetworkReply* getReply(QNetworkRequest&) override;
private:
QString m_textureType;
};

View File

@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Fjord Launcher - Minecraft Launcher
* Copyright (C) 2024 Evan Goode <mail@evangoo.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* This file incorporates work covered by the following copyright and
* permission notice:
*
* 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 "AuthlibInjectorTextureUpload.h"
#include <QHttpMultiPart>
#include "FileSystem.h"
#include "net/ByteArraySink.h"
#include "net/RawHeaderProxy.h"
AuthlibInjectorTextureUpload::AuthlibInjectorTextureUpload(QString path, std::optional<QString> skin_variant) : NetRequest(), m_path(path), m_skin_variant(skin_variant)
{
logCat = taskMCSkinsLogC;
}
QNetworkReply* AuthlibInjectorTextureUpload::getReply(QNetworkRequest& request)
{
QHttpMultiPart* multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType, this);
QHttpPart file;
file.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("image/png"));
file.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"file\"; filename=\"texture.png\""));
file.setBody(FS::read(m_path));
multiPart->append(file);
if (m_skin_variant.has_value()) {
QHttpPart model;
model.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"model\""));
model.setBody(m_skin_variant->toUtf8());
multiPart->append(model);
}
setStatus(tr("Uploading texture"));
return m_network->put(request, multiPart);
}
AuthlibInjectorTextureUpload::Ptr AuthlibInjectorTextureUpload::make(MinecraftAccountPtr account, QString path, std::optional<QString> skin_variant)
{
auto up = makeShared<AuthlibInjectorTextureUpload>(path, skin_variant);
QString token = account->accessToken();
QString textureType = skin_variant.has_value() ? "skin" : "cape";
up->m_url = QUrl(account->accountServerUrl() + "/user/profile/" + account->profileId() + "/" + textureType);
up->setObjectName(QString("BYTES:") + up->m_url.toString());
up->m_sink.reset(new Net::ByteArraySink(std::make_shared<QByteArray>()));
up->addHeaderProxy(new Net::RawHeaderProxy(QList<Net::HeaderPair>{
{ "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() },
}));
return up;
}

View File

@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-only
/*
* Fjord Launcher - Minecraft Launcher
* Copyright (C) 2024 Evan Goode <mail@evangoo.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#pragma once
#include <minecraft/auth/MinecraftAccount.h>
#include "net/NetRequest.h"
class AuthlibInjectorTextureUpload : public Net::NetRequest {
Q_OBJECT
public:
using Ptr = shared_qobject_ptr<AuthlibInjectorTextureUpload>;
// Note this class takes ownership of the file.
AuthlibInjectorTextureUpload(QString path, std::optional<QString> skin_variant);
virtual ~AuthlibInjectorTextureUpload() = default;
static AuthlibInjectorTextureUpload::Ptr make(MinecraftAccountPtr account, QString path, std::optional<QString> skin_variant);
protected:
virtual QNetworkReply* getReply(QNetworkRequest&) override;
private:
QString m_path;
std::optional<QString> m_skin_variant;
};

View File

@ -37,6 +37,8 @@
#include "QObjectPtr.h" #include "QObjectPtr.h"
#include "minecraft/auth/Parsers.h" #include "minecraft/auth/Parsers.h"
#include "minecraft/skins/AuthlibInjectorTextureDelete.h"
#include "minecraft/skins/AuthlibInjectorTextureUpload.h"
#include "minecraft/skins/CapeChange.h" #include "minecraft/skins/CapeChange.h"
#include "minecraft/skins/SkinDelete.h" #include "minecraft/skins/SkinDelete.h"
#include "minecraft/skins/SkinList.h" #include "minecraft/skins/SkinList.h"
@ -45,6 +47,7 @@
#include "net/Download.h" #include "net/Download.h"
#include "net/NetJob.h" #include "net/NetJob.h"
#include "net/Upload.h"
#include "tasks/Task.h" #include "tasks/Task.h"
#include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/CustomMessageBox.h"
@ -162,6 +165,12 @@ QPixmap previewCape(QPixmap capeImage)
void SkinManageDialog::setupCapes() void SkinManageDialog::setupCapes()
{ {
// Capes are currently unsupported on authlib-injector accounts.
if (m_acct->accountType() == AccountType::AuthlibInjector) {
ui->capeCombo->setEnabled(false);
return;
}
// FIXME: add a model for this, download/refresh the capes on demand // FIXME: add a model for this, download/refresh the capes on demand
auto& accountData = *m_acct->accountData(); auto& accountData = *m_acct->accountData();
int index = 0; int index = 0;
@ -252,10 +261,26 @@ void SkinManageDialog::accept()
return; return;
} }
switch (m_acct->accountType()) {
case AccountType::MSA: {
skinUpload->addNetAction(SkinUpload::make(m_acct, skin->getPath(), skin->getModelString())); skinUpload->addNetAction(SkinUpload::make(m_acct, skin->getPath(), skin->getModelString()));
break;
};
case AccountType::AuthlibInjector: {
const auto& variant = skin->getModel() == SkinModel::SLIM ? "slim" : "";
skinUpload->addNetAction(AuthlibInjectorTextureUpload::make(m_acct, skin->getPath(), variant));
break;
};
case AccountType::Offline: {
qDebug() << "Unhandled account type: Offline";
reject();
return;
};
}
const bool canChangeCapes = m_acct->accountType() != AccountType::AuthlibInjector;
auto selectedCape = skin->getCapeId(); auto selectedCape = skin->getCapeId();
if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { if (canChangeCapes && selectedCape != m_acct->accountData()->minecraftProfile.currentCape) {
skinUpload->addNetAction(CapeChange::make(m_acct, selectedCape)); skinUpload->addNetAction(CapeChange::make(m_acct, selectedCape));
} }
@ -273,7 +298,23 @@ void SkinManageDialog::on_resetBtn_clicked()
{ {
ProgressDialog prog(this); ProgressDialog prog(this);
NetJob::Ptr skinReset{ new NetJob(tr("Reset skin"), APPLICATION->network(), 1) }; NetJob::Ptr skinReset{ new NetJob(tr("Reset skin"), APPLICATION->network(), 1) };
switch (m_acct->accountType()) {
case AccountType::MSA: {
skinReset->addNetAction(SkinDelete::make(m_acct)); skinReset->addNetAction(SkinDelete::make(m_acct));
break;
};
case AccountType::AuthlibInjector: {
skinReset->addNetAction(AuthlibInjectorTextureDelete::make(m_acct, "skin"));
break;
};
case AccountType::Offline: {
qDebug() << "Unhandled account type: Offline";
reject();
return;
};
}
skinReset->addTask(m_acct->refresh().staticCast<Task>()); skinReset->addTask(m_acct->refresh().staticCast<Task>());
if (prog.execWithTask(skinReset.get()) != QDialog::Accepted) { if (prog.execWithTask(skinReset.get()) != QDialog::Accepted) {
CustomMessageBox::selectable(this, tr("Skin Delete"), tr("Failed to delete current skin!"), QMessageBox::Warning)->exec(); CustomMessageBox::selectable(this, tr("Skin Delete"), tr("Failed to delete current skin!"), QMessageBox::Warning)->exec();
@ -409,6 +450,8 @@ void SkinManageDialog::on_userBtn_clicked()
if (user.isEmpty()) { if (user.isEmpty()) {
return; return;
} }
const auto & account = m_acct;
MinecraftProfile mcProfile; MinecraftProfile mcProfile;
auto path = FS::PathCombine(m_list.getDir(), user + ".png"); auto path = FS::PathCombine(m_list.getDir(), user + ".png");
@ -421,7 +464,9 @@ void SkinManageDialog::on_userBtn_clicked()
auto uuidLoop = makeShared<WaitTask>(); auto uuidLoop = makeShared<WaitTask>();
auto profileLoop = makeShared<WaitTask>(); auto profileLoop = makeShared<WaitTask>();
auto getUUID = Net::Download::makeByteArray("https://api.mojang.com/users/profiles/minecraft/" + user, uuidOut); // authlib-injector only specifies the POST /profiles/minecraft route, so we have to use it.
const auto & payload = QJsonDocument(QJsonArray{user}).toJson(QJsonDocument::Compact);
auto getUUID = Net::Upload::makeByteArray(m_acct->accountServerUrl()+"/profiles/minecraft", uuidOut, payload);
auto getProfile = Net::Download::makeByteArray(QUrl(), profileOut); auto getProfile = Net::Download::makeByteArray(QUrl(), profileOut);
auto downloadSkin = Net::Download::makeFile(QUrl(), path); auto downloadSkin = Net::Download::makeFile(QUrl(), path);
@ -444,7 +489,7 @@ void SkinManageDialog::on_userBtn_clicked()
failReason = tr("failed to download skin"); failReason = tr("failed to download skin");
}); });
connect(getUUID.get(), &Task::succeeded, this, [uuidLoop, uuidOut, job, getProfile, &failReason] { connect(getUUID.get(), &Task::succeeded, this, [account, uuidLoop, uuidOut, job, getProfile, &failReason] {
try { try {
QJsonParseError parse_error{}; QJsonParseError parse_error{};
QJsonDocument doc = QJsonDocument::fromJson(*uuidOut, &parse_error); QJsonDocument doc = QJsonDocument::fromJson(*uuidOut, &parse_error);
@ -455,14 +500,15 @@ void SkinManageDialog::on_userBtn_clicked()
uuidLoop->quit(); uuidLoop->quit();
return; return;
} }
const auto root = doc.object(); const auto& root = doc.array();
auto id = Json::ensureString(root, "id"); if (root.size() != 1) {
if (!id.isEmpty()) {
getProfile->setUrl("https://sessionserver.mojang.com/session/minecraft/profile/" + id);
} else {
failReason = tr("user id is empty"); failReason = tr("user id is empty");
job->abort(); job->abort();
} }
const auto& nameIdObject = root[0].toObject();
const auto& id = nameIdObject.value("id").toString();
getProfile->setUrl(account->sessionServerUrl()+"/session/minecraft/profile/"+id);
} catch (const Exception& e) { } catch (const Exception& e) {
qCritical() << "Couldn't load skin json:" << e.cause(); qCritical() << "Couldn't load skin json:" << e.cause();
failReason = tr("failed to parse get user UUID response"); failReason = tr("failed to parse get user UUID response");

View File

@ -223,17 +223,17 @@ void AccountListPage::updateButtonStates()
bool hasSelection = !selection.empty(); bool hasSelection = !selection.empty();
bool accountIsReady = false; bool accountIsReady = false;
bool accountIsOnline = false; bool accountIsOnline = false;
bool accountSupportsSkinManagement = false; bool accountCanUploadSkins = false;
if (hasSelection) { if (hasSelection) {
QModelIndex selected = selection.first(); QModelIndex selected = selection.first();
MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value<MinecraftAccountPtr>(); MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value<MinecraftAccountPtr>();
accountIsReady = !account->isActive(); accountIsReady = !account->isActive();
accountIsOnline = account->accountType() != AccountType::Offline; accountIsOnline = account->accountType() != AccountType::Offline;
accountSupportsSkinManagement = account->supportsSkinManagement(); accountCanUploadSkins = account->canUploadSkins();
} }
ui->actionRemove->setEnabled(accountIsReady); ui->actionRemove->setEnabled(accountIsReady);
ui->actionSetDefault->setEnabled(accountIsReady); ui->actionSetDefault->setEnabled(accountIsReady);
ui->actionManageSkins->setEnabled(accountIsReady && accountIsOnline && accountSupportsSkinManagement); ui->actionManageSkins->setEnabled(accountIsReady && accountIsOnline && accountCanUploadSkins);
ui->actionRefresh->setEnabled(accountIsReady && accountIsOnline); ui->actionRefresh->setEnabled(accountIsReady && accountIsOnline);
if (m_accounts->defaultAccount().get() == nullptr) { if (m_accounts->defaultAccount().get() == nullptr) {