diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index 3b6218b7f..6914b3385 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -377,13 +377,17 @@ 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 + # Minecraft skins + minecraft/skins/CapeChange.cpp + minecraft/skins/CapeChange.h + minecraft/skins/SkinUpload.cpp + minecraft/skins/SkinUpload.h + minecraft/skins/SkinDelete.cpp + minecraft/skins/SkinDelete.h + minecraft/skins/SkinModel.cpp + minecraft/skins/SkinModel.h + minecraft/skins/SkinList.cpp + minecraft/skins/SkinList.h minecraft/Agent.h) @@ -742,8 +746,6 @@ SET(LAUNCHER_SOURCES ui/InstanceWindow.cpp # FIXME: maybe find a better home for this. - SkinUtils.cpp - SkinUtils.h FileIgnoreProxy.cpp FileIgnoreProxy.h FastFileIconProvider.cpp @@ -965,8 +967,6 @@ SET(LAUNCHER_SOURCES ui/dialogs/ReviewMessageBox.h ui/dialogs/VersionSelectDialog.cpp ui/dialogs/VersionSelectDialog.h - ui/dialogs/SkinUploadDialog.cpp - ui/dialogs/SkinUploadDialog.h ui/dialogs/ResourceDownloadDialog.cpp ui/dialogs/ResourceDownloadDialog.h ui/dialogs/ScrollMessageBox.cpp @@ -980,6 +980,9 @@ SET(LAUNCHER_SOURCES ui/dialogs/InstallLoaderDialog.cpp ui/dialogs/InstallLoaderDialog.h + ui/dialogs/skins/SkinManageDialog.cpp + ui/dialogs/skins/SkinManageDialog.h + # GUI - widgets ui/widgets/Common.cpp ui/widgets/Common.h @@ -1096,7 +1099,6 @@ qt_wrap_ui(LAUNCHER_UI ui/dialogs/NewComponentDialog.ui ui/dialogs/NewsDialog.ui ui/dialogs/ProfileSelectDialog.ui - ui/dialogs/SkinUploadDialog.ui ui/dialogs/ExportInstanceDialog.ui ui/dialogs/ExportPackDialog.ui ui/dialogs/ExportToModListDialog.ui @@ -1111,6 +1113,8 @@ qt_wrap_ui(LAUNCHER_UI ui/dialogs/ScrollMessageBox.ui ui/dialogs/BlockedModsDialog.ui ui/dialogs/ChooseProviderDialog.ui + + ui/dialogs/skins/SkinManageDialog.ui ) qt_add_resources(LAUNCHER_RESOURCES diff --git a/launcher/SkinUtils.cpp b/launcher/SkinUtils.cpp deleted file mode 100644 index 989114ad5..000000000 --- a/launcher/SkinUtils.cpp +++ /dev/null @@ -1,52 +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 "Application.h" -#include "net/HttpMetaCache.h" - -#include -#include -#include -#include -#include - -namespace SkinUtils { -/* - * Given a username, return a pixmap of the cached skin (if it exists), QPixmap() otherwise - */ -QPixmap getFaceFromCache(QString username, int height, int width) -{ - QFile fskin(APPLICATION->metacache()->resolveEntry("skins", username + ".png")->getFullPath()); - - if (fskin.exists()) { - QPixmap skinTexture(fskin.fileName()); - if (!skinTexture.isNull()) { - QPixmap skin = QPixmap(8, 8); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - skin.fill(QColorConstants::Transparent); -#else - skin.fill(QColor(0, 0, 0, 0)); -#endif - 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(); -} -} // namespace SkinUtils diff --git a/launcher/SkinUtils.h b/launcher/SkinUtils.h deleted file mode 100644 index 11bc8bc6f..000000000 --- a/launcher/SkinUtils.h +++ /dev/null @@ -1,22 +0,0 @@ -/* Copyright 2013-2021 MultiMC Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include - -namespace SkinUtils { -QPixmap getFaceFromCache(QString id, int height = 64, int width = 64); -} diff --git a/launcher/minecraft/services/CapeChange.h b/launcher/minecraft/services/CapeChange.h deleted file mode 100644 index 74805ef43..000000000 --- a/launcher/minecraft/services/CapeChange.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include "net/NetRequest.h" - -class CapeChange : public Net::NetRequest { - Q_OBJECT - public: - using Ptr = shared_qobject_ptr; - CapeChange(QString token, QString capeId); - virtual ~CapeChange() = default; - - static CapeChange::Ptr make(QString token, QString capeId); - void init() override; - - protected: - virtual QNetworkReply* getReply(QNetworkRequest&) override; - - private: - QString m_capeId; - QString m_token; -}; diff --git a/launcher/minecraft/services/SkinDelete.h b/launcher/minecraft/services/SkinDelete.h deleted file mode 100644 index b0fb866cd..000000000 --- a/launcher/minecraft/services/SkinDelete.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "net/NetRequest.h" - -class SkinDelete : public Net::NetRequest { - Q_OBJECT - public: - using Ptr = shared_qobject_ptr; - SkinDelete(QString token); - virtual ~SkinDelete() = default; - - static SkinDelete::Ptr make(QString token); - void init() override; - - protected: - virtual QNetworkReply* getReply(QNetworkRequest&) override; - - private: - QString m_token; -}; diff --git a/launcher/minecraft/services/SkinUpload.h b/launcher/minecraft/services/SkinUpload.h deleted file mode 100644 index 2da836d52..000000000 --- a/launcher/minecraft/services/SkinUpload.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "net/NetRequest.h" - -class SkinUpload : public Net::NetRequest { - Q_OBJECT - public: - using Ptr = shared_qobject_ptr; - enum Model { STEVE, ALEX }; - - // Note this class takes ownership of the file. - SkinUpload(QString token, QByteArray skin, Model model = STEVE); - virtual ~SkinUpload() = default; - - static SkinUpload::Ptr make(QString token, QByteArray skin, Model model = STEVE); - void init() override; - - protected: - virtual QNetworkReply* getReply(QNetworkRequest&) override; - - private: - Model m_model; - QByteArray m_skin; - QString m_token; -}; diff --git a/launcher/minecraft/services/CapeChange.cpp b/launcher/minecraft/skins/CapeChange.cpp similarity index 90% rename from launcher/minecraft/services/CapeChange.cpp rename to launcher/minecraft/skins/CapeChange.cpp index 5a7820b54..863e89844 100644 --- a/launcher/minecraft/services/CapeChange.cpp +++ b/launcher/minecraft/skins/CapeChange.cpp @@ -2,6 +2,7 @@ /* * Prism Launcher - Minecraft Launcher * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 * * 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 @@ -42,7 +43,7 @@ CapeChange::CapeChange(QString token, QString cape) : NetRequest(), m_capeId(cape), m_token(token) { - logCat = taskMCServicesLogC; + logCat = taskMCSkinsLogC; }; QNetworkReply* CapeChange::getReply(QNetworkRequest& request) @@ -52,7 +53,7 @@ QNetworkReply* CapeChange::getReply(QNetworkRequest& request) return m_network->deleteResource(request); } else { setStatus(tr("Equipping cape")); - return m_network->post(request, QString("{\"capeId\":\"%1\"}").arg(m_capeId).toUtf8()); + return m_network->put(request, QString("{\"capeId\":\"%1\"}").arg(m_capeId).toUtf8()); } } @@ -67,6 +68,7 @@ CapeChange::Ptr CapeChange::make(QString token, QString capeId) { auto up = makeShared(token, capeId); up->m_url = QUrl("https://api.minecraftservices.com/minecraft/profile/capes/active"); + up->setObjectName(QString("BYTES:") + up->m_url.toString()); up->m_sink.reset(new Net::ByteArraySink(std::make_shared())); return up; } diff --git a/launcher/minecraft/skins/CapeChange.h b/launcher/minecraft/skins/CapeChange.h new file mode 100644 index 000000000..bcafcde87 --- /dev/null +++ b/launcher/minecraft/skins/CapeChange.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 "net/NetRequest.h" + +class CapeChange : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + CapeChange(QString token, QString capeId); + virtual ~CapeChange() = default; + + static CapeChange::Ptr make(QString token, QString capeId); + void init() override; + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + QString m_capeId; + QString m_token; +}; diff --git a/launcher/minecraft/services/SkinDelete.cpp b/launcher/minecraft/skins/SkinDelete.cpp similarity index 96% rename from launcher/minecraft/services/SkinDelete.cpp rename to launcher/minecraft/skins/SkinDelete.cpp index 7944637f6..982cac1b7 100644 --- a/launcher/minecraft/services/SkinDelete.cpp +++ b/launcher/minecraft/skins/SkinDelete.cpp @@ -2,6 +2,7 @@ /* * Prism Launcher - Minecraft Launcher * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 * * 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 @@ -40,7 +41,7 @@ SkinDelete::SkinDelete(QString token) : NetRequest(), m_token(token) { - logCat = taskMCServicesLogC; + logCat = taskMCSkinsLogC; }; QNetworkReply* SkinDelete::getReply(QNetworkRequest& request) diff --git a/launcher/minecraft/skins/SkinDelete.h b/launcher/minecraft/skins/SkinDelete.h new file mode 100644 index 000000000..5d02e0cc4 --- /dev/null +++ b/launcher/minecraft/skins/SkinDelete.h @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 "net/NetRequest.h" + +class SkinDelete : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + SkinDelete(QString token); + virtual ~SkinDelete() = default; + + static SkinDelete::Ptr make(QString token); + void init() override; + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + QString m_token; +}; diff --git a/launcher/minecraft/skins/SkinList.cpp b/launcher/minecraft/skins/SkinList.cpp new file mode 100644 index 000000000..be329564b --- /dev/null +++ b/launcher/minecraft/skins/SkinList.cpp @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 . + */ + +#include "SkinList.h" + +#include +#include + +#include "FileSystem.h" +#include "Json.h" +#include "minecraft/skins/SkinModel.h" + +SkinList::SkinList(QObject* parent, QString path, MinecraftAccountPtr acct) : QAbstractListModel(parent), m_acct(acct) +{ + FS::ensureFolderPathExists(m_dir.absolutePath()); + m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files | QDir::Dirs); + m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware); + m_watcher.reset(new QFileSystemWatcher(this)); + is_watching = false; + connect(m_watcher.get(), &QFileSystemWatcher::directoryChanged, this, &SkinList::directoryChanged); + connect(m_watcher.get(), &QFileSystemWatcher::fileChanged, this, &SkinList::fileChanged); + directoryChanged(path); +} + +void SkinList::startWatching() +{ + if (is_watching) { + return; + } + update(); + is_watching = m_watcher->addPath(m_dir.absolutePath()); + if (is_watching) { + qDebug() << "Started watching " << m_dir.absolutePath(); + } else { + qDebug() << "Failed to start watching " << m_dir.absolutePath(); + } +} + +void SkinList::stopWatching() +{ + save(); + if (!is_watching) { + return; + } + is_watching = !m_watcher->removePath(m_dir.absolutePath()); + if (!is_watching) { + qDebug() << "Stopped watching " << m_dir.absolutePath(); + } else { + qDebug() << "Failed to stop watching " << m_dir.absolutePath(); + } +} + +bool SkinList::update() +{ + QVector newSkins; + m_dir.refresh(); + + auto manifestInfo = QFileInfo(m_dir.absoluteFilePath("index.json")); + if (manifestInfo.exists()) { + try { + auto doc = Json::requireDocument(manifestInfo.absoluteFilePath(), "SkinList JSON file"); + const auto root = doc.object(); + auto skins = Json::ensureArray(root, "skins"); + for (auto jSkin : skins) { + SkinModel s(m_dir, Json::ensureObject(jSkin)); + if (s.isValid()) { + newSkins << s; + } + } + } catch (const Exception& e) { + qCritical() << "Couldn't load skins json:" << e.cause(); + } + } else { + newSkins = loadMinecraftSkins(); + } + + bool needsSave = false; + const auto& skin = m_acct->accountData()->minecraftProfile.skin; + if (!skin.url.isEmpty() && !skin.data.isEmpty()) { + QPixmap skinTexture; + SkinModel* nskin = nullptr; + for (auto i = 0; i < newSkins.size(); i++) { + if (newSkins[i].getURL() == skin.url) { + nskin = &newSkins[i]; + break; + } + } + if (!nskin) { + auto name = m_acct->profileName() + ".png"; + if (QFileInfo(m_dir.absoluteFilePath(name)).exists()) { + name = QUrl(skin.url).fileName() + ".png"; + } + auto path = m_dir.absoluteFilePath(name); + if (skinTexture.loadFromData(skin.data, "PNG") && skinTexture.save(path)) { + SkinModel s(path); + s.setModel(SkinModel::CLASSIC); // maybe better model detection + s.setCapeId(m_acct->accountData()->minecraftProfile.currentCape); + s.setURL(skin.url); + newSkins << s; + needsSave = true; + } + } else { + nskin->setCapeId(m_acct->accountData()->minecraftProfile.currentCape); + } + } + + auto folderContents = m_dir.entryInfoList(); + // if there are any untracked files... + for (QFileInfo entry : folderContents) { + if (!entry.isFile() && entry.suffix() != "png") + continue; + + SkinModel w(entry.absoluteFilePath()); + if (w.isValid()) { + auto add = true; + for (auto s : newSkins) { + if (s.name() == w.name()) { + add = false; + break; + } + } + if (add) { + newSkins.append(w); + needsSave = true; + } + } + } + std::sort(newSkins.begin(), newSkins.end(), + [](const SkinModel& a, const SkinModel& b) { return a.getPath().localeAwareCompare(b.getPath()) < 0; }); + beginResetModel(); + m_skin_list.swap(newSkins); + endResetModel(); + if (needsSave) + save(); + return true; +} + +void SkinList::directoryChanged(const QString& path) +{ + QDir new_dir(path); + if (!new_dir.exists()) + if (!FS::ensureFolderPathExists(new_dir.absolutePath())) + return; + if (m_dir.absolutePath() != new_dir.absolutePath()) { + m_dir.setPath(path); + m_dir.refresh(); + if (is_watching) + stopWatching(); + startWatching(); + } + update(); +} + +void SkinList::fileChanged(const QString& path) +{ + qDebug() << "Checking " << path; + QFileInfo checkfile(path); + if (!checkfile.exists()) + return; + + for (int i = 0; i < m_skin_list.count(); i++) { + if (m_skin_list[i].getPath() == checkfile.absoluteFilePath()) { + m_skin_list[i].refresh(); + dataChanged(index(i), index(i)); + break; + } + } +} + +QStringList SkinList::mimeTypes() const +{ + return { "text/uri-list" }; +} + +Qt::DropActions SkinList::supportedDropActions() const +{ + return Qt::CopyAction; +} + +bool SkinList::dropMimeData(const QMimeData* data, + Qt::DropAction action, + [[maybe_unused]] int row, + [[maybe_unused]] int column, + [[maybe_unused]] const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) + return true; + // check if the action is supported + if (!data || !(action & supportedDropActions())) + return false; + + // files dropped from outside? + if (data->hasUrls()) { + auto urls = data->urls(); + QStringList iconFiles; + for (auto url : urls) { + // only local files may be dropped... + if (!url.isLocalFile()) + continue; + iconFiles += url.toLocalFile(); + } + installSkins(iconFiles); + return true; + } + return false; +} + +Qt::ItemFlags SkinList::flags(const QModelIndex& index) const +{ + Qt::ItemFlags f = Qt::ItemIsDropEnabled | QAbstractListModel::flags(index); + if (index.isValid()) { + f |= (Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable); + } + return f; +} + +QVariant SkinList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + int row = index.row(); + + if (row < 0 || row >= m_skin_list.size()) + return QVariant(); + auto skin = m_skin_list[row]; + switch (role) { + case Qt::DecorationRole: + return skin.getTexture(); + case Qt::DisplayRole: + return skin.name(); + case Qt::UserRole: + return skin.name(); + case Qt::EditRole: + return skin.name(); + default: + return QVariant(); + } +} + +int SkinList::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_skin_list.size(); +} + +void SkinList::installSkins(const QStringList& iconFiles) +{ + for (QString file : iconFiles) + installSkin(file, {}); +} + +void SkinList::installSkin(const QString& file, const QString& name) +{ + QFileInfo fileinfo(file); + if (!fileinfo.isReadable() || !fileinfo.isFile()) + return; + + if (fileinfo.suffix() != "png" && !SkinModel(fileinfo.absoluteFilePath()).isValid()) + return; + + QString target = FS::PathCombine(m_dir.absolutePath(), name.isEmpty() ? fileinfo.fileName() : name); + QFile::copy(file, target); +} + +int SkinList::getSkinIndex(const QString& key) const +{ + for (int i = 0; i < m_skin_list.count(); i++) { + if (m_skin_list[i].name() == key) { + return i; + } + } + return -1; +} + +const SkinModel* SkinList::skin(const QString& key) const +{ + int idx = getSkinIndex(key); + if (idx == -1) + return nullptr; + return &m_skin_list[idx]; +} + +SkinModel* SkinList::skin(const QString& key) +{ + int idx = getSkinIndex(key); + if (idx == -1) + return nullptr; + return &m_skin_list[idx]; +} + +bool SkinList::deleteSkin(const QString& key, const bool trash) +{ + int idx = getSkinIndex(key); + if (idx != -1) { + auto s = m_skin_list[idx]; + if (trash) { + if (FS::trash(s.getPath(), nullptr)) { + m_skin_list.remove(idx); + return true; + } + } else if (QFile::remove(s.getPath())) { + m_skin_list.remove(idx); + return true; + } + } + return false; +} + +void SkinList::save() +{ + QJsonObject doc; + QJsonArray arr; + for (auto s : m_skin_list) { + arr << s.toJSON(); + } + doc["skins"] = arr; + Json::write(doc, m_dir.absoluteFilePath("index.json")); +} + +int SkinList::getSelectedAccountSkin() +{ + const auto& skin = m_acct->accountData()->minecraftProfile.skin; + for (int i = 0; i < m_skin_list.count(); i++) { + if (m_skin_list[i].getURL() == skin.url) { + return i; + } + } + return -1; +} + +bool SkinList::setData(const QModelIndex& idx, const QVariant& value, int role) +{ + if (!idx.isValid() || role != Qt::EditRole) { + return false; + } + + int row = idx.row(); + if (row < 0 || row >= m_skin_list.size()) + return false; + auto skin = m_skin_list[row]; + auto newName = value.toString(); + if (skin.name() != newName) { + skin.rename(newName); + save(); + } + return true; +} + +QVector SkinList::loadMinecraftSkins() +{ + QString partialPath; +#if defined(Q_OS_OSX) + partialPath = FS::PathCombine(QDir::homePath(), "Library/Application Support"); +#elif defined(Q_OS_WIN32) + partialPath = QProcessEnvironment::systemEnvironment().value("LOCALAPPDATA", ""); +#else + partialPath = QDir::homePath(); +#endif + QVector newSkins; + auto path = FS::PathCombine(partialPath, ".minecraft", "launcher_custom_skins.json"); + auto manifestInfo = QFileInfo(path); + if (!manifestInfo.exists()) + return {}; + try { + auto doc = Json::requireDocument(manifestInfo.absoluteFilePath(), "SkinList JSON file"); + const auto root = doc.object(); + auto skins = Json::ensureObject(root, "customSkins"); + for (auto key : skins.keys()) { + SkinModel s(m_dir, Json::ensureObject(skins, key)); + if (s.isValid()) { + newSkins << s; + } + } + } catch (const Exception& e) { + qCritical() << "Couldn't load minecraft skins json:" << e.cause(); + } + return newSkins; +} diff --git a/launcher/minecraft/skins/SkinList.h b/launcher/minecraft/skins/SkinList.h new file mode 100644 index 000000000..8d8266d79 --- /dev/null +++ b/launcher/minecraft/skins/SkinList.h @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 +#include + +#include "QObjectPtr.h" +#include "SkinModel.h" +#include "minecraft/auth/MinecraftAccount.h" + +class SkinList : public QAbstractListModel { + Q_OBJECT + public: + explicit SkinList(QObject* parent, QString path, MinecraftAccountPtr acct); + virtual ~SkinList() { save(); }; + + int getSkinIndex(const QString& key) const; + + virtual QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& idx, const QVariant& value, int role) 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; + + bool deleteSkin(const QString& key, const bool trash); + + void installSkins(const QStringList& iconFiles); + void installSkin(const QString& file, const QString& name); + + const SkinModel* skin(const QString& key) const; + SkinModel* skin(const QString& key); + + void startWatching(); + void stopWatching(); + + QString getDir() const { return m_dir.absolutePath(); } + void save(); + int getSelectedAccountSkin(); + + private: + // hide copy constructor + SkinList(const SkinList&) = delete; + // hide assign op + SkinList& operator=(const SkinList&) = delete; + + QVector loadMinecraftSkins(); + + protected slots: + void directoryChanged(const QString& path); + void fileChanged(const QString& path); + bool update(); + + private: + shared_qobject_ptr m_watcher; + bool is_watching; + QVector m_skin_list; + QDir m_dir; + MinecraftAccountPtr m_acct; +}; \ No newline at end of file diff --git a/launcher/minecraft/skins/SkinModel.cpp b/launcher/minecraft/skins/SkinModel.cpp new file mode 100644 index 000000000..3b467019c --- /dev/null +++ b/launcher/minecraft/skins/SkinModel.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 . + */ + +#include "SkinModel.h" +#include +#include +#include +#include + +#include "FileSystem.h" +#include "Json.h" + +SkinModel::SkinModel(QString path) : m_path(path), m_texture(path), m_model(Model::CLASSIC) {} + +SkinModel::SkinModel(QDir skinDir, QJsonObject obj) + : m_cape_id(Json::ensureString(obj, "capeId")), m_model(Model::CLASSIC), m_url(Json::ensureString(obj, "url")) +{ + auto name = Json::ensureString(obj, "name"); + auto skinImage = Json::ensureString(obj, "skinImage"); + if (!skinImage.isEmpty()) { // minecraft skin model + skinImage = skinImage.mid(22); + m_texture.loadFromData(QByteArray::fromBase64(skinImage.toUtf8()), "PNG"); + auto textureId = Json::ensureString(obj, "textureId"); + if (name.isEmpty()) { + name = textureId; + } + if (Json::ensureBoolean(obj, "slim", false)) { + m_model = Model::SLIM; + } + } else { + if (auto model = Json::ensureString(obj, "model"); model == "SLIM") { + m_model = Model::SLIM; + } + } + m_path = skinDir.absoluteFilePath(name) + ".png"; + if (!QFileInfo(m_path).exists() && isValid()) { + m_texture.save(m_path, "PNG"); + } else { + m_texture = QPixmap(m_path); + } +} + +QString SkinModel::name() const +{ + return QFileInfo(m_path).baseName(); +} + +bool SkinModel::rename(QString newName) +{ + auto info = QFileInfo(m_path); + m_path = FS::PathCombine(info.absolutePath(), newName + ".png"); + return FS::move(info.absoluteFilePath(), m_path); +} + +QJsonObject SkinModel::toJSON() const +{ + QJsonObject obj; + obj["name"] = name(); + obj["capeId"] = m_cape_id; + obj["url"] = m_url; + obj["model"] = getModelString(); + return obj; +} + +QString SkinModel::getModelString() const +{ + switch (m_model) { + case CLASSIC: + return "CLASSIC"; + case SLIM: + return "SLIM"; + } + return {}; +} + +bool SkinModel::isValid() const +{ + return !m_texture.isNull() && (m_texture.size().height() == 32 || m_texture.size().height() == 64) && m_texture.size().width() == 64; +} + +QPixmap SkinModel::renderFrontBody() const +{ + auto isSlim = m_model == SLIM; + auto slimOffset = isSlim ? 1 : 0; + auto isOldSkin = m_texture.height() < 64; + + auto head = m_texture.copy(QRect(8, 8, 16, 16)); + auto torso = m_texture.copy(QRect(20, 20, 28, 32)); + auto rightArm = m_texture.copy(QRect(44, 20, 48 - slimOffset, 32)); + auto rightLeg = m_texture.copy(QRect(4, 20, 8, 32)); + QPixmap leftArm, leftLeg; + + if (isOldSkin) { + leftArm = rightArm.transformed(QTransform().scale(-1, 1)); + leftLeg = rightLeg.transformed(QTransform().scale(-1, 1)); + } else { + leftArm = m_texture.copy(QRect(36, 52, 40 - slimOffset, 64)); + leftLeg = m_texture.copy(QRect(20, 52, 24, 64)); + } + QPixmap output(16, 32); + output.fill(Qt::black); + QPainter p; + if (!p.begin(&output)) + return {}; + p.drawPixmap(QPoint(4, 0), head); + p.drawPixmap(QPoint(4, 8), torso); + p.drawPixmap(QPoint(12, 8), leftArm); + p.drawPixmap(QPoint(slimOffset, 8), rightArm); + p.drawPixmap(QPoint(8, 20), leftLeg); + p.drawPixmap(QPoint(4, 20), leftArm); + + return output.scaled(128, 128, Qt::KeepAspectRatioByExpanding, Qt::FastTransformation); +} \ No newline at end of file diff --git a/launcher/minecraft/skins/SkinModel.h b/launcher/minecraft/skins/SkinModel.h new file mode 100644 index 000000000..6d135c7f7 --- /dev/null +++ b/launcher/minecraft/skins/SkinModel.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 +#include + +class SkinModel { + public: + enum Model { CLASSIC, SLIM }; + + SkinModel(QString path); + SkinModel(QDir skinDir, QJsonObject obj); + virtual ~SkinModel() = default; + + QString name() const; + QString getModelString() const; + bool isValid() const; + QString getPath() const { return m_path; } + QPixmap getTexture() const { return m_texture; } + QString getCapeId() const { return m_cape_id; } + Model getModel() const { return m_model; } + QString getURL() const { return m_url; } + + bool rename(QString newName); + void setCapeId(QString capeID) { m_cape_id = capeID; } + void setModel(Model model) { m_model = model; } + void setURL(QString url) { m_url = url; } + void refresh() { m_texture = QPixmap(m_path); } + + QJsonObject toJSON() const; + + QPixmap renderFrontBody() const; + + private: + QString m_path; + QPixmap m_texture; + QString m_cape_id; + Model m_model; + QString m_url; +}; \ No newline at end of file diff --git a/launcher/minecraft/services/SkinUpload.cpp b/launcher/minecraft/skins/SkinUpload.cpp similarity index 79% rename from launcher/minecraft/services/SkinUpload.cpp rename to launcher/minecraft/skins/SkinUpload.cpp index 0400fa0f4..4e56bd7e6 100644 --- a/launcher/minecraft/services/SkinUpload.cpp +++ b/launcher/minecraft/skins/SkinUpload.cpp @@ -2,6 +2,7 @@ /* * Prism Launcher - Minecraft Launcher * Copyright (C) 2022 Sefa Eyeoglu + * Copyright (c) 2023 Trial97 * * 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 @@ -37,12 +38,13 @@ #include +#include "FileSystem.h" #include "net/ByteArraySink.h" #include "net/StaticHeaderProxy.h" -SkinUpload::SkinUpload(QString token, QByteArray skin, SkinUpload::Model model) : NetRequest(), m_model(model), m_skin(skin), m_token(token) +SkinUpload::SkinUpload(QString token, SkinModel* skin) : NetRequest(), m_skin(skin), m_token(token) { - logCat = taskMCServicesLogC; + logCat = taskUploadLogC; }; QNetworkReply* SkinUpload::getReply(QNetworkRequest& request) @@ -52,23 +54,12 @@ QNetworkReply* SkinUpload::getReply(QNetworkRequest& request) 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); + + skin.setBody(FS::read(m_skin->getPath())); QHttpPart model; model.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"variant\"")); - - switch (m_model) { - default: - qDebug() << "Unknown skin type!"; - emitFailed("Unknown skin type!"); - return nullptr; - case SkinUpload::STEVE: - model.setBody("CLASSIC"); - break; - case SkinUpload::ALEX: - model.setBody("SLIM"); - break; - } + model.setBody(m_skin->getModelString().toUtf8()); multiPart->append(skin); multiPart->append(model); @@ -83,10 +74,11 @@ void SkinUpload::init() })); } -SkinUpload::Ptr SkinUpload::make(QString token, QByteArray skin, SkinUpload::Model model) +SkinUpload::Ptr SkinUpload::make(QString token, SkinModel* skin) { - auto up = makeShared(token, skin, model); + auto up = makeShared(token, skin); up->m_url = QUrl("https://api.minecraftservices.com/minecraft/profile/skins"); + up->setObjectName(QString("BYTES:") + up->m_url.toString()); up->m_sink.reset(new Net::ByteArraySink(std::make_shared())); return up; } diff --git a/launcher/minecraft/skins/SkinUpload.h b/launcher/minecraft/skins/SkinUpload.h new file mode 100644 index 000000000..d070f301d --- /dev/null +++ b/launcher/minecraft/skins/SkinUpload.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 "minecraft/skins/SkinModel.h" +#include "net/NetRequest.h" + +class SkinUpload : public Net::NetRequest { + Q_OBJECT + public: + using Ptr = shared_qobject_ptr; + + // Note this class takes ownership of the file. + SkinUpload(QString token, SkinModel* skin); + virtual ~SkinUpload() = default; + + static SkinUpload::Ptr make(QString token, SkinModel* skin); + void init() override; + + protected: + virtual QNetworkReply* getReply(QNetworkRequest&) override; + + private: + SkinModel* m_skin; + QString m_token; +}; diff --git a/launcher/net/Logging.cpp b/launcher/net/Logging.cpp index 45d2dcc20..cd0c88d3c 100644 --- a/launcher/net/Logging.cpp +++ b/launcher/net/Logging.cpp @@ -22,6 +22,6 @@ Q_LOGGING_CATEGORY(taskNetLogC, "launcher.task.net") Q_LOGGING_CATEGORY(taskDownloadLogC, "launcher.task.net.download") Q_LOGGING_CATEGORY(taskUploadLogC, "launcher.task.net.upload") -Q_LOGGING_CATEGORY(taskMCServicesLogC, "launcher.task.minecraft.servicies") +Q_LOGGING_CATEGORY(taskMCSkinsLogC, "launcher.task.minecraft.skins") Q_LOGGING_CATEGORY(taskMetaCacheLogC, "launcher.task.net.metacache") Q_LOGGING_CATEGORY(taskHttpMetaCacheLogC, "launcher.task.net.metacache.http") diff --git a/launcher/net/Logging.h b/launcher/net/Logging.h index d3a11cdce..2536f31aa 100644 --- a/launcher/net/Logging.h +++ b/launcher/net/Logging.h @@ -24,6 +24,6 @@ Q_DECLARE_LOGGING_CATEGORY(taskNetLogC) Q_DECLARE_LOGGING_CATEGORY(taskDownloadLogC) Q_DECLARE_LOGGING_CATEGORY(taskUploadLogC) -Q_DECLARE_LOGGING_CATEGORY(taskMCServicesLogC) +Q_DECLARE_LOGGING_CATEGORY(taskMCSkinsLogC) Q_DECLARE_LOGGING_CATEGORY(taskMetaCacheLogC) Q_DECLARE_LOGGING_CATEGORY(taskHttpMetaCacheLogC) diff --git a/launcher/net/NetJob.h b/launcher/net/NetJob.h index cc63f4497..26791bc0d 100644 --- a/launcher/net/NetJob.h +++ b/launcher/net/NetJob.h @@ -52,8 +52,8 @@ class NetJob : public ConcurrentTask { public: using Ptr = shared_qobject_ptr; - explicit NetJob(QString job_name, shared_qobject_ptr network) - : ConcurrentTask(nullptr, job_name), m_network(network) + explicit NetJob(QString job_name, shared_qobject_ptr network, int max_concurrent = 6) + : ConcurrentTask(nullptr, job_name, max_concurrent), m_network(network) {} ~NetJob() override = default; diff --git a/launcher/net/NetRequest.cpp b/launcher/net/NetRequest.cpp index eef550e15..853873528 100644 --- a/launcher/net/NetRequest.cpp +++ b/launcher/net/NetRequest.cpp @@ -5,6 +5,7 @@ * Copyright (C) 2022 Sefa Eyeoglu * Copyright (C) 2023 TheKodeToad * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (c) 2023 Trial97 * * 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 diff --git a/launcher/net/NetRequest.h b/launcher/net/NetRequest.h index ee47ab2a6..917495ed9 100644 --- a/launcher/net/NetRequest.h +++ b/launcher/net/NetRequest.h @@ -4,6 +4,7 @@ * Copyright (c) 2022 flowln * Copyright (C) 2022 Sefa Eyeoglu * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (c) 2023 Trial97 * * 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 diff --git a/launcher/net/StaticHeaderProxy.h b/launcher/net/StaticHeaderProxy.h index 0e62d80ff..aabbc9c92 100644 --- a/launcher/net/StaticHeaderProxy.h +++ b/launcher/net/StaticHeaderProxy.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only /* * Prism Launcher - Minecraft Launcher - * Copyright (C) 2023 Rachel Powers <508861+Ryex@users.noreply.github.com> + * Copyright (c) 2023 Trial97 * * 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 diff --git a/launcher/ui/MainWindow.cpp b/launcher/ui/MainWindow.cpp index a82932e08..4858e7d46 100644 --- a/launcher/ui/MainWindow.cpp +++ b/launcher/ui/MainWindow.cpp @@ -77,7 +77,6 @@ #include #include #include -#include #include #include #include diff --git a/launcher/ui/dialogs/ProfileSelectDialog.cpp b/launcher/ui/dialogs/ProfileSelectDialog.cpp index a62238bdb..fe03e1b6b 100644 --- a/launcher/ui/dialogs/ProfileSelectDialog.cpp +++ b/launcher/ui/dialogs/ProfileSelectDialog.cpp @@ -20,7 +20,6 @@ #include #include "Application.h" -#include "SkinUtils.h" #include "ui/dialogs/ProgressDialog.h" diff --git a/launcher/ui/dialogs/SkinUploadDialog.cpp b/launcher/ui/dialogs/SkinUploadDialog.cpp deleted file mode 100644 index 70f1e6760..000000000 --- a/launcher/ui/dialogs/SkinUploadDialog.cpp +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -/* - * Prism Launcher - Minecraft Launcher - * Copyright (C) 2022 Sefa Eyeoglu - * - * 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 -#include -#include -#include - -#include - -#include -#include -#include - -#include "CustomMessageBox.h" -#include "ProgressDialog.h" -#include "SkinUploadDialog.h" -#include "ui_SkinUploadDialog.h" - -void SkinUploadDialog::on_buttonBox_rejected() -{ - close(); -} - -void SkinUploadDialog::on_buttonBox_accepted() -{ - QString fileName; - QString input = ui->skinPathTextBox->text(); - ProgressDialog prog(this); - SequentialTask skinUpload; - - if (!input.isEmpty()) { - QRegularExpression urlPrefixMatcher(QRegularExpression::anchoredPattern("^([a-z]+)://.+$")); - bool isLocalFile = false; - // it has an URL prefix -> it is an URL - if (urlPrefixMatcher.match(input).hasMatch()) { - QUrl fileURL = input; - if (fileURL.isValid()) { - // local? - if (fileURL.isLocalFile()) { - isLocalFile = true; - fileName = fileURL.toLocalFile(); - } else { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Using remote URLs for setting skins is not implemented yet."), - QMessageBox::Warning) - ->exec(); - close(); - return; - } - } else { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("You cannot use an invalid URL for uploading skins."), - QMessageBox::Warning) - ->exec(); - close(); - return; - } - } else { - // just assume it's a path then - isLocalFile = true; - fileName = ui->skinPathTextBox->text(); - } - if (isLocalFile && !QFile::exists(fileName)) { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Skin file does not exist!"), QMessageBox::Warning)->exec(); - close(); - return; - } - SkinUpload::Model model = SkinUpload::STEVE; - if (ui->steveBtn->isChecked()) { - model = SkinUpload::STEVE; - } else if (ui->alexBtn->isChecked()) { - model = SkinUpload::ALEX; - } - skinUpload.addTask(SkinUpload::make(m_acct->accessToken(), FS::read(fileName), model)); - } - - auto selectedCape = ui->capeCombo->currentData().toString(); - if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { - skinUpload.addTask(CapeChange::make(m_acct->accessToken(), selectedCape)); - } - if (prog.execWithTask(&skinUpload) != QDialog::Accepted) { - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Failed to upload skin!"), QMessageBox::Warning)->exec(); - close(); - return; - } - CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Success"), QMessageBox::Information)->exec(); - close(); -} - -void SkinUploadDialog::on_skinBrowseBtn_clicked() -{ - auto filter = QMimeDatabase().mimeTypeForName("image/png").filterString(); - QString raw_path = QFileDialog::getOpenFileName(this, tr("Select Skin Texture"), QString(), filter); - if (raw_path.isEmpty() || !QFileInfo::exists(raw_path)) { - return; - } - QString cooked_path = FS::NormalizePath(raw_path); - ui->skinPathTextBox->setText(cooked_path); -} - -SkinUploadDialog::SkinUploadDialog(MinecraftAccountPtr acct, QWidget* parent) : QDialog(parent), m_acct(acct), ui(new Ui::SkinUploadDialog) -{ - ui->setupUi(this); - - // FIXME: add a model for this, download/refresh the capes on demand - auto& accountData = *acct->accountData(); - int index = 0; - ui->capeCombo->addItem(tr("No Cape"), QVariant()); - auto currentCape = accountData.minecraftProfile.currentCape; - if (currentCape.isEmpty()) { - ui->capeCombo->setCurrentIndex(index); - } - - for (auto& cape : accountData.minecraftProfile.capes) { - index++; - if (cape.data.size()) { - QPixmap capeImage; - if (capeImage.loadFromData(cape.data, "PNG")) { - QPixmap preview = QPixmap(10, 16); - QPainter painter(&preview); - painter.drawPixmap(0, 0, capeImage.copy(1, 1, 10, 16)); - ui->capeCombo->addItem(capeImage, cape.alias, cape.id); - if (currentCape == cape.id) { - ui->capeCombo->setCurrentIndex(index); - } - continue; - } - } - ui->capeCombo->addItem(cape.alias, cape.id); - if (currentCape == cape.id) { - ui->capeCombo->setCurrentIndex(index); - } - } -} diff --git a/launcher/ui/dialogs/SkinUploadDialog.h b/launcher/ui/dialogs/SkinUploadDialog.h deleted file mode 100644 index 81d6140cc..000000000 --- a/launcher/ui/dialogs/SkinUploadDialog.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include - -namespace Ui { -class SkinUploadDialog; -} - -class SkinUploadDialog : public QDialog { - Q_OBJECT - public: - explicit SkinUploadDialog(MinecraftAccountPtr acct, QWidget* parent = 0); - virtual ~SkinUploadDialog(){}; - - public slots: - void on_buttonBox_accepted(); - - void on_buttonBox_rejected(); - - void on_skinBrowseBtn_clicked(); - - protected: - MinecraftAccountPtr m_acct; - - private: - Ui::SkinUploadDialog* ui; -}; diff --git a/launcher/ui/dialogs/SkinUploadDialog.ui b/launcher/ui/dialogs/SkinUploadDialog.ui deleted file mode 100644 index c6df92df3..000000000 --- a/launcher/ui/dialogs/SkinUploadDialog.ui +++ /dev/null @@ -1,95 +0,0 @@ - - - SkinUploadDialog - - - - 0 - 0 - 394 - 360 - - - - Skin Upload - - - - - - Skin File - - - - - - Leave empty to keep current skin - - - - - - - - 0 - 0 - - - - Browse - - - - - - - - - - Player Model - - - - - - Steve Model - - - true - - - - - - - Alex Model - - - - - - - - - - Cape - - - - - - - - - - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.cpp b/launcher/ui/dialogs/skins/SkinManageDialog.cpp new file mode 100644 index 000000000..1ba7e7055 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.cpp @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 . + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "Application.h" +#include "DesktopServices.h" +#include "QObjectPtr.h" +#include "SkinManageDialog.h" + +#include "minecraft/auth/AccountTask.h" +#include "minecraft/skins/CapeChange.h" +#include "minecraft/skins/SkinDelete.h" +#include "minecraft/skins/SkinList.h" +#include "minecraft/skins/SkinModel.h" +#include "minecraft/skins/SkinUpload.h" + +#include "net/NetJob.h" +#include "tasks/Task.h" + +#include "ui/dialogs/CustomMessageBox.h" +#include "ui/dialogs/ProgressDialog.h" +#include "ui/instanceview/InstanceDelegate.h" +#include "ui_SkinManageDialog.h" + +SkinManageDialog::SkinManageDialog(QWidget* parent, MinecraftAccountPtr acct) + : QDialog(parent), m_acct(acct), ui(new Ui::SkinManageDialog), m_list(this, APPLICATION->settings()->get("SkinsDir").toString(), acct) +{ + ui->setupUi(this); + + setWindowModality(Qt::WindowModal); + + auto contentsWidget = ui->listView; + contentsWidget->setViewMode(QListView::IconMode); + contentsWidget->setFlow(QListView::LeftToRight); + contentsWidget->setIconSize(QSize(48, 48)); + contentsWidget->setMovement(QListView::Static); + contentsWidget->setResizeMode(QListView::Adjust); + contentsWidget->setSelectionMode(QAbstractItemView::SingleSelection); + contentsWidget->setSpacing(5); + contentsWidget->setWordWrap(false); + contentsWidget->setWrapping(true); + contentsWidget->setUniformItemSizes(true); + contentsWidget->setTextElideMode(Qt::ElideRight); + contentsWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + contentsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + contentsWidget->installEventFilter(this); + contentsWidget->setItemDelegate(new ListViewDelegate()); + + contentsWidget->setAcceptDrops(true); + contentsWidget->setDropIndicatorShown(true); + contentsWidget->viewport()->setAcceptDrops(true); + contentsWidget->setDragDropMode(QAbstractItemView::DropOnly); + contentsWidget->setDefaultDropAction(Qt::CopyAction); + + contentsWidget->installEventFilter(this); + contentsWidget->setModel(&m_list); + + connect(contentsWidget, SIGNAL(doubleClicked(QModelIndex)), SLOT(activated(QModelIndex))); + + connect(contentsWidget->selectionModel(), SIGNAL(selectionChanged(QItemSelection, QItemSelection)), + SLOT(selectionChanged(QItemSelection, QItemSelection))); + connect(ui->listView, &QListView::customContextMenuRequested, this, &SkinManageDialog::show_context_menu); + + setupCapes(); + + ui->listView->setCurrentIndex(m_list.index(m_list.getSelectedAccountSkin())); +} + +SkinManageDialog::~SkinManageDialog() +{ + delete ui; +} + +void SkinManageDialog::activated(QModelIndex index) +{ + m_selected_skin = index.data(Qt::UserRole).toString(); + accept(); +} + +void SkinManageDialog::selectionChanged(QItemSelection selected, QItemSelection deselected) +{ + if (selected.empty()) + return; + + QString key = selected.first().indexes().first().data(Qt::UserRole).toString(); + if (key.isEmpty()) + return; + m_selected_skin = key; + auto skin = m_list.skin(key); + if (!skin) + return; + ui->selectedModel->setPixmap(skin->getTexture().scaled(128, 128, Qt::KeepAspectRatio, Qt::FastTransformation)); + ui->capeCombo->setCurrentIndex(m_capes_idx.value(skin->getCapeId())); + ui->steveBtn->setChecked(skin->getModel() == SkinModel::CLASSIC); + ui->alexBtn->setChecked(skin->getModel() == SkinModel::SLIM); +} + +void SkinManageDialog::delayed_scroll(QModelIndex model_index) +{ + auto contentsWidget = ui->listView; + contentsWidget->scrollTo(model_index); +} + +void SkinManageDialog::on_openDirBtn_clicked() +{ + DesktopServices::openDirectory(m_list.getDir(), true); +} + +void SkinManageDialog::on_addBtn_clicked() +{ + auto filter = QMimeDatabase().mimeTypeForName("image/png").filterString(); + QString raw_path = QFileDialog::getOpenFileName(this, tr("Select Skin Texture"), QString(), filter); + if (raw_path.isEmpty() || !QFileInfo::exists(raw_path)) { + return; + } + if (!SkinModel(raw_path).isValid()) { + CustomMessageBox::selectable(this, tr("Selected file is not a valid skin"), + tr("Skin images must be 64x64 or 64x32 pixel PNG files."), QMessageBox::Critical) + ->show(); + return; + } + m_list.installSkin(raw_path, {}); +} + +QPixmap previewCape(QPixmap capeImage) +{ + QPixmap preview = QPixmap(10, 16); + QPainter painter(&preview); + painter.drawPixmap(0, 0, capeImage.copy(1, 1, 10, 16)); + return preview.scaled(80, 128, Qt::IgnoreAspectRatio, Qt::FastTransformation); +} + +void SkinManageDialog::setupCapes() +{ + // FIXME: add a model for this, download/refresh the capes on demand + auto& accountData = *m_acct->accountData(); + int index = 0; + ui->capeCombo->addItem(tr("No Cape"), QVariant()); + auto currentCape = accountData.minecraftProfile.currentCape; + if (currentCape.isEmpty()) { + ui->capeCombo->setCurrentIndex(index); + } + + auto capesDir = FS::PathCombine(m_list.getDir(), "capes"); + NetJob::Ptr job{ new NetJob(tr("Download capes"), APPLICATION->network()) }; + bool needsToDownload = false; + for (auto& cape : accountData.minecraftProfile.capes) { + auto path = FS::PathCombine(capesDir, cape.id + ".png"); + if (cape.data.size()) { + QPixmap capeImage; + if (capeImage.loadFromData(cape.data, "PNG") && capeImage.save(path)) { + m_capes[cape.id] = previewCape(capeImage); + continue; + } + } + if (QFileInfo(path).exists()) { + continue; + } + if (!cape.url.isEmpty()) { + needsToDownload = true; + job->addNetAction(Net::Download::makeFile(cape.url, path)); + } + } + if (needsToDownload) { + ProgressDialog dlg(this); + dlg.execWithTask(job.get()); + } + for (auto& cape : accountData.minecraftProfile.capes) { + index++; + QPixmap capeImage; + if (!m_capes.contains(cape.id)) { + auto path = FS::PathCombine(capesDir, cape.id + ".png"); + if (QFileInfo(path).exists() && capeImage.load(path)) { + capeImage = previewCape(capeImage); + m_capes[cape.id] = capeImage; + } + } + if (!capeImage.isNull()) { + ui->capeCombo->addItem(capeImage, cape.alias, cape.id); + } else { + ui->capeCombo->addItem(cape.alias, cape.id); + } + + m_capes_idx[cape.id] = index; + } +} + +void SkinManageDialog::on_capeCombo_currentIndexChanged(int index) +{ + auto id = ui->capeCombo->currentData(); + ui->capeImage->setPixmap(m_capes.value(id.toString(), {})); + if (auto skin = m_list.skin(m_selected_skin); skin) { + skin->setCapeId(id.toString()); + } +} + +void SkinManageDialog::on_steveBtn_toggled(bool checked) +{ + if (auto skin = m_list.skin(m_selected_skin); skin) { + skin->setModel(checked ? SkinModel::CLASSIC : SkinModel::SLIM); + } +} + +void SkinManageDialog::accept() +{ + auto skin = m_list.skin(m_selected_skin); + if (!skin) + reject(); + auto path = skin->getPath(); + + ProgressDialog prog(this); + NetJob::Ptr skinUpload{ new NetJob(tr("Change skin"), APPLICATION->network(), 1) }; + + if (!QFile::exists(path)) { + CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Skin file does not exist!"), QMessageBox::Warning)->exec(); + reject(); + return; + } + + skinUpload->addNetAction(SkinUpload::make(m_acct->accessToken(), skin)); + + auto selectedCape = skin->getCapeId(); + if (selectedCape != m_acct->accountData()->minecraftProfile.currentCape) { + skinUpload->addNetAction(CapeChange::make(m_acct->accessToken(), selectedCape)); + } + + skinUpload->addTask(m_acct->refresh().staticCast()); + if (prog.execWithTask(skinUpload.get()) != QDialog::Accepted) { + CustomMessageBox::selectable(this, tr("Skin Upload"), tr("Failed to upload skin!"), QMessageBox::Warning)->exec(); + reject(); + return; + } + skin->setURL(m_acct->accountData()->minecraftProfile.skin.url); + QDialog::accept(); +} + +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->accessToken())); + 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(); + reject(); + return; + } + QDialog::accept(); +} + +void SkinManageDialog::show_context_menu(const QPoint& pos) +{ + QMenu myMenu(tr("Context menu"), this); + myMenu.addAction(ui->action_Rename_Skin); + myMenu.addAction(ui->action_Delete_Skin); + + myMenu.exec(ui->listView->mapToGlobal(pos)); +} + +bool SkinManageDialog::eventFilter(QObject* obj, QEvent* ev) +{ + if (obj == ui->listView) { + if (ev->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(ev); + switch (keyEvent->key()) { + case Qt::Key_Delete: + on_action_Delete_Skin_triggered(false); + return true; + case Qt::Key_F2: + on_action_Rename_Skin_triggered(false); + return true; + default: + break; + } + } + } + return QDialog::eventFilter(obj, ev); +} + +void SkinManageDialog::on_action_Rename_Skin_triggered(bool checked) +{ + if (!m_selected_skin.isEmpty()) { + ui->listView->edit(ui->listView->currentIndex()); + } +} + +void SkinManageDialog::on_action_Delete_Skin_triggered(bool checked) +{ + if (m_selected_skin.isEmpty()) + return; + + if (m_list.getSkinIndex(m_selected_skin) == m_list.getSelectedAccountSkin()) { + CustomMessageBox::selectable(this, tr("Delete error"), tr("Can not delete skin that is in use."), QMessageBox::Warning); + return; + } + + auto skin = m_list.skin(m_selected_skin); + if (!skin) + return; + + auto response = CustomMessageBox::selectable(this, tr("Confirm Deletion"), + tr("You are about to delete \"%1\".\n" + "Are you sure?") + .arg(skin->name()), + QMessageBox::Warning, QMessageBox::Yes | QMessageBox::No, QMessageBox::No) + ->exec(); + + if (response == QMessageBox::Yes) { + if (!m_list.deleteSkin(m_selected_skin, true)) { + m_list.deleteSkin(m_selected_skin, false); + } + } +} diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.h b/launcher/ui/dialogs/skins/SkinManageDialog.h new file mode 100644 index 000000000..8c55c3310 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-only +/* + * Prism Launcher - Minecraft Launcher + * Copyright (c) 2023 Trial97 + * + * 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 +#include + +#include "minecraft/auth/MinecraftAccount.h" +#include "minecraft/skins/SkinList.h" + +namespace Ui { +class SkinManageDialog; +} + +class SkinManageDialog : public QDialog { + Q_OBJECT + public: + explicit SkinManageDialog(QWidget* parent, MinecraftAccountPtr acct); + virtual ~SkinManageDialog(); + + public slots: + void selectionChanged(QItemSelection, QItemSelection); + void activated(QModelIndex); + void delayed_scroll(QModelIndex); + void on_openDirBtn_clicked(); + void on_addBtn_clicked(); + void accept() override; + void on_capeCombo_currentIndexChanged(int index); + void on_steveBtn_toggled(bool checked); + void on_resetBtn_clicked(); + void show_context_menu(const QPoint& pos); + bool eventFilter(QObject* obj, QEvent* ev) override; + void on_action_Rename_Skin_triggered(bool checked); + void on_action_Delete_Skin_triggered(bool checked); + + private: + void setupCapes(); + + MinecraftAccountPtr m_acct; + Ui::SkinManageDialog* ui; + SkinList m_list; + QString m_selected_skin; + QHash m_capes; + QHash m_capes_idx; +}; diff --git a/launcher/ui/dialogs/skins/SkinManageDialog.ui b/launcher/ui/dialogs/skins/SkinManageDialog.ui new file mode 100644 index 000000000..6ad826478 --- /dev/null +++ b/launcher/ui/dialogs/skins/SkinManageDialog.ui @@ -0,0 +1,193 @@ + + + SkinManageDialog + + + + 0 + 0 + 968 + 757 + + + + Skin Upload + + + + + + + + + + + + + true + + + + + + + + 0 + 0 + + + + Model + + + + + + Clasic + + + true + + + + + + + Slim + + + + + + + + + + Cape + + + + + + + + + + + + true + + + + + + + + + + + + Qt::CustomContextMenu + + + false + + + 0 + + + + + + + + + + + Open Folder + + + + + + + Import Skin + + + + + + + Reset Skin + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + &Delete Skin + + + Deletes selected skin + + + Del + + + + + &Rename Skin + + + Rename selected skin + + + F2 + + + + + + + buttonBox + rejected() + SkinManageDialog + reject() + + + 617 + 736 + + + 483 + 378 + + + + + buttonBox + accepted() + SkinManageDialog + accept() + + + 617 + 736 + + + 483 + 378 + + + + + diff --git a/launcher/ui/pages/global/AccountListPage.cpp b/launcher/ui/pages/global/AccountListPage.cpp index 3dcf05e0a..25ccfd0d7 100644 --- a/launcher/ui/pages/global/AccountListPage.cpp +++ b/launcher/ui/pages/global/AccountListPage.cpp @@ -35,6 +35,7 @@ */ #include "AccountListPage.h" +#include "ui/dialogs/skins/SkinManageDialog.h" #include "ui_AccountListPage.h" #include @@ -42,23 +43,13 @@ #include -#include "net/NetJob.h" - #include "ui/dialogs/CustomMessageBox.h" #include "ui/dialogs/LoginDialog.h" #include "ui/dialogs/MSALoginDialog.h" #include "ui/dialogs/OfflineLoginDialog.h" -#include "ui/dialogs/ProgressDialog.h" -#include "ui/dialogs/SkinUploadDialog.h" - -#include "minecraft/auth/AccountTask.h" -#include "minecraft/services/SkinDelete.h" -#include "tasks/Task.h" #include "Application.h" -#include "BuildConfig.h" - AccountListPage::AccountListPage(QWidget* parent) : QMainWindow(parent), ui(new Ui::AccountListPage) { ui->setupUi(this); @@ -235,8 +226,7 @@ void AccountListPage::updateButtonStates() } ui->actionRemove->setEnabled(accountIsReady); ui->actionSetDefault->setEnabled(accountIsReady); - ui->actionUploadSkin->setEnabled(accountIsReady && accountIsOnline); - ui->actionDeleteSkin->setEnabled(accountIsReady && accountIsOnline); + ui->actionManageSkins->setEnabled(accountIsReady && accountIsOnline); ui->actionRefresh->setEnabled(accountIsReady && accountIsOnline); if (m_accounts->defaultAccount().get() == nullptr) { @@ -248,29 +238,13 @@ void AccountListPage::updateButtonStates() } } -void AccountListPage::on_actionUploadSkin_triggered() +void AccountListPage::on_actionManageSkins_triggered() { QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); if (selection.size() > 0) { QModelIndex selected = selection.first(); MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - SkinUploadDialog dialog(account, this); + SkinManageDialog dialog(this, account); dialog.exec(); } } - -void AccountListPage::on_actionDeleteSkin_triggered() -{ - QModelIndexList selection = ui->listView->selectionModel()->selectedIndexes(); - if (selection.size() <= 0) - return; - - QModelIndex selected = selection.first(); - MinecraftAccountPtr account = selected.data(AccountList::PointerRole).value(); - ProgressDialog prog(this); - auto deleteSkinTask = SkinDelete::make(account->accessToken()); - if (prog.execWithTask((Task*)deleteSkinTask.get()) != QDialog::Accepted) { - CustomMessageBox::selectable(this, tr("Skin Delete"), tr("Failed to delete current skin!"), QMessageBox::Warning)->exec(); - return; - } -} diff --git a/launcher/ui/pages/global/AccountListPage.h b/launcher/ui/pages/global/AccountListPage.h index add0f4aa0..64702cff7 100644 --- a/launcher/ui/pages/global/AccountListPage.h +++ b/launcher/ui/pages/global/AccountListPage.h @@ -77,8 +77,7 @@ class AccountListPage : public QMainWindow, public BasePage { void on_actionRefresh_triggered(); void on_actionSetDefault_triggered(); void on_actionNoDefault_triggered(); - void on_actionUploadSkin_triggered(); - void on_actionDeleteSkin_triggered(); + void on_actionManageSkins_triggered(); void listChanged(); diff --git a/launcher/ui/pages/global/AccountListPage.ui b/launcher/ui/pages/global/AccountListPage.ui index 469955b51..0e73f8c52 100644 --- a/launcher/ui/pages/global/AccountListPage.ui +++ b/launcher/ui/pages/global/AccountListPage.ui @@ -60,19 +60,13 @@ - - + Add &Mojang - - - Remo&ve - - &Set Default @@ -86,17 +80,12 @@ &No Default - + - &Upload Skin - - - - - &Delete Skin + &Manage Skins - Delete the currently active skin and go back to the default one + Manage Skins @@ -117,6 +106,11 @@ Refresh the account tokens + + + Remo&ve + +