diff --git a/CMakeLists.txt b/CMakeLists.txt index 490c7f6..db6fa0a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,8 @@ qt_add_qml_module(appMinecraft_launcher SOURCES launcherbackend.h launcherbackend.cpp + launcherpaths.h + launcherpaths.cpp minecraftversion.h minecraftversion.cpp javalocator.h diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 5614398..64f0bb5 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -3,6 +3,7 @@ #include "authservice.h" #include "gamelauncher.h" #include "javalocator.h" +#include "launcherpaths.h" #include #include @@ -11,7 +12,7 @@ #include #include #include -#include +#include #include #include @@ -32,13 +33,18 @@ int settingInt(const QVariantMap &settings, const QString &key, int fallback) LauncherBackend::LauncherBackend(QObject *parent) : QObject(parent) - , m_dataDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) , m_auth(new AuthService(this)) , m_launcher(new GameLauncher(this)) { - QDir().mkpath(m_dataDir); + // Папка лаунчера создаётся при первом запуске и проверяется при каждом + // следующем — если её удалили, она появится снова. + 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); @@ -51,6 +57,13 @@ LauncherBackend::LauncherBackend(QObject *parent) emit gameRunningChanged(); emit gameFinished(exitCode, crashed); }); + + // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно. + if (!m_storageIssues.isEmpty()) { + QTimer::singleShot(0, this, [this] { + emit launchError(m_storageIssues.join(u'\n')); + }); + } } LauncherBackend::~LauncherBackend() = default; @@ -194,11 +207,22 @@ QStringList LauncherBackend::checkInstallation(int versionIndex) const if (versionIndex < 0 || versionIndex >= m_versions.size()) return {tr("Версия не выбрана")}; - const QString id = versionIdOf(m_versions[versionIndex]); + 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» не установлена").arg(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(); @@ -225,20 +249,33 @@ void LauncherBackend::launchGame(int profileIndex, int versionIndex) return; } + // Дальше проверяется только папка игры: свои файлы лаунчер уже разложил + // в galeonLauncher при запуске. const QString directory = gameDir(); if (!QDir(directory).exists()) { - emit launchError(tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(directory))); + emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.") + .arg(QDir::toNativeSeparators(directory))); return; } - const QString id = versionIdOf(m_versions[versionIndex]); + 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(QDir::toNativeSeparators(directory + QStringLiteral("/versions"))) - : tr("Версия «%1» не установлена. Доступны: %2") - .arg(id, installed.join(QStringLiteral(", ")))); + ? tr("В %1 нет ни одной установленной версии").arg(versionsDir) + : tr("Версия «%1» из записи «%2» не установлена в %3. Доступны: %4") + .arg(id, + entry.name, + versionsDir, + installed.join(QStringLiteral(", ")))); return; } @@ -291,7 +328,8 @@ void LauncherBackend::beginAuthentication() // Ely.by работает только вместе с authlib-injector — без него игра пойдёт // в сессионный сервер Mojang и токен окажется недействительным. - m_auth->ensureAuthlibInjector(m_dataDir, [this](const QString &path, const QString &error) { + m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(), + [this](const QString &path, const QString &error) { if (m_pendingProfile < 0) return; // запуск отменили, пока качался инжектор if (!error.isEmpty()) { @@ -445,21 +483,11 @@ void LauncherBackend::setBusy(bool busy) emit busyChanged(); } -QString LauncherBackend::defaultMinecraftDir() const -{ -#if defined(Q_OS_WIN) - return QDir::homePath() + QStringLiteral("/AppData/Roaming/.minecraft"); -#elif defined(Q_OS_MACOS) - return QDir::homePath() + QStringLiteral("/Library/Application Support/minecraft"); -#else // Linux и прочие Unix-системы - return QDir::homePath() + QStringLiteral("/.minecraft"); -#endif -} - QString LauncherBackend::gameDir() const { const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed(); - return configured.isEmpty() ? defaultMinecraftDir() : QDir::fromNativeSeparators(configured); + return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir() + : QDir::fromNativeSeparators(configured); } QVariantMap LauncherBackend::settings() const @@ -512,17 +540,105 @@ void LauncherBackend::openGameFolder() 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 = [](const QString &path, auto handler) { - QFile f(path); - if (!f.open(QIODevice::ReadOnly)) - return; - for (const auto &val : QJsonDocument::fromJson(f.readAll()).array()) + auto loadFile = [this](const QString &path, auto handler) { + for (const auto &val : readArrayFile(path)) handler(val.toObject()); }; - loadFile(m_dataDir + "/profiles.json", [this](const QJsonObject &o) { + loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) { Profile profile; profile.id = o["id"].toInt(); profile.name = o["name"].toString(); @@ -543,17 +659,41 @@ void LauncherBackend::loadData() std::sort(m_profiles.begin(), m_profiles.end(), [](const Profile &a, const Profile &b) { return a.id < b.id; }); - loadFile(m_dataDir + "/versions.json", [this](const QJsonObject &o) { + int skippedVersions = 0; + loadFile(LauncherPaths::versionsFile(), [this, &skippedVersions](const QJsonObject &o) { const int id = o["id"].toInt(); - m_versions.append({id, o["name"].toString(), o["serverUrl"].toString(), - o.value("versionId").toString()}); + 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; @@ -569,9 +709,7 @@ void LauncherBackend::saveProfiles() {"resolvedName", p.resolvedName}, {"userType", p.userType}}); - QFile f(m_dataDir + "/profiles.json"); - if (f.open(QIODevice::WriteOnly)) - f.write(QJsonDocument(arr).toJson()); + writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr)); } void LauncherBackend::saveVersions() @@ -583,9 +721,7 @@ void LauncherBackend::saveVersions() {"serverUrl", v.serverUrl}, {"versionId", v.versionId}}); - QFile f(m_dataDir + "/versions.json"); - if (f.open(QIODevice::WriteOnly)) - f.write(QJsonDocument(arr).toJson()); + writeJsonFile(LauncherPaths::versionsFile(), QJsonDocument(arr)); } void LauncherBackend::loadSettings() @@ -601,18 +737,13 @@ void LauncherBackend::loadSettings() {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}}; - QFile f(m_dataDir + "/settings.json"); - if (!f.open(QIODevice::ReadOnly)) - return; - - const QJsonObject stored = QJsonDocument::fromJson(f.readAll()).object(); + 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() { - QFile f(m_dataDir + "/settings.json"); - if (f.open(QIODevice::WriteOnly)) - f.write(QJsonDocument(QJsonObject::fromVariantMap(m_settings)).toJson()); + writeJsonFile(LauncherPaths::settingsFile(), + QJsonDocument(QJsonObject::fromVariantMap(m_settings))); } diff --git a/launcherbackend.h b/launcherbackend.h index 5d4d5ac..099a40c 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -2,6 +2,9 @@ #include "minecraftversion.h" +#include +#include +#include #include #include #include @@ -101,11 +104,20 @@ private: void loadData(); void loadSettings(); + void ensureDataFiles(); void saveProfiles(); void saveVersions(); void saveSettings(); - QString defaultMinecraftDir() const; + // Чтение файлов лаунчера. Отсутствие файла — норма (первый запуск), а вот + // повреждённое содержимое отводится в .bak, чтобы файл создался заново. + QJsonArray readArrayFile(const QString &path); + QJsonObject readObjectFile(const QString &path); + bool moveAsideBroken(const QString &path, const QString &reason); + + // Проверяет папку лаунчера и пишет документ. Об ошибке сообщает через launchError. + bool writeJsonFile(const QString &path, const QJsonDocument &document); + QString gameDir() const; QString versionIdOf(const Version &version) const; @@ -117,7 +129,9 @@ private: QList m_profiles; QList m_versions; QVariantMap m_settings; - QString m_dataDir; + // Проблемы хранилища, замеченные на старте: показываются одним сообщением, + // когда интерфейс уже подключился к сигналам. + QStringList m_storageIssues; int m_nextProfileId = 1; int m_nextVersionId = 1; diff --git a/launcherpaths.cpp b/launcherpaths.cpp new file mode 100644 index 0000000..d460112 --- /dev/null +++ b/launcherpaths.cpp @@ -0,0 +1,84 @@ +#include "launcherpaths.h" + +#include +#include +#include + +namespace { + +const QString kFolderName = QStringLiteral("galeonLauncher"); + +QString translate(const char *text) +{ + return QCoreApplication::translate("LauncherPaths", text); +} + +} // namespace + +QString LauncherPaths::containerDir() +{ +#if defined(Q_OS_WIN) + return QDir::homePath() + QStringLiteral("/AppData/Roaming"); +#elif defined(Q_OS_MACOS) + return QDir::homePath() + QStringLiteral("/Library/Application Support"); +#else // Linux и прочие Unix-системы + return QDir::homePath(); +#endif +} + +QString LauncherPaths::rootDir() +{ + return containerDir() + u'/' + kFolderName; +} + +QString LauncherPaths::defaultMinecraftDir() +{ +#if defined(Q_OS_MACOS) + // На macOS папка игры исторически без точки. + return containerDir() + QStringLiteral("/minecraft"); +#else + return containerDir() + QStringLiteral("/.minecraft"); +#endif +} + +QString LauncherPaths::settingsFile() +{ + return rootDir() + QStringLiteral("/settings.json"); +} + +QString LauncherPaths::profilesFile() +{ + return rootDir() + QStringLiteral("/profiles.json"); +} + +QString LauncherPaths::versionsFile() +{ + return rootDir() + QStringLiteral("/versions.json"); +} + +QString LauncherPaths::runtimeDir() +{ + return rootDir() + QStringLiteral("/runtime"); +} + +bool LauncherPaths::ensureRootExists(QString *error) +{ + const QStringList required{rootDir(), runtimeDir()}; + for (const QString &path : required) { + if (!QDir().mkpath(path)) { + if (error) + *error = translate("не удалось создать %1").arg(QDir::toNativeSeparators(path)); + return false; + } + + // mkpath отвечает true и для существующего каталога, в который нельзя писать, + // и для подсунутого вместо папки файла — тогда все сохранения провалились бы молча. + const QFileInfo info(path); + if (!info.isDir() || !info.isWritable()) { + if (error) + *error = translate("нет доступа на запись в %1").arg(QDir::toNativeSeparators(path)); + return false; + } + } + return true; +} diff --git a/launcherpaths.h b/launcherpaths.h new file mode 100644 index 0000000..11bd77b --- /dev/null +++ b/launcherpaths.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// Пути к данным лаунчера. Собственная папка galeonLauncher лежит рядом со +// стандартной .minecraft, а не в системном каталоге приложения. +namespace LauncherPaths { + +// Родительская папка, в которой лежит .minecraft (и наша galeonLauncher). +QString containerDir(); + +// /galeonLauncher — корень данных лаунчера. +QString rootDir(); + +// Стандартная папка игры. +QString defaultMinecraftDir(); + +QString settingsFile(); +QString profilesFile(); +QString versionsFile(); + +// Сюда качается authlib-injector. +QString runtimeDir(); + +// Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед +// каждой записью. false и текст в error, если папки нет или в неё не пишется. +bool ensureRootExists(QString *error = nullptr); + +} // namespace LauncherPaths