From 7050d01aab991621a8f1b6d3051a8c22f17f46fe Mon Sep 17 00:00:00 2001 From: Evan Goode Date: Tue, 25 Mar 2025 16:16:40 -0400 Subject: [PATCH] Support authlib-injector skin upload --- launcher/CMakeLists.txt | 4 + launcher/minecraft/auth/AccountData.cpp | 11 ++- launcher/minecraft/auth/AccountData.h | 3 +- launcher/minecraft/auth/MinecraftAccount.h | 2 +- launcher/minecraft/auth/Parsers.cpp | 34 +++++--- .../skins/AuthlibInjectorTextureDelete.cpp | 62 ++++++++++++++ .../skins/AuthlibInjectorTextureDelete.h | 38 +++++++++ .../skins/AuthlibInjectorTextureUpload.cpp | 83 +++++++++++++++++++ .../skins/AuthlibInjectorTextureUpload.h | 41 +++++++++ .../ui/dialogs/skins/SkinManageDialog.cpp | 66 ++++++++++++--- launcher/ui/pages/global/AccountListPage.cpp | 6 +- 11 files changed, 323 insertions(+), 27 deletions(-) create mode 100644 launcher/minecraft/skins/AuthlibInjectorTextureDelete.cpp create mode 100644 launcher/minecraft/skins/AuthlibInjectorTextureDelete.h create mode 100644 launcher/minecraft/skins/AuthlibInjectorTextureUpload.cpp create mode 100644 launcher/minecraft/skins/AuthlibInjectorTextureUpload.h diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 057c6f984..c855745cc 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -387,6 +387,10 @@ set(MINECRAFT_SOURCES minecraft/AssetsUtils.cpp # 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.h minecraft/skins/SkinUpload.cpp diff --git a/launcher/minecraft/auth/AccountData.cpp b/launcher/minecraft/auth/AccountData.cpp index 65aa8cf81..157ea0c99 100644 --- a/launcher/minecraft/auth/AccountData.cpp +++ b/launcher/minecraft/auth/AccountData.cpp @@ -133,6 +133,8 @@ void profileToJSONV3(QJsonObject& parent, MinecraftProfile p, const char* tokenN out["skin"] = skinObj; } + out["canUploadSkins"] = p.canUploadSkins; + QJsonArray capesArray; for (auto& cape : p.capes) { 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"); if (!capesV.isArray()) { @@ -383,9 +390,9 @@ bool AccountData::usesCustomApiServers() const return type == AccountType::AuthlibInjector; } -bool AccountData::supportsSkinManagement() const +bool AccountData::canUploadSkins() const { - return type == AccountType::MSA; + return minecraftProfile.canUploadSkins; } QString AccountData::authServerUrl() const diff --git a/launcher/minecraft/auth/AccountData.h b/launcher/minecraft/auth/AccountData.h index a0b0a17f2..f8ae891de 100644 --- a/launcher/minecraft/auth/AccountData.h +++ b/launcher/minecraft/auth/AccountData.h @@ -82,6 +82,7 @@ struct MinecraftEntitlement { struct MinecraftProfile { QString id; QString name; + bool canUploadSkins = true; Skin skin; QString currentCape; QMap capes; @@ -96,7 +97,7 @@ struct AccountData { QJsonObject saveState() const; bool resumeStateFromV3(QJsonObject data); - bool supportsSkinManagement() const; + bool canUploadSkins() const; bool usesCustomApiServers() const; QString authServerUrl() const; QString accountServerUrl() const; diff --git a/launcher/minecraft/auth/MinecraftAccount.h b/launcher/minecraft/auth/MinecraftAccount.h index c4495dcac..ab3103f03 100644 --- a/launcher/minecraft/auth/MinecraftAccount.h +++ b/launcher/minecraft/auth/MinecraftAccount.h @@ -119,7 +119,7 @@ class MinecraftAccount : public QObject, public Usable { bool usesCustomApiServers() const { return data.usesCustomApiServers(); } - bool supportsSkinManagement() const { return data.supportsSkinManagement(); } + bool canUploadSkins() const { return data.canUploadSkins(); } QString accountDisplayString() const { return data.accountDisplayString(); } diff --git a/launcher/minecraft/auth/Parsers.cpp b/launcher/minecraft/auth/Parsers.cpp index f61c05c5d..3b653bbfc 100644 --- a/launcher/minecraft/auth/Parsers.cpp +++ b/launcher/minecraft/auth/Parsers.cpp @@ -188,6 +188,9 @@ bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output) output.skin = skinOut; break; } + + output.canUploadSkins = true; + auto capesArray = obj.value("capes").toArray(); QString currentCape; @@ -306,26 +309,38 @@ bool parseMinecraftProfileMojang(QByteArray& data, MinecraftProfile& output) auto propsArray = obj.value("properties").toArray(); QByteArray texturePayload; + bool canUploadSkins = true; for (auto p : propsArray) { auto pObj = p.toObject(); auto name = pObj.value("name"); - if (!name.isString() || name.toString() != "textures") { + if (!name.isString()) { continue; } - auto value = pObj.value("value"); - if (value.isString()) { + const auto& nameString = name.toString(); + if (nameString == "textures") { + auto value = pObj.value("value"); + if (value.isString()) { #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); + texturePayload = QByteArray::fromBase64(value.toString().toUtf8(), QByteArray::AbortOnBase64DecodingErrors); #else - texturePayload = QByteArray::fromBase64(value.toString().toUtf8()); + texturePayload = QByteArray::fromBase64(value.toString().toUtf8()); #endif - } - - if (!texturePayload.isEmpty()) { - break; + } + } else if (nameString == "uploadableTextures") { + // 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 + 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()) { 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 // so just fake it - changing capes is probably locked anyway :( capeOut.alias = "cape"; - capeOut.id = "00000000-0000-0000-0000-000000000000"; } } } diff --git a/launcher/minecraft/skins/AuthlibInjectorTextureDelete.cpp b/launcher/minecraft/skins/AuthlibInjectorTextureDelete.cpp new file mode 100644 index 000000000..6a75cc02f --- /dev/null +++ b/launcher/minecraft/skins/AuthlibInjectorTextureDelete.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Fjord Launcher - Minecraft Launcher + * Copyright (C) 2024 Evan Goode + * + * 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 . + * + * 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(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())); + up->addHeaderProxy(new Net::RawHeaderProxy(QList{ + { "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() }, + })); + return up; +} diff --git a/launcher/minecraft/skins/AuthlibInjectorTextureDelete.h b/launcher/minecraft/skins/AuthlibInjectorTextureDelete.h new file mode 100644 index 000000000..fbf539c64 --- /dev/null +++ b/launcher/minecraft/skins/AuthlibInjectorTextureDelete.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Fjord Launcher - Minecraft Launcher + * Copyright (C) 2024 Evan Goode + * + * 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 . + */ + +#pragma once + +#include +#include "net/NetRequest.h" + +class AuthlibInjectorTextureDelete : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + AuthlibInjectorTextureDelete(QString textureType); + virtual ~AuthlibInjectorTextureDelete() = default; + + static AuthlibInjectorTextureDelete::Ptr make(MinecraftAccountPtr account, QString textureType); + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + QString m_textureType; +}; diff --git a/launcher/minecraft/skins/AuthlibInjectorTextureUpload.cpp b/launcher/minecraft/skins/AuthlibInjectorTextureUpload.cpp new file mode 100644 index 000000000..d6f6dfd9e --- /dev/null +++ b/launcher/minecraft/skins/AuthlibInjectorTextureUpload.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Fjord Launcher - Minecraft Launcher + * Copyright (C) 2024 Evan Goode + * + * 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 . + * + * 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 + +#include "FileSystem.h" +#include "net/ByteArraySink.h" +#include "net/RawHeaderProxy.h" + +AuthlibInjectorTextureUpload::AuthlibInjectorTextureUpload(QString path, std::optional 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 skin_variant) +{ + auto up = makeShared(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())); + up->addHeaderProxy(new Net::RawHeaderProxy(QList{ + { "Authorization", QString("Bearer %1").arg(token).toLocal8Bit() }, + })); + return up; +} diff --git a/launcher/minecraft/skins/AuthlibInjectorTextureUpload.h b/launcher/minecraft/skins/AuthlibInjectorTextureUpload.h new file mode 100644 index 000000000..00509a457 --- /dev/null +++ b/launcher/minecraft/skins/AuthlibInjectorTextureUpload.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Fjord Launcher - Minecraft Launcher + * Copyright (C) 2024 Evan Goode + * + * 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 . + */ + +#pragma once + +#include +#include "net/NetRequest.h" + +class AuthlibInjectorTextureUpload : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + // Note this class takes ownership of the file. + AuthlibInjectorTextureUpload(QString path, std::optional skin_variant); + virtual ~AuthlibInjectorTextureUpload() = default; + + static AuthlibInjectorTextureUpload::Ptr make(MinecraftAccountPtr account, QString path, std::optional skin_variant); + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + QString m_path; + std::optional m_skin_variant; +}; diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.cpp b/launcher/ui/dialogs/skins/SkinManageDialog.cpp index e6e21e147..57855d17c 100644 --- a/launcher/ui/dialogs/skins/SkinManageDialog.cpp +++ b/launcher/ui/dialogs/skins/SkinManageDialog.cpp @@ -37,6 +37,8 @@ #include "QObjectPtr.h" #include "minecraft/auth/Parsers.h" +#include "minecraft/skins/AuthlibInjectorTextureDelete.h" +#include "minecraft/skins/AuthlibInjectorTextureUpload.h" #include "minecraft/skins/CapeChange.h" #include "minecraft/skins/SkinDelete.h" #include "minecraft/skins/SkinList.h" @@ -45,6 +47,7 @@ #include "net/Download.h" #include "net/NetJob.h" +#include "net/Upload.h" #include "tasks/Task.h" #include "ui/dialogs/CustomMessageBox.h" @@ -162,6 +165,12 @@ QPixmap previewCape(QPixmap capeImage) 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 auto& accountData = *m_acct->accountData(); int index = 0; @@ -252,10 +261,26 @@ void SkinManageDialog::accept() return; } - skinUpload->addNetAction(SkinUpload::make(m_acct, skin->getPath(), skin->getModelString())); + switch (m_acct->accountType()) { + case AccountType::MSA: { + 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(); - if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { + if (canChangeCapes && selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { skinUpload->addNetAction(CapeChange::make(m_acct, selectedCape)); } @@ -273,7 +298,23 @@ void SkinManageDialog::on_resetBtn_clicked() { ProgressDialog prog(this); NetJob::Ptr skinReset{ new NetJob(tr("Reset skin"), APPLICATION->network(), 1) }; - skinReset->addNetAction(SkinDelete::make(m_acct)); + + switch (m_acct->accountType()) { + case AccountType::MSA: { + 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()); if (prog.execWithTask(skinReset.get()) != QDialog::Accepted) { 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()) { return; } + const auto & account = m_acct; + MinecraftProfile mcProfile; auto path = FS::PathCombine(m_list.getDir(), user + ".png"); @@ -421,7 +464,9 @@ void SkinManageDialog::on_userBtn_clicked() auto uuidLoop = makeShared(); auto profileLoop = makeShared(); - 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 downloadSkin = Net::Download::makeFile(QUrl(), path); @@ -444,7 +489,7 @@ void SkinManageDialog::on_userBtn_clicked() 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 { QJsonParseError parse_error{}; QJsonDocument doc = QJsonDocument::fromJson(*uuidOut, &parse_error); @@ -455,14 +500,15 @@ void SkinManageDialog::on_userBtn_clicked() uuidLoop->quit(); return; } - const auto root = doc.object(); - auto id = Json::ensureString(root, "id"); - if (!id.isEmpty()) { - getProfile->setUrl("https://sessionserver.mojang.com/session/minecraft/profile/" + id); - } else { + const auto& root = doc.array(); + if (root.size() != 1) { failReason = tr("user id is empty"); 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) { qCritical() << "Couldn't load skin json:" << e.cause(); failReason = tr("failed to parse get user UUID response"); diff --git a/launcher/ui/pages/global/AccountListPage.cpp b/launcher/ui/pages/global/AccountListPage.cpp index f1d17d1f6..eef73b93e 100644 --- a/launcher/ui/pages/global/AccountListPage.cpp +++ b/launcher/ui/pages/global/AccountListPage.cpp @@ -223,17 +223,17 @@ void AccountListPage::updateButtonStates() bool hasSelection = !selection.empty(); bool accountIsReady = false; bool accountIsOnline = false; - bool accountSupportsSkinManagement = false; + bool accountCanUploadSkins = false; if (hasSelection) { QModelIndex selected = selection.first(); MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); accountIsReady = !account->isActive(); accountIsOnline = account->accountType() != AccountType::Offline; - accountSupportsSkinManagement = account->supportsSkinManagement(); + accountCanUploadSkins = account->canUploadSkins(); } ui->actionRemove->setEnabled(accountIsReady); ui->actionSetDefault->setEnabled(accountIsReady); - ui->actionManageSkins->setEnabled(accountIsReady && accountIsOnline && accountSupportsSkinManagement); + ui->actionManageSkins->setEnabled(accountIsReady && accountIsOnline && accountCanUploadSkins); ui->actionRefresh->setEnabled(accountIsReady && accountIsOnline); if (m_accounts->defaultAccount().get() == nullptr) {