diff options
Diffstat (limited to 'meshmc/launcher/minecraft/auth')
36 files changed, 4450 insertions, 0 deletions
diff --git a/meshmc/launcher/minecraft/auth/AccountData.cpp b/meshmc/launcher/minecraft/auth/AccountData.cpp new file mode 100644 index 0000000000..74d67c0478 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountData.cpp @@ -0,0 +1,362 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "AccountData.h" +#include <QJsonDocument> +#include <QJsonObject> +#include <QJsonArray> +#include <QDebug> +#include <QUuid> +#include <QRegularExpression> + +namespace +{ + void tokenToJSONV3(QJsonObject& parent, Katabasis::Token t, + const char* tokenName) + { + if (!t.persistent) { + return; + } + QJsonObject out; + if (t.issueInstant.isValid()) { + out["iat"] = QJsonValue(t.issueInstant.toMSecsSinceEpoch() / 1000); + } + + if (t.notAfter.isValid()) { + out["exp"] = QJsonValue(t.notAfter.toMSecsSinceEpoch() / 1000); + } + + bool save = false; + if (!t.token.isEmpty()) { + out["token"] = QJsonValue(t.token); + save = true; + } + if (!t.refresh_token.isEmpty()) { + out["refresh_token"] = QJsonValue(t.refresh_token); + save = true; + } + if (t.extra.size()) { + out["extra"] = QJsonObject::fromVariantMap(t.extra); + save = true; + } + if (save) { + parent[tokenName] = out; + } + } + + Katabasis::Token tokenFromJSONV3(const QJsonObject& parent, + const char* tokenName) + { + Katabasis::Token out; + auto tokenObject = parent.value(tokenName).toObject(); + if (tokenObject.isEmpty()) { + return out; + } + auto issueInstant = tokenObject.value("iat"); + if (issueInstant.isDouble()) { + out.issueInstant = QDateTime::fromMSecsSinceEpoch( + ((int64_t)issueInstant.toDouble()) * 1000); + } + + auto notAfter = tokenObject.value("exp"); + if (notAfter.isDouble()) { + out.notAfter = QDateTime::fromMSecsSinceEpoch( + ((int64_t)notAfter.toDouble()) * 1000); + } + + auto token = tokenObject.value("token"); + if (token.isString()) { + out.token = token.toString(); + out.validity = Katabasis::Validity::Assumed; + } + + auto refresh_token = tokenObject.value("refresh_token"); + if (refresh_token.isString()) { + out.refresh_token = refresh_token.toString(); + } + + auto extra = tokenObject.value("extra"); + if (extra.isObject()) { + out.extra = extra.toObject().toVariantMap(); + } + return out; + } + + void profileToJSONV3(QJsonObject& parent, MinecraftProfile p, + const char* tokenName) + { + if (p.id.isEmpty()) { + return; + } + QJsonObject out; + out["id"] = QJsonValue(p.id); + out["name"] = QJsonValue(p.name); + if (!p.currentCape.isEmpty()) { + out["cape"] = p.currentCape; + } + + { + QJsonObject skinObj; + skinObj["id"] = p.skin.id; + skinObj["url"] = p.skin.url; + skinObj["variant"] = p.skin.variant; + if (p.skin.data.size()) { + skinObj["data"] = QString::fromLatin1(p.skin.data.toBase64()); + } + out["skin"] = skinObj; + } + + QJsonArray capesArray; + for (auto& cape : p.capes) { + QJsonObject capeObj; + capeObj["id"] = cape.id; + capeObj["url"] = cape.url; + capeObj["alias"] = cape.alias; + if (cape.data.size()) { + capeObj["data"] = QString::fromLatin1(cape.data.toBase64()); + } + capesArray.push_back(capeObj); + } + out["capes"] = capesArray; + parent[tokenName] = out; + } + + MinecraftProfile profileFromJSONV3(const QJsonObject& parent, + const char* tokenName) + { + MinecraftProfile out; + auto tokenObject = parent.value(tokenName).toObject(); + if (tokenObject.isEmpty()) { + return out; + } + { + auto idV = tokenObject.value("id"); + auto nameV = tokenObject.value("name"); + if (!idV.isString() || !nameV.isString()) { + qWarning() << "mandatory profile attributes are missing or of " + "unexpected type"; + return MinecraftProfile(); + } + out.name = nameV.toString(); + out.id = idV.toString(); + } + + { + auto skinV = tokenObject.value("skin"); + if (!skinV.isObject()) { + qWarning() << "skin is missing"; + return MinecraftProfile(); + } + auto skinObj = skinV.toObject(); + auto idV = skinObj.value("id"); + auto urlV = skinObj.value("url"); + auto variantV = skinObj.value("variant"); + if (!idV.isString() || !urlV.isString() || !variantV.isString()) { + qWarning() << "mandatory skin attributes are missing or of " + "unexpected type"; + return MinecraftProfile(); + } + out.skin.id = idV.toString(); + out.skin.url = urlV.toString(); + out.skin.variant = variantV.toString(); + + // data for skin is optional + auto dataV = skinObj.value("data"); + if (dataV.isString()) { + // TODO: validate base64 + out.skin.data = + QByteArray::fromBase64(dataV.toString().toLatin1()); + } else if (!dataV.isUndefined()) { + qWarning() << "skin data is something unexpected"; + return MinecraftProfile(); + } + } + + { + auto capesV = tokenObject.value("capes"); + if (!capesV.isArray()) { + qWarning() << "capes is not an array!"; + return MinecraftProfile(); + } + auto capesArray = capesV.toArray(); + for (auto capeV : capesArray) { + if (!capeV.isObject()) { + qWarning() << "cape is not an object!"; + return MinecraftProfile(); + } + auto capeObj = capeV.toObject(); + auto idV = capeObj.value("id"); + auto urlV = capeObj.value("url"); + auto aliasV = capeObj.value("alias"); + if (!idV.isString() || !urlV.isString() || !aliasV.isString()) { + qWarning() << "mandatory skin attributes are missing or of " + "unexpected type"; + return MinecraftProfile(); + } + Cape cape; + cape.id = idV.toString(); + cape.url = urlV.toString(); + cape.alias = aliasV.toString(); + + // data for cape is optional. + auto dataV = capeObj.value("data"); + if (dataV.isString()) { + // TODO: validate base64 + cape.data = + QByteArray::fromBase64(dataV.toString().toLatin1()); + } else if (!dataV.isUndefined()) { + qWarning() << "cape data is something unexpected"; + return MinecraftProfile(); + } + out.capes[cape.id] = cape; + } + } + // current cape + { + auto capeV = tokenObject.value("cape"); + if (capeV.isString()) { + auto currentCape = capeV.toString(); + if (out.capes.contains(currentCape)) { + out.currentCape = currentCape; + } + } + } + out.validity = Katabasis::Validity::Assumed; + return out; + } + + void entitlementToJSONV3(QJsonObject& parent, MinecraftEntitlement p) + { + if (p.validity == Katabasis::Validity::None) { + return; + } + QJsonObject out; + out["ownsMinecraft"] = QJsonValue(p.ownsMinecraft); + out["canPlayMinecraft"] = QJsonValue(p.canPlayMinecraft); + parent["entitlement"] = out; + } + + bool entitlementFromJSONV3(const QJsonObject& parent, + MinecraftEntitlement& out) + { + auto entitlementObject = parent.value("entitlement").toObject(); + if (entitlementObject.isEmpty()) { + return false; + } + { + auto ownsMinecraftV = entitlementObject.value("ownsMinecraft"); + auto canPlayMinecraftV = + entitlementObject.value("canPlayMinecraft"); + if (!ownsMinecraftV.isBool() || !canPlayMinecraftV.isBool()) { + qWarning() + << "mandatory attributes are missing or of unexpected type"; + return false; + } + out.canPlayMinecraft = canPlayMinecraftV.toBool(false); + out.ownsMinecraft = ownsMinecraftV.toBool(false); + out.validity = Katabasis::Validity::Assumed; + } + return true; + } + +} // namespace + +bool AccountData::resumeStateFromV3(QJsonObject data) +{ + auto typeV = data.value("type"); + if (!typeV.isString()) { + qWarning() << "Failed to parse account data: type is missing."; + return false; + } + auto typeS = typeV.toString(); + if (typeS == "MSA") { + type = AccountType::MSA; + } else { + qWarning() << "Failed to parse account data: type is not recognized " + "(only MSA is supported)."; + return false; + } + + msaToken = tokenFromJSONV3(data, "msa"); + userToken = tokenFromJSONV3(data, "utoken"); + xboxApiToken = tokenFromJSONV3(data, "xrp-main"); + mojangservicesToken = tokenFromJSONV3(data, "xrp-mc"); + + yggdrasilToken = tokenFromJSONV3(data, "ygg"); + minecraftProfile = profileFromJSONV3(data, "profile"); + if (!entitlementFromJSONV3(data, minecraftEntitlement)) { + if (minecraftProfile.validity != Katabasis::Validity::None) { + minecraftEntitlement.canPlayMinecraft = true; + minecraftEntitlement.ownsMinecraft = true; + minecraftEntitlement.validity = Katabasis::Validity::Assumed; + } + } + + validity_ = minecraftProfile.validity; + return true; +} + +QJsonObject AccountData::saveState() const +{ + QJsonObject output; + output["type"] = "MSA"; + tokenToJSONV3(output, msaToken, "msa"); + tokenToJSONV3(output, userToken, "utoken"); + tokenToJSONV3(output, xboxApiToken, "xrp-main"); + tokenToJSONV3(output, mojangservicesToken, "xrp-mc"); + + tokenToJSONV3(output, yggdrasilToken, "ygg"); + profileToJSONV3(output, minecraftProfile, "profile"); + entitlementToJSONV3(output, minecraftEntitlement); + return output; +} + +QString AccountData::accessToken() const +{ + return yggdrasilToken.token; +} + +QString AccountData::profileId() const +{ + return minecraftProfile.id; +} + +QString AccountData::profileName() const +{ + if (minecraftProfile.name.size() == 0) { + return QObject::tr("No profile (%1)").arg(accountDisplayString()); + } else { + return minecraftProfile.name; + } +} + +QString AccountData::accountDisplayString() const +{ + if (xboxApiToken.extra.contains("gtg")) { + return xboxApiToken.extra["gtg"].toString(); + } + return "Xbox profile missing"; +} + +QString AccountData::lastError() const +{ + return errorString; +} diff --git a/meshmc/launcher/minecraft/auth/AccountData.h b/meshmc/launcher/minecraft/auth/AccountData.h new file mode 100644 index 0000000000..9e791c568e --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountData.h @@ -0,0 +1,103 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QString> +#include <QByteArray> +#include <QVector> +#include <katabasis/Bits.h> +#include <QJsonObject> + +struct Skin { + QString id; + QString url; + QString variant; + + QByteArray data; +}; + +struct Cape { + QString id; + QString url; + QString alias; + + QByteArray data; +}; + +struct MinecraftEntitlement { + bool ownsMinecraft = false; + bool canPlayMinecraft = false; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + +struct MinecraftProfile { + QString id; + QString name; + Skin skin; + QString currentCape; + QMap<QString, Cape> capes; + Katabasis::Validity validity = Katabasis::Validity::None; +}; + +enum class AccountType { MSA }; + +enum class AccountState { + Unchecked, + Offline, + Working, + Online, + Errored, + Expired, + Gone +}; + +struct AccountData { + QJsonObject saveState() const; + bool resumeStateFromV3(QJsonObject data); + + //! Gamertag for MSA + QString accountDisplayString() const; + + //! Yggdrasil access token, as passed to the game. + QString accessToken() const; + + QString profileId() const; + QString profileName() const; + + QString lastError() const; + + AccountType type = AccountType::MSA; + + Katabasis::Token msaToken; + Katabasis::Token userToken; + Katabasis::Token xboxApiToken; + Katabasis::Token mojangservicesToken; + + Katabasis::Token yggdrasilToken; + MinecraftProfile minecraftProfile; + MinecraftEntitlement minecraftEntitlement; + Katabasis::Validity validity_ = Katabasis::Validity::None; + + // runtime only information (not saved with the account) + QString internalId; + QString errorString; + AccountState accountState = AccountState::Unchecked; +}; diff --git a/meshmc/launcher/minecraft/auth/AccountList.cpp b/meshmc/launcher/minecraft/auth/AccountList.cpp new file mode 100644 index 0000000000..f12a00815c --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountList.cpp @@ -0,0 +1,722 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 "AccountList.h" +#include "AccountData.h" +#include "AccountTask.h" + +#include <QIODevice> +#include <QFile> +#include <QTextStream> +#include <QJsonDocument> +#include <QJsonArray> +#include <QJsonObject> +#include <QJsonParseError> +#include <QDir> +#include <QTimer> + +#include <QDebug> + +#include <FileSystem.h> +#include <QSaveFile> + +#include <chrono> + +enum AccountListVersion { MojangOnly = 2, MojangMSA = 3 }; + +AccountList::AccountList(QObject* parent) : QAbstractListModel(parent) +{ + m_refreshTimer = new QTimer(this); + m_refreshTimer->setSingleShot(true); + connect(m_refreshTimer, &QTimer::timeout, this, &AccountList::fillQueue); + m_nextTimer = new QTimer(this); + m_nextTimer->setSingleShot(true); + connect(m_nextTimer, &QTimer::timeout, this, &AccountList::tryNext); +} + +AccountList::~AccountList() noexcept {} + +int AccountList::findAccountByProfileId(const QString& profileId) const +{ + for (int i = 0; i < count(); i++) { + MinecraftAccountPtr account = at(i); + if (account->profileId() == profileId) { + return i; + } + } + return -1; +} + +MinecraftAccountPtr +AccountList::getAccountByProfileName(const QString& profileName) const +{ + for (int i = 0; i < count(); i++) { + MinecraftAccountPtr account = at(i); + if (account->profileName() == profileName) { + return account; + } + } + return nullptr; +} + +const MinecraftAccountPtr AccountList::at(int i) const +{ + return MinecraftAccountPtr(m_accounts.at(i)); +} + +QStringList AccountList::profileNames() const +{ + QStringList out; + for (auto& account : m_accounts) { + auto profileName = account->profileName(); + if (profileName.isEmpty()) { + continue; + } + out.append(profileName); + } + return out; +} + +void AccountList::addAccount(const MinecraftAccountPtr account) +{ + // NOTE: Do not allow adding something that's already there + if (m_accounts.contains(account)) { + return; + } + + // hook up notifications for changes in the account + connect(account.get(), &MinecraftAccount::changed, this, + &AccountList::accountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, + &AccountList::accountActivityChanged); + + // override/replace existing account with the same profileId + auto profileId = account->profileId(); + if (profileId.size()) { + auto existingAccount = findAccountByProfileId(profileId); + if (existingAccount != -1) { + MinecraftAccountPtr existingAccountPtr = + m_accounts[existingAccount]; + m_accounts[existingAccount] = account; + if (m_defaultAccount == existingAccountPtr) { + m_defaultAccount = account; + } + // disconnect notifications for changes in the account being + // replaced + existingAccountPtr->disconnect(this); + emit dataChanged( + index(existingAccount), + index(existingAccount, columnCount(QModelIndex()) - 1)); + onListChanged(); + return; + } + } + + // if we don't have this profileId yet, add the account to the end + int row = m_accounts.count(); + beginInsertRows(QModelIndex(), row, row); + m_accounts.append(account); + endInsertRows(); + onListChanged(); +} + +void AccountList::removeAccount(QModelIndex index) +{ + int row = index.row(); + if (index.isValid() && row >= 0 && row < m_accounts.size()) { + auto& account = m_accounts[row]; + if (account == m_defaultAccount) { + m_defaultAccount = nullptr; + onDefaultAccountChanged(); + } + account->disconnect(this); + + beginRemoveRows(QModelIndex(), row, row); + m_accounts.removeAt(index.row()); + endRemoveRows(); + onListChanged(); + } +} + +MinecraftAccountPtr AccountList::defaultAccount() const +{ + return m_defaultAccount; +} + +void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount) +{ + if (!newAccount && m_defaultAccount) { + int idx = 0; + auto previousDefaultAccount = m_defaultAccount; + m_defaultAccount = nullptr; + for (MinecraftAccountPtr account : m_accounts) { + if (account == previousDefaultAccount) { + emit dataChanged(index(idx), + index(idx, columnCount(QModelIndex()) - 1)); + } + idx++; + } + onDefaultAccountChanged(); + } else { + auto currentDefaultAccount = m_defaultAccount; + int currentDefaultAccountIdx = -1; + auto newDefaultAccount = m_defaultAccount; + int newDefaultAccountIdx = -1; + int idx = 0; + for (MinecraftAccountPtr account : m_accounts) { + if (account == newAccount) { + newDefaultAccount = account; + newDefaultAccountIdx = idx; + } + if (currentDefaultAccount == account) { + currentDefaultAccountIdx = idx; + } + idx++; + } + if (currentDefaultAccount != newDefaultAccount) { + emit dataChanged(index(currentDefaultAccountIdx), + index(currentDefaultAccountIdx, + columnCount(QModelIndex()) - 1)); + emit dataChanged( + index(newDefaultAccountIdx), + index(newDefaultAccountIdx, columnCount(QModelIndex()) - 1)); + m_defaultAccount = newDefaultAccount; + onDefaultAccountChanged(); + } + } +} + +void AccountList::accountChanged() +{ + // the list changed. there is no doubt. + onListChanged(); +} + +void AccountList::accountActivityChanged(bool active) +{ + MinecraftAccount* account = qobject_cast<MinecraftAccount*>(sender()); + bool found = false; + for (int i = 0; i < count(); i++) { + if (at(i).get() == account) { + emit dataChanged(index(i), + index(i, columnCount(QModelIndex()) - 1)); + found = true; + break; + } + } + if (found) { + emit listActivityChanged(); + if (active) { + beginActivity(); + } else { + endActivity(); + } + } +} + +void AccountList::onListChanged() +{ + if (m_autosave) + // TODO: Alert the user if this fails. + saveList(); + + emit listChanged(); +} + +void AccountList::onDefaultAccountChanged() +{ + if (m_autosave) + saveList(); + + emit defaultAccountChanged(); +} + +int AccountList::count() const +{ + return m_accounts.count(); +} + +QVariant AccountList::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + if (index.row() > count()) + return QVariant(); + + MinecraftAccountPtr account = at(index.row()); + + switch (role) { + case Qt::DisplayRole: + switch (index.column()) { + case NameColumn: + return account->accountDisplayString(); + + case TypeColumn: { + auto typeStr = account->typeString(); + typeStr[0] = typeStr[0].toUpper(); + return typeStr; + } + + case StatusColumn: { + switch (account->accountState()) { + case AccountState::Unchecked: { + return tr("Unchecked", "Account status"); + } + case AccountState::Offline: { + return tr("Offline", "Account status"); + } + case AccountState::Online: { + return tr("Online", "Account status"); + } + case AccountState::Working: { + return tr("Working", "Account status"); + } + case AccountState::Errored: { + return tr("Errored", "Account status"); + } + case AccountState::Expired: { + return tr("Expired", "Account status"); + } + case AccountState::Gone: { + return tr("Gone", "Account status"); + } + } + } + + case ProfileNameColumn: { + return account->profileName(); + } + + default: + return QVariant(); + } + + case Qt::ToolTipRole: + return account->accountDisplayString(); + + case PointerRole: + return QVariant::fromValue(account); + + case Qt::CheckStateRole: + switch (index.column()) { + case NameColumn: + return account == m_defaultAccount ? Qt::Checked + : Qt::Unchecked; + } + + default: + return QVariant(); + } +} + +QVariant AccountList::headerData(int section, Qt::Orientation orientation, + int role) const +{ + switch (role) { + case Qt::DisplayRole: + switch (section) { + case NameColumn: + return tr("Account"); + case TypeColumn: + return tr("Type"); + case StatusColumn: + return tr("Status"); + case ProfileNameColumn: + return tr("Profile"); + default: + return QVariant(); + } + + case Qt::ToolTipRole: + switch (section) { + case NameColumn: + return tr("User name of the account."); + case TypeColumn: + return tr("Type of the account."); + case StatusColumn: + return tr("Current status of the account."); + case ProfileNameColumn: + return tr("Name of the Minecraft profile associated with " + "the account."); + default: + return QVariant(); + } + + default: + return QVariant(); + } +} + +int AccountList::rowCount(const QModelIndex&) const +{ + // Return count + return count(); +} + +int AccountList::columnCount(const QModelIndex&) const +{ + return NUM_COLUMNS; +} + +Qt::ItemFlags AccountList::flags(const QModelIndex& index) const +{ + if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) { + return Qt::NoItemFlags; + } + + return Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +bool AccountList::setData(const QModelIndex& idx, const QVariant& value, + int role) +{ + if (idx.row() < 0 || idx.row() >= rowCount(idx) || !idx.isValid()) { + return false; + } + + if (role == Qt::CheckStateRole) { + if (value == Qt::Checked) { + MinecraftAccountPtr account = at(idx.row()); + setDefaultAccount(account); + } + } + + emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1)); + return true; +} + +bool AccountList::loadList() +{ + if (m_listFilePath.isEmpty()) { + qCritical() << "Can't load account list. No file path given and no " + "default set."; + return false; + } + + QFile file(m_listFilePath); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << QString("Failed to read the account list file (%1).") + .arg(m_listFilePath) + .toUtf8(); + return false; + } + + // Read the file and close it. + QByteArray jsonData = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError); + + // Fail if the JSON is invalid. + if (parseError.error != QJsonParseError::NoError) { + qCritical() << QString( + "Failed to parse account list file: %1 at offset %2") + .arg(parseError.errorString(), + QString::number(parseError.offset)) + .toUtf8(); + return false; + } + + // Make sure the root is an object. + if (!jsonDoc.isObject()) { + qCritical() << "Invalid account list JSON: Root should be an array."; + return false; + } + + QJsonObject root = jsonDoc.object(); + + // Make sure the format version matches. + auto listVersion = root.value("formatVersion").toVariant().toInt(); + switch (listVersion) { + case AccountListVersion::MojangMSA: { + return loadV3(root); + } break; + default: { + QString newName = "accounts-old.json"; + qWarning() << "Unknown format version when loading account list. " + "Existing one will be renamed to" + << newName; + // Attempt to rename the old version. + file.rename(newName); + return false; + } + } +} + +bool AccountList::loadV3(QJsonObject& root) +{ + beginResetModel(); + QJsonArray accounts = root.value("accounts").toArray(); + for (QJsonValue accountVal : accounts) { + QJsonObject accountObj = accountVal.toObject(); + MinecraftAccountPtr account = + MinecraftAccount::loadFromJsonV3(accountObj); + if (account.get() != nullptr) { + auto profileId = account->profileId(); + if (profileId.size()) { + if (findAccountByProfileId(profileId) != -1) { + continue; + } + } + connect(account.get(), &MinecraftAccount::changed, this, + &AccountList::accountChanged); + connect(account.get(), &MinecraftAccount::activityChanged, this, + &AccountList::accountActivityChanged); + m_accounts.append(account); + if (accountObj.value("active").toBool(false)) { + m_defaultAccount = account; + } + } else { + qWarning() << "Failed to load an account."; + } + } + endResetModel(); + return true; +} + +bool AccountList::saveList() +{ + if (m_listFilePath.isEmpty()) { + qCritical() << "Can't save account list. No file path given and no " + "default set."; + return false; + } + + // make sure the parent folder exists + if (!FS::ensureFilePathExists(m_listFilePath)) + return false; + + // make sure the file wasn't overwritten with a folder before (fixes a bug) + QFileInfo finfo(m_listFilePath); + if (finfo.isDir()) { + QDir badDir(m_listFilePath); + badDir.removeRecursively(); + } + + qDebug() << "Writing account list to" << m_listFilePath; + + qDebug() << "Building JSON data structure."; + // Build the JSON document to write to the list file. + QJsonObject root; + + root.insert("formatVersion", AccountListVersion::MojangMSA); + + // Build a list of accounts. + qDebug() << "Building account array."; + QJsonArray accounts; + for (MinecraftAccountPtr account : m_accounts) { + QJsonObject accountObj = account->saveToJson(); + if (m_defaultAccount == account) { + accountObj["active"] = true; + } + accounts.append(accountObj); + } + + // Insert the account list into the root object. + root.insert("accounts", accounts); + + // Create a JSON document object to convert our JSON to bytes. + QJsonDocument doc(root); + + // Now that we're done building the JSON object, we can write it to the + // file. + qDebug() << "Writing account list to file."; + QSaveFile file(m_listFilePath); + + // Try to open the file and fail if we can't. + // TODO: We should probably report this error to the user. + if (!file.open(QIODevice::WriteOnly)) { + qCritical() << QString("Failed to read the account list file (%1).") + .arg(m_listFilePath) + .toUtf8(); + return false; + } + + // Write the JSON to the file. + file.write(doc.toJson()); + file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser | + QFile::WriteUser); + if (file.commit()) { + qDebug() << "Saved account list to" << m_listFilePath; + return true; + } else { + qDebug() << "Failed to save accounts to" << m_listFilePath; + return false; + } +} + +void AccountList::setListFilePath(QString path, bool autosave) +{ + m_listFilePath = path; + m_autosave = autosave; +} + +bool AccountList::anyAccountIsValid() +{ + for (auto account : m_accounts) { + if (account->ownsMinecraft()) { + return true; + } + } + return false; +} + +void AccountList::fillQueue() +{ + + if (m_defaultAccount && m_defaultAccount->shouldRefresh()) { + auto idToRefresh = m_defaultAccount->internalId(); + m_refreshQueue.push_back(idToRefresh); + qDebug() << "AccountList: Queued default account with internal ID " + << idToRefresh << " to refresh first"; + } + + for (int i = 0; i < count(); i++) { + auto account = at(i); + if (account == m_defaultAccount) { + continue; + } + + if (account->shouldRefresh()) { + auto idToRefresh = account->internalId(); + queueRefresh(idToRefresh); + } + } + tryNext(); +} + +void AccountList::requestRefresh(QString accountId) +{ + auto index = m_refreshQueue.indexOf(accountId); + if (index != -1) { + m_refreshQueue.removeAt(index); + } + m_refreshQueue.push_front(accountId); + qDebug() << "AccountList: Pushed account with internal ID " << accountId + << " to the front of the queue"; + if (!isActive()) { + tryNext(); + } +} + +void AccountList::queueRefresh(QString accountId) +{ + if (m_refreshQueue.indexOf(accountId) != -1) { + return; + } + m_refreshQueue.push_back(accountId); + qDebug() << "AccountList: Queued account with internal ID " << accountId + << " to refresh"; +} + +void AccountList::tryNext() +{ + while (m_refreshQueue.length()) { + auto accountId = m_refreshQueue.front(); + m_refreshQueue.pop_front(); + for (int i = 0; i < count(); i++) { + auto account = at(i); + if (account->internalId() == accountId) { + m_currentTask = account->refresh(); + if (m_currentTask) { + connect(m_currentTask.get(), &AccountTask::succeeded, this, + &AccountList::authSucceeded); + connect(m_currentTask.get(), &AccountTask::failed, this, + &AccountList::authFailed); + m_currentTask->start(); + qDebug() << "RefreshSchedule: Processing account " + << account->accountDisplayString() + << " with internal ID " << accountId; + return; + } + } + } + qDebug() << "RefreshSchedule: Account with with internal ID " + << accountId << " not found."; + } + // if we get here, no account needed refreshing. Schedule refresh in an + // hour. + m_refreshTimer->start(1000 * 3600); +} + +void AccountList::authSucceeded() +{ + qDebug() << "RefreshSchedule: Background account refresh succeeded"; + m_currentTask.reset(); + m_nextTimer->start(1000 * 20); +} + +void AccountList::authFailed(QString reason) +{ + qDebug() << "RefreshSchedule: Background account refresh failed: " + << reason; + m_currentTask.reset(); + m_nextTimer->start(1000 * 20); +} + +bool AccountList::isActive() const +{ + return m_activityCount != 0; +} + +void AccountList::beginActivity() +{ + bool activating = m_activityCount == 0; + m_activityCount++; + if (activating) { + emit activityChanged(true); + } +} + +void AccountList::endActivity() +{ + if (m_activityCount == 0) { + qWarning() << m_name << " - Activity count would become below zero"; + return; + } + bool deactivating = m_activityCount == 1; + m_activityCount--; + if (deactivating) { + emit activityChanged(false); + } +} diff --git a/meshmc/launcher/minecraft/auth/AccountList.h b/meshmc/launcher/minecraft/auth/AccountList.h new file mode 100644 index 0000000000..2d352532a8 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountList.h @@ -0,0 +1,187 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#pragma once + +#include "MinecraftAccount.h" + +#include <QObject> +#include <QVariant> +#include <QAbstractListModel> +#include <QSharedPointer> + +/*! + * List of available Mojang accounts. + * This should be loaded in the background by MeshMC on startup. + */ +class AccountList : public QAbstractListModel +{ + Q_OBJECT + public: + enum ModelRoles { PointerRole = 0x34B1CB48 }; + + enum VListColumns { + // TODO: Add icon column. + NameColumn = 0, + ProfileNameColumn, + TypeColumn, + StatusColumn, + + NUM_COLUMNS + }; + + explicit AccountList(QObject* parent = 0); + virtual ~AccountList() noexcept; + + const MinecraftAccountPtr at(int i) const; + int count() const; + + //////// List Model Functions //////// + QVariant data(const QModelIndex& index, int role) const override; + virtual QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + virtual int rowCount(const QModelIndex& parent) const override; + virtual int columnCount(const QModelIndex& parent) const override; + virtual Qt::ItemFlags flags(const QModelIndex& index) const override; + virtual bool setData(const QModelIndex& index, const QVariant& value, + int role) override; + + void addAccount(const MinecraftAccountPtr account); + void removeAccount(QModelIndex index); + int findAccountByProfileId(const QString& profileId) const; + MinecraftAccountPtr + getAccountByProfileName(const QString& profileName) const; + QStringList profileNames() const; + + // requesting a refresh pushes it to the front of the queue + void requestRefresh(QString accountId); + // queuing a refresh will let it go to the back of the queue (unless it's + // somewhere inside the queue already) + void queueRefresh(QString accountId); + + /*! + * Sets the path to load/save the list file from/to. + * If autosave is true, this list will automatically save to the given path + * whenever it changes. THIS FUNCTION DOES NOT LOAD THE LIST. If you set + * autosave, be sure to call loadList() immediately after calling this + * function to ensure an autosaved change doesn't overwrite the list you + * intended to load. + */ + void setListFilePath(QString path, bool autosave = false); + + bool loadList(); + bool loadV3(QJsonObject& root); + bool saveList(); + + MinecraftAccountPtr defaultAccount() const; + void setDefaultAccount(MinecraftAccountPtr profileId); + bool anyAccountIsValid(); + + bool isActive() const; + + protected: + void beginActivity(); + void endActivity(); + + private: + const char* m_name; + uint32_t m_activityCount = 0; + signals: + void listChanged(); + void listActivityChanged(); + void defaultAccountChanged(); + void activityChanged(bool active); + + public slots: + /** + * This is called when one of the accounts changes and the list needs to be + * updated + */ + void accountChanged(); + + /** + * This is called when a (refresh/login) task involving the account starts + * or ends + */ + void accountActivityChanged(bool active); + + /** + * This is initially to run background account refresh tasks, or on a hourly + * timer + */ + void fillQueue(); + + private slots: + void tryNext(); + + void authSucceeded(); + void authFailed(QString reason); + + protected: + QList<QString> m_refreshQueue; + QTimer* m_refreshTimer; + QTimer* m_nextTimer; + shared_qobject_ptr<AccountTask> m_currentTask; + + /*! + * Called whenever the list changes. + * This emits the listChanged() signal and autosaves the list (if autosave + * is enabled). + */ + void onListChanged(); + + /*! + * Called whenever the active account changes. + * Emits the defaultAccountChanged() signal and autosaves the list if + * enabled. + */ + void onDefaultAccountChanged(); + + QList<MinecraftAccountPtr> m_accounts; + + MinecraftAccountPtr m_defaultAccount; + + //! Path to the account list file. Empty string if there isn't one. + QString m_listFilePath; + + /*! + * If true, the account list will automatically save to the account list + * path when it changes. Ignored if m_listFilePath is blank. + */ + bool m_autosave = false; +}; diff --git a/meshmc/launcher/minecraft/auth/AccountTask.cpp b/meshmc/launcher/minecraft/auth/AccountTask.cpp new file mode 100644 index 0000000000..ddcf1918db --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountTask.cpp @@ -0,0 +1,129 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 "AccountTask.h" +#include "MinecraftAccount.h" + +#include <QObject> +#include <QString> +#include <QJsonObject> +#include <QJsonDocument> +#include <QNetworkReply> +#include <QByteArray> + +#include <QDebug> + +AccountTask::AccountTask(AccountData* data, QObject* parent) + : Task(parent), m_data(data) +{ + changeState(AccountTaskState::STATE_CREATED); +} + +QString AccountTask::getStateMessage() const +{ + switch (m_taskState) { + case AccountTaskState::STATE_CREATED: + return "Waiting..."; + case AccountTaskState::STATE_WORKING: + return tr("Sending request to auth servers..."); + case AccountTaskState::STATE_SUCCEEDED: + return tr("Authentication task succeeded."); + case AccountTaskState::STATE_OFFLINE: + return tr("Failed to contact the authentication server."); + case AccountTaskState::STATE_FAILED_SOFT: + return tr("Encountered an error during authentication."); + case AccountTaskState::STATE_FAILED_HARD: + return tr("Failed to authenticate. The session has expired."); + case AccountTaskState::STATE_FAILED_GONE: + return tr("Failed to authenticate. The account no longer exists."); + default: + return tr("..."); + } +} + +bool AccountTask::changeState(AccountTaskState newState, QString reason) +{ + m_taskState = newState; + setStatus(getStateMessage()); + switch (newState) { + case AccountTaskState::STATE_CREATED: { + m_data->errorString.clear(); + return true; + } + case AccountTaskState::STATE_WORKING: { + m_data->accountState = AccountState::Working; + return true; + } + case AccountTaskState::STATE_SUCCEEDED: { + m_data->accountState = AccountState::Online; + emitSucceeded(); + return false; + } + case AccountTaskState::STATE_OFFLINE: { + m_data->errorString = reason; + m_data->accountState = AccountState::Offline; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_SOFT: { + m_data->errorString = reason; + m_data->accountState = AccountState::Errored; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_HARD: { + m_data->errorString = reason; + m_data->accountState = AccountState::Expired; + emitFailed(reason); + return false; + } + case AccountTaskState::STATE_FAILED_GONE: { + m_data->errorString = reason; + m_data->accountState = AccountState::Gone; + emitFailed(reason); + return false; + } + default: { + QString error = + tr("Unknown account task state: %1").arg(int(newState)); + m_data->accountState = AccountState::Errored; + emitFailed(error); + return false; + } + } +} diff --git a/meshmc/launcher/minecraft/auth/AccountTask.h b/meshmc/launcher/minecraft/auth/AccountTask.h new file mode 100644 index 0000000000..184b8b4c01 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AccountTask.h @@ -0,0 +1,101 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#pragma once + +#include <tasks/Task.h> + +#include <QString> +#include <QJsonObject> +#include <QTimer> +#include <qsslerror.h> + +#include "MinecraftAccount.h" + +class QNetworkReply; + +/** + * Enum for describing the state of the current task. + * Used by the getStateMessage function to determine what the status message + * should be. + */ +enum class AccountTaskState { + STATE_CREATED, + STATE_WORKING, + STATE_SUCCEEDED, + STATE_FAILED_SOFT, //!< soft failure. authentication went through partially + STATE_FAILED_HARD, //!< hard failure. main tokens are invalid + STATE_FAILED_GONE, //!< hard failure. main tokens are invalid, and the + //!< account no longer exists + STATE_OFFLINE //!< soft failure. authentication failed in the first step in + //!< a 'soft' way +}; + +class AccountTask : public Task +{ + Q_OBJECT + public: + explicit AccountTask(AccountData* data, QObject* parent = 0); + virtual ~AccountTask() {}; + + AccountTaskState m_taskState = AccountTaskState::STATE_CREATED; + + AccountTaskState taskState() + { + return m_taskState; + } + + signals: + void authorizeWithBrowser(const QUrl& url); + + protected: + /** + * Returns the state message for the given state. + * Used to set the status message for the task. + * Should be overridden by subclasses that want to change messages for a + * given state. + */ + virtual QString getStateMessage() const; + + protected slots: + // NOTE: true -> non-terminal state, false -> terminal state + bool changeState(AccountTaskState newState, QString reason = QString()); + + protected: + AccountData* m_data = nullptr; +}; diff --git a/meshmc/launcher/minecraft/auth/AuthRequest.cpp b/meshmc/launcher/minecraft/auth/AuthRequest.cpp new file mode 100644 index 0000000000..9edf238c57 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthRequest.cpp @@ -0,0 +1,162 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include <cassert> + +#include <QDebug> +#include <QTimer> +#include <QBuffer> +#include <QUrlQuery> + +#include "Application.h" +#include "AuthRequest.h" +#include "katabasis/Globals.h" + +AuthRequest::AuthRequest(QObject* parent) : QObject(parent) {} + +AuthRequest::~AuthRequest() {} + +void AuthRequest::get(const QNetworkRequest& req, int timeout /* = 60*1000*/) +{ + setup(req, QNetworkAccessManager::GetOperation); + reply_ = APPLICATION->network()->get(request_); + status_ = Requesting; + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); +} + +void AuthRequest::post(const QNetworkRequest& req, const QByteArray& data, + int timeout /* = 60*1000*/) +{ + setup(req, QNetworkAccessManager::PostOperation); + data_ = data; + status_ = Requesting; + reply_ = APPLICATION->network()->post(request_, data_); + timedReplies_.add(new Katabasis::Reply(reply_, timeout)); + connect(reply_, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, + SLOT(onRequestError(QNetworkReply::NetworkError))); + connect(reply_, SIGNAL(finished()), this, SLOT(onRequestFinished())); + connect(reply_, &QNetworkReply::sslErrors, this, &AuthRequest::onSslErrors); + connect(reply_, SIGNAL(uploadProgress(qint64, qint64)), this, + SLOT(onUploadProgress(qint64, qint64))); +} + +void AuthRequest::onRequestFinished() +{ + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast<QNetworkReply*>(sender())) { + return; + } + httpStatus_ = + reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + finish(); +} + +void AuthRequest::onRequestError(QNetworkReply::NetworkError error) +{ + qWarning() << "AuthRequest::onRequestError: Error" << (int)error; + if (status_ == Idle) { + return; + } + if (reply_ != qobject_cast<QNetworkReply*>(sender())) { + return; + } + errorString_ = reply_->errorString(); + httpStatus_ = + reply_->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + error_ = error; + qWarning() << "AuthRequest::onRequestError: Error string: " << errorString_; + qWarning() << "AuthRequest::onRequestError: HTTP status" << httpStatus_ + << reply_->attribute(QNetworkRequest::HttpReasonPhraseAttribute) + .toString(); + + // QTimer::singleShot(10, this, SLOT(finish())); +} + +void AuthRequest::onSslErrors(QList<QSslError> errors) +{ + int i = 1; + for (auto error : errors) { + qCritical() << "LOGIN SSL Error #" << i << " : " << error.errorString(); + auto cert = error.certificate(); + qCritical() << "Certificate in question:\n" << cert.toText(); + i++; + } +} + +void AuthRequest::onUploadProgress(qint64 uploaded, qint64 total) +{ + if (status_ == Idle) { + qWarning() << "AuthRequest::onUploadProgress: No pending request"; + return; + } + if (reply_ != qobject_cast<QNetworkReply*>(sender())) { + return; + } + // Restart timeout because request in progress + Katabasis::Reply* o2Reply = timedReplies_.find(reply_); + if (o2Reply) { + o2Reply->start(); + } + emit uploadProgress(uploaded, total); +} + +void AuthRequest::setup(const QNetworkRequest& req, + QNetworkAccessManager::Operation operation, + const QByteArray& verb) +{ + request_ = req; + operation_ = operation; + url_ = req.url(); + + QUrl url = url_; + request_.setUrl(url); + + if (!verb.isEmpty()) { + request_.setRawHeader(Katabasis::HTTP_HTTP_HEADER, verb); + } + + status_ = Requesting; + error_ = QNetworkReply::NoError; + errorString_.clear(); + httpStatus_ = 0; +} + +void AuthRequest::finish() +{ + QByteArray data; + if (status_ == Idle) { + qWarning() << "AuthRequest::finish: No pending request"; + return; + } + data = reply_->readAll(); + status_ = Idle; + timedReplies_.remove(reply_); + reply_->disconnect(this); + reply_->deleteLater(); + QList<QNetworkReply::RawHeaderPair> headers = reply_->rawHeaderPairs(); + emit finished(error_, data, headers); +} diff --git a/meshmc/launcher/minecraft/auth/AuthRequest.h b/meshmc/launcher/minecraft/auth/AuthRequest.h new file mode 100644 index 0000000000..cd57fa34db --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthRequest.h @@ -0,0 +1,93 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> +#include <QNetworkRequest> +#include <QNetworkReply> +#include <QNetworkAccessManager> +#include <QUrl> +#include <QByteArray> + +#include "katabasis/Reply.h" + +/// Makes authentication requests. +class AuthRequest : public QObject +{ + Q_OBJECT + + public: + explicit AuthRequest(QObject* parent = 0); + ~AuthRequest(); + + public slots: + void get(const QNetworkRequest& req, int timeout = 60 * 1000); + void post(const QNetworkRequest& req, const QByteArray& data, + int timeout = 60 * 1000); + + signals: + + /// Emitted when a request has been completed or failed. + void finished(QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers); + + /// Emitted when an upload has progressed. + void uploadProgress(qint64 bytesSent, qint64 bytesTotal); + + protected slots: + + /// Handle request finished. + void onRequestFinished(); + + /// Handle request error. + void onRequestError(QNetworkReply::NetworkError error); + + /// Handle ssl errors. + void onSslErrors(QList<QSslError> errors); + + /// Finish the request, emit finished() signal. + void finish(); + + /// Handle upload progress. + void onUploadProgress(qint64 uploaded, qint64 total); + + public: + QNetworkReply::NetworkError error_; + int httpStatus_ = 0; + QString errorString_; + + protected: + void setup(const QNetworkRequest& request, + QNetworkAccessManager::Operation operation, + const QByteArray& verb = QByteArray()); + + enum Status { Idle, Requesting, ReRequesting }; + + QNetworkRequest request_; + QByteArray data_; + QNetworkReply* reply_; + Status status_; + QNetworkAccessManager::Operation operation_; + QUrl url_; + Katabasis::ReplyList timedReplies_; + + QTimer* timer_; +}; diff --git a/meshmc/launcher/minecraft/auth/AuthSession.cpp b/meshmc/launcher/minecraft/auth/AuthSession.cpp new file mode 100644 index 0000000000..53366077ae --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthSession.cpp @@ -0,0 +1,57 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "AuthSession.h" +#include <QJsonObject> +#include <QJsonArray> +#include <QJsonDocument> +#include <QStringList> + +QString AuthSession::serializeUserProperties() +{ + QJsonObject userAttrs; + /* + for (auto key : u.properties.keys()) + { + auto array = QJsonArray::fromStringList(u.properties.values(key)); + userAttrs.insert(key, array); + } + */ + QJsonDocument value(userAttrs); + return value.toJson(QJsonDocument::Compact); +} + +bool AuthSession::MakeOffline(QString offline_playername) +{ + if (status != PlayableOffline && status != PlayableOnline) { + return false; + } + session = "-"; + player_name = offline_playername; + status = PlayableOffline; + return true; +} + +void AuthSession::MakeDemo() +{ + player_name = "Player"; + demo = true; +} diff --git a/meshmc/launcher/minecraft/auth/AuthSession.h b/meshmc/launcher/minecraft/auth/AuthSession.h new file mode 100644 index 0000000000..80525bb972 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthSession.h @@ -0,0 +1,71 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QString> +#include <QMultiMap> +#include <memory> +#include "QObjectPtr.h" + +class MinecraftAccount; +class QNetworkAccessManager; + +struct AuthSession { + bool MakeOffline(QString offline_playername); + void MakeDemo(); + + QString serializeUserProperties(); + + enum Status { + Undetermined, + RequiresOAuth, + RequiresPassword, + RequiresProfileSetup, + PlayableOffline, + PlayableOnline, + GoneOrMigrated + } status = Undetermined; + + // client token + QString client_token; + // account user name + QString username; + // combined session ID + QString session; + // volatile auth token + QString access_token; + // profile name + QString player_name; + // profile ID + QString uuid; + // 'legacy' or 'mojang', depending on account type + QString user_type; + // Did the auth server reply? + bool auth_server_online = false; + // Did the user request online mode? + bool wants_online = true; + + // Is this a demo session? + bool demo = false; +}; + +typedef std::shared_ptr<AuthSession> AuthSessionPtr; diff --git a/meshmc/launcher/minecraft/auth/AuthStep.cpp b/meshmc/launcher/minecraft/auth/AuthStep.cpp new file mode 100644 index 0000000000..459d74d63d --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthStep.cpp @@ -0,0 +1,26 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "AuthStep.h" + +AuthStep::AuthStep(AccountData* data) : QObject(nullptr), m_data(data) {} + +AuthStep::~AuthStep() noexcept = default; diff --git a/meshmc/launcher/minecraft/auth/AuthStep.h b/meshmc/launcher/minecraft/auth/AuthStep.h new file mode 100644 index 0000000000..0c9c758b4e --- /dev/null +++ b/meshmc/launcher/minecraft/auth/AuthStep.h @@ -0,0 +1,54 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> +#include <QList> +#include <QNetworkReply> + +#include "QObjectPtr.h" +#include "minecraft/auth/AccountData.h" +#include "AccountTask.h" + +class AuthStep : public QObject +{ + Q_OBJECT + + public: + using Ptr = shared_qobject_ptr<AuthStep>; + + public: + explicit AuthStep(AccountData* data); + virtual ~AuthStep() noexcept; + + virtual QString describe() = 0; + + public slots: + virtual void perform() = 0; + virtual void rehydrate() = 0; + + signals: + void finished(AccountTaskState resultingState, QString message); + void authorizeWithBrowser(const QUrl& url); + + protected: + AccountData* m_data; +}; diff --git a/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp b/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp new file mode 100644 index 0000000000..53e77bbed0 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/MinecraftAccount.cpp @@ -0,0 +1,257 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 "MinecraftAccount.h" + +#include <QUuid> +#include <QJsonObject> +#include <QJsonArray> +#include <QRegularExpression> +#include <QStringList> +#include <QJsonDocument> + +#include <QDebug> + +#include <QPainter> + +#include "flows/MSA.h" + +MinecraftAccount::MinecraftAccount(QObject* parent) : QObject(parent) +{ + data.internalId = + QUuid::createUuid().toString().remove(QRegularExpression("[{}-]")); +} + +MinecraftAccountPtr MinecraftAccount::loadFromJsonV3(const QJsonObject& json) +{ + MinecraftAccountPtr account(new MinecraftAccount()); + if (account->data.resumeStateFromV3(json)) { + return account; + } + return nullptr; +} + +MinecraftAccountPtr MinecraftAccount::createBlankMSA() +{ + MinecraftAccountPtr account(new MinecraftAccount()); + account->data.type = AccountType::MSA; + return account; +} + +QJsonObject MinecraftAccount::saveToJson() const +{ + return data.saveState(); +} + +AccountState MinecraftAccount::accountState() const +{ + return data.accountState; +} + +QPixmap MinecraftAccount::getFace() const +{ + QPixmap skinTexture; + if (!skinTexture.loadFromData(data.minecraftProfile.skin.data, "PNG")) { + return QPixmap(); + } + QPixmap skin = QPixmap(8, 8); + QPainter painter(&skin); + painter.drawPixmap(0, 0, skinTexture.copy(8, 8, 8, 8)); + painter.drawPixmap(0, 0, skinTexture.copy(40, 8, 8, 8)); + return skin.scaled(64, 64, Qt::KeepAspectRatio); +} + +shared_qobject_ptr<AccountTask> MinecraftAccount::loginMSA() +{ + Q_ASSERT(m_currentTask.get() == nullptr); + + m_currentTask.reset(new MSAInteractive(&data)); + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), + SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr<AccountTask> MinecraftAccount::refresh() +{ + if (m_currentTask) { + return m_currentTask; + } + + m_currentTask.reset(new MSASilent(&data)); + + connect(m_currentTask.get(), SIGNAL(succeeded()), SLOT(authSucceeded())); + connect(m_currentTask.get(), SIGNAL(failed(QString)), + SLOT(authFailed(QString))); + emit activityChanged(true); + return m_currentTask; +} + +shared_qobject_ptr<AccountTask> MinecraftAccount::currentTask() +{ + return m_currentTask; +} + +void MinecraftAccount::authSucceeded() +{ + m_currentTask.reset(); + emit changed(); + emit activityChanged(false); +} + +void MinecraftAccount::authFailed(QString reason) +{ + switch (m_currentTask->taskState()) { + case AccountTaskState::STATE_OFFLINE: + case AccountTaskState::STATE_FAILED_SOFT: { + // NOTE: this doesn't do much. There was an error of some sort. + } break; + case AccountTaskState::STATE_FAILED_HARD: { + data.msaToken.token = QString(); + data.msaToken.refresh_token = QString(); + data.msaToken.validity = Katabasis::Validity::None; + data.validity_ = Katabasis::Validity::None; + emit changed(); + } break; + case AccountTaskState::STATE_FAILED_GONE: { + data.validity_ = Katabasis::Validity::None; + emit changed(); + } break; + case AccountTaskState::STATE_CREATED: + case AccountTaskState::STATE_WORKING: + case AccountTaskState::STATE_SUCCEEDED: { + // Not reachable here, as they are not failures. + } + } + m_currentTask.reset(); + emit activityChanged(false); +} + +bool MinecraftAccount::isActive() const +{ + return m_currentTask; +} + +bool MinecraftAccount::shouldRefresh() const +{ + /* + * Never refresh accounts that are being used by the game, it breaks the + * game session. Always refresh accounts that have not been refreshed yet + * during this session. Don't refresh broken accounts. Refresh accounts that + * would expire in the next 12 hours (fresh token validity is 24 hours). + */ + if (isInUse()) { + return false; + } + switch (data.validity_) { + case Katabasis::Validity::Certain: { + break; + } + case Katabasis::Validity::None: { + return false; + } + case Katabasis::Validity::Assumed: { + return true; + } + } + auto now = QDateTime::currentDateTimeUtc(); + auto issuedTimestamp = data.msaToken.issueInstant; + auto expiresTimestamp = data.msaToken.notAfter; + + if (!expiresTimestamp.isValid()) { + expiresTimestamp = issuedTimestamp.addSecs(24 * 3600); + } + if (now.secsTo(expiresTimestamp) < (12 * 3600)) { + return true; + } + return false; +} + +void MinecraftAccount::fillSession(AuthSessionPtr session) +{ + if (ownsMinecraft() && !hasProfile()) { + session->status = AuthSession::RequiresProfileSetup; + } else { + if (session->wants_online) { + session->status = AuthSession::PlayableOnline; + } else { + session->status = AuthSession::PlayableOffline; + } + } + + // the user name + session->username = data.profileName(); + // volatile auth token + session->access_token = data.accessToken(); + // the semi-permanent client token + session->client_token = QString(); + // profile name + session->player_name = data.profileName(); + // profile ID + session->uuid = data.profileId(); + // 'legacy' or 'mojang', depending on account type + session->user_type = typeString(); + if (!session->access_token.isEmpty()) { + session->session = + "token:" + data.accessToken() + ":" + data.profileId(); + } else { + session->session = "-"; + } +} + +void MinecraftAccount::decrementUses() +{ + Usable::decrementUses(); + if (!isInUse()) { + emit changed(); + // FIXME: we now need a better way to identify accounts... + qWarning() << "Profile" << data.profileId() << "is no longer in use."; + } +} + +void MinecraftAccount::incrementUses() +{ + bool wasInUse = isInUse(); + Usable::incrementUses(); + if (!wasInUse) { + emit changed(); + // FIXME: we now need a better way to identify accounts... + qWarning() << "Profile" << data.profileId() << "is now in use."; + } +} diff --git a/meshmc/launcher/minecraft/auth/MinecraftAccount.h b/meshmc/launcher/minecraft/auth/MinecraftAccount.h new file mode 100644 index 0000000000..1d25fa7d57 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/MinecraftAccount.h @@ -0,0 +1,198 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#pragma once + +#include <QObject> +#include <QString> +#include <QList> +#include <QJsonObject> +#include <QPair> +#include <QMap> +#include <QPixmap> + +#include <memory> + +#include "AuthSession.h" +#include "Usable.h" +#include "AccountData.h" +#include "QObjectPtr.h" + +class Task; +class AccountTask; +class MinecraftAccount; + +typedef shared_qobject_ptr<MinecraftAccount> MinecraftAccountPtr; +Q_DECLARE_METATYPE(MinecraftAccountPtr) + +/** + * A profile within someone's Mojang account. + * + * Currently, the profile system has not been implemented by Mojang yet, + * but we might as well add some things for it in MeshMC right now so + * we don't have to rip the code to pieces to add it later. + */ +struct AccountProfile { + QString id; + QString name; + bool legacy; +}; + +/** + * Object that stores information about a certain Mojang account. + * + * Said information may include things such as that account's username, client + * token, and access token if the user chose to stay logged in. + */ +class MinecraftAccount : public QObject, public Usable +{ + Q_OBJECT + public: /* construction */ + //! Do not copy accounts. ever. + explicit MinecraftAccount(const MinecraftAccount& other, + QObject* parent) = delete; + + //! Default constructor + explicit MinecraftAccount(QObject* parent = 0); + + static MinecraftAccountPtr createBlankMSA(); + + static MinecraftAccountPtr loadFromJsonV3(const QJsonObject& json); + + //! Saves a MinecraftAccount to a JSON object and returns it. + QJsonObject saveToJson() const; + + public: /* manipulation */ + shared_qobject_ptr<AccountTask> loginMSA(); + + shared_qobject_ptr<AccountTask> refresh(); + + shared_qobject_ptr<AccountTask> currentTask(); + + public: /* queries */ + QString internalId() const + { + return data.internalId; + } + + QString accountDisplayString() const + { + return data.accountDisplayString(); + } + + QString accessToken() const + { + return data.accessToken(); + } + + QString profileId() const + { + return data.profileId(); + } + + QString profileName() const + { + return data.profileName(); + } + + bool isActive() const; + + bool isMSA() const + { + return data.type == AccountType::MSA; + } + + bool ownsMinecraft() const + { + return data.minecraftEntitlement.ownsMinecraft; + } + + bool hasProfile() const + { + return data.profileId().size() != 0; + } + + QString typeString() const + { + return "msa"; + } + + QPixmap getFace() const; + + //! Returns the current state of the account + AccountState accountState() const; + + AccountData* accountData() + { + return &data; + } + + bool shouldRefresh() const; + + void fillSession(AuthSessionPtr session); + + QString lastError() const + { + return data.lastError(); + } + + signals: + /** + * This signal is emitted when the account changes + */ + void changed(); + + void activityChanged(bool active); + + // TODO: better signalling for the various possible state changes - + // especially errors + + protected: /* variables */ + AccountData data; + + // current task we are executing here + shared_qobject_ptr<AccountTask> m_currentTask; + + protected: /* methods */ + void incrementUses() override; + void decrementUses() override; + + private slots: + void authSucceeded(); + void authFailed(QString reason); +}; diff --git a/meshmc/launcher/minecraft/auth/Parsers.cpp b/meshmc/launcher/minecraft/auth/Parsers.cpp new file mode 100644 index 0000000000..6a4690942c --- /dev/null +++ b/meshmc/launcher/minecraft/auth/Parsers.cpp @@ -0,0 +1,366 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "Parsers.h" + +#include <QJsonDocument> +#include <QJsonArray> +#include <QDebug> + +namespace Parsers +{ + + bool getDateTime(QJsonValue value, QDateTime& out) + { + if (!value.isString()) { + return false; + } + out = QDateTime::fromString(value.toString(), Qt::ISODate); + return out.isValid(); + } + + bool getString(QJsonValue value, QString& out) + { + if (!value.isString()) { + return false; + } + out = value.toString(); + return true; + } + + bool getNumber(QJsonValue value, double& out) + { + if (!value.isDouble()) { + return false; + } + out = value.toDouble(); + return true; + } + + bool getNumber(QJsonValue value, int64_t& out) + { + if (!value.isDouble()) { + return false; + } + out = (int64_t)value.toDouble(); + return true; + } + + bool getBool(QJsonValue value, bool& out) + { + if (!value.isBool()) { + return false; + } + out = value.toBool(); + return true; + } + + /* + { + "IssueInstant":"2020-12-07T19:52:08.4463796Z", + "NotAfter":"2020-12-21T19:52:08.4463796Z", + "Token":"token", + "DisplayClaims":{ + "xui":[ + { + "uhs":"userhash" + } + ] + } + } + */ + // TODO: handle error responses ... + /* + { + "Identity":"0", + "XErr":2148916238, + "Message":"", + "Redirect":"https://start.ui.xboxlive.com/AddChildToFamily" + } + // 2148916233 = missing XBox account + // 2148916238 = child account not linked to a family + */ + + bool parseXTokenResponse(QByteArray& data, Katabasis::Token& output, + QString name) + { + qDebug() << "Parsing" << name << ":"; +#ifndef NDEBUG + qDebug() << data; +#endif + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "user.auth.xboxlive.com as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if (!getDateTime(obj.value("IssueInstant"), output.issueInstant)) { + qWarning() << "User IssueInstant is not a timestamp"; + return false; + } + if (!getDateTime(obj.value("NotAfter"), output.notAfter)) { + qWarning() << "User NotAfter is not a timestamp"; + return false; + } + if (!getString(obj.value("Token"), output.token)) { + qWarning() << "User Token is not a string"; + return false; + } + auto arrayVal = obj.value("DisplayClaims").toObject().value("xui"); + if (!arrayVal.isArray()) { + qWarning() << "Missing xui claims array"; + return false; + } + bool foundUHS = false; + for (auto item : arrayVal.toArray()) { + if (!item.isObject()) { + continue; + } + auto obj = item.toObject(); + if (obj.contains("uhs")) { + foundUHS = true; + } else { + continue; + } + // consume all 'display claims' ... whatever that means + for (auto iter = obj.begin(); iter != obj.end(); iter++) { + QString claim; + if (!getString(obj.value(iter.key()), claim)) { + qWarning() << "display claim " << iter.key() + << " is not a string..."; + return false; + } + output.extra[iter.key()] = claim; + } + + break; + } + if (!foundUHS) { + qWarning() << "Missing uhs"; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << name << "is valid."; + return true; + } + + bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output) + { + qDebug() << "Parsing Minecraft profile..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "user.auth.xboxlive.com as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + if (!getString(obj.value("id"), output.id)) { + qWarning() << "Minecraft profile id is not a string"; + return false; + } + + if (!getString(obj.value("name"), output.name)) { + qWarning() << "Minecraft profile name is not a string"; + return false; + } + + auto skinsArray = obj.value("skins").toArray(); + for (auto skin : skinsArray) { + auto skinObj = skin.toObject(); + Skin skinOut; + if (!getString(skinObj.value("id"), skinOut.id)) { + continue; + } + QString state; + if (!getString(skinObj.value("state"), state)) { + continue; + } + if (state != "ACTIVE") { + continue; + } + if (!getString(skinObj.value("url"), skinOut.url)) { + continue; + } + if (!getString(skinObj.value("variant"), skinOut.variant)) { + continue; + } + // we deal with only the active skin + output.skin = skinOut; + break; + } + auto capesArray = obj.value("capes").toArray(); + + QString currentCape; + for (auto cape : capesArray) { + auto capeObj = cape.toObject(); + Cape capeOut; + if (!getString(capeObj.value("id"), capeOut.id)) { + continue; + } + QString state; + if (!getString(capeObj.value("state"), state)) { + continue; + } + if (state == "ACTIVE") { + currentCape = capeOut.id; + } + if (!getString(capeObj.value("url"), capeOut.url)) { + continue; + } + if (!getString(capeObj.value("alias"), capeOut.alias)) { + continue; + } + + output.capes[capeOut.id] = capeOut; + } + output.currentCape = currentCape; + output.validity = Katabasis::Validity::Certain; + return true; + } + + bool parseMinecraftEntitlements(QByteArray& data, + MinecraftEntitlement& output) + { + qDebug() << "Parsing Minecraft entitlements..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "user.auth.xboxlive.com as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + output.canPlayMinecraft = false; + output.ownsMinecraft = false; + + auto itemsArray = obj.value("items").toArray(); + for (auto item : itemsArray) { + auto itemObj = item.toObject(); + QString name; + if (!getString(itemObj.value("name"), name)) { + continue; + } + if (name == "game_minecraft") { + output.canPlayMinecraft = true; + } + if (name == "product_minecraft") { + output.ownsMinecraft = true; + } + } + output.validity = Katabasis::Validity::Certain; + return true; + } + + bool parseRolloutResponse(QByteArray& data, bool& result) + { + qDebug() << "Parsing Rollout response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "https://api.minecraftservices.com/rollout/v1/" + "msamigration as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + QString feature; + if (!getString(obj.value("feature"), feature)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + if (feature != "msamigration") { + qWarning() << "Rollout feature is not what we expected " + "(msamigration), but is instead \"" + << feature << "\""; + return false; + } + if (!getBool(obj.value("rollout"), result)) { + qWarning() << "Rollout feature is not a string"; + return false; + } + return true; + } + + bool parseMojangResponse(QByteArray& data, Katabasis::Token& output) + { + QJsonParseError jsonError; + qDebug() << "Parsing Mojang response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Failed to parse response from " + "api.minecraftservices.com/launcher/login as JSON: " + << jsonError.errorString(); + return false; + } + + auto obj = doc.object(); + double expires_in = 0; + if (!getNumber(obj.value("expires_in"), expires_in)) { + qWarning() << "expires_in is not a valid number"; + return false; + } + auto currentTime = QDateTime::currentDateTimeUtc(); + output.issueInstant = currentTime; + output.notAfter = currentTime.addSecs(expires_in); + + QString username; + if (!getString(obj.value("username"), username)) { + qWarning() << "username is not valid"; + return false; + } + + // TODO: it's a JWT... validate it? + if (!getString(obj.value("access_token"), output.token)) { + qWarning() << "access_token is not valid"; + return false; + } + output.validity = Katabasis::Validity::Certain; + qDebug() << "Mojang response is valid."; + return true; + } + +} // namespace Parsers diff --git a/meshmc/launcher/minecraft/auth/Parsers.h b/meshmc/launcher/minecraft/auth/Parsers.h new file mode 100644 index 0000000000..62fd056b92 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/Parsers.h @@ -0,0 +1,42 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 "AccountData.h" + +namespace Parsers +{ + bool getDateTime(QJsonValue value, QDateTime& out); + bool getString(QJsonValue value, QString& out); + bool getNumber(QJsonValue value, double& out); + bool getNumber(QJsonValue value, int64_t& out); + bool getBool(QJsonValue value, bool& out); + + bool parseXTokenResponse(QByteArray& data, Katabasis::Token& output, + QString name); + bool parseMojangResponse(QByteArray& data, Katabasis::Token& output); + + bool parseMinecraftProfile(QByteArray& data, MinecraftProfile& output); + bool parseMinecraftEntitlements(QByteArray& data, + MinecraftEntitlement& output); + bool parseRolloutResponse(QByteArray& data, bool& result); +} // namespace Parsers diff --git a/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp b/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp new file mode 100644 index 0000000000..ef29e9e77f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/AuthFlow.cpp @@ -0,0 +1,93 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include <QNetworkAccessManager> +#include <QNetworkRequest> +#include <QNetworkReply> +#include <QDebug> + +#include "AuthFlow.h" +#include "katabasis/Globals.h" + +#include <Application.h> + +AuthFlow::AuthFlow(AccountData* data, QObject* parent) + : AccountTask(data, parent) +{ +} + +void AuthFlow::succeed() +{ + m_data->validity_ = Katabasis::Validity::Certain; + changeState(AccountTaskState::STATE_SUCCEEDED, + tr("Finished all authentication steps")); +} + +void AuthFlow::executeTask() +{ + if (m_currentStep) { + return; + } + changeState(AccountTaskState::STATE_WORKING, tr("Initializing")); + nextStep(); +} + +void AuthFlow::nextStep() +{ + if (m_steps.size() == 0) { + // we got to the end without an incident... assume this is all. + m_currentStep.reset(); + succeed(); + return; + } + m_currentStep = m_steps.front(); + qDebug() << "AuthFlow:" << m_currentStep->describe(); + m_steps.pop_front(); + connect(m_currentStep.get(), &AuthStep::finished, this, + &AuthFlow::stepFinished); + connect(m_currentStep.get(), &AuthStep::authorizeWithBrowser, this, + &AuthFlow::authorizeWithBrowser); + + m_currentStep->perform(); +} + +QString AuthFlow::getStateMessage() const +{ + switch (m_taskState) { + case AccountTaskState::STATE_WORKING: { + if (m_currentStep) { + return m_currentStep->describe(); + } else { + return tr("Working..."); + } + } + default: { + return AccountTask::getStateMessage(); + } + } +} + +void AuthFlow::stepFinished(AccountTaskState resultingState, QString message) +{ + if (changeState(resultingState, message)) { + nextStep(); + } +} diff --git a/meshmc/launcher/minecraft/auth/flows/AuthFlow.h b/meshmc/launcher/minecraft/auth/flows/AuthFlow.h new file mode 100644 index 0000000000..0a4a431b71 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/AuthFlow.h @@ -0,0 +1,64 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> +#include <QList> +#include <QVector> +#include <QSet> +#include <QNetworkReply> +#include <QImage> + +#include "minecraft/auth/AccountData.h" +#include "minecraft/auth/AccountTask.h" +#include "minecraft/auth/AuthStep.h" + +class AuthFlow : public AccountTask +{ + Q_OBJECT + + public: + explicit AuthFlow(AccountData* data, QObject* parent = 0); + + Katabasis::Validity validity() + { + return m_data->validity_; + }; + + QString getStateMessage() const override; + + void executeTask() override; + + signals: + // No extra signals needed - authorizeWithBrowser is on AccountTask + + private slots: + void stepFinished(AccountTaskState resultingState, QString message); + + protected: + void succeed(); + void nextStep(); + + protected: + QList<AuthStep::Ptr> m_steps; + AuthStep::Ptr m_currentStep; +}; diff --git a/meshmc/launcher/minecraft/auth/flows/MSA.cpp b/meshmc/launcher/minecraft/auth/flows/MSA.cpp new file mode 100644 index 0000000000..2b5908932a --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/MSA.cpp @@ -0,0 +1,65 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "MSA.h" + +#include "minecraft/auth/steps/MSAStep.h" +#include "minecraft/auth/steps/XboxUserStep.h" +#include "minecraft/auth/steps/XboxAuthorizationStep.h" +#include "minecraft/auth/steps/MeshMCLoginStep.h" +#include "minecraft/auth/steps/XboxProfileStep.h" +#include "minecraft/auth/steps/EntitlementsStep.h" +#include "minecraft/auth/steps/MinecraftProfileStep.h" +#include "minecraft/auth/steps/GetSkinStep.h" + +MSASilent::MSASilent(AccountData* data, QObject* parent) + : AuthFlow(data, parent) +{ + m_steps.append(new MSAStep(m_data, MSAStep::Action::Refresh)); + m_steps.append(new XboxUserStep(m_data)); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken, + "http://xboxlive.com", "Xbox")); + m_steps.append( + new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken, + "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(new MeshMCLoginStep(m_data)); + m_steps.append(new XboxProfileStep(m_data)); + m_steps.append(new EntitlementsStep(m_data)); + m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new GetSkinStep(m_data)); +} + +MSAInteractive::MSAInteractive(AccountData* data, QObject* parent) + : AuthFlow(data, parent) +{ + m_steps.append(new MSAStep(m_data, MSAStep::Action::Login)); + m_steps.append(new XboxUserStep(m_data)); + m_steps.append(new XboxAuthorizationStep(m_data, &m_data->xboxApiToken, + "http://xboxlive.com", "Xbox")); + m_steps.append( + new XboxAuthorizationStep(m_data, &m_data->mojangservicesToken, + "rp://api.minecraftservices.com/", "Mojang")); + m_steps.append(new MeshMCLoginStep(m_data)); + m_steps.append(new XboxProfileStep(m_data)); + m_steps.append(new EntitlementsStep(m_data)); + m_steps.append(new MinecraftProfileStep(m_data)); + m_steps.append(new GetSkinStep(m_data)); +} diff --git a/meshmc/launcher/minecraft/auth/flows/MSA.h b/meshmc/launcher/minecraft/auth/flows/MSA.h new file mode 100644 index 0000000000..cdeb1ff30f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/flows/MSA.h @@ -0,0 +1,37 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 "AuthFlow.h" + +class MSAInteractive : public AuthFlow +{ + Q_OBJECT + public: + explicit MSAInteractive(AccountData* data, QObject* parent = 0); +}; + +class MSASilent : public AuthFlow +{ + Q_OBJECT + public: + explicit MSASilent(AccountData* data, QObject* parent = 0); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp new file mode 100644 index 0000000000..8d0418042a --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.cpp @@ -0,0 +1,80 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "EntitlementsStep.h" + +#include <QNetworkRequest> +#include <QUuid> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +EntitlementsStep::EntitlementsStep(AccountData* data) : AuthStep(data) {} + +EntitlementsStep::~EntitlementsStep() noexcept = default; + +QString EntitlementsStep::describe() +{ + return tr("Determining game ownership."); +} + +void EntitlementsStep::perform() +{ + auto uuid = QUuid::createUuid(); + m_entitlementsRequestId = uuid.toString().remove('{').remove('}'); + auto url = + "https://api.minecraftservices.com/entitlements/license?requestId=" + + m_entitlementsRequestId; + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader( + "Authorization", + QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &EntitlementsStep::onRequestDone); + requestor->get(request); + qDebug() << "Getting entitlements..."; +} + +void EntitlementsStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void EntitlementsStep::onRequestDone( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + + // TODO: check presence of same entitlementsRequestId? + // TODO: validate JWTs? + Parsers::parseMinecraftEntitlements(data, m_data->minecraftEntitlement); + + emit finished(AccountTaskState::STATE_WORKING, tr("Got entitlements")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h new file mode 100644 index 0000000000..bd97a1c59e --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/EntitlementsStep.h @@ -0,0 +1,47 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class EntitlementsStep : public AuthStep +{ + Q_OBJECT + + public: + explicit EntitlementsStep(AccountData* data); + virtual ~EntitlementsStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); + + private: + QString m_entitlementsRequestId; +}; diff --git a/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp new file mode 100644 index 0000000000..abf5db950f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.cpp @@ -0,0 +1,64 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "GetSkinStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +GetSkinStep::GetSkinStep(AccountData* data) : AuthStep(data) {} + +GetSkinStep::~GetSkinStep() noexcept = default; + +QString GetSkinStep::describe() +{ + return tr("Getting skin."); +} + +void GetSkinStep::perform() +{ + auto url = QUrl(m_data->minecraftProfile.skin.url); + QNetworkRequest request = QNetworkRequest(url); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &GetSkinStep::onRequestDone); + requestor->get(request); +} + +void GetSkinStep::rehydrate() +{ + // NOOP, for now. +} + +void GetSkinStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + if (error == QNetworkReply::NoError) { + m_data->minecraftProfile.skin.data = data; + } + emit finished(AccountTaskState::STATE_SUCCEEDED, tr("Got skin")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h new file mode 100644 index 0000000000..ed6a288cdb --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/GetSkinStep.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class GetSkinStep : public AuthStep +{ + Q_OBJECT + + public: + explicit GetSkinStep(AccountData* data); + virtual ~GetSkinStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp b/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp new file mode 100644 index 0000000000..9be4761549 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MSAStep.cpp @@ -0,0 +1,161 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "MSAStep.h" + +#include <QNetworkRequest> +#include <QDesktopServices> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +#include "Application.h" + +MSAStep::MSAStep(AccountData* data, Action action) + : AuthStep(data), m_action(action) +{ + m_replyHandler = new QOAuthHttpServerReplyHandler(this); + m_replyHandler->setCallbackText( + tr("Login successful! You can close this page and return to MeshMC.")); + + m_oauth2 = new QOAuth2AuthorizationCodeFlow(this); + m_oauth2->setClientIdentifier(APPLICATION->msaClientId()); + m_oauth2->setAuthorizationUrl(QUrl( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize")); + m_oauth2->setTokenUrl( + QUrl("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")); + m_oauth2->setScope("XboxLive.signin offline_access"); + m_oauth2->setReplyHandler(m_replyHandler); + m_oauth2->setNetworkAccessManager(APPLICATION->network().get()); + + connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::granted, this, + &MSAStep::onGranted); + connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::requestFailed, this, + &MSAStep::onRequestFailed); + connect(m_oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, + &MSAStep::onOpenBrowser); +} + +MSAStep::~MSAStep() noexcept = default; + +QString MSAStep::describe() +{ + return tr("Logging in with Microsoft account."); +} + +void MSAStep::rehydrate() +{ + switch (m_action) { + case Refresh: { + // TODO: check the tokens and see if they are old (older than a day) + return; + } + case Login: { + // NOOP + return; + } + } +} + +void MSAStep::perform() +{ + switch (m_action) { + case Refresh: { + // Load the refresh token from stored account data + m_oauth2->setRefreshToken(m_data->msaToken.refresh_token); + m_oauth2->refreshTokens(); + return; + } + case Login: { + *m_data = AccountData(); + if (!m_replyHandler->isListening()) { + if (!m_replyHandler->listen(QHostAddress::LocalHost)) { + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Failed to start local HTTP server for " + "OAuth2 callback.")); + return; + } + } + m_oauth2->setModifyParametersFunction( + [](QAbstractOAuth::Stage stage, + QMultiMap<QString, QVariant>* parameters) { + if (stage == + QAbstractOAuth::Stage::RequestingAuthorization) { + parameters->insert("prompt", "select_account"); + } + }); + m_oauth2->grant(); + return; + } + } +} + +void MSAStep::onOpenBrowser(const QUrl& url) +{ + emit authorizeWithBrowser(url); + QDesktopServices::openUrl(url); +} + +void MSAStep::onGranted() +{ + m_replyHandler->close(); + + // Store the tokens in account data + m_data->msaToken.token = m_oauth2->token(); + m_data->msaToken.refresh_token = m_oauth2->refreshToken(); + m_data->msaToken.issueInstant = QDateTime::currentDateTimeUtc(); + m_data->msaToken.notAfter = m_oauth2->expirationAt(); + if (!m_data->msaToken.notAfter.isValid()) { + m_data->msaToken.notAfter = m_data->msaToken.issueInstant.addSecs(3600); + } + m_data->msaToken.validity = Katabasis::Validity::Certain; + + emit finished(AccountTaskState::STATE_WORKING, tr("Got MSA token.")); +} + +void MSAStep::onRequestFailed(QAbstractOAuth::Error error) +{ + m_replyHandler->close(); + + switch (error) { + case QAbstractOAuth::Error::NetworkError: + emit finished( + AccountTaskState::STATE_OFFLINE, + tr("Microsoft authentication failed due to a network error.")); + return; + case QAbstractOAuth::Error::ServerError: + case QAbstractOAuth::Error::OAuthTokenNotFoundError: + case QAbstractOAuth::Error::OAuthTokenSecretNotFoundError: + case QAbstractOAuth::Error::OAuthCallbackNotVerified: + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Microsoft authentication failed.")); + return; + case QAbstractOAuth::Error::ExpiredError: + emit finished(AccountTaskState::STATE_FAILED_GONE, + tr("Microsoft authentication token expired.")); + return; + default: + emit finished(AccountTaskState::STATE_FAILED_HARD, + tr("Microsoft authentication failed with an " + "unrecognized error.")); + return; + } +} diff --git a/meshmc/launcher/minecraft/auth/steps/MSAStep.h b/meshmc/launcher/minecraft/auth/steps/MSAStep.h new file mode 100644 index 0000000000..2e223024e3 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MSAStep.h @@ -0,0 +1,55 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +#include <QOAuth2AuthorizationCodeFlow> +#include <QOAuthHttpServerReplyHandler> + +class MSAStep : public AuthStep +{ + Q_OBJECT + public: + enum Action { Refresh, Login }; + + public: + explicit MSAStep(AccountData* data, Action action); + virtual ~MSAStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onGranted(); + void onRequestFailed(QAbstractOAuth::Error error); + void onOpenBrowser(const QUrl& url); + + private: + QOAuth2AuthorizationCodeFlow* m_oauth2 = nullptr; + QOAuthHttpServerReplyHandler* m_replyHandler = nullptr; + Action m_action; +}; diff --git a/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp new file mode 100644 index 0000000000..19afcda3fc --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.cpp @@ -0,0 +1,98 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "MeshMCLoginStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" +#include "minecraft/auth/AccountTask.h" + +MeshMCLoginStep::MeshMCLoginStep(AccountData* data) : AuthStep(data) {} + +MeshMCLoginStep::~MeshMCLoginStep() noexcept = default; + +QString MeshMCLoginStep::describe() +{ + return tr("Accessing Mojang services."); +} + +void MeshMCLoginStep::perform() +{ + auto requestURL = "https://api.minecraftservices.com/launcher/login"; + auto uhs = m_data->mojangservicesToken.extra["uhs"].toString(); + auto xToken = m_data->mojangservicesToken.token; + + QString mc_auth_template = R"XXX( +{ + "xtoken": "XBL3.0 x=%1;%2", + "platform": "PC_LAUNCHER" +} +)XXX"; + auto requestBody = mc_auth_template.arg(uhs, xToken); + + QNetworkRequest request = QNetworkRequest(QUrl(requestURL)); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &MeshMCLoginStep::onRequestDone); + requestor->post(request, requestBody.toUtf8()); + qDebug() << "Getting Minecraft access token..."; +} + +void MeshMCLoginStep::rehydrate() +{ + // TODO: check the token validity +} + +void MeshMCLoginStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + qDebug() << data; + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; +#ifndef NDEBUG + qDebug() << data; +#endif + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to get Minecraft access token: %1") + .arg(requestor->errorString_)); + return; + } + + if (!Parsers::parseMojangResponse(data, m_data->yggdrasilToken)) { + qWarning() << "Could not parse login_with_xbox response..."; +#ifndef NDEBUG + qDebug() << data; +#endif + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to parse the Minecraft access token response.")); + return; + } + emit finished(AccountTaskState::STATE_WORKING, tr("")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h new file mode 100644 index 0000000000..859ae867f3 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MeshMCLoginStep.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class MeshMCLoginStep : public AuthStep +{ + Q_OBJECT + + public: + explicit MeshMCLoginStep(AccountData* data); + virtual ~MeshMCLoginStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp new file mode 100644 index 0000000000..9955ff9738 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.cpp @@ -0,0 +1,101 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "MinecraftProfileStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +MinecraftProfileStep::MinecraftProfileStep(AccountData* data) : AuthStep(data) +{ +} + +MinecraftProfileStep::~MinecraftProfileStep() noexcept = default; + +QString MinecraftProfileStep::describe() +{ + return tr("Fetching the Minecraft profile."); +} + +void MinecraftProfileStep::perform() +{ + auto url = QUrl("https://api.minecraftservices.com/minecraft/profile"); + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader( + "Authorization", + QString("Bearer %1").arg(m_data->yggdrasilToken.token).toUtf8()); + + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &MinecraftProfileStep::onRequestDone); + requestor->get(request); +} + +void MinecraftProfileStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void MinecraftProfileStep::onRequestDone( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + if (error == QNetworkReply::ContentNotFoundError) { + // NOTE: Succeed even if we do not have a profile. This is a valid + // account state. + m_data->minecraftProfile = MinecraftProfile(); + emit finished(AccountTaskState::STATE_SUCCEEDED, + tr("Account has no Minecraft profile.")); + return; + } + if (error != QNetworkReply::NoError) { + qWarning() << "Error getting profile:"; + qWarning() << " HTTP Status: " << requestor->httpStatus_; + qWarning() << " Internal error no.: " << error; + qWarning() << " Error string: " << requestor->errorString_; + + qWarning() << " Response:"; + qWarning() << QString::fromUtf8(data); + + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile acquisition failed.")); + return; + } + if (!Parsers::parseMinecraftProfile(data, m_data->minecraftProfile)) { + m_data->minecraftProfile = MinecraftProfile(); + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Minecraft Java profile response could not be parsed")); + return; + } + + emit finished(AccountTaskState::STATE_WORKING, + tr("Minecraft Java profile acquisition succeeded.")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h new file mode 100644 index 0000000000..eb0594bdf8 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/MinecraftProfileStep.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class MinecraftProfileStep : public AuthStep +{ + Q_OBJECT + + public: + explicit MinecraftProfileStep(AccountData* data); + virtual ~MinecraftProfileStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp new file mode 100644 index 0000000000..b54ad2a32b --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.cpp @@ -0,0 +1,191 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "XboxAuthorizationStep.h" + +#include <QNetworkRequest> +#include <QJsonParseError> +#include <QJsonDocument> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +XboxAuthorizationStep::XboxAuthorizationStep(AccountData* data, + Katabasis::Token* token, + QString relyingParty, + QString authorizationKind) + : AuthStep(data), m_token(token), m_relyingParty(relyingParty), + m_authorizationKind(authorizationKind) +{ +} + +XboxAuthorizationStep::~XboxAuthorizationStep() noexcept = default; + +QString XboxAuthorizationStep::describe() +{ + return tr("Getting authorization to access %1 services.") + .arg(m_authorizationKind); +} + +void XboxAuthorizationStep::rehydrate() +{ + // FIXME: check if the tokens are good? +} + +void XboxAuthorizationStep::perform() +{ + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [ + "%1" + ] + }, + "RelyingParty": "%2", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = + xbox_auth_template.arg(m_data->userToken.token, m_relyingParty); + // http://xboxlive.com + QNetworkRequest request = + QNetworkRequest(QUrl("https://xsts.auth.xboxlive.com/xsts/authorize")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &XboxAuthorizationStep::onRequestDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "Getting authorization token for " << m_relyingParty; +} + +void XboxAuthorizationStep::onRequestDone( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + +#ifndef NDEBUG + qDebug() << data; +#endif + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + if (!processSTSError(error, data, headers)) { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to get authorization for %1 services. Error %1.") + .arg(m_authorizationKind, error)); + } + return; + } + + Katabasis::Token temp; + if (!Parsers::parseXTokenResponse(data, temp, m_authorizationKind)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Could not parse authorization response for access to " + "%1 services.") + .arg(m_authorizationKind)); + return; + } + + if (temp.extra["uhs"] != m_data->userToken.extra["uhs"]) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Server has changed %1 authorization user hash in the " + "reply. Something is wrong.") + .arg(m_authorizationKind)); + return; + } + auto& token = *m_token; + token = temp; + + emit finished(AccountTaskState::STATE_WORKING, + tr("Got authorization to access %1").arg(m_relyingParty)); +} + +bool XboxAuthorizationStep::processSTSError( + QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + if (error == QNetworkReply::AuthenticationRequiredError) { + QJsonParseError jsonError; + QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); + if (jsonError.error) { + qWarning() << "Cannot parse error XSTS response as JSON: " + << jsonError.errorString(); + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("Cannot parse %1 authorization error response as JSON: %2") + .arg(m_authorizationKind, jsonError.errorString())); + return true; + } + + int64_t errorCode = -1; + auto obj = doc.object(); + if (!Parsers::getNumber(obj.value("XErr"), errorCode)) { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XErr element is missing from %1 authorization " + "error response.") + .arg(m_authorizationKind)); + return true; + } + switch (errorCode) { + case 2148916233: { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account does not have an XBox Live " + "profile. Buy the game on %1 first.") + .arg("<a " + "href=\"https://www.minecraft.net/en-us/store/" + "minecraft-java-edition\">minecraft.net</a>")); + return true; + } + case 2148916235: { + // NOTE: this is the Grulovia error + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XBox Live is not available in your country. " + "You've been blocked.")); + return true; + } + case 2148916238: { + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("This Microsoft account is underaged and is not linked " + "to a family.\n\nPlease set up your account according " + "to %1.") + .arg( + "<a " + "href=\"https://help.minecraft.net/hc/en-us/" + "articles/4403181904525\">help.minecraft.net</a>")); + return true; + } + default: { + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XSTS authentication ended with unrecognized " + "error(s):\n\n%1") + .arg(errorCode)); + return true; + } + } + } + return false; +} diff --git a/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h new file mode 100644 index 0000000000..a8413c939f --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxAuthorizationStep.h @@ -0,0 +1,55 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class XboxAuthorizationStep : public AuthStep +{ + Q_OBJECT + + public: + explicit XboxAuthorizationStep(AccountData* data, Katabasis::Token* token, + QString relyingParty, + QString authorizationKind); + virtual ~XboxAuthorizationStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private: + bool processSTSError(QNetworkReply::NetworkError error, QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers); + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); + + private: + Katabasis::Token* m_token; + QString m_relyingParty; + QString m_authorizationKind; +}; diff --git a/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp new file mode 100644 index 0000000000..aae94b0403 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.cpp @@ -0,0 +1,96 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "XboxProfileStep.h" + +#include <QNetworkRequest> +#include <QUrlQuery> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +XboxProfileStep::XboxProfileStep(AccountData* data) : AuthStep(data) {} + +XboxProfileStep::~XboxProfileStep() noexcept = default; + +QString XboxProfileStep::describe() +{ + return tr("Fetching Xbox profile."); +} + +void XboxProfileStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void XboxProfileStep::perform() +{ + auto url = QUrl("https://profile.xboxlive.com/users/me/profile/settings"); + QUrlQuery q; + q.addQueryItem( + "settings", + "GameDisplayName,AppDisplayName,AppDisplayPicRaw,GameDisplayPicRaw," + "PublicGamerpic,ShowUserAsAvatar,Gamerscore,Gamertag,ModernGamertag," + "ModernGamertagSuffix," + "UniqueModernGamertag,AccountTier,TenureLevel,XboxOneRep," + "PreferredColor,Location,Bio,Watermarks," + "RealName,RealNameOverride,IsQuarantined"); + url.setQuery(q); + + QNetworkRequest request = QNetworkRequest(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + request.setRawHeader("x-xbl-contract-version", "3"); + request.setRawHeader("Authorization", + QString("XBL3.0 x=%1;%2") + .arg(m_data->userToken.extra["uhs"].toString(), + m_data->xboxApiToken.token) + .toUtf8()); + AuthRequest* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &XboxProfileStep::onRequestDone); + requestor->get(request); + qDebug() << "Getting Xbox profile..."; +} + +void XboxProfileStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; +#ifndef NDEBUG + qDebug() << data; +#endif + finished(AccountTaskState::STATE_FAILED_SOFT, + tr("Failed to retrieve the Xbox profile.")); + return; + } + +#ifndef NDEBUG + qDebug() << "XBox profile: " << data; +#endif + + emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox profile")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h new file mode 100644 index 0000000000..cf2c0c3c9b --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxProfileStep.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class XboxProfileStep : public AuthStep +{ + Q_OBJECT + + public: + explicit XboxProfileStep(AccountData* data); + virtual ~XboxProfileStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; diff --git a/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp new file mode 100644 index 0000000000..77afa17fb9 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.cpp @@ -0,0 +1,93 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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/>. + */ + +#include "XboxUserStep.h" + +#include <QNetworkRequest> + +#include "minecraft/auth/AuthRequest.h" +#include "minecraft/auth/Parsers.h" + +XboxUserStep::XboxUserStep(AccountData* data) : AuthStep(data) {} + +XboxUserStep::~XboxUserStep() noexcept = default; + +QString XboxUserStep::describe() +{ + return tr("Logging in as an Xbox user."); +} + +void XboxUserStep::rehydrate() +{ + // NOOP, for now. We only save bools and there's nothing to check. +} + +void XboxUserStep::perform() +{ + QString xbox_auth_template = R"XXX( +{ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": "d=%1" + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" +} +)XXX"; + auto xbox_auth_data = xbox_auth_template.arg(m_data->msaToken.token); + + QNetworkRequest request = QNetworkRequest( + QUrl("https://user.auth.xboxlive.com/user/authenticate")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + auto* requestor = new AuthRequest(this); + connect(requestor, &AuthRequest::finished, this, + &XboxUserStep::onRequestDone); + requestor->post(request, xbox_auth_data.toUtf8()); + qDebug() << "First layer of XBox auth ... commencing."; +} + +void XboxUserStep::onRequestDone(QNetworkReply::NetworkError error, + QByteArray data, + QList<QNetworkReply::RawHeaderPair> headers) +{ + auto requestor = qobject_cast<AuthRequest*>(QObject::sender()); + requestor->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "Reply error:" << error; + emit finished(AccountTaskState::STATE_FAILED_SOFT, + tr("XBox user authentication failed.")); + return; + } + + Katabasis::Token temp; + if (!Parsers::parseXTokenResponse(data, temp, "UToken")) { + qWarning() << "Could not parse user authentication response..."; + emit finished( + AccountTaskState::STATE_FAILED_SOFT, + tr("XBox user authentication response could not be understood.")); + return; + } + m_data->userToken = temp; + emit finished(AccountTaskState::STATE_WORKING, tr("Got Xbox user token")); +} diff --git a/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h new file mode 100644 index 0000000000..d783b534c9 --- /dev/null +++ b/meshmc/launcher/minecraft/auth/steps/XboxUserStep.h @@ -0,0 +1,44 @@ +/* SPDX-FileCopyrightText: 2026 Project Tick + * SPDX-FileContributor: Project Tick + * SPDX-License-Identifier: GPL-3.0-or-later + * + * MeshMC - A Custom Launcher for Minecraft + * Copyright (C) 2026 Project Tick + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 <QObject> + +#include "QObjectPtr.h" +#include "minecraft/auth/AuthStep.h" + +class XboxUserStep : public AuthStep +{ + Q_OBJECT + + public: + explicit XboxUserStep(AccountData* data); + virtual ~XboxUserStep() noexcept; + + void perform() override; + void rehydrate() override; + + QString describe() override; + + private slots: + void onRequestDone(QNetworkReply::NetworkError, QByteArray, + QList<QNetworkReply::RawHeaderPair>); +}; |
