// SPDX-License-Identifier: GPL-3.0-only /* * PolyMC - Minecraft Launcher * Copyright (C) 2022 Sefa Eyeoglu * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . * * This file incorporates work covered by the following copyright and * permission notice: * * Copyright 2013-2021 MultiMC Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #include "AccountList.h" #include "AccountData.h" #include "AccountTask.h" #include #include #include #include #include #include #include #include #include #include #include #include #include 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. We shouldn't let it continue // because of the signal / slot connections after this. if (m_accounts.contains(account)) { qDebug() << "Tried to add account that's already on the accounts list!"; 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) { qDebug() << "Replacing old account with a new one with the same profile ID!"; 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(); qDebug() << "Inserting account at index" << row; 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(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 ProfileNameColumn: { return account->profileName(); } 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("Ready", "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::Disabled: { return tr("Disabled", "Account status"); } case AccountState::Gone: { return tr("Gone", "Account status"); } } } case MigrationColumn: { if(!account->isMojang()) { return tr("N/A", "Can Migrate?"); } if (account->canMigrate()) { return tr("Yes", "Can Migrate?"); } else { return tr("No", "Can Migrate?"); } } default: return QVariant(); } case Qt::ToolTipRole: return account->accountDisplayString(); case PointerRole: return QVariant::fromValue(account); case Qt::CheckStateRole: switch (index.column()) { case ProfileNameColumn: 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 ProfileNameColumn: return tr("Username"); case NameColumn: return tr("Account"); case TypeColumn: return tr("Type"); case StatusColumn: return tr("Status"); case MigrationColumn: return tr("Can Migrate?"); default: return QVariant(); } case Qt::ToolTipRole: switch (section) { case ProfileNameColumn: return tr("Minecraft username associated with the account."); case NameColumn: return tr("User name of the account."); case TypeColumn: return tr("Type of the account - Mojang or MSA."); case StatusColumn: return tr("Current status of the account."); case MigrationColumn: return tr("Can this account migrate to a Microsoft 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 Mojang 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::MojangOnly: { return loadV2(root); } break; 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::loadV2(QJsonObject& root) { beginResetModel(); auto defaultUserName = root.value("activeAccount").toString(""); QJsonArray accounts = root.value("accounts").toArray(); for (QJsonValue accountVal : accounts) { QJsonObject accountObj = accountVal.toObject(); MinecraftAccountPtr account = MinecraftAccount::loadFromJsonV2(accountObj); if (account.get() != nullptr) { auto profileId = account->profileId(); if(!profileId.size()) { continue; } 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 (defaultUserName.size() && account->mojangUserName() == defaultUserName) { m_defaultAccount = account; } } else { qWarning() << "Failed to load an account."; } } endResetModel(); return true; } 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 Mojang 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); } }