Files
minecraft-launcher/launcherbackend.cpp
T

987 lines
37 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "launcherbackend.h"
#include "authservice.h"
#include "gamelauncher.h"
#include "javalocator.h"
#include "launcherpaths.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHash>
#include <QRegularExpression>
#include <QSet>
#include <QTimer>
#include <QUrl>
#include <algorithm>
namespace {
const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby");
int settingInt(const QVariantMap &settings, const QString &key, int fallback)
{
const QVariant value = settings.value(key);
bool ok = false;
const int parsed = value.toInt(&ok);
return ok ? parsed : fallback;
}
} // namespace
LauncherBackend::LauncherBackend(QObject *parent)
: QObject(parent)
, m_auth(new AuthService(this))
, m_launcher(new GameLauncher(this))
, m_manifest(new VersionManifestService(this))
, m_installer(new VersionInstaller(m_manifest, this))
{
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
// следующем — если её удалили, она появится снова.
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError))
m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError);
loadSettings();
loadData();
ensureDataFiles();
connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
emit gameOutput(commandLine);
emit gameRunningChanged();
});
connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) {
emit gameRunningChanged();
emit gameFinished(exitCode, crashed);
});
connect(m_manifest, &VersionManifestService::versionsChanged,
this, &LauncherBackend::invalidateCatalog);
connect(m_manifest, &VersionManifestService::refreshingChanged,
this, &LauncherBackend::catalogLoadingChanged);
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1»…").arg(id));
});
connect(m_installer, &VersionInstaller::progressChanged,
this, &LauncherBackend::downloadChanged);
// Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог,
// и устаревший кэш вернулся бы наружу.
connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Версия «%1» загружена").arg(id));
});
connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
const QString &message) {
// Часть файлов всё же появилась — список установленного мог измениться.
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
});
connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
invalidateCatalog();
emit installedVersionsChanged();
emit downloadChanged();
emit busyChanged();
emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
});
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
if (!m_storageIssues.isEmpty()) {
QTimer::singleShot(0, this, [this] {
emit launchError(m_storageIssues.join(u'\n'));
});
}
}
LauncherBackend::~LauncherBackend() = default;
QStringList LauncherBackend::profileNames() const
{
QStringList names;
for (const auto &p : m_profiles)
names << p.name;
return names;
}
QStringList LauncherBackend::versionNames() const
{
QStringList names;
for (const auto &v : m_versions)
names << v.name;
return names;
}
QStringList LauncherBackend::installedVersions() const
{
return VersionLoader::installedVersions(gameDir());
}
bool LauncherBackend::gameRunning() const
{
return m_launcher->isRunning();
}
void LauncherBackend::addProfile(const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
Profile profile;
profile.id = m_nextProfileId++;
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::addVersion(const QString &name, const QString &serverUrl, const QString &versionId)
{
m_versions.append({m_nextVersionId++, name, serverUrl, versionId});
saveVersions();
emit versionsChanged();
maybeInstall(versionIdOf(m_versions.last()));
}
void LauncherBackend::updateProfile(int index,
const QString &name,
const QString &login,
const QString &password,
const QString &authType)
{
if (index < 0 || index >= m_profiles.size())
return;
Profile &profile = m_profiles[index];
// Смена логина или пароля обесценивает сохранённый токен Ely.by.
if (profile.login != login || profile.password != password) {
profile.accessToken.clear();
profile.uuid.clear();
profile.resolvedName.clear();
}
profile.name = name;
profile.login = login;
profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::updateVersion(int index,
const QString &name,
const QString &serverUrl,
const QString &versionId)
{
if (index < 0 || index >= m_versions.size())
return;
m_versions[index].name = name;
m_versions[index].serverUrl = serverUrl;
m_versions[index].versionId = versionId;
saveVersions();
emit versionsChanged();
maybeInstall(versionIdOf(m_versions[index]));
}
QVariantMap LauncherBackend::profileAt(int index) const
{
if (index < 0 || index >= m_profiles.size())
return {};
const auto &p = m_profiles[index];
return {{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType}};
}
QVariantMap LauncherBackend::versionAt(int index) const
{
if (index < 0 || index >= m_versions.size())
return {};
const auto &v = m_versions[index];
return {{"name", v.name}, {"serverUrl", v.serverUrl}, {"versionId", versionIdOf(v)}};
}
void LauncherBackend::removeProfile(int index)
{
if (index < 0 || index >= m_profiles.size())
return;
m_profiles.removeAt(index);
saveProfiles();
emit profilesChanged();
}
void LauncherBackend::removeVersion(int index)
{
if (index < 0 || index >= m_versions.size())
return;
m_versions.removeAt(index);
saveVersions();
emit versionsChanged();
}
QString LauncherBackend::versionIdOf(const Version &version) const
{
// Пользователь мог назвать запись как угодно — тогда версией считаем само имя.
return version.versionId.isEmpty() ? version.name : version.versionId;
}
QStringList LauncherBackend::checkInstallation(int versionIndex) const
{
if (versionIndex < 0 || versionIndex >= m_versions.size())
return {tr("Версия не выбрана")};
const Version &entry = m_versions[versionIndex];
const QString id = versionIdOf(entry);
if (id.trimmed().isEmpty())
return {tr("У записи лаунчера не указан ID версии. Проверьте %1")
.arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))};
QString error;
const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
if (!version.isValid()) {
return {error.isEmpty()
? tr("Версия «%1» из записи «%2» не установлена в %3")
.arg(id,
entry.name,
QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
: error};
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = id;
return GameLauncher::missingFiles(options, version);
}
// ── Каталог версий ─────────────────────────────────────────────────────────
void LauncherBackend::invalidateCatalog()
{
m_catalogDirty = true;
emit versionCatalogChanged();
}
bool LauncherBackend::catalogLoading() const
{
return m_manifest->isRefreshing();
}
void LauncherBackend::refreshVersionCatalog(bool force)
{
// Список установленного мог поменяться и без нас (папку добавили руками).
invalidateCatalog();
m_manifest->ensureLoaded([this](bool ok, const QString &warning) {
if (!ok && !warning.isEmpty())
emit launchError(warning);
}, force);
}
bool LauncherBackend::isVersionInstalled(const QString &versionId) const
{
if (versionId.trimmed().isEmpty())
return false;
return VersionLoader::installedVersions(gameDir()).contains(versionId);
}
QVariantList LauncherBackend::versionCatalog() const
{
if (m_catalogDirty) {
rebuildCatalog();
m_catalogDirty = false;
}
return m_catalog;
}
void LauncherBackend::rebuildCatalog() const
{
m_catalog.clear();
const QList<RemoteVersionEntry> remote = m_manifest->versions();
QHash<QString, int> remoteIndex;
remoteIndex.reserve(remote.size());
for (int i = 0; i < remote.size(); ++i)
remoteIndex.insert(remote.at(i).id, i);
const auto makeVersionRow = [](const QString &id, const QString &type, bool installed) {
return QVariantMap{{QStringLiteral("kind"),
installed ? QStringLiteral("installed") : QStringLiteral("remote")},
{QStringLiteral("id"), id},
{QStringLiteral("type"), type},
{QStringLiteral("label"), id},
{QStringLiteral("installed"), installed},
{QStringLiteral("selectable"), true},
{QStringLiteral("search"), (id + u' ' + type).toLower()}};
};
// Установленные: сначала знакомые манифесту, свежие сверху, затем сборки
// вроде fabric-loader-…, о которых манифест ничего не знает.
QStringList installed = VersionLoader::installedVersions(gameDir());
std::stable_sort(installed.begin(), installed.end(),
[&remoteIndex, &remote](const QString &a, const QString &b) {
const auto ia = remoteIndex.constFind(a);
const auto ib = remoteIndex.constFind(b);
const bool knownA = ia != remoteIndex.constEnd();
const bool knownB = ib != remoteIndex.constEnd();
if (knownA != knownB)
return knownA;
if (knownA)
return ia.value() < ib.value(); // манифест уже отсортирован
return a < b;
});
QSet<QString> installedSet;
for (const QString &id : std::as_const(installed)) {
const auto it = remoteIndex.constFind(id);
const QString type = it != remoteIndex.constEnd() ? remote.at(it.value()).type : QString();
installedSet.insert(id);
m_catalog.append(makeVersionRow(id, type, true));
}
m_catalog.append(QVariantMap{{QStringLiteral("kind"), QStringLiteral("separator")},
{QStringLiteral("id"), QString()},
{QStringLiteral("type"), QString()},
{QStringLiteral("label"), tr("Все версии Minecraft")},
{QStringLiteral("installed"), false},
{QStringLiteral("selectable"), false},
{QStringLiteral("search"), QString()}});
if (remote.isEmpty()) {
m_catalog.append(
QVariantMap{{QStringLiteral("kind"), QStringLiteral("status")},
{QStringLiteral("id"), QString()},
{QStringLiteral("type"), QString()},
{QStringLiteral("label"),
m_manifest->isRefreshing()
? tr("Загрузка списка версий…")
: tr("Список версий недоступен — проверьте соединение")},
{QStringLiteral("installed"), false},
{QStringLiteral("selectable"), false},
{QStringLiteral("search"), QString()}});
return;
}
for (const RemoteVersionEntry &entry : remote) {
if (installedSet.contains(entry.id))
continue;
m_catalog.append(makeVersionRow(entry.id, entry.type, false));
}
}
// ── Загрузка версий ────────────────────────────────────────────────────────
void LauncherBackend::maybeInstall(const QString &versionId)
{
const QString id = versionId.trimmed();
if (id.isEmpty() || isVersionInstalled(id))
return;
if (m_installer->versionId() == id || m_installer->isQueued(id))
return;
// Манифест здесь не спрашиваем: установщик сам скажет, что версии в нём нет,
// и это правильный ответ на вручную вписанный id сборки.
m_installer->install(gameDir(), id);
}
void LauncherBackend::installVersion(const QString &versionId)
{
const QString id = versionId.trimmed();
if (id.isEmpty())
return;
if (m_installer->versionId() == id || m_installer->isQueued(id))
return;
m_installer->install(gameDir(), id);
}
void LauncherBackend::cancelDownload()
{
m_installer->cancel();
}
bool LauncherBackend::downloading() const
{
return m_installer->isRunning();
}
double LauncherBackend::downloadProgress() const
{
return m_installer->fraction();
}
QString LauncherBackend::downloadVersion() const
{
return m_installer->versionId();
}
QString LauncherBackend::downloadStatus() const
{
const QString stage = m_installer->stage();
const QString file = m_installer->currentFile();
if (stage.isEmpty())
return file;
if (file.isEmpty())
return stage;
return QStringLiteral("%1 — %2").arg(stage, file);
}
qint64 LauncherBackend::downloadBytesDone() const
{
return m_installer->bytesDone();
}
qint64 LauncherBackend::downloadBytesTotal() const
{
return m_installer->bytesTotal();
}
void LauncherBackend::launchGame(int profileIndex, int versionIndex)
{
if (m_installer->isRunning()) {
emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания")
.arg(m_installer->versionId()));
return;
}
if (m_busy) {
emit launchError(tr("Запуск уже выполняется"));
return;
}
if (m_launcher->isRunning()) {
emit launchError(tr("Игра уже запущена"));
return;
}
if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
emit launchError(tr("Выберите профиль перед запуском"));
return;
}
if (versionIndex < 0 || versionIndex >= m_versions.size()) {
emit launchError(tr("Выберите версию перед запуском"));
return;
}
// Дальше проверяется только папка игры: свои файлы лаунчер уже разложил
// в galeonLauncher при запуске.
const QString directory = gameDir();
if (!QDir(directory).exists()) {
emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.")
.arg(QDir::toNativeSeparators(directory)));
return;
}
const Version &entry = m_versions[versionIndex];
const QString id = versionIdOf(entry);
if (id.trimmed().isEmpty()) {
emit launchError(tr("У записи лаунчера не указан ID версии. Проверьте %1")
.arg(QDir::toNativeSeparators(LauncherPaths::versionsFile())));
return;
}
const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions"));
const QStringList installed = VersionLoader::installedVersions(directory);
if (!installed.contains(id)) {
emit launchError(installed.isEmpty()
? tr("В %1 нет ни одной установленной версии").arg(versionsDir)
: tr("Версия «%1» из записи «%2» не установлена в %3. Доступны: %4")
.arg(id,
entry.name,
versionsDir,
installed.join(QStringLiteral(", "))));
return;
}
setBusy(true);
emit launchProgress(tr("Чтение описания версии «%1»…").arg(id));
QSet<QString> features;
if (settingInt(m_settings, QStringLiteral("windowWidth"), 0) > 0
&& settingInt(m_settings, QStringLiteral("windowHeight"), 0) > 0)
features << QStringLiteral("has_custom_resolution");
QString error;
m_pendingVersionData = VersionLoader::load(directory, id, features, &error);
if (!m_pendingVersionData.isValid()) {
failLaunch(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(id) : error);
return;
}
LaunchOptions probe;
probe.gameDir = directory;
probe.versionId = id;
const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData);
if (!missing.isEmpty()) {
failLaunch(tr("В папке игры не хватает файлов (%1):\n%2")
.arg(missing.size())
.arg(missing.join(u'\n')));
return;
}
m_pendingProfile = profileIndex;
m_pendingVersion = versionIndex;
m_pendingInjectorPath.clear();
beginAuthentication();
}
void LauncherBackend::beginAuthentication()
{
const Profile &profile = m_profiles[m_pendingProfile];
if (profile.authType != kElyBy) {
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
const AuthResult result = AuthService::offline(nickname);
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
return;
}
// Ely.by работает только вместе с authlib-injector — без него игра пойдёт
// в сессионный сервер Mojang и токен окажется недействительным.
m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
[this](const QString &path, const QString &error) {
if (m_pendingProfile < 0)
return; // запуск отменили, пока качался инжектор
if (!error.isEmpty()) {
failLaunch(error);
return;
}
m_pendingInjectorPath = path;
const Profile &pending = m_profiles[m_pendingProfile];
m_auth->loginElyBy(pending.login,
pending.password,
pending.clientToken,
pending.accessToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (result.twoFactorRequired) {
setBusy(false);
emit twoFactorRequired(m_profiles[m_pendingProfile].name);
return;
}
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
});
}
void LauncherBackend::submitTwoFactorCode(const QString &code)
{
if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) {
emit launchError(tr("Запуск уже отменён"));
return;
}
if (code.trimmed().isEmpty()) {
emit launchError(tr("Введите код двухфакторной аутентификации"));
return;
}
setBusy(true);
const Profile &profile = m_profiles[m_pendingProfile];
m_auth->loginElyByWithTotp(profile.login,
profile.password,
code.trimmed(),
profile.clientToken,
[this](const AuthResult &result) {
if (m_pendingProfile < 0)
return;
if (!result.ok) {
failLaunch(result.error);
return;
}
continueLaunch(result);
});
}
void LauncherBackend::cancelPendingLaunch()
{
m_pendingProfile = -1;
m_pendingVersion = -1;
m_pendingInjectorPath.clear();
setBusy(false);
}
void LauncherBackend::continueLaunch(const AuthResult &auth)
{
if (m_pendingProfile < 0 || m_pendingVersion < 0) {
failLaunch(tr("Запуск был отменён"));
return;
}
Profile &profile = m_profiles[m_pendingProfile];
const Version &version = m_versions[m_pendingVersion];
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
// чтобы в следующий раз обойтись без пароля.
if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
profile.accessToken = auth.accessToken;
profile.clientToken = auth.clientToken;
if (!auth.uuid.isEmpty())
profile.uuid = auth.uuid;
if (!auth.playerName.isEmpty())
profile.resolvedName = auth.playerName;
profile.userType = auth.userType;
saveProfiles();
}
LaunchOptions options;
options.gameDir = gameDir();
options.versionId = m_pendingVersionData.id;
options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName;
if (options.playerName.isEmpty())
options.playerName = profile.login.isEmpty() ? profile.name : profile.login;
options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid;
options.accessToken = auth.accessToken;
options.clientToken = auth.clientToken;
options.userType = auth.userType;
options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512);
options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096);
options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
options.serverAddress = version.serverUrl.trimmed();
options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();
const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
if (!extra.isEmpty())
options.extraJvmArgs = extra.split(QRegularExpression(QStringLiteral("\\s+")),
Qt::SkipEmptyParts);
if (profile.authType == kElyBy)
options.authlibInjectorPath = m_pendingInjectorPath;
QString error;
const bool started = m_launcher->launch(options, m_pendingVersionData, &error);
const QString profileName = profile.name;
const QString versionName = version.name;
const QString serverUrl = version.serverUrl;
cancelPendingLaunch();
if (!started) {
emit launchError(error);
return;
}
emit gameRunningChanged();
emit launched(profileName, versionName, serverUrl);
}
void LauncherBackend::stopGame()
{
m_launcher->terminate();
}
void LauncherBackend::failLaunch(const QString &message)
{
cancelPendingLaunch();
emit launchError(message);
}
bool LauncherBackend::busy() const
{
return m_busy || m_installer->isRunning();
}
void LauncherBackend::setBusy(bool busy)
{
if (m_busy == busy)
return;
m_busy = busy;
emit busyChanged();
}
QString LauncherBackend::gameDir() const
{
const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed();
return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
: QDir::fromNativeSeparators(configured);
}
QVariantMap LauncherBackend::settings() const
{
QVariantMap result = m_settings;
result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir());
return result;
}
void LauncherBackend::updateSettings(const QVariantMap &values)
{
const QString previousGameDir = gameDir();
for (auto it = values.cbegin(); it != values.cend(); ++it)
m_settings.insert(it.key(), it.value());
saveSettings();
emit settingsChanged();
if (gameDir() != previousGameDir) {
invalidateCatalog();
emit installedVersionsChanged();
}
}
QStringList LauncherBackend::detectedJava() const
{
QStringList result;
const QStringList candidates = JavaLocator::findAll(gameDir());
for (const QString &candidate : candidates) {
const int major = JavaLocator::majorVersion(candidate);
result << (major > 0 ? QStringLiteral("Java %1 — %2")
.arg(major)
.arg(QDir::toNativeSeparators(candidate))
: QDir::toNativeSeparators(candidate));
}
return result;
}
void LauncherBackend::openMinecraftFolder()
{
const QString modsDir = gameDir() + QStringLiteral("/mods");
QDir().mkpath(modsDir);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir)))
emit launchError(tr("Не удалось открыть папку с модами"));
}
void LauncherBackend::openGameFolder()
{
const QString directory = gameDir();
QDir().mkpath(directory);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
emit launchError(tr("Не удалось открыть папку игры"));
}
// Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти
// руками, а ensureDataFiles() создал на его месте пустой.
bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason)
{
const QString backup = path + QStringLiteral(".bak");
QFile::remove(backup);
if (!QFile::rename(path, backup)) {
m_storageIssues << tr("Файл %1 повреждён (%2), и его не удалось отложить в сторону")
.arg(QDir::toNativeSeparators(path), reason);
return false;
}
m_storageIssues << tr("Файл %1 повреждён (%2). Копия сохранена как %3, файл создан заново.")
.arg(QDir::toNativeSeparators(path),
reason,
QDir::toNativeSeparators(backup));
return true;
}
QJsonArray LauncherBackend::readArrayFile(const QString &path)
{
QFile file(path);
if (!file.exists())
return {}; // первый запуск — это не ошибка
if (!file.open(QIODevice::ReadOnly)) {
m_storageIssues << tr("Не удалось прочитать %1: %2")
.arg(QDir::toNativeSeparators(path), file.errorString());
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
file.close();
moveAsideBroken(path, parseError.errorString());
return {};
}
if (!document.isArray()) {
file.close();
moveAsideBroken(path, tr("ожидался список записей"));
return {};
}
return document.array();
}
QJsonObject LauncherBackend::readObjectFile(const QString &path)
{
QFile file(path);
if (!file.exists())
return {};
if (!file.open(QIODevice::ReadOnly)) {
m_storageIssues << tr("Не удалось прочитать %1: %2")
.arg(QDir::toNativeSeparators(path), file.errorString());
return {};
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
file.close();
moveAsideBroken(path, parseError.errorString());
return {};
}
if (!document.isObject()) {
file.close();
moveAsideBroken(path, tr("ожидался набор настроек"));
return {};
}
return document.object();
}
// Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить
// уже после запуска, и тогда данные молча пропадали бы.
bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document)
{
QString storageError;
if (!LauncherPaths::ensureRootExists(&storageError)) {
emit launchError(tr("Не удалось создать папку лаунчера: %1").arg(storageError));
return false;
}
const QByteArray payload = document.toJson();
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
emit launchError(tr("Не удалось сохранить %1: %2")
.arg(QDir::toNativeSeparators(path), file.errorString()));
return false;
}
return true;
}
void LauncherBackend::loadData()
{
auto loadFile = [this](const QString &path, auto handler) {
for (const auto &val : readArrayFile(path))
handler(val.toObject());
};
loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) {
Profile profile;
profile.id = o["id"].toInt();
profile.name = o["name"].toString();
profile.login = o["login"].toString();
profile.password = o["password"].toString();
profile.authType = o.value("authType").toString(kOffline);
profile.clientToken = o.value("clientToken").toString();
profile.accessToken = o.value("accessToken").toString();
profile.uuid = o.value("uuid").toString();
profile.resolvedName = o.value("resolvedName").toString();
profile.userType = o.value("userType").toString();
if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile);
if (profile.id >= m_nextProfileId)
m_nextProfileId = profile.id + 1;
});
std::sort(m_profiles.begin(), m_profiles.end(),
[](const Profile &a, const Profile &b) { return a.id < b.id; });
int skippedVersions = 0;
loadFile(LauncherPaths::versionsFile(), [this, &skippedVersions](const QJsonObject &o) {
const int id = o["id"].toInt();
const QString name = o["name"].toString();
const QString versionId = o.value("versionId").toString();
// Без имени и без ID запись нерабочая: versionIdOf() вернёт пустую строку.
if (name.trimmed().isEmpty() && versionId.trimmed().isEmpty()) {
++skippedVersions;
return;
}
m_versions.append({id, name, o["serverUrl"].toString(), versionId});
if (id >= m_nextVersionId)
m_nextVersionId = id + 1;
});
if (skippedVersions > 0) {
m_storageIssues << tr("В %1 пропущены записи без имени и ID версии: %2")
.arg(QDir::toNativeSeparators(LauncherPaths::versionsFile()))
.arg(skippedVersions);
}
std::sort(m_versions.begin(), m_versions.end(),
[](const Version &a, const Version &b) { return a.id < b.id; });
}
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles()
{
if (!QFile::exists(LauncherPaths::settingsFile()))
saveSettings();
if (!QFile::exists(LauncherPaths::profilesFile()))
saveProfiles();
if (!QFile::exists(LauncherPaths::versionsFile()))
saveVersions();
}
void LauncherBackend::saveProfiles()
{
QJsonArray arr;
for (const auto &p : m_profiles)
arr.append(QJsonObject{{"id", p.id},
{"name", p.name},
{"login", p.login},
{"password", p.password},
{"authType", p.authType},
{"clientToken", p.clientToken},
{"accessToken", p.accessToken},
{"uuid", p.uuid},
{"resolvedName", p.resolvedName},
{"userType", p.userType}});
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
}
void LauncherBackend::saveVersions()
{
QJsonArray arr;
for (const auto &v : m_versions)
arr.append(QJsonObject{{"id", v.id},
{"name", v.name},
{"serverUrl", v.serverUrl},
{"versionId", v.versionId}});
writeJsonFile(LauncherPaths::versionsFile(), QJsonDocument(arr));
}
void LauncherBackend::loadSettings()
{
m_settings = {{QStringLiteral("gameDir"), QString()},
{QStringLiteral("javaPath"), QString()},
{QStringLiteral("minMemoryMb"), 512},
{QStringLiteral("maxMemoryMb"), 4096},
{QStringLiteral("jvmArgs"), QString()},
{QStringLiteral("windowWidth"), 0},
{QStringLiteral("windowHeight"), 0},
{QStringLiteral("fullscreen"), false},
{QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
{QStringLiteral("launcherVersion"), QStringLiteral("1.0")}};
const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
m_settings.insert(it.key(), it.value().toVariant());
}
void LauncherBackend::saveSettings()
{
writeJsonFile(LauncherPaths::settingsFile(),
QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
}