diff --git a/BuildsDialog.qml b/BuildsDialog.qml index b6f2c0e..f25f1d3 100644 --- a/BuildsDialog.qml +++ b/BuildsDialog.qml @@ -4,22 +4,11 @@ import QtQuick import QtQuick.Controls 2.15 import QtQuick.Layouts 2.15 -// Пользовательские сборки одним окном: слева список со сменой активной, -// справа карточка выбранной — имя, сервер, версия Minecraft и модлоадеры. -// -// Карточка сохраняет правки по ходу редактирования, отдельной кнопки -// «Сохранить» нет: иначе появляется неочевидное несохранённое состояние, -// пока пользователь переключается между сборками в левом списке. Dialog { id: buildsDialog required property var backend - - // Сборка, открытая в карточке справа. С активной не связана: активную - // меняет отдельная кнопка, чтобы случайный клик по списку не запускал - // архивацию .minecraft. property int editIndex: -1 - // Пока карточка заполняется, обработчики полей не должны писать обратно. property bool loading: false modal: true @@ -48,9 +37,6 @@ Dialog { buildsDialog.refreshStatus() } - // Удаление необратимо и уносит с собой архив сборки, поэтому сначала - // спрашиваем. Индекс запоминаем здесь: к моменту ответа строка списка под - // курсором может быть уже другой. function askRemove(index) { const info = buildsDialog.backend.customBuildRemovalInfo(index) if (!info || info.name === undefined) @@ -251,8 +237,6 @@ Dialog { onVersionChosen: (versionId) => bdMinecraft.selectedId = versionId } - // Установка версии или модлоадера меняет комплектность сборки — строка - // состояния должна это заметить, не дожидаясь переоткрытия окна. Connections { target: buildsDialog.backend function onInstalledVersionsChanged() { buildsDialog.refreshStatus() } @@ -287,7 +271,6 @@ Dialog { contentItem: RowLayout { spacing: 0 - // ── Список сборок ─────────────────────────────────────────────── Item { Layout.preferredWidth: 260 Layout.fillHeight: true @@ -402,7 +385,6 @@ Dialog { color: "#333333" } - // ── Карточка выбранной сборки ─────────────────────────────────── Item { Layout.fillWidth: true Layout.fillHeight: true @@ -444,8 +426,6 @@ Dialog { font.pixelSize: 11 } - // Само поле только показывает выбор: версий около тысячи, и - // разбираться в них удобнее в отдельном окне с категориями. Rectangle { id: bdMinecraft property string selectedId: "" @@ -494,9 +474,6 @@ Dialog { font.pixelSize: 11 } - // Лоадеры несовместимы между собой: игра запускается ровно с - // одним профилем versions/. Поэтому отметка одного снимает - // остальные, а «ничего не отмечено» — это чистая ваниль. Column { id: loaderPanel width: parent.width @@ -530,8 +507,6 @@ Dialog { buildsDialog.commit({ "loader": row ? row.loaderKey : "", "loaderVersion": row ? row.selectedVersion : "", - // Профиль появится только после установки: пока - // сборка запускается на чистой ванили. "resolvedVersionId": "" }) } diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a17fd2..c18af84 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -154,7 +154,7 @@ endif() include(GNUInstallDirs) if(UNIX AND NOT APPLE) - set(LAUNCHER_ZLIB_DIR "${CMAKE_INSTALL_LIBDIR}/galeonLauncher") + set(LAUNCHER_ZLIB_DIR "${CMAKE_INSTALL_LIBDIR}/BiLauncher") set(LAUNCHER_ZLIB_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/zlib") add_library(launcher_zlib_reference SHARED @@ -189,7 +189,7 @@ if(UNIX AND NOT APPLE) add_dependencies(BiLauncher launcher_zlib_reference) target_compile_definitions(BiLauncher PRIVATE - LAUNCHER_ZLIB_INSTALL_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/galeonLauncher" + LAUNCHER_ZLIB_INSTALL_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/BiLauncher" ) install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR}) diff --git a/Main.qml b/Main.qml index 99cf10b..702fad2 100644 --- a/Main.qml +++ b/Main.qml @@ -232,12 +232,14 @@ Window { autoTransform: true } + // Оформление повторяет кнопку выбора сборок: тёмный фон, зелёная + // рамка при наведении и зелёная заливка, пока список раскрыт. background: Rectangle { implicitWidth: 120; implicitHeight: 40 color: profileBox.down ? "#91B315" : "#232323" radius: 6 - border.color: profileBox.pressed ? "#ffffff" : "#232323" - border.width: profileBox.visualFocus ? 2 : 1 + border.color: profileBox.hovered ? "#91B315" : "#232323" + border.width: 1 } popup: Popup { @@ -503,11 +505,12 @@ Window { verticalAlignment: Text.AlignVCenter } + // То же оформление, что у кнопки выбора сборок и списка профилей. background: Rectangle { - color: seasonsButton.pressed ? "#2d2d2d" : "#232323" + color: seasonsButton.pressed || seasonalDialog.opened ? "#91B315" : "#232323" radius: 6 - border.color: "#91B315" - border.width: seasonsButton.hovered ? 2 : 1 + border.color: seasonsButton.hovered ? "#91B315" : "#232323" + border.width: 1 } ToolTip.visible: hovered diff --git a/authservice.cpp b/authservice.cpp index 34eb082..816e4a4 100644 --- a/authservice.cpp +++ b/authservice.cpp @@ -17,7 +17,6 @@ const QString kAuthServer = QStringLiteral("https://authserver.ely.by"); const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json"); const QString kInjectorFileName = QStringLiteral("authlib-injector.jar"); -// Ely.by отдаёт UUID без дефисов, вручную введённый мог прийти и с ними. QString normalizeUuid(const QString &uuid) { return QString(uuid).remove(u'-'); @@ -34,7 +33,7 @@ QString describeElyError(const QJsonObject &response, int status) return Loc::text("auth.error.serverCode").arg(status); } -} // namespace +} AuthService::AuthService(QObject *parent) : QObject(parent) @@ -110,7 +109,6 @@ void AuthService::loginElyBy(const QString &login, const QString token = clientToken.isEmpty() ? generateClientToken() : clientToken; - // Токена ещё нет — сразу за паролем. if (accessToken.isEmpty()) { authenticate(login, password, token, std::move(callback)); return; @@ -170,7 +168,6 @@ void AuthService::loginElyByWithTotp(const QString &login, const QString &clientToken, Callback callback) { - // Ely.by ждёт двухфакторный код прямо в пароле, через двоеточие. authenticate(login, password + u':' + totp, clientToken.isEmpty() ? generateClientToken() : clientToken, @@ -207,7 +204,6 @@ void AuthService::authenticate(const QString &login, if (status != 200) { const QString message = describeElyError(response, status); - // Единственный случай, когда ошибка лечится вводом кода, а не пароля. result.twoFactorRequired = message.contains(QStringLiteral("two factor"), Qt::CaseInsensitive); result.error = result.twoFactorRequired diff --git a/authservice.h b/authservice.h index 26aaa71..5c2108b 100644 --- a/authservice.h +++ b/authservice.h @@ -8,13 +8,10 @@ class QNetworkAccessManager; -// Результат авторизации — то, что подставляется в ${auth_*} аргументы запуска. struct AuthResult { bool ok = false; bool twoFactorRequired = false; - // Вход в Microsoft прошёл, но копии игры на аккаунте нет: чинится только - // покупкой, поэтому обрабатывается отдельно от прочих ошибок. bool licenseMissing = false; QString error; @@ -30,8 +27,6 @@ struct AuthResult QDateTime expiresAt; // UTC-время, когда протухает accessToken }; -// Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные: -// колбэк вызывается один раз в потоке GUI. class AuthService : public QObject { Q_OBJECT @@ -40,31 +35,21 @@ public: using Callback = std::function; explicit AuthService(QObject *parent = nullptr); - - // UUID выводится из ника так же, как это делает сам Minecraft в офлайне: - // UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8)). static AuthResult offline(const QString &nickname); - - // Случайный clientToken лаунчера — генерируется один раз на профиль. static QString generateClientToken(); - // Полный цикл: validate -> refresh -> authenticate. password можно оставить - // пустым, если уже есть рабочий accessToken. void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback); - // Повтор авторизации с одноразовым кодом двухфакторки (пароль:код). void loginElyByWithTotp(const QString &login, const QString &password, const QString &totp, const QString &clientToken, Callback callback); - // Скачивает authlib-injector в targetDir, если его там ещё нет. - // Колбэк получает путь к jar либо текст ошибки. void ensureAuthlibInjector(const QString &targetDir, std::function callback); diff --git a/buildarchiveworker.cpp b/buildarchiveworker.cpp index e59b4ac..6073cc4 100644 --- a/buildarchiveworker.cpp +++ b/buildarchiveworker.cpp @@ -12,10 +12,6 @@ #include namespace { - -// Имена внутри архива приходят из файла и превращаются в пути на диске — -// «../..» увёл бы распаковку за пределы .minecraft. Правило то же, что у -// установщика версий. bool isSafeRelativePath(const QString &path) { if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':')) @@ -27,30 +23,20 @@ bool isSafeRelativePath(const QString &path) } return true; } - -// Последняя проверка перед записью: куда бы ни указывало имя из архива после -// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён -// выше это уже гарантирует, но проверка по готовому пути не зависит от того, -// как именно QZipReader разбирает записи. bool isInside(const QDir &root, const QString &absolutePath) { const QString base = QDir::cleanPath(root.absolutePath()) + u'/'; return QDir::cleanPath(absolutePath).startsWith(base); } -// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в -// общие каталоги лаунчера. QString topSegment(const QString &path) { const int slash = path.indexOf(u'/'); return slash < 0 ? path : path.left(slash); } -// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого -// набора модов выглядит как незавершённая установка. void pruneEmptyDirs(const QDir &root, QStringList dirs) { - // Сначала самые глубокие: удаление вложенной может опустошить родителя. std::sort(dirs.begin(), dirs.end(), [](const QString &a, const QString &b) { return a.size() > b.size(); }); for (const QString &relative : std::as_const(dirs)) { @@ -62,7 +48,6 @@ void pruneEmptyDirs(const QDir &root, QStringList dirs) } } -// Файлы, которые попадут в архив, — относительными путями от gameDir. QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop) { QStringList result; @@ -73,7 +58,7 @@ QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop) if (excludeTop.contains(entry.fileName())) continue; if (entry.isSymLink()) - continue; // ссылку в архив не положить, а идти по ней опасно + continue; if (entry.isFile()) { result << entry.fileName(); continue; @@ -90,7 +75,7 @@ QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop) return result; } -} // namespace +} BuildArchiveWorker::BuildArchiveWorker(QObject *parent) : QObject(parent) @@ -118,8 +103,6 @@ void BuildArchiveWorker::archive(const QString &gameDir, .arg(QDir::toNativeSeparators(tempZipPath))); return; } - // Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть - // решает эвристика, а не мы. writer.setCompressionPolicy(QZipWriter::AutoCompress); int done = 0; @@ -133,9 +116,6 @@ void BuildArchiveWorker::archive(const QString &gameDir, QFile file(root.absoluteFilePath(relative)); if (!file.open(QIODevice::ReadOnly)) { - // Один нечитаемый файл не повод потерять всю сборку, но и молча - // выкидывать его нельзя — иначе пользователь узнает о пропаже - // уже после очистки .minecraft. writer.close(); QFile::remove(tempZipPath); emit finished(false, @@ -166,7 +146,7 @@ void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTo { QDir root(gameDir); if (!root.exists()) { - emit finished(true, {}); // чистить нечего + emit finished(true, {}); return; } @@ -181,8 +161,6 @@ void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTo emit progress(0, victims.size(), {}); int done = 0; for (const QFileInfo &entry : victims) { - // Отмена посреди очистки оставила бы полупустую папку, которую нечем - // склеить обратно, — на этом шаге её не слушаем. const bool ok = entry.isDir() && !entry.isSymLink() ? QDir(entry.absoluteFilePath()).removeRecursively() : QFile::remove(entry.absoluteFilePath()); @@ -267,9 +245,6 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath))); return; } - - // Весь архив проверяется до первой записи на диск: наполовину раскатанный - // пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии. const QList entries = reader.fileInfoList(); QStringList applied; applied.reserve(entries.size()); @@ -299,22 +274,16 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, const int total = removeRelative.size() + applied.size(); emit progress(0, total, {}); int done = 0; - - // ── Убираем то, чего в новой ревизии больше нет ────────────────────── QStringList touchedDirs; for (const QString &relative : removeRelative) { if (canceled()) { emit finished(false, Loc::text("switch.status.installCancelled")); return; } - // Список пришёл из нашего же файла состояния, но он лежит на диске - // рядом с игрой и мог быть отредактирован — правила те же, что у архива. if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) { emit progress(++done, total, relative); continue; } - // Файл мог быть удалён игроком или прийти заново в новой ревизии — - // и то и другое нормально. const QString target = root.absoluteFilePath(relative); if (!isInside(root, target)) { emit progress(++done, total, relative); @@ -331,8 +300,6 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, emit progress(++done, total, relative); } pruneEmptyDirs(root, touchedDirs); - - // ── Раскладываем новую ревизию поверх ──────────────────────────────── for (const QString &relative : std::as_const(applied)) { if (canceled()) { emit finished(false, Loc::text("switch.status.installCancelled")); @@ -355,8 +322,6 @@ void BuildArchiveWorker::applyPack(const QString &zipPath, QFile file(target); const QByteArray data = reader.fileData(relative); - // QIODevice::Truncate обязателен: поверх более длинного старого файла - // иначе остался бы его хвост. if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate) || file.write(data) != data.size()) { emit finished(false, diff --git a/buildarchiveworker.h b/buildarchiveworker.h index c494d2c..fc56ffa 100644 --- a/buildarchiveworker.h +++ b/buildarchiveworker.h @@ -5,43 +5,22 @@ #include #include -// Три операции над содержимым .minecraft, выполняемые в отдельном потоке: -// упаковать, вычистить, распаковать. Гигабайты модов и миров нельзя гонять в -// GUI-потоке — окно бы замерзало на всё время смены сборки. -// -// Объект живёт в своём QThread, вызывается только через очередь сигналов и -// сам ничего не знает ни о сборках, ни о путях лаунчера: порядок шагов и -// восстановление после сбоя — дело BuildSwitcher. class BuildArchiveWorker : public QObject { Q_OBJECT public: explicit BuildArchiveWorker(QObject *parent = nullptr); - - // Вызывается из GUI-потока во время работы: отмена проверяется между файлами. void requestCancel() { m_cancel.storeRelaxed(1); } void clearCancel() { m_cancel.storeRelaxed(0); } public slots: - // Упаковывает всё содержимое gameDir, кроме элементов верхнего уровня из - // excludeTop, во временный файл tempZipPath. void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop); - // Удаляет из gameDir всё, кроме элементов верхнего уровня из keepTop. void clear(const QString &gameDir, const QStringList &keepTop); - // Распаковывает архив в gameDir. void restore(const QString &zipPath, const QString &gameDir); - // Докатывает пак сезонной сборки поверх уже разложенного содержимого: - // сначала удаляет removeRelative (файлы, ушедшие из сборки в новой - // ревизии), затем распаковывает архив с перезаписью. Того, чего нет ни в - // списке, ни в архиве, операция не касается — миры и скриншоты остаются. - // - // forbiddenTop — элементы верхнего уровня, которые паку трогать нельзя - // (общие каталоги лаунчера). Проверяется здесь, а не только у издателя: - // архив приезжает из сети. void applyPack(const QString &zipPath, const QString &gameDir, const QStringList &removeRelative, @@ -50,8 +29,6 @@ public slots: signals: void progress(int done, int total, const QString ¤tPath); void finished(bool ok, const QString &error); - // Что именно принёс пак — относительными путями. Пригодится следующему - // обновлению, чтобы вычислить, какие файлы из сборки ушли. void packEntries(const QStringList &entries); private: diff --git a/buildswitcher.cpp b/buildswitcher.cpp index 57570ad..d1e926f 100644 --- a/buildswitcher.cpp +++ b/buildswitcher.cpp @@ -22,13 +22,8 @@ const QString kIndexFile = QStringLiteral("index.json"); const QString kStateSwitching = QStringLiteral("switching"); const QString kStateApplying = QStringLiteral("applying"); const QString kStateIdle = QStringLiteral("idle"); - -// Запас на архив: точный размер заранее неизвестен, но упасть на середине -// записи из-за полного диска хуже, чем отказаться заранее. constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024; -// Имя файла из названия сборки: оно попадает в файловую систему, поэтому всё -// небезопасное заменяется подчёркиванием. QString slug(const QString &name) { QString result; @@ -68,14 +63,10 @@ qint64 directorySize(const QString &path, const QStringList &excludeTop) return total; } -} // namespace +} QStringList BuildSwitcher::sharedTopLevel() { - // Общий кэш, одинаковый для всех сборок и всегда восстановимый загрузкой. - // versions/ здесь не случайно: без libraries/ описания версий бесполезны, а - // две сборки на одной версии игры иначе таскали бы по копии клиентского jar. - // Чем сборка запускается, помнит resolvedVersionId, а не набор папок. return {QStringLiteral("assets"), QStringLiteral("libraries"), QStringLiteral("versions"), @@ -118,13 +109,9 @@ QString BuildSwitcher::buildDir(int buildId) const return LauncherPaths::buildDir(buildId); } -// На сборку — ровно один архив: имя с датой меняется от переключения к -// переключению, поэтому ищем по расширению, а не по имени. QString BuildSwitcher::archivePathOf(int buildId) const { const QDir dir(buildDir(buildId)); - // Рядом лежат служебные .tmp-*.zip и .pack-*.zip. На Unix они скрытые и в - // выборку не попадают, на Windows — попадают, поэтому отсекаем по имени. const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time); for (const QString &name : zips) { if (!name.startsWith(u'.')) @@ -165,7 +152,6 @@ void BuildSwitcher::switchTo(int fromBuildId, m_total = 0; m_worker->clearCancel(); - // Первая сборка за всё время: архивировать нечего, сразу разворачиваем цель. if (fromBuildId < 0 || !QDir(gameDir).exists()) { m_state = Clearing; beginClearing(); @@ -201,8 +187,6 @@ void BuildSwitcher::switchTo(int fromBuildId, void BuildSwitcher::cancel() { - // Отмена имеет смысл только на архивации: после очистки папки отступать - // уже некуда, там нужно доводить дело до конца. if (m_state == Archiving) m_worker->requestCancel(); } @@ -260,8 +244,6 @@ void BuildSwitcher::onWorkerFinished(bool ok, const QString &error) return; case Clearing: if (!ok) { - // Папка осталась разобранной: активной сборкой считается прежняя, - // отметка о незавершённом переключении в index.json остаётся. fail(error, /*gameDirIntact=*/false); return; } @@ -276,10 +258,6 @@ void BuildSwitcher::onWorkerFinished(bool ok, const QString &error) return; case Applying: if (!ok) { - // Архив проверяется целиком до первой записи, поэтому нулевой - // счётчик означает, что в папке игры ничего не тронуто. Иначе - // часть файлов уже заменена: отметка в index.json остаётся, и - // следующий запуск доиграет раскатку из сохранённого пака. fail(error, /*gameDirIntact=*/m_done == 0); return; } @@ -294,7 +272,6 @@ void BuildSwitcher::commitArchive() + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd")) + u'_' + slug(m_fromName) + QStringLiteral(".zip"); - // На сборку — один архив: новый встаёт на место, все прежние уходят. if (QFile::exists(target) && !QFile::remove(target)) { QFile::remove(m_tempZip); fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true); @@ -309,14 +286,10 @@ void BuildSwitcher::commitArchive() const QDir dir(buildDir(m_fromId)); const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files); for (const QString &name : zips) { - // Служебные файлы под удаление не попадают: в .pack-*.zip может лежать - // пак недоигранной раскатки, без которого её нечем довести до конца. if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target) QFile::remove(dir.absoluteFilePath(name)); } - // Отметка ставится до первого разрушающего действия: по ней следующий - // запуск поймёт, что переключение оборвалось на середине. QJsonObject index = readIndex(); index.insert(QStringLiteral("state"), kStateSwitching); index.insert(QStringLiteral("from"), m_fromId); @@ -344,8 +317,6 @@ void BuildSwitcher::beginRestoring() { const QString archive = archivePathOf(m_toId); if (archive.isEmpty()) { - // Сборка ещё ни разу не запускалась: пустая .minecraft рядом с общими - // кэшами — это и есть её правильное начальное состояние. m_state = Restoring; finish(); return; @@ -356,8 +327,6 @@ void BuildSwitcher::beginRestoring() requestRestore(archive, m_gameDir); } -// ── Раскатка пака сезонной сборки ────────────────────────────────────────── - void BuildSwitcher::applyPack(int buildId, const QString &buildName, const QString &packZipPath, @@ -396,8 +365,6 @@ void BuildSwitcher::applyPack(int buildId, m_total = 0; m_worker->clearCancel(); - // Отметка ставится до первого разрушающего действия — как и при смене - // сборки. Пак лежит рядом и переживёт перезапуск вместе с ней. QJsonObject index = readIndex(); index.insert(QStringLiteral("state"), kStateApplying); index.insert(QStringLiteral("to"), buildId); @@ -419,8 +386,6 @@ void BuildSwitcher::beginApplying() void BuildSwitcher::finishApplying() { - // Пак сделал своё дело: держать сотни мегабайт рядом с архивом сборки не - // за чем, а его наличие означало бы недоигранную раскатку. QFile::remove(m_packPath); QJsonObject index = readIndex(); @@ -477,16 +442,11 @@ void BuildSwitcher::fail(const QString &message, bool gameDirIntact) emit failed(toId, message, gameDirIntact); } -// ── Удаление сборки ──────────────────────────────────────────────────────── - bool BuildSwitcher::forgetBuild(int buildId) { if (buildId < 0) return true; - // Запись убираем в любом случае: даже если файл не удалось стереть, ссылаться - // на архив несуществующей сборки нельзя — иначе он «воскреснет» под новой - // сборкой с тем же id. QJsonObject index = readIndex(); QJsonObject builds = index.value(QStringLiteral("builds")).toObject(); builds.remove(QString::number(buildId)); @@ -499,8 +459,6 @@ bool BuildSwitcher::forgetBuild(int buildId) return !dir.exists() || dir.removeRecursively(); } -// ── Незавершённое переключение ───────────────────────────────────────────── - QString BuildSwitcher::interruptedSwitchWarning() const { const QJsonObject index = readIndex(); @@ -540,8 +498,6 @@ void BuildSwitcher::resumeInterrupted(const QString &gameDir) if (toId < 0 || m_state != Idle) return; - // Архив исходной сборки уже записан целиком — доигрываем только вторую - // половину: очистку и распаковку цели. m_fromId = -1; m_toId = toId; m_toName = index.value(QStringLiteral("builds")) @@ -566,10 +522,6 @@ void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &game const QString packName = index.value(QStringLiteral("pack")).toString(); const QString packPath = buildDir(toId) + u'/' + packName; if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) { - // Пак не сохранился — доигрывать нечем. Отметку снимаем, иначе - // предупреждение о незавершённой установке будет всплывать вечно; - // сборка при этом остаётся наполовину обновлённой, и лаунчер покажет - // её как требующую обновления. QJsonObject cleaned = index; cleaned.insert(QStringLiteral("state"), kStateIdle); cleaned.remove(QStringLiteral("pack")); @@ -601,8 +553,6 @@ void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &game beginApplying(); } -// ── Реестр архивов ───────────────────────────────────────────────────────── - QJsonObject BuildSwitcher::readIndex() const { QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile); @@ -626,9 +576,6 @@ void BuildSwitcher::writeIndex(const QJsonObject &index) const file.commit(); } -// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может -// занимать гигабайты. Пакеты .pack-*.zip не трогаем: по ним доигрывается -// прерванная раскатка сезонной сборки, а лишний тут удаляет resumeApplying. void BuildSwitcher::sweepTempArchives() const { QDir root(LauncherPaths::buildStorageDir()); diff --git a/buildswitcher.h b/buildswitcher.h index dd438b9..fe13e6a 100644 --- a/buildswitcher.h +++ b/buildswitcher.h @@ -7,15 +7,6 @@ class QThread; class BuildArchiveWorker; - -// Смена активной сборки: содержимое .minecraft уезжает в архив своей сборки, -// папка чистится, на её место разворачивается архив выбранной. -// -// Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и -// не переименован на место, из .minecraft не удаляется ничего. Отметка о -// начатом переключении пишется в builds/index.json до первого разрушающего -// действия, поэтому обрыв питания или kill -9 всегда обнаружим на следующем -// запуске — см. recoverIfNeeded(). class BuildSwitcher : public QObject { Q_OBJECT @@ -27,9 +18,7 @@ public: bool isRunning() const { return m_state != Idle; } QString stage() const { return m_stage; } QString status() const { return m_status; } - double fraction() const; // 0..1, либо -1 пока итог неизвестен - - // Общие для всех сборок каталоги: они не архивируются и не чистятся. + double fraction() const; static QStringList sharedTopLevel(); void switchTo(int fromBuildId, @@ -38,14 +27,6 @@ public: const QString &toName, const QString &gameDir); - // Докатывает пак сезонной сборки поверх содержимого .minecraft. Вызывается - // только для уже активной сборки: пак ложится на то, что сейчас разложено, - // а не внутрь чужого архива. - // - // note — непрозрачные данные вызывающей стороны: они переживают перезапуск - // вместе с отметкой о незавершённой операции и возвращаются через - // lastPackNote(), когда раскатка доиграна. Сам переключатель в них не - // заглядывает. void applyPack(int buildId, const QString &buildName, const QString &packZipPath, @@ -53,23 +34,13 @@ public: const QJsonObject ¬e, const QString &gameDir); - // Что принёс последний успешно раскатанный пак и с чем он пришёл. QStringList lastPackEntries() const { return m_lastPackEntries; } QJsonObject lastPackNote() const { return m_lastPackNote; } void cancel(); - - // Сборку удалили: убираем её архив и запись о нём. Возвращает false, если - // папку архива не удалось удалить целиком. bool forgetBuild(int buildId); - - // Есть ли у сборки сохранённый архив (для текста предупреждения об удалении). bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); } - - // Незавершённое переключение с прошлого запуска. Возвращает пустую строку, - // если всё в порядке, иначе — текст для пользователя. QString interruptedSwitchWarning() const; - // Доигрывает прерванное переключение: очистка и распаковка целевой сборки. void resumeInterrupted(const QString &gameDir); signals: @@ -79,8 +50,6 @@ signals: private: enum State { Idle, Archiving, Clearing, Restoring, Applying }; - - // Сигналы к рабочему объекту в другом потоке. void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude); void requestClear(const QString &gameDir, const QStringList &keep); void requestRestore(const QString &zipPath, const QString &gameDir); @@ -123,9 +92,6 @@ private: QString m_toName; QString m_gameDir; QString m_tempZip; - - // Раскатка пака: путь к архиву, список уходящих файлов и данные вызывающей - // стороны, которые надо вернуть по завершении. QString m_packPath; QStringList m_packRemove; QJsonObject m_packNote; diff --git a/doc/cpp/GameLauncher.md b/doc/cpp/GameLauncher.md index da007a2..63eb9e9 100644 --- a/doc/cpp/GameLauncher.md +++ b/doc/cpp/GameLauncher.md @@ -55,7 +55,7 @@ Minecraft. К моменту его вызова уже известно всё | `serverAddress` | `QString` | — | `host[:port]` для автоматического захода на сервер | | `authlibInjectorPath` | `QString` | — | Путь к `authlib-injector`; пустое значение — не подключать | | `authlibInjectorApi` | `QString` | `ely.by` | Сервер авторизации, на который перенаправляется игра | -| `launcherName` | `QString` | `KishkaLauncher` | Имя лаунчера, которое видит игра | +| `launcherName` | `QString` | `BiLauncher` | Имя лаунчера, которое видит игра | | `launcherVersion` | `QString` | `1.0` | Версия лаунчера | ## Публичные методы diff --git a/doc/cpp/launcherpaths.md b/doc/cpp/launcherpaths.md index ba4f44a..0cf752d 100644 --- a/doc/cpp/launcherpaths.md +++ b/doc/cpp/launcherpaths.md @@ -6,7 +6,7 @@ сборок, архивы содержимого `.minecraft`, скачанные сборки Java и кэши каталогов. Пространство имён `LauncherPaths` — единственное место, где эти пути вычисляются. -Собственная папка `galeonLauncher` лежит рядом со стандартной `.minecraft`, а не в системном +Собственная папка `BiLauncher` лежит рядом со стандартной `.minecraft`, а не в системном каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся вместе с ней. @@ -24,12 +24,12 @@ #### QString containerDir() -Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `galeonLauncher`. От неё +Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё отсчитываются и папка игры по умолчанию, и корень данных лаунчера. #### QString rootDir() -Корень данных лаунчера — `/galeonLauncher`. +Корень данных лаунчера — `/BiLauncher`. #### QString defaultMinecraftDir() diff --git a/gamelauncher.cpp b/gamelauncher.cpp index a2439fc..b8c138e 100644 --- a/gamelauncher.cpp +++ b/gamelauncher.cpp @@ -97,8 +97,6 @@ QStringList GameLauncher::missingFiles(const LaunchOptions &options, namespace { -// -DignoreList у Forge 1.17+ — список префиксов имён файлов, которые -// BootstrapLauncher не берёт в слой модулей. Сравнение идёт по началу имени. bool ignoreListCovers(const QString &ignoreListArgument, const QString &fileName) { const QString value = ignoreListArgument.mid(QStringLiteral("-DignoreList=").size()); @@ -119,7 +117,7 @@ QStringList GameLauncher::buildArguments(const LaunchOptions &options, QStringList classpath; for (const MinecraftLibrary &library : version.libraries) { if (library.native) - continue; // натив-библиотеки только распаковываются + continue; classpath << nativeToken(libraryPath(options.gameDir, library)); } classpath << nativeToken(version.clientJarPath); @@ -204,7 +202,6 @@ QStringList GameLauncher::buildArguments(const LaunchOptions &options, arguments << resolved; } - // Разрешение окна: у старых версий фич-флага нет, аргументы всё равно понимаются. if (options.windowWidth > 0 && options.windowHeight > 0 && !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) { arguments << QStringLiteral("--width") << QString::number(options.windowWidth) @@ -269,8 +266,6 @@ bool GameLauncher::extractNatives(const LaunchOptions &options, if (excluded) continue; - // java.library.path ищет библиотеки только в корне папки, поэтому - // складываем их плоско, без структуры каталогов внутри jar. const QString fileName = QFileInfo(entry.filePath).fileName(); if (!isNativeLibraryFile(fileName)) continue; @@ -278,7 +273,7 @@ bool GameLauncher::extractNatives(const LaunchOptions &options, const QString target = nativesDir + u'/' + fileName; const QByteArray data = reader.fileData(entry.filePath); if (QFileInfo(target).size() == data.size() && QFile::exists(target)) - continue; // уже распаковано этим же лаунчером + continue; QFile file(target); if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) { diff --git a/gamelauncher.h b/gamelauncher.h index ef6aac1..ec8c1d1 100644 --- a/gamelauncher.h +++ b/gamelauncher.h @@ -33,11 +33,10 @@ struct LaunchOptions QString authlibInjectorPath; // Пусто — не подключаем QString authlibInjectorApi = QStringLiteral("ely.by"); - QString launcherName = QStringLiteral("KishkaLauncher"); + QString launcherName = QStringLiteral("BiLauncher"); QString launcherVersion = QStringLiteral("1.0"); }; -// Готовит и запускает JVM. Один экземпляр — одна игра одновременно. class GameLauncher : public QObject { Q_OBJECT @@ -46,26 +45,19 @@ public: explicit GameLauncher(QObject *parent = nullptr); bool isRunning() const; - - // Проверяет .minecraft на комплектность. Пустой список — всё на месте. - // Возвращает описания недостающих файлов, но не больше limit штук. static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12); - // Собирает аргументы ровно в том порядке, в котором их ждёт JVM: - // <аргументы игры>. static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir); - // Распаковывает .dll/.so/.dylib из натив-библиотек в <версия>/natives. static bool extractNatives(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir, QString *error); - // Полный цикл: проверка, натив-библиотеки, поиск java, старт процесса. bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString *error); void terminate(); diff --git a/javainstaller.cpp b/javainstaller.cpp index 9de5fa5..0bb1795 100644 --- a/javainstaller.cpp +++ b/javainstaller.cpp @@ -18,8 +18,6 @@ #include -// Читатель архива вместе с разобранным оглавлением: fileInfoList() каждый раз -// заново разбирает центральный каталог zip, а записей в JDK десятки тысяч. struct ZipExtraction { explicit ZipExtraction(const QString &path) @@ -39,8 +37,6 @@ constexpr int kMaxParallel = 4; constexpr int kMaxAttempts = 3; constexpr int kProgressIntervalMs = 100; -// Записей zip за один проход таймера. Сотня файлов по паре мегабайт — это -// заметная, но ещё не видимая глазом задержка кадра. constexpr int kZipChunk = 24; bool isTransient(QNetworkReply::NetworkError error, int status) @@ -59,8 +55,6 @@ bool isTransient(QNetworkReply::NetworkError error, int status) } } -// Путь из манифеста или архива не должен уводить за пределы папки сборки: -// «../../» в имени записи — известный способ подменить чужой файл. bool isInside(const QString &root, const QString &path) { const QString normalizedRoot = QDir::cleanPath(root) + u'/'; @@ -76,7 +70,7 @@ void makeExecutable(const QString &path) | QFileDevice::ExeOther); } -} // namespace +} JavaInstaller::JavaInstaller(QObject *parent) : QObject(parent) @@ -133,8 +127,6 @@ void JavaInstaller::install(const JavaRuntimeEntry &entry) return; } - // Переустановка поверх недокачанной прошлой попытки: старое дерево мешает - // и размером, и тем, что locateBinary нашла бы в нём чужой java. if (!JavaRuntimeStore::remove(entry.id, &error)) { emit failed(m_label, error); return; @@ -176,8 +168,6 @@ void JavaInstaller::cancel() if (m_tar) m_tar->kill(); - // Прерванная установка оставляет дерево наполовину собранным: запускать из - // него нечего, а locateBinary могла бы найти в нём java. if (m_active.isEmpty() && !m_reply && !m_tar) { const QString label = m_label; JavaRuntimeStore::remove(m_entry.id, nullptr); @@ -186,8 +176,6 @@ void JavaInstaller::cancel() } } -// ── Temurin: один архив ───────────────────────────────────────────────────── - void JavaInstaller::beginArchive() { if (m_entry.archive != QLatin1String("zip") @@ -287,8 +275,6 @@ void JavaInstaller::handleArchiveFinished(QNetworkReply *reply) void JavaInstaller::startExtraction() { - // Байты кончились, дальше считаем шаги: панель по нулевому итогу перестанет - // показывать мегабайты, а проценты продолжит. m_bytesTotal = 0; m_bytesSettled = 0; setStage(Loc::text("java.progress.extracting").arg(m_entry.version)); @@ -333,7 +319,6 @@ void JavaInstaller::extractZipChunk() QDir().mkpath(target); continue; } - // Ссылок в zip-сборках Temurin не бывает — под Unix они отдают tar.gz. if (!info.isFile) continue; @@ -369,8 +354,6 @@ void JavaInstaller::extractZipChunk() void JavaInstaller::startTarExtraction() { - // tar есть в любой macOS и практически в любом дистрибутиве Linux; своего - // распаковщика gzip-tar в Qt нет, а тянуть ради него зависимость незачем. m_tar = new QProcess(this); m_tar->setWorkingDirectory(m_targetDir); m_tar->setProgram(QStringLiteral("tar")); @@ -418,8 +401,6 @@ void JavaInstaller::startTarExtraction() m_tar->start(); } -// ── Mojang: дерево файлов ─────────────────────────────────────────────────── - void JavaInstaller::beginMojang() { setStage(Loc::text("java.progress.fileList").arg(m_entry.version)); @@ -498,7 +479,6 @@ bool JavaInstaller::prepareTree(const QJsonObject &files) } if (type == QLatin1String("link")) { - // Ссылки создаём в конце: их цели ещё не скачаны. m_links << qMakePair(target, node.value(QStringLiteral("target")).toString()); continue; } @@ -666,11 +646,7 @@ bool JavaInstaller::applyLinks() continue; if (!QDir().mkpath(QFileInfo(linkPath).absolutePath())) continue; - - // Цель относительна самой ссылке — QFile::link кладёт её как есть. if (!QFile::link(link.second, linkPath)) { - // Под Windows символических ссылок в рантаймах Mojang нет, но если - // они появятся, копия работает не хуже. const QString source = QDir::cleanPath(QFileInfo(linkPath).absolutePath() + u'/' + link.second); if (!QFile::copy(source, linkPath)) { @@ -683,8 +659,6 @@ bool JavaInstaller::applyLinks() return true; } -// ── Завершение ────────────────────────────────────────────────────────────── - void JavaInstaller::complete() { setStage(Loc::text("java.progress.verifying")); @@ -695,7 +669,6 @@ void JavaInstaller::complete() return; } #if !defined(Q_OS_WIN) - // tar сохраняет права, а вот zip и файлы Mojang без флага executable — нет. makeExecutable(javaPath); #endif @@ -713,7 +686,6 @@ void JavaInstaller::complete() return; } - // Архив нужен был только на распаковку — двести мегабайт в кэше ни к чему. if (!m_archivePath.isEmpty()) QFile::remove(m_archivePath); @@ -729,7 +701,6 @@ void JavaInstaller::fail(const QString &message) m_zip.reset(); if (!m_archivePath.isEmpty()) QFile::remove(m_archivePath); - // Половина рантайма хуже, чем его отсутствие: следующая попытка начнётся с чистой папки. JavaRuntimeStore::remove(m_entry.id, nullptr); reset(); emit failed(label, message); diff --git a/javainstaller.h b/javainstaller.h index db77dfd..5a18f14 100644 --- a/javainstaller.h +++ b/javainstaller.h @@ -18,12 +18,8 @@ class QNetworkReply; class QProcess; class QSaveFile; class QTimer; - -// Состояние распаковки zip. Спрятано в .cpp, чтобы приватный заголовок -// QZipReader не расходился по проекту вместе с этим. struct ZipExtraction; -// Один файл рантайма Mojang. struct JavaFileTask { QUrl url; @@ -34,7 +30,6 @@ struct JavaFileTask int attempts = 0; }; -// Файл в процессе скачивания. Пишем потоком: рантайм — это две сотни мегабайт. struct JavaActiveDownload { JavaFileTask task; @@ -44,15 +39,6 @@ struct JavaActiveDownload qint64 received = 0; }; -// Установка сборки Java в /java/. Два разных пути под одним фасадом: -// -// * Temurin отдаёт один архив — качаем, сверяем sha256 и распаковываем; -// -// * Mojang отдаёт манифест с деревом файлов — качаем файлы по отдельности, -// как это делает официальный лаунчер. -// -// Набор геттеров повторяет VersionInstaller: панель загрузки в интерфейсе -// читает их одинаково, независимо от того, кто сейчас работает. class JavaInstaller : public QObject { Q_OBJECT @@ -67,8 +53,6 @@ public: QString stage() const { return m_stage; } QString currentFile() const { return m_currentFile; } - // Байты считаются только на загрузке: на распаковке считать нечего, и панель - // по нулевому итогу сама прячет мегабайты. qint64 bytesDone() const; qint64 bytesTotal() const { return m_bytesTotal; } double fraction() const; // 0..1, либо -1 пока итог неизвестен @@ -122,7 +106,6 @@ private: qint64 m_bytesSettled = 0; qint64 m_bytesTotal = 0; - // Шаги распаковки: у архива — записи внутри него, у Mojang — файлы дерева. int m_stepsDone = 0; int m_stepsTotal = 0; @@ -135,13 +118,11 @@ private: QTimer *m_progressTimer; bool m_progressDirty = false; - // Распаковка zip по кускам: держать GUI-поток занятым на всю сотню - // мегабайт нельзя, а поток ради одной операции заводить незачем. std::unique_ptr m_zip; QQueue m_queue; QList> m_active; - QList> m_links; // {абсолютный путь ссылки, цель} + QList> m_links; bool m_running = false; bool m_canceling = false; diff --git a/javalocator.cpp b/javalocator.cpp index 3146f8e..e5f50c7 100644 --- a/javalocator.cpp +++ b/javalocator.cpp @@ -24,7 +24,6 @@ constexpr auto kExecutable = "java"; constexpr auto kWindowless = "java"; #endif -// Корни, в которых Windows/Linux/macOS обычно держат JDK и JRE. QStringList installationRoots() { QStringList roots; @@ -58,7 +57,6 @@ QStringList installationRoots() return roots; } -// Ищем именно java.exe: у javaw.exe нет вывода, а версию мы определяем запуском. void appendIfExecutable(QStringList &target, const QString &binDir) { const QFileInfo info(binDir + u'/' + QString::fromLatin1(kExecutable)); @@ -69,21 +67,18 @@ void appendIfExecutable(QStringList &target, const QString &binDir) target << path; } -} // namespace +} QStringList JavaLocator::findAll(const QString &gameDir) { QStringList result; - // 0. Сборки, скачанные самим лаунчером. Идут первыми: пользователь выбирал - // их осознанно, в отличие от того, что нашлось в системе. const QList managed = JavaRuntimeStore::installed(); for (const InstalledJavaRuntime &entry : managed) { if (!result.contains(entry.javaPath)) result << entry.javaPath; } - // 1. Рантайм, скачанный самим Minecraft: .minecraft/runtime////bin const QString runtime = gameDir + QStringLiteral("/runtime"); if (QDir(runtime).exists()) { QDirIterator it(runtime, QStringList{QString::fromLatin1(kExecutable)}, @@ -94,17 +89,14 @@ QStringList JavaLocator::findAll(const QString &gameDir) } } - // 2. JAVA_HOME const QString javaHome = qEnvironmentVariable("JAVA_HOME"); if (!javaHome.isEmpty()) appendIfExecutable(result, javaHome + QStringLiteral("/bin")); - // 3. PATH const QString onPath = QStandardPaths::findExecutable(QString::fromLatin1(kExecutable)); if (!onPath.isEmpty()) appendIfExecutable(result, QFileInfo(onPath).absolutePath()); - // 4. Типовые каталоги установки const QStringList roots = installationRoots(); for (const QString &root : roots) { const QDir dir(root); @@ -133,14 +125,12 @@ int JavaLocator::majorVersion(const QString &javaPath) QProcess process; process.start(javaPath, {QStringLiteral("-version")}); if (process.waitForFinished(5000)) { - // java печатает версию в stderr: openjdk version "17.0.2" 2022-01-18 const QString output = QString::fromLocal8Bit(process.readAllStandardError()) + QString::fromLocal8Bit(process.readAllStandardOutput()); static const QRegularExpression re(QStringLiteral("version\\s+\"(\\d+)(?:\\.(\\d+))?")); const QRegularExpressionMatch match = re.match(output); if (match.hasMatch()) { const int first = match.captured(1).toInt(); - // До Java 9 версия выглядела как 1.8.0_301 — мажорная тут вторая цифра. major = (first == 1) ? match.captured(2).toInt() : first; } } @@ -166,7 +156,6 @@ QString JavaLocator::select(const QString &gameDir, QStringList candidates; if (!preferred.isEmpty()) { QFileInfo info(preferred); - // В настройках можно указать как сам java.exe, так и корень JDK. if (info.isDir()) appendIfExecutable(candidates, info.absoluteFilePath() + QStringLiteral("/bin")); else if (info.isFile()) diff --git a/javalocator.h b/javalocator.h index f4b7ea2..7e2288a 100644 --- a/javalocator.h +++ b/javalocator.h @@ -5,21 +5,15 @@ namespace JavaLocator { -// Все java, которые удалось найти: рантайм самого Minecraft, JAVA_HOME, PATH, -// стандартные каталоги установки JDK/JRE. Без дублей, в порядке приоритета. QStringList findAll(const QString &gameDir); -// Мажорная версия (8, 17, 21...) или 0, если запустить не удалось. Результат кешируется. int majorVersion(const QString &javaPath); -// java.exe -> javaw.exe, чтобы игра не открывала окно консоли. На Unix возвращает вход. QString windowlessVariant(const QString &javaPath); -// Выбирает java не ниже requiredMajor. preferred, если задан, проверяется первым. -// При неудаче возвращает пустую строку и заполняет error. QString select(const QString &gameDir, int requiredMajor, const QString &preferred, QString *error); -} // namespace JavaLocator +} diff --git a/javaruntime.cpp b/javaruntime.cpp index adc66c2..4d8141c 100644 --- a/javaruntime.cpp +++ b/javaruntime.cpp @@ -24,7 +24,7 @@ constexpr auto kExecutable = "java"; const QString kMetaFile = QStringLiteral("runtime.json"); -} // namespace +} QString javaKindKey(JavaRuntimeKind kind) { @@ -65,7 +65,6 @@ QString JavaRuntimeStore::sanitizeId(const QString &id) const bool safe = ch.isLetterOrNumber() || ch == u'.' || ch == u'-' || ch == u'_'; out += safe ? ch : u'_'; } - // Точки в начале превратили бы папку в скрытую, а «..» — в путь наружу. while (out.startsWith(u'.')) out.remove(0, 1); return out; @@ -84,8 +83,6 @@ QString JavaRuntimeStore::locateBinary(const QString &rootDir) if (rootDir.isEmpty() || !QDir(rootDir).exists()) return {}; - // Кандидатов бывает несколько: в JDK рядом с bin/java лежит ещё и - // jre/bin/java. Берём самый короткий путь — это корневой интерпретатор. QString best; QDirIterator it(rootDir, QStringList{QString::fromLatin1(kExecutable)}, QDir::Files, QDirIterator::Subdirectories); @@ -119,8 +116,6 @@ std::optional JavaRuntimeStore::find(const QString &id) runtime.version = object.value(QStringLiteral("version")).toString(); runtime.size = static_cast(object.value(QStringLiteral("size")).toDouble()); - // Путь хранится относительным: папку лаунчера могли перенести вместе с - // профилем пользователя, а абсолютный путь этого не переживает. const QString relative = object.value(QStringLiteral("javaPath")).toString(); runtime.javaPath = relative.isEmpty() ? QString() : QDir(dir).absoluteFilePath(relative); @@ -185,8 +180,6 @@ bool JavaRuntimeStore::writeMeta(const InstalledJavaRuntime &runtime, QString *e bool JavaRuntimeStore::remove(const QString &id, QString *error) { const QString dir = dirFor(id); - // Удаляем только внутри /java: sanitizeId не пропустит «..», но - // проверка стоит одной строки, а цена ошибки — чужая папка. if (dir.isEmpty() || !dir.startsWith(LauncherPaths::javaDir() + u'/')) { if (error) *error = Loc::text("java.error.invalidRuntimeId"); @@ -208,9 +201,6 @@ int JavaRequirement::minimumFor(const QString &minecraftVersionId) if (id.isEmpty()) return 0; - // Снапшоты — YYwWWa. Ровных границ по неделям нет, но год отвечает на - // вопрос «какая Java» точнее, чем ничего: 1.17 (Java 16) вышла в 21-м году, - // 1.18 (Java 17) в конце того же года, 1.20.5 (Java 21) — в 24-м. static const QRegularExpression snapshot(QStringLiteral("^(\\d{2})w\\d{2}[a-z]$")); const QRegularExpressionMatch snapshotMatch = snapshot.match(id); if (snapshotMatch.hasMatch()) { @@ -224,18 +214,16 @@ int JavaRequirement::minimumFor(const QString &minecraftVersionId) return 8; } - // Релизы и их пре-релизы: 1.20.5, 1.21, 1.21.4-pre1, 26.2. static const QRegularExpression release( QStringLiteral("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?")); const QRegularExpressionMatch releaseMatch = release.match(id); if (!releaseMatch.hasMatch()) - return 8; // a1.2.6, b1.7.3, rd-132211 и прочая доисторическая ваниль + return 8; const int epoch = releaseMatch.captured(1).toInt(); const int major = releaseMatch.captured(2).toInt(); const int patch = releaseMatch.captured(3).toInt(); - // Нумерация после 1.21.x пошла по годам (26.2) — это заведомо новее 1.20.5. if (epoch != 1) return 21; if (major >= 21) diff --git a/javaruntime.h b/javaruntime.h index 32ac0b2..e343b4e 100644 --- a/javaruntime.h +++ b/javaruntime.h @@ -7,18 +7,16 @@ #include -// Откуда взялась сборка Java и что именно в ней лежит. enum class JavaRuntimeKind { - Mojang, // «Java» — тот же рантайм, которым игру запускает официальный лаунчер - Jdk, // Eclipse Temurin JDK: компилятор и инструменты в комплекте - Jre // Eclipse Temurin JRE: только то, что нужно для запуска + Mojang, + Jdk, + Jre }; -QString javaKindKey(JavaRuntimeKind kind); // java | jdk | jre +QString javaKindKey(JavaRuntimeKind kind); std::optional javaKindFromKey(const QString &key); QString javaKindTitle(JavaRuntimeKind kind); -// Строка каталога — то, что можно скачать. struct JavaRuntimeEntry { QString id; // temurin-jdk-21.0.12.1_1 | mojang-java-runtime-delta @@ -37,7 +35,6 @@ struct JavaRuntimeEntry bool isValid() const { return !id.isEmpty() && major > 0 && url.isValid(); } }; -// Сборка, уже распакованная в папке лаунчера. struct InstalledJavaRuntime { QString id; @@ -48,39 +45,16 @@ struct InstalledJavaRuntime qint64 size = 0; // сколько заняло на диске по итогам установки }; -// Папка /java: одна подпапка на сборку плюс её описание внутри. Отдельного -// индекса нет намеренно — удалённую вручную папку не пришлось бы вычищать ещё и -// из общего файла. namespace JavaRuntimeStore { - QString dirFor(const QString &id); - -// Всё, что лежит в /java и на что нашлась java. Новые версии сверху. QList installed(); std::optional find(const QString &id); - -// Ищет bin/java в распакованном дереве: у Temurin на macOS это -// Contents/Home/bin, у Mojang — jre.bundle/Contents/Home/bin. QString locateBinary(const QString &rootDir); - bool writeMeta(const InstalledJavaRuntime &runtime, QString *error); bool remove(const QString &id, QString *error); - -// Имя папки из идентификатора каталога: всё, кроме букв, цифр, точки, дефиса и -// подчёркивания, заменяется — идентификатор приходит из сети. QString sanitizeId(const QString &id); - -} // namespace JavaRuntimeStore - -// Какая Java нужна какой версии игры. Точный ответ лежит в client.json -// (javaVersion.majorVersion) и берётся оттуда при запуске; эта таблица нужна -// раньше — когда версия ещё не скачана, а подсказку в каталоге показать надо. +} namespace JavaRequirement { - -// Минимальная мажорная версия Java для версии игры. 0 — разобрать не удалось. int minimumFor(const QString &minecraftVersionId); - -// «Minecraft 1.20.5 и новее» — подпись к строке каталога. QString coverage(int javaMajor); - -} // namespace JavaRequirement +} diff --git a/javaruntimeservice.cpp b/javaruntimeservice.cpp index 770814b..6c956e2 100644 --- a/javaruntimeservice.cpp +++ b/javaruntimeservice.cpp @@ -22,20 +22,14 @@ namespace { const QString kAdoptiumApi = QStringLiteral("https://api.adoptium.net/v3"); -// Список компонентов рантайма Mojang. Ссылка зашита в официальный лаунчер и -// меняется вместе с ним; сами компоненты обновляются по этому же адресу. const QString kMojangRuntimesUrl = QStringLiteral( "https://piston-meta.mojang.com/v1/products/java-runtime/" "2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"); -// Сборки Java выходят раз в квартал, а не раз в неделю: суток кэша достаточно, -// чтобы новая появилась в тот же день, и хватает на десяток открытий окна. constexpr qint64 kCacheTtlSeconds = 24 * 60 * 60; constexpr int kTransferTimeoutMs = 20000; -// Сколько последних сборок каждой мажорной версии показывать. Больше трёх -// смысла нет: старые патчи отличаются только исправлениями безопасности. constexpr int kReleasesPerMajor = 3; QString adoptiumOs() @@ -63,7 +57,6 @@ QString adoptiumArch() return arch; } -// Ключ платформы в манифесте Mojang. QString mojangPlatform() { const QString arch = QSysInfo::currentCpuArchitecture(); @@ -90,7 +83,6 @@ QNetworkRequest makeRequest(const QUrl &url) return request; } -// «1.8.0_504-b01» никто не узнаёт, а «8u504» — общепринятое имя той же сборки. QString displayVersion(const QJsonObject &versionData) { const int major = versionData.value(QStringLiteral("major")).toInt(); @@ -138,8 +130,6 @@ std::optional JavaRuntimeService::bestFor(int major) const if (major <= 0) return std::nullopt; - // Каталог уже отфильтрован по текущей ОС и архитектуре, поэтому здесь - // остаётся выбрать из заведомо запускаемого. const auto rank = [](JavaRuntimeKind kind) { switch (kind) { case JavaRuntimeKind::Jdk: @@ -160,8 +150,6 @@ std::optional JavaRuntimeService::bestFor(int major) const best = &entry; continue; } - // Чем ближе мажорная версия к нужной, тем меньше шансов на сюрприз: - // игра на Java 8 под Java 21 просто не стартует. if (entry.major != best->major) { if (entry.major < best->major) best = &entry; @@ -206,8 +194,6 @@ void JavaRuntimeService::ensureLoaded(Callback callback, bool forceRefresh) const bool fresh = hasData() && m_fetchedAt.isValid() && m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds; if (!forceRefresh && fresh) { - // Колбэк, который то синхронный, то нет, ломает вызывающий код — - // на попадании в кэш отвечаем через событийный цикл. if (callback) QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); return; @@ -225,9 +211,6 @@ void JavaRuntimeService::startRefresh() m_incoming.clear(); m_warnings.clear(); m_ltsVersions.clear(); - - // Счётчик поднимаем на оба стартовых запроса разом: иначе ответ первого - // успел бы обнулить его и завершить обновление раньше времени. m_outstanding = 2; emit refreshingChanged(); @@ -265,8 +248,6 @@ void JavaRuntimeService::requestAdoptiumReleases() return; } - // Запросы добавляем до того, как отчитается этот: иначе счётчик уйдёт - // в ноль между двумя строчками. m_outstanding += majors.size(); const QString arch = adoptiumArch(); for (const int major : std::as_const(majors)) @@ -298,8 +279,6 @@ void JavaRuntimeService::requestFeatureRelease(int major, connect(reply, &QNetworkReply::finished, this, [this, reply, major, allowFallback] { reply->deleteLater(); - // 404 значит «под эту платформу такой сборки нет» — это не ошибка сети, - // а нормальный ответ: Java 8 и 16 под Apple Silicon просто не собирали. const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); QList parsed; @@ -307,7 +286,6 @@ void JavaRuntimeService::requestFeatureRelease(int major, parsed = parseAdoptium(reply->readAll(), m_ltsVersions); if (parsed.isEmpty() && allowFallback && (status == 404 || status == 200)) { - // На Apple Silicon старые сборки берём под x64: Rosetta их запускает. m_outstanding += 1; requestFeatureRelease(major, QStringLiteral("x64"), false); requestFinished({}); @@ -354,7 +332,6 @@ void JavaRuntimeService::finishRefresh() { m_outstanding = 0; const QString warning = m_warnings.join(QStringLiteral("; ")); - // Пустой разбор — испорченные ответы; хороший кэш ими не затираем. const bool ok = !m_incoming.isEmpty(); if (ok) { applyEntries(std::exchange(m_incoming, {})); @@ -365,11 +342,8 @@ void JavaRuntimeService::finishRefresh() m_incoming.clear(); m_warnings.clear(); emit refreshingChanged(); - - // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. const QList pending = std::exchange(m_pending, {}); for (const Callback &callback : pending) { - // Сеть подвела, но старые данные есть — работаем на них. if (!ok && hasData()) callback(true, warning.isEmpty() ? Loc::text("java.status.catalogFromCache") : warning); else @@ -400,13 +374,11 @@ QList JavaRuntimeService::parseAdoptium(const QByteArray &payl const QJsonObject binary = binaryValue.toObject(); const auto kind = javaKindFromKey(binary.value(QStringLiteral("image_type")).toString()); if (!kind || *kind == JavaRuntimeKind::Mojang) - continue; // testimage, debugimage, sources и прочее нам не нужно + continue; const QJsonObject package = binary.value(QStringLiteral("package")).toObject(); const QUrl url(package.value(QStringLiteral("link")).toString()); const QString archive = archiveKind(package.value(QStringLiteral("name")).toString()); - // Распаковать умеем только zip и tar.gz; https — потому что ссылку - // мы получили из сети и открываем её же. if (archive.isEmpty() || url.scheme() != QLatin1String("https")) continue; @@ -438,13 +410,12 @@ QList JavaRuntimeService::parseMojang(const QByteArray &payloa const QJsonObject platform = root.value(mojangPlatform()).toObject(); for (auto it = platform.constBegin(); it != platform.constEnd(); ++it) { - // minecraft-java-exe — это лаунчер игры, а не Java. if (it.key() == QLatin1String("minecraft-java-exe")) continue; const QJsonArray variants = it.value().toArray(); if (variants.isEmpty()) - continue; // компонента под эту платформу нет + continue; const QJsonObject variant = variants.first().toObject(); const QJsonObject manifest = variant.value(QStringLiteral("manifest")).toObject(); @@ -461,7 +432,6 @@ QList JavaRuntimeService::parseMojang(const QByteArray &payloa entry.kind = JavaRuntimeKind::Mojang; entry.component = it.key(); entry.version = version; - // «8u51-cacert462b08» у jre-legacy, «21.0.7» у остальных. entry.major = version.startsWith(QLatin1String("8u")) ? 8 : version.section(u'.', 0, 0).toInt(); @@ -506,7 +476,6 @@ bool JavaRuntimeService::readCache() return false; const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); - // Кэш платформозависим: папку лаунчера могли принести с другой машины. if (root.value(QStringLiteral("platform")).toString() != mojangPlatform()) return false; @@ -547,7 +516,6 @@ bool JavaRuntimeService::readCache() void JavaRuntimeService::writeCache() { - // Папку кэша могли удалить уже после запуска лаунчера. if (!LauncherPaths::ensureRootExists()) return; diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 0733818..f7202bc 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -590,8 +590,6 @@ void LauncherBackend::removeCustomBuild(int index) || m_builds.isEmpty(); saveCustomBuilds(); - // Раньше удалялась только запись, а архив сборки оставался лежать в - // galeonLauncher/builds вместе со ссылкой на него в index.json. if (!m_switcher->forgetBuild(removed.id)) { emit launchError(Loc::text("build.warning.archiveNotRemoved") .arg(removed.name, @@ -604,9 +602,6 @@ void LauncherBackend::removeCustomBuild(int index) if (!wasActive) return; - // Удалили активную сборку: в .minecraft лежит её содержимое, а активной - // становится другая. Оставить как есть нельзя — при следующем переключении - // чужое содержимое уехало бы в архив новой сборки поверх её собственного. m_settings.remove(QStringLiteral("activeBuildId")); saveSettings(); @@ -617,8 +612,6 @@ void LauncherBackend::removeCustomBuild(int index) const CustomBuild &next = m_builds.first(); emit launchProgress(Loc::text("build.progress.activating").arg(next.name)); - // from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе - // с ней. Дальше обычная очистка и распаковка новой активной сборки. m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir()); emit switchChanged(); emit busyChanged(); @@ -1118,8 +1111,6 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) return; } - // Дальше проверяется только папка игры: свои файлы лаунчер уже разложил - // в galeonLauncher при запуске. const QString directory = gameDir(); if (!QDir(directory).exists()) { emit launchError(Loc::text("launch.error.gameDirMissing") @@ -2552,7 +2543,7 @@ void LauncherBackend::loadSettings() {QStringLiteral("fullscreen"), false}, // Язык интерфейса: "system" — по системной локали. {QStringLiteral("language"), QStringLiteral("system")}, - {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")}, + {QStringLiteral("launcherName"), QStringLiteral("BiLauncher")}, {QStringLiteral("launcherVersion"), QStringLiteral("1.0")}, {QStringLiteral("activeBuildId"), 0}, // Сервер готовых сборок. В интерфейс не вынесен: менять его diff --git a/launcherpaths.cpp b/launcherpaths.cpp index 74c75c4..e9bbd07 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -6,7 +6,7 @@ namespace { -const QString kFolderName = QStringLiteral("galeonLauncher"); +const QString kFolderName = QStringLiteral("BiLauncher"); } // namespace diff --git a/launcherpaths.h b/launcherpaths.h index ef93759..cd8a11c 100644 --- a/launcherpaths.h +++ b/launcherpaths.h @@ -2,71 +2,27 @@ #include -// Пути к данным лаунчера. Собственная папка galeonLauncher лежит рядом со -// стандартной .minecraft, а не в системном каталоге приложения. namespace LauncherPaths { -// Родительская папка, в которой лежит .minecraft (и наша galeonLauncher). QString containerDir(); - -// /galeonLauncher — корень данных лаунчера. QString rootDir(); - -// Стандартная папка игры. QString defaultMinecraftDir(); - QString settingsFile(); QString profilesFile(); - -// /customBuilds.json — пользовательские сборки. QString customBuildsFile(); - -// /versions.json — как сборки назывались до переименования. Читается -// один раз при миграции и больше ни для чего не нужен. QString legacyCustomBuildsFile(); - -// /builds — архивы содержимого .minecraft по одному на сборку. QString buildStorageDir(); - -// /builds/ — папка одной сборки: её архив и, у сезонных, скачанный -// пак с описанием установленной ревизии. QString buildDir(int buildId); - -// /builds//season.json — какая ревизия сезонной сборки установлена и -// какие файлы она принесла. Список нужен, чтобы при обновлении убрать те, что -// из сборки ушли. QString seasonalStateFile(int buildId); - -// Сюда качается authlib-injector. QString runtimeDir(); - -// /java — сборки Java, скачанные лаунчером. Каждая в своей подпапке, -// имя подпапки — идентификатор сборки из каталога. QString javaDir(); - -// Слепок каталога доступных сборок Java с отметкой времени. QString javaCatalogFile(); - -// Сюда качаются архивы Temurin до распаковки. QString javaDownloadDir(); - -// /cache — данные, которые можно удалить без потерь. QString cacheDir(); - -// Слепок манифеста версий Mojang с отметкой времени. QString versionManifestFile(); - -// Слепок каталога сезонных сборок с отметкой времени. QString seasonalCatalogFile(); - -// Слепок списка версий одного модлоадера: loaderKey — forge|fabric|neoforge|quilt. QString loaderCacheFile(const QString &loaderKey); - -// Сюда качаются installer.jar модлоадеров. QString loaderDownloadDir(); - -// Создаёт папку лаунчера, если её нет. Вызывается при каждом запуске и перед -// каждой записью. false и текст в error, если папки нет или в неё не пишется. bool ensureRootExists(QString *error = nullptr); -} // namespace LauncherPaths +} diff --git a/localization.cpp b/localization.cpp index 4820cfe..f286e16 100644 --- a/localization.cpp +++ b/localization.cpp @@ -21,8 +21,6 @@ const QString kCatalogPath = QStringLiteral(":/qt/qml/BiLauncher/i18n/translations.json"); const QString kSystem = QStringLiteral("system"); - -// Язык по системной локали. Промежуточных вариантов нет: языков в каталоге два. QString systemLanguage() { return QLocale::system().language() == QLocale::Russian ? QStringLiteral("ru") @@ -220,10 +218,6 @@ QStringList Localization::listFor(const char *key) const } namespace { - -// Раскладывает плоский ключ "a.b.c" по вложенным картам. Рекурсия, а не спуск -// указателем: QVariant не отдаёт ссылку на вложенную карту, её приходится -// доставать копией и класть обратно. void insertPath(QVariantMap &node, const QStringList &parts, int depth, const QVariant &value) { if (depth == parts.size() - 1) { @@ -235,10 +229,8 @@ void insertPath(QVariantMap &node, const QStringList &parts, int depth, const QV node[parts.at(depth)] = child; } -} // namespace +} -// Дерево для QML: плоские ключи "a.b.c" разбираются в вложенные объекты, чтобы -// в разметке писалось Loc.t.a.b.c. Строится один раз на смену языка. void Localization::rebuildTree() { if (!m_engine) @@ -267,4 +259,4 @@ QStringList list(const char *key) return Localization::instance().listFor(key); } -} // namespace Loc +} diff --git a/main.cpp b/main.cpp index f63bb40..76f0245 100644 --- a/main.cpp +++ b/main.cpp @@ -12,22 +12,11 @@ int main(int argc, char *argv[]) { #ifdef LAUNCHER_HAS_WEBENGINE - // Вход в аккаунт Microsoft показывается встроенным окном браузера. - // Инициализация обязана идти до QGuiApplication: она выставляет общий - // контекст OpenGL, а после создания приложения это уже не действует. QtWebEngineQuick::initialize(); #endif QGuiApplication app(argc, argv); - // Иконка окна и панели задач. Отдаём все размеры сразу: оконный менеджер - // сам выберет подходящий, а готовый растр всегда чётче, чем полученный - // масштабированием одной большой картинки. - // - // На macOS этого делать нельзя: setWindowIcon подменяет иконку в доке - // растром во весь холст, и лаунчер выглядит заметно крупнее соседей. Там - // иконку берут из icon.icns внутри бандла — он нарисован по сетке Apple, - // с полями вокруг арта. #ifndef Q_OS_MACOS QIcon appIcon; for (int size : {16, 24, 32, 48, 64, 128, 256, 512}) @@ -37,19 +26,11 @@ int main(int argc, char *argv[]) QGuiApplication::setWindowIcon(appIcon); #endif - // На Wayland окно связывается с установленным .desktop-файлом только по - // этому имени; без него композитор покажет иконку по умолчанию. QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher")); - // Язык интерфейса нужно знать до того, как QML вычислит первую привязку, - // поэтому каталог читается здесь, а не в LauncherBackend: тот появляется - // только вместе с движком. Каталог лежит в ресурсах — не прочитался, значит - // сломана сборка, и запускаться дальше незачем. if (!Localization::instance().load()) return -1; - // Нативный стиль Windows игнорирует пользовательские contentItem/background - // и сыплет предупреждениями, а всё окно стилизовано вручную — берём Basic. QQuickStyle::setStyle("Basic"); QQmlApplicationEngine engine; diff --git a/minecraftversion.cpp b/minecraftversion.cpp index bb99fb6..5133bd5 100644 --- a/minecraftversion.cpp +++ b/minecraftversion.cpp @@ -16,7 +16,6 @@ namespace { -// Больше десяти уровней inheritsFrom не бывает даже у Forge поверх Fabric-подобных сборок. constexpr int kMaxInheritanceDepth = 10; QString versionJsonPath(const QString &gameDir, const QString &id) @@ -34,14 +33,11 @@ QString versionsRoot(const QString &gameDir) return gameDir + QStringLiteral("/versions"); } -// Идентификатор версии — это имя папки, поэтому «чинить» его как sanitizeId() -// нельзя: подстановка символов увела бы нас не в ту версию. Только проверяем. bool isSafeVersionId(const QString &id) { return !id.isEmpty() && !id.startsWith(u'.') && !id.contains(u'/') && !id.contains(u'\\'); } -// Верхний объект .json без разворачивания цепочки: нужен только inheritsFrom. QJsonObject readVersionObject(const QString &gameDir, const QString &id) { QFile file(versionJsonPath(gameDir, id)); @@ -50,7 +46,6 @@ QJsonObject readVersionObject(const QString &gameDir, const QString &id) return QJsonDocument::fromJson(file.readAll()).object(); } -// org.lwjgl:lwjgl:3.3.1:natives-windows -> org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar QString mavenToPath(const QString &name) { QString coords = name; @@ -74,8 +69,6 @@ QString mavenToPath(const QString &name) + u'/' + file + u'.' + extension; } -// Ключ для дедупликации: у наследника и родителя одна и та же библиотека часто -// разных версий, и побеждать должна версия наследника. QString libraryKey(const QString &name) { const QStringList parts = name.split(u':'); @@ -184,14 +177,10 @@ void parseLibraries(const QJsonObject &root, for (const QJsonValue &value : extract.value(QStringLiteral("exclude")).toArray()) exclude << value.toString(); - // Основной артефакт. В новом формате (1.19+) натив-библиотеки — это - // отдельные записи с классификатором natives-*, у них тоже есть artifact. if (downloads.contains(QStringLiteral("artifact")) || !downloads.contains(QStringLiteral("classifiers"))) { MinecraftLibrary library = libraryFromDownload( name, downloads.value(QStringLiteral("artifact")).toObject(), relativePath); - // У Fabric у части библиотек нет блока downloads вообще — только - // name и адрес Maven-репозитория, путь собираем из координат. if (library.url.isEmpty()) { const QString repository = object.value(QStringLiteral("url")).toString(); if (!repository.isEmpty()) @@ -206,7 +195,6 @@ void parseLibraries(const QJsonObject &root, } } - // Старый формат (до 1.18): отдельная карта natives + downloads.classifiers. const QJsonObject natives = object.value(QStringLiteral("natives")).toObject(); if (natives.isEmpty()) continue; @@ -232,8 +220,6 @@ void parseLibraries(const QJsonObject &root, } } -// Для 1.19+ в списке лежат natives-windows, natives-windows-x86 и natives-windows-arm64 -// одновременно — правила фильтруют только по ОС. Оставляем по одной на артефакт. void pickNativesForCurrentArch(QList &libraries) { const QString exact = VersionLoader::nativeClassifier(); @@ -242,7 +228,7 @@ void pickNativesForCurrentArch(QList &libraries) : VersionLoader::osName(); const QString base = QStringLiteral("natives-") + osPart; - QHash chosen; // group:artifact -> классификатор + QHash chosen; for (const MinecraftLibrary &library : libraries) { if (!library.native) continue; @@ -268,7 +254,7 @@ void pickNativesForCurrentArch(QList &libraries) }); } -} // namespace +} QString VersionLoader::osName() { @@ -286,7 +272,7 @@ QString VersionLoader::osArch() const QString arch = QSysInfo::currentCpuArchitecture(); if (arch == QLatin1String("i386") || arch == QLatin1String("i686")) return QStringLiteral("x86"); - return arch; // x86_64 / arm64 — совпадают с тем, что пишет Mojang + return arch; } QString VersionLoader::nativeClassifier() @@ -339,8 +325,6 @@ QStringList VersionLoader::dependentsOf(const QString &gameDir, const QString &v if (versionId.isEmpty()) return {}; - // Одного уровня хватает: профили модлоадеров наследуются от ванильных версий, - // а не друг от друга. QStringList result; const QStringList installed = installedVersions(gameDir); for (const QString &id : installed) { @@ -378,8 +362,6 @@ bool VersionLoader::remove(const QString &gameDir, const QString &versionId, QSt return false; } - // Удаляем только внутри /versions: проверка id уже не пропустит «..», - // но цена ошибки — чужая папка, а проверка стоит одной строки. const QString dir = QDir::cleanPath(root + u'/' + versionId); if (!dir.startsWith(root + u'/')) { if (error) @@ -403,7 +385,6 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, { MinecraftVersion version; - // Цепочка от самой производной версии к базовой: Fabric -> 1.20.1. QList chain; QStringList chainIds; QString currentId = versionId; @@ -445,7 +426,6 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, version.id = versionId; - // Скаляры: идём от базовой версии к производной, наследник перекрывает родителя. for (int i = chain.size() - 1; i >= 0; --i) { const QJsonObject &object = chain[i]; @@ -480,7 +460,6 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, version.loggingConfigPath = gameDir + QStringLiteral("/assets/log_configs/") + fileId; } - // minecraftArguments (до 1.13) заменяет список целиком, arguments.game — дополняет. if (object.contains(QStringLiteral("minecraftArguments"))) { version.gameArgs = object.value(QStringLiteral("minecraftArguments")) .toString() @@ -506,7 +485,6 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, if (version.type.isEmpty()) version.type = QStringLiteral("release"); - // Библиотеки: наследник первым, дубли по group:artifact:classifier отбрасываем. QList collected; for (const QJsonObject &object : chain) parseLibraries(object, collected, features); @@ -521,7 +499,6 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, } pickNativesForCurrentArch(version.libraries); - // До 1.13 jvm-аргументов в json нет — собираем их сами. const bool hasClasspathArg = std::any_of( version.jvmArgs.cbegin(), version.jvmArgs.cend(), [](const QString &argument) { return argument == QLatin1String("-cp") || argument == QLatin1String("-classpath"); @@ -533,7 +510,6 @@ MinecraftVersion VersionLoader::load(const QString &gameDir, << QStringLiteral("-cp") << QStringLiteral("${classpath}"); } - // Клиентский jar: у Fabric-версии он свой, у Forge обычно только у родителя. for (const QString &id : std::as_const(chainIds)) { const QString jar = versionJarPath(gameDir, id); if (QFile::exists(jar)) { diff --git a/modloaderinstaller.cpp b/modloaderinstaller.cpp index 319a934..dc9c198 100644 --- a/modloaderinstaller.cpp +++ b/modloaderinstaller.cpp @@ -27,15 +27,12 @@ namespace { constexpr int kTransferTimeoutMs = 30000; -// Установщик Forge после распаковки ещё патчит клиентский jar: на медленной -// машине это несколько минут. Обрываем только совсем зависший процесс. constexpr int kInstallerTimeoutMs = 15 * 60 * 1000; constexpr int kLogTailLines = 12; const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/"); const QString kQuiltProfile = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/"); -// Тот же запрет, что у VersionInstaller: id версии становится именем каталога. bool isSafeSegment(const QString &value) { if (value.isEmpty() || value.contains(u'/') || value.contains(u'\\') || value.contains(u':')) @@ -48,7 +45,7 @@ QString versionJsonPath(const QString &gameDir, const QString &id) return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json"); } -} // namespace +} ModLoaderInstaller::ModLoaderInstaller(ModLoaderVersionService *meta, VersionInstaller *versionInstaller, @@ -58,8 +55,6 @@ ModLoaderInstaller::ModLoaderInstaller(ModLoaderVersionService *meta, , m_versionInstaller(versionInstaller) , m_network(new QNetworkAccessManager(this)) { - // Последний шаг обоих путей одинаков: VersionInstaller дотягивает всё, чего - // не хватает получившемуся профилю. connect(m_versionInstaller, &VersionInstaller::finished, this, [this](const QString &id) { if (m_awaitingVersionId.isEmpty() || id != m_awaitingVersionId) return; @@ -96,7 +91,7 @@ ModLoaderInstaller::~ModLoaderInstaller() double ModLoaderInstaller::fraction() const { if (m_bytesTotal <= 0) - return -1.0; // панель нарисует «…» + return -1.0; return double(m_bytesDone) / double(m_bytesTotal); } @@ -131,9 +126,6 @@ void ModLoaderInstaller::install(const QString &gameDir, emit started(m_label); setStage(Loc::text("loader.progress.preparing").arg(loaderTitle(loader))); - // Список версий нужен и для ссылки на installer.jar, и для точного id - // профиля. После перезапуска лаунчера он ещё не прочитан с диска, поэтому - // спрашиваем его до всего остального: попадание в кэш обойдётся без сети. m_meta->ensureLoaded(loader, m_gameVersion, [this, loader](bool ok, const QString &warning) { if (!m_running || m_canceling) return; @@ -142,9 +134,6 @@ void ModLoaderInstaller::install(const QString &gameDir, : warning); return; } - // Любой лоадер — надстройка над ванильной версией: и профиль Fabric с его - // inheritsFrom, и патч клиентского jar у Forge упрутся в недокачанную базу. - // Поэтому шлюз один на всех и стоит до выбора ветки. ensureBaseVersion([this, loader] { if (loader == ModLoader::Fabric || loader == ModLoader::Quilt) beginFabricLike(); @@ -172,7 +161,7 @@ void ModLoaderInstaller::cancel() removeVersionsAddedByInstaller(); if (!m_awaitingVersionId.isEmpty()) { m_versionInstaller->cancel(); - return; // финал придёт из VersionInstaller::canceled + return; } const QString label = m_label; @@ -180,8 +169,6 @@ void ModLoaderInstaller::cancel() emit canceled(label); } -// ── Fabric и Quilt ───────────────────────────────────────────────────────── - void ModLoaderInstaller::beginFabricLike() { const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion); @@ -227,8 +214,6 @@ void ModLoaderInstaller::beginFabricLike() const QString id = root.value(QStringLiteral("id")).toString(); const QString inherits = root.value(QStringLiteral("inheritsFrom")).toString(); - // Описание приходит из сети и превращается в путь на диске — проверяем - // и id, и то, что версия действительно наследует выбранную ванильную. if (id.isEmpty() || !isSafeSegment(id)) { fail(Loc::text("loader.error.descriptionBadId") .arg(loaderTitle(m_loader))); @@ -260,8 +245,6 @@ void ModLoaderInstaller::beginFabricLike() }); } -// ── Forge и NeoForge ─────────────────────────────────────────────────────── - void ModLoaderInstaller::beginInstallerJar() { const auto entry = m_meta->find(m_loader, m_gameVersion, m_loaderVersion); @@ -274,16 +257,11 @@ void ModLoaderInstaller::beginInstallerJar() fail(Loc::text("loader.error.installerBadUrl").arg(loaderTitle(m_loader))); return; } - - // Ванильную версию к этому моменту уже проверил ensureBaseVersion из install(). downloadInstaller(entry->installerUrl, [this](const QString &path) { runInstaller(path); }); } void ModLoaderInstaller::ensureBaseVersion(std::function next) { - // Наличие versions//.json ничего не значит: VersionInstaller кладёт его - // до загрузки файлов, так что оборванная установка оставляет ровно такой след. - // Проверяем тем же, чем пользуются приёмка VersionInstaller и запуск игры. QString loadError; const MinecraftVersion installed = VersionLoader::load(m_gameDir, m_gameVersion, {}, &loadError); @@ -379,9 +357,6 @@ void ModLoaderInstaller::downloadInstaller(const QUrl &url, std::functionsetWorkingDirectory(LauncherPaths::loaderDownloadDir()); m_process->setProcessChannelMode(QProcess::MergedChannels); - - // Установщик сверяет sha1 всего, что собрал сам, а на системах с zlib-ng - // сжатие получается другое — без подмены он падает на любой версии игры. QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); QString zlibNote; m_zlibNgUnpatched = !ZlibReference::applyTo(environment, &zlibNote); @@ -483,7 +449,6 @@ void ModLoaderInstaller::runInstaller(const QString &installerPath) appendInstallerLog(text); emit log(text); } - // Хвоста хватает, чтобы показать причину отказа; весь лог не нужен. while (m_installerLog.size() > kLogTailLines) m_installerLog.removeFirst(); }); @@ -493,7 +458,7 @@ void ModLoaderInstaller::runInstaller(const QString &installerPath) }); connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { if (m_canceling || error == QProcess::Crashed) - return; // Crashed придёт ещё и в finished + return; fail(Loc::text("loader.error.installerStartFailed") .arg(loaderTitle(m_loader), m_process->errorString())); }); @@ -505,7 +470,6 @@ void ModLoaderInstaller::runInstaller(const QString &installerPath) return; } - // Совсем зависший установщик не должен блокировать лаунчер навсегда. QTimer::singleShot(kInstallerTimeoutMs, this, [this] { if (m_process && m_process->state() != QProcess::NotRunning) { m_process->kill(); @@ -541,13 +505,8 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) return; } - // Какой профиль появился — выясняем разницей: id у Forge разные по эпохам - // (1.20.1-forge-47.4.0, 1.12.2-forge-14.23.5.2859, neoforge-21.1.66). QStringList added = versionsAddedByInstaller(); if (added.isEmpty()) { - // Разницы нет, если профиль лежал в versions ещё до запуска: переустановка - // поверх или остаток прошлой попытки. Тогда ищем его среди наследников - // выбранной ванильной версии. const QStringList dependents = VersionLoader::dependentsOf(m_gameDir, m_gameVersion); for (const QString &id : dependents) { if (id.contains(m_loaderVersion)) @@ -566,16 +525,12 @@ void ModLoaderInstaller::handleInstallerFinished(int exitCode, bool crashed) return; } if (added.size() > 1) { - // Берём тот, где встречается версия лоадера: остальные — попутно - // доустановленные зависимости. const auto match = std::find_if(added.cbegin(), added.cend(), [this](const QString &id) { return id.contains(m_loaderVersion); }); added = {match != added.cend() ? *match : added.first()}; } - // Профиль на месте — дальше его судьба в руках VersionInstaller, мусором он - // больше не считается. m_installerStarted = false; m_versionsBefore.clear(); handOffToVersionInstaller(added.first()); @@ -597,9 +552,6 @@ QStringList ModLoaderInstaller::versionsAddedByInstaller() const void ModLoaderInstaller::removeVersionsAddedByInstaller() { - // Forge пишет versions//.json до запуска процессоров, поэтому после - // неудачи остаётся профиль-призрак: в списке версий он выглядит установленным, - // а следующей попытке мешает — разница по versions у неё выйдет пустой. const QStringList added = versionsAddedByInstaller(); for (const QString &id : added) { QString error; @@ -643,8 +595,6 @@ void ModLoaderInstaller::closeInstallerLog() m_installerLogFile = nullptr; } -// ── Общий хвост ──────────────────────────────────────────────────────────── - void ModLoaderInstaller::handOffToVersionInstaller(const QString &versionId) { m_awaitingVersionId = versionId; diff --git a/modloaderversionservice.cpp b/modloaderversionservice.cpp index 8c6e223..1ea8b9a 100644 --- a/modloaderversionservice.cpp +++ b/modloaderversionservice.cpp @@ -21,8 +21,6 @@ namespace { -// Списки лоадеров меняются реже манифеста Mojang, но шесть часов держим те же: -// одна цифра на весь лаунчер понятнее двух разных. constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60; constexpr int kTransferTimeoutMs = 20000; @@ -34,7 +32,6 @@ const QString kForgePromotions = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json"); const QString kNeoForgeMaven = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/"); -// Под 1.20.1 NeoForge выходил ещё под старым именем артефакта. const QString kNeoForgeLegacyMaven = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/"); @@ -46,8 +43,6 @@ bool looksUnstable(const QString &version) || lower.contains(u'+'); } -// Сравнение версий по числовым компонентам: «47.4.23» новее «47.4.2», хотя как -// строка стоит раньше. Нечисловые хвосты (alpha, +snapshot) сравниваются как есть. bool isNewer(const QString &a, const QString &b) { const QStringList pa = a.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts); @@ -63,7 +58,6 @@ bool isNewer(const QString &a, const QString &b) return na > nb; continue; } - // Число всегда «новее» суффикса: 26.1.0.0 идёт впереди 26.1.0.0-alpha.1. if (okA != okB) return okA; if (sa != sb) @@ -72,9 +66,6 @@ bool isNewer(const QString &a, const QString &b) return false; } -// «21.1.248» -> «1.21.1», «21.0.167» -> «1.21», «26.1.2.97» -> «26.1.2», -// «26.2.0.68» -> «26.2». До 1.20.2 Minecraft нумеровался как 1.x.y, с 26.1 — -// как x.y, поэтому у NeoForge две схемы: три компонента и четыре. QString neoForgeGameVersion(const QString &neoVersion) { const QString head = neoVersion.section(u'-', 0, 0).section(u'+', 0, 0); @@ -111,7 +102,7 @@ LoaderVersionEntry entryFromJson(const QJsonObject &o) return entry; } -} // namespace +} ModLoaderVersionService::ModLoaderVersionService(QObject *parent) : QObject(parent) @@ -138,8 +129,6 @@ const ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader QString ModLoaderVersionService::requestKey(ModLoader loader, const QString &gameVersion) { - // У Forge и NeoForge один запрос покрывает все версии игры — они делят - // очередь колбэков, чтобы не качать maven-metadata по разу на версию. return isPerGameVersion(loader) ? loaderKey(loader) + u'/' + gameVersion : loaderKey(loader); } @@ -194,8 +183,6 @@ void ModLoaderVersionService::ensureLoaded(ModLoader loader, } if (!forceRefresh && isFresh(loader, gameVersion)) { - // Колбэк, который то синхронный, то нет, ломает вызывающий код — - // на попадании в кэш отвечаем через событийный цикл. if (callback) QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); return; @@ -236,8 +223,6 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game } const QByteArray payload = reply->readAll(); - // Пустой список — законный ответ: этот лоадер не поддерживает такую - // версию игры. Отличаем его от испорченного ответа по самому JSON. const QJsonDocument document = QJsonDocument::fromJson(payload); if (!document.isArray()) { finishRefresh(loader, gameVersion, false, @@ -255,14 +240,11 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game return; } - // Forge и NeoForge: основной maven-metadata плюс вспомогательный запрос - // (рекомендованные сборки у Forge, старая схема 1.20.1 у NeoForge). - // Считаем оба и сводим результат, когда придёт последний. struct Pending { int outstanding = 2; QHash> merged; - QHash recommended; // версия игры -> версия лоадера + QHash recommended; bool primaryOk = false; QString warning; }; @@ -327,7 +309,6 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game it.value().toString()); } } - // Метка «рекомендовано» необязательна: без неё список всё равно рабочий. settle(); }); } else { @@ -354,12 +335,9 @@ void ModLoaderVersionService::finishRefresh(ModLoader loader, const QString key = requestKey(loader, gameVersion); m_inFlight.remove(key); emit refreshingChanged(); - - // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. const QList pending = m_pending.take(key); const bool haveData = !bucket(loader).byGame.isEmpty(); for (const Callback &callback : pending) { - // Сеть недоступна, но старые данные есть — работаем на них. if (!ok && haveData) callback(true, warning); else @@ -389,7 +367,6 @@ QList ModLoaderVersionService::parseFabricLike(const QByteAr list << entry; } - // Fabric и Quilt отдают новые сверху, но зависеть от этого не стоит. std::stable_sort(list.begin(), list.end(), [](const LoaderVersionEntry &a, const LoaderVersionEntry &b) { return isNewer(a.loaderVersion, b.loaderVersion); @@ -400,9 +377,6 @@ QList ModLoaderVersionService::parseFabricLike(const QByteAr QHash> ModLoaderVersionService::parseMavenMetadata( const QByteArray &xml, ModLoader loader, bool legacyNeoForge) { - // Версия игры зашита в саму строку версии лоадера, поэтому раскладка по - // версиям игры — это и есть проверка совместимости: несовместимой пары в - // получившемся отображении просто не существует. const bool splitOnDash = loader == ModLoader::Forge || legacyNeoForge; const QString base = loader == ModLoader::Forge ? kForgeMaven : legacyNeoForge ? kNeoForgeLegacyMaven @@ -445,8 +419,6 @@ QHash> ModLoaderVersionService::parseMavenMet if (reader.hasError()) return {}; - // maven-metadata приходит неотсортированным: 1.20.1-47.4.23 стоит между - // версиями 26.x. Без сортировки «самой новой» оказалась бы случайная сборка. for (auto it = byGame.begin(); it != byGame.end(); ++it) { std::stable_sort(it.value().begin(), it.value().end(), [](const LoaderVersionEntry &a, const LoaderVersionEntry &b) { @@ -488,7 +460,6 @@ bool ModLoaderVersionService::readCache(ModLoader loader) void ModLoaderVersionService::writeCache(ModLoader loader) { - // Папку кэша могли удалить уже после запуска лаунчера. if (!LauncherPaths::ensureRootExists()) return; @@ -507,8 +478,6 @@ void ModLoaderVersionService::writeCache(ModLoader loader) const QJsonObject root{{QStringLiteral("fetchedAt"), b.fetchedAt.toString(Qt::ISODate)}, {QStringLiteral("games"), games}}; - // QSaveFile: оборванная запись не оставит обрезанный кэш, который потом - // пришлось бы отличать от настоящего. QSaveFile file(LauncherPaths::loaderCacheFile(loaderKey(loader))); if (!file.open(QIODevice::WriteOnly)) return; diff --git a/msaauthservice.cpp b/msaauthservice.cpp index 2f3aaf2..d6386f4 100644 --- a/msaauthservice.cpp +++ b/msaauthservice.cpp @@ -46,8 +46,6 @@ QString normalizeUuid(const QString &uuid) return QString(uuid).remove(u'-'); } -// Первый элемент DisplayClaims.xui — единственный, который отдают и Xbox Live, -// и XSTS: в нём лежат uhs (хеш пользователя) и xid (он же XUID). QJsonObject displayClaim(const QJsonObject &response) { return response.value(QStringLiteral("DisplayClaims")) @@ -58,8 +56,6 @@ QJsonObject displayClaim(const QJsonObject &response) .toObject(); } -// XSTS отказывает не текстом, а числовым кодом — без расшифровки пользователь -// видит просто «401». QString describeXstsError(const QJsonObject &response) { const qint64 code = static_cast(response.value(QStringLiteral("XErr")).toDouble()); @@ -92,7 +88,7 @@ QString describeOauthError(const QJsonObject &response, int status) return Loc::text("auth.msa.error.microsoftCode").arg(status); } -} // namespace +} MsaAuthService::MsaAuthService(QObject *parent) : QObject(parent) @@ -113,8 +109,6 @@ QUrl MsaAuthService::authorizationUrl() query.addQueryItem(QStringLiteral("response_type"), QStringLiteral("code")); query.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri); query.addQueryItem(QStringLiteral("scope"), kScope); - // Без этого Microsoft молча пускает по прошлой сессии браузера, и добавить - // второй аккаунт становится нечем. query.addQueryItem(QStringLiteral("prompt"), QStringLiteral("select_account")); url.setQuery(query); return url; @@ -127,8 +121,6 @@ bool MsaAuthService::matchRedirect(const QUrl &url, QString *code, QString *erro || url.path() != redirect.path()) return false; - // Код приходит в query, но часть приложений Azure настроена на response_mode - // с фрагментом — разбираем оба места. QUrlQuery query(url.query()); if (!query.hasQueryItem(QStringLiteral("code")) && !query.hasQueryItem(QStringLiteral("error"))) query = QUrlQuery(url.fragment()); @@ -268,8 +260,6 @@ void MsaAuthService::exchangeToken(const QByteArray &form, Callback callback) } const QString accessToken = response.value(QStringLiteral("access_token")).toString(); - // Refresh-токен приходит и при продлении: Microsoft выдаёт новый - // и обесценивает прежний, поэтому сохранять надо свежий. const QString refreshToken = response.value(QStringLiteral("refresh_token")).toString(); if (accessToken.isEmpty()) { AuthResult result; @@ -414,9 +404,6 @@ void MsaAuthService::checkLicense(AuthResult result, Callback callback) getJson(QUrl(kEntitlementsUrl), result.accessToken, [this, result, callback](int status, const QJsonObject &response, const QString &) { - // Сетевую ошибку здесь не считаем отказом: окончательный ответ - // про лицензию даёт профиль, а список покупок нужен только - // затем, чтобы точнее объяснить отказ. const bool owns = status == 200 && !response.value(QStringLiteral("items")).toArray().isEmpty(); fetchProfile(result, owns, callback); @@ -435,8 +422,6 @@ void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Call return; } - // 404 здесь — штатный ответ для аккаунта, на котором нет копии - // игры: профиль игрока создаётся только вместе с покупкой. if (status == 404 || status == 401) { result.licenseMissing = true; result.error = diff --git a/seasonalbuildservice.cpp b/seasonalbuildservice.cpp index c2a0067..d327ef2 100644 --- a/seasonalbuildservice.cpp +++ b/seasonalbuildservice.cpp @@ -20,27 +20,18 @@ namespace { -// Формат каталога, который понимает этот лаунчер. Сервер, отдающий версию -// новее, обслуживает лаунчер новее — молча показывать половину полей нельзя. constexpr int kSupportedFormatVersion = 1; -// Каталог весит килобайты, а новая ревизия сборки должна доезжать до игроков -// быстро. Четверть часа — это и не поток запросов при каждом открытии окна, и -// не полдня ожидания после публикации; кнопка «Обновить список» ходит с force. constexpr qint64 kCacheTtlSeconds = 15 * 60; constexpr int kTransferTimeoutMs = 20000; -// id попадает в имена файлов и в сравнение с локальными записями, поэтому -// набор символов узкий и проверяется до всего остального. bool isSafeCatalogId(const QString &id) { static const QRegularExpression re(QStringLiteral("^[a-z0-9][a-z0-9._-]{0,63}$")); return re.match(id).hasMatch(); } -// Тот же запрет, что у идентификаторов версий в minecraftversion.cpp: строка -// становится именем папки в versions. bool isSafeVersionId(const QString &id) { if (id.isEmpty() || id.size() > 128) @@ -61,7 +52,6 @@ bool isSha256Hex(const QString &value) return true; } -// null или пустая строка — даты нет; в таблице на её месте прочерк. QDate readDate(const QJsonValue &value) { const QString text = value.toString(); @@ -73,7 +63,7 @@ QString dateToJson(const QDate &date) return date.isValid() ? date.toString(Qt::ISODate) : QString(); } -} // namespace +} SeasonalBuildService::SeasonalBuildService(QObject *parent) : QObject(parent) @@ -95,9 +85,6 @@ void SeasonalBuildService::setBaseUrl(const QUrl &baseUrl) return; m_baseUrl = baseUrl; - // Ссылки на архивы привязаны к прежнему хосту, а проверка их происхождения - // сверяется с базовым адресом — держать такие данные после смены сервера - // нельзя. m_builds.clear(); m_indexById.clear(); m_fetchedAt = QDateTime(); @@ -154,28 +141,21 @@ QList SeasonalBuildService::parseCatalog(const QJsonObject & entry.archiveSize = qint64(archive.value(QStringLiteral("size")).toDouble()); entry.archiveSha256 = archive.value(QStringLiteral("sha256")).toString().toLower(); - // Данные пришли из сети и превратятся в пути на диске и в запуск игры, - // поэтому строка отбрасывается целиком при любой странности. if (!isSafeCatalogId(entry.id) || entry.name.isEmpty() || entry.revision <= 0) continue; if (!isSafeVersionId(entry.minecraftVersion)) continue; if (!entry.loader.isEmpty() && !loaderFromKey(entry.loader)) continue; - // Загрузчик без версии установить нечем. if (!entry.loader.isEmpty() && entry.loaderVersion.isEmpty()) continue; if (!isSha256Hex(entry.archiveSha256) || entry.archiveSize < 0) continue; - // Ссылка обязана вести на тот же сервер, с которого пришёл каталог: - // подменённый index.json иначе увёл бы загрузку куда угодно. if (entry.archiveUrl.scheme() != m_baseUrl.scheme() || entry.archiveUrl.host() != m_baseUrl.host() || entry.archiveUrl.port(-1) != m_baseUrl.port(-1)) { continue; } - // http допустим только если сам адрес сервера задан по http — так - // работает отладка против локального python3 -m http.server. if (entry.archiveUrl.scheme() != QLatin1String("https") && entry.archiveUrl.scheme() != QLatin1String("http")) { continue; @@ -184,7 +164,6 @@ QList SeasonalBuildService::parseCatalog(const QJsonObject & list << entry; } - // Свежие сезоны сверху: сначала те, что ещё идут, потом по дате старта. std::stable_sort(list.begin(), list.end(), [](const SeasonalBuildEntry &a, const SeasonalBuildEntry &b) { const bool aOpen = !a.seasonEnd.isValid(); @@ -220,8 +199,6 @@ void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh) const bool fresh = hasData() && m_fetchedAt.isValid() && m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds; if (!forceRefresh && fresh) { - // Колбэк, который то синхронный, то нет, ломает вызывающий код — - // на попадании в кэш отвечаем через событийный цикл. if (callback) QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); return; @@ -260,10 +237,6 @@ void SeasonalBuildService::startRefresh() QString parseError; QList parsed = parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError); - // Пустой разбор — испорченный ответ; хороший кэш им не затираем. - // Пустой каталог на сервере от испорченного не отличить, поэтому - // «сборок нет» и «ответ битый» — одно и то же состояние: список - // остаётся прежним, а причина уходит в lastError. if (parsed.isEmpty()) { finishRefresh(false, parseError.isEmpty() ? Loc::text("seasonal.error.catalogEmpty") @@ -284,14 +257,10 @@ void SeasonalBuildService::finishRefresh(bool ok, const QString &warning) m_lastError = ok ? QString() : warning; m_refreshing = false; emit refreshingChanged(); - // Строка ошибки — часть того, что показывает окно, поэтому список - // перечитывается и на неудаче. emit buildsChanged(); - // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. const QList pending = std::exchange(m_pending, {}); for (const Callback &callback : pending) { - // Сеть недоступна, но старые данные есть — работаем на них. if (!ok && hasData()) callback(true, warning); else @@ -306,7 +275,6 @@ bool SeasonalBuildService::readCache() return false; const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); - // Кэш от другого сервера бесполезен: ссылки в нём ведут не туда. if (root.value(QStringLiteral("baseUrl")).toString() != m_baseUrl.toString()) return false; @@ -322,7 +290,6 @@ bool SeasonalBuildService::readCache() void SeasonalBuildService::writeCache() { - // Папку кэша могли удалить уже после запуска лаунчера. if (!LauncherPaths::ensureRootExists()) return; @@ -347,8 +314,6 @@ void SeasonalBuildService::writeCache() {QStringLiteral("sha256"), entry.archiveSha256}}}}; } - // Своя обёртка: в каталоге нет ни отметки свежести кэша, ни адреса, с - // которого он приехал, — а без них кэш нечем проверить. const QJsonObject root{{QStringLiteral("formatVersion"), kSupportedFormatVersion}, {QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)}, {QStringLiteral("baseUrl"), m_baseUrl.toString()}, diff --git a/seasonalpackdownloader.cpp b/seasonalpackdownloader.cpp index 9e179ef..599599d 100644 --- a/seasonalpackdownloader.cpp +++ b/seasonalpackdownloader.cpp @@ -12,15 +12,11 @@ namespace { -// Пак большой, но идёт с нашего же сервера: минуты простоя без единого байта -// означают, что соединение умерло, а не что файл тяжёлый. constexpr int kTransferTimeoutMs = 60000; -// Столько же, сколько у остальных установщиков: сигналы прогресса приходят -// пачками, а перерисовывать панель чаще десяти раз в секунду незачем. constexpr int kProgressIntervalMs = 100; -} // namespace +} SeasonalPackDownloader::SeasonalPackDownloader(QObject *parent) : QObject(parent) @@ -91,15 +87,12 @@ void SeasonalPackDownloader::download(const QUrl &url, if (chunk.isEmpty()) return; m_hash.addData(chunk); - // Диск кончился или файл забрали из-под нас: продолжать качать незачем, - // ошибку разберёт обработчик finished. if (m_file->write(chunk) != chunk.size()) m_reply->abort(); }); connect(m_reply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64 total) { m_bytesDone = received; - // Размер из каталога может разойтись с реальным — верим серверу. if (total > 0) m_bytesTotal = total; scheduleProgress(); @@ -159,7 +152,6 @@ void SeasonalPackDownloader::cancel() return; } - // Запрос ещё не создан — обрывать нечего, отвечаем сразу. if (m_file) m_file->cancelWriting(); m_file.reset(); @@ -174,8 +166,6 @@ void SeasonalPackDownloader::fail(const QString &message) if (m_file) m_file->cancelWriting(); m_file.reset(); - // Обрезанный пак нельзя ни распаковать, ни докачать: следующая попытка - // начнётся с чистого файла. if (!m_targetPath.isEmpty()) QFile::remove(m_targetPath); reset(); diff --git a/versioninstaller.cpp b/versioninstaller.cpp index bc6d549..da0bc30 100644 --- a/versioninstaller.cpp +++ b/versioninstaller.cpp @@ -19,21 +19,15 @@ #include namespace { - -// Своя очередь, а не тысячи QNetworkReply внутри QNetworkAccessManager: там они -// встают в невидимую очередь, каждый со своим объектом и без управления отменой. constexpr int kMaxParallel = 8; constexpr int kMaxAttempts = 3; constexpr int kTransferTimeoutMs = 30000; -// Копирование тысяч мелких файлов одним циклом заморозило бы окно на секунды. constexpr int kLegacyCopiesPerTick = 64; -// Столько уже имеющихся файлов проверяем за один заход, остальное — следующим. constexpr int kSkipsPerTick = 256; constexpr int kMaxInheritDepth = 10; const QString kResourcesUrl = QStringLiteral("https://resources.download.minecraft.net/"); -// Хэш из json попадает в путь на диске — принимаем только настоящий sha1. bool isSha1Hex(const QString &value) { if (value.size() != 40) @@ -45,8 +39,6 @@ bool isSha1Hex(const QString &value) return true; } -// Относительный путь из json нельзя подставлять как есть: «../..» увёл бы -// запись за пределы .minecraft. bool isSafeRelativePath(const QString &path) { if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':')) @@ -59,7 +51,6 @@ bool isSafeRelativePath(const QString &path) return true; } -// Одиночный сегмент: id версии, id индекса ресурсов, имя конфига логирования. bool isSafeRelativeSegment(const QString &value) { return !value.contains(u'/') && isSafeRelativePath(value); @@ -93,7 +84,7 @@ QString fileNameOf(const QString &path) return QFileInfo(path).fileName(); } -} // namespace +} VersionInstaller::VersionInstaller(VersionManifestService *manifest, QObject *parent) : QObject(parent) @@ -102,8 +93,6 @@ VersionInstaller::VersionInstaller(VersionManifestService *manifest, QObject *pa , m_progressTimer(new QTimer(this)) , m_legacyTimer(new QTimer(this)) { - // Восемь параллельных ответов шлют downloadProgress на каждый кусок данных: - // без склейки привязки в QML пересчитывались бы сотни раз в секунду. m_progressTimer->setInterval(100); connect(m_progressTimer, &QTimer::timeout, this, &VersionInstaller::flushProgress); @@ -113,14 +102,10 @@ VersionInstaller::VersionInstaller(VersionManifestService *manifest, QObject *pa VersionInstaller::~VersionInstaller() { - // Из деструктора сигналы уже некому слушать, а QSaveFile сам выбросит - // недописанные временные файлы. blockSignals(true); cancel(); } -// ── Прогресс ─────────────────────────────────────────────────────────────── - qint64 VersionInstaller::bytesDone() const { qint64 total = m_bytesSettled; @@ -161,8 +146,6 @@ void VersionInstaller::flushProgress() emit progressChanged(); } -// ── Очередь установок ────────────────────────────────────────────────────── - bool VersionInstaller::isQueued(const QString &versionId) const { for (const auto &entry : m_pendingInstalls) { @@ -255,16 +238,12 @@ void VersionInstaller::resolveManifestEntry() return; } - // Версии нет в манифесте, но описание могло уже лечь на диск: так - // ставятся сборки модлоадеров, чьи id Mojang не знает. QFile local(versionJsonPath(m_gameDir, id)); if (local.open(QIODevice::ReadOnly)) { adoptVersionJson(local.readAll(), /*alreadySaved=*/true); return; } - // Иначе это вручную вписанный id сборки — сообщение не должно - // выглядеть как сетевая ошибка. failInstall(Loc::text("version.error.notInManifest") .arg(id)); }); @@ -306,8 +285,6 @@ void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expected }); } -// Общий хвост для описания версии, откуда бы оно ни пришло: из сети или уже -// лежащим на диске (сборки модлоадеров кладут его туда сами). void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadySaved) { const QString id = m_versionId; @@ -334,8 +311,6 @@ void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadyS } } - // Ванильные версии ничего не наследуют, но сборка могла бы — тогда - // сначала ставим родителя, а потом возвращаемся к этой версии. const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString(); if (!parentId.isEmpty() && isSafeRelativeSegment(parentId) && !QFile::exists(versionJsonPath(m_gameDir, parentId))) { @@ -355,12 +330,9 @@ void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadyS pump(); } -// ── Составление списка файлов ────────────────────────────────────────────── - void VersionInstaller::enqueue(DownloadTask task) { if (task.url.scheme() != QLatin1String("https")) { - // Подменённое описание версии не должно уводить загрузку на чужой адрес. m_warnings << Loc::text("version.warning.skippedBadUrl").arg(task.label); return; } @@ -373,8 +345,6 @@ bool VersionInstaller::prepareTasks() setStage(Loc::text("version.progress.preparing")); QString error; - // features пустые намеренно: Mojang отбирает библиотеки только по ОС, - // а feature-правила живут в arguments, которые установщику не нужны. m_version = VersionLoader::load(m_gameDir, m_versionId, {}, &error); if (!m_version.isValid()) { failInstall(error.isEmpty() ? Loc::text("common.error.versionReadFailed").arg(m_versionId) @@ -397,8 +367,6 @@ bool VersionInstaller::prepareTasks() enqueue(std::move(task)); } - // url, sha1 и size у библиотек уже разобраны VersionLoader — это готовые - // описания загрузки, разбирать json заново не нужно. for (const MinecraftLibrary &library : std::as_const(m_version.libraries)) { if (library.url.isEmpty() || !isSafeRelativePath(library.path)) { m_warnings << Loc::text("version.error.libraryNoUrl").arg(library.name); @@ -425,9 +393,6 @@ bool VersionInstaller::prepareTasks() task.size = assetIndex.value(QStringLiteral("size")).toInteger(); task.label = Loc::text("version.progress.assetIndex"); enqueue(std::move(task)); - - // totalSize — суммарный вес всех ресурсов, известный ещё до скачивания - // индекса: без него полоса первые секунды стояла бы и потом прыгнула. m_assetsDeclaredTotal = assetIndex.value(QStringLiteral("totalSize")).toInteger(); m_bytesTotal += m_assetsDeclaredTotal; } @@ -454,8 +419,6 @@ bool VersionInstaller::prepareTasks() return true; } -// ── Движок загрузки ──────────────────────────────────────────────────────── - void VersionInstaller::schedulePump() { if (m_pumpScheduled) @@ -476,12 +439,9 @@ void VersionInstaller::pump() while (m_active.size() < kMaxParallel && !m_queue.isEmpty()) { const int before = m_active.size(); startTask(m_queue.dequeue()); - // startTask мог провалить установку — тогда очередь уже разобрана. if (!m_running || m_canceling) return; if (m_active.size() == before) { - // Файл уже был на месте — сотни таких проверок подряд ощутимы, - // поэтому остаток берём следующим проходом событийного цикла. if (++skipped >= kSkipsPerTick && !m_queue.isEmpty()) { schedulePump(); return; @@ -504,8 +464,6 @@ void VersionInstaller::startTask(DownloadTask task) return; } - // Временный файл QSaveFile ложится рядом с целевым, поэтому папка нужна - // даже до открытия. if (!QDir().mkpath(info.absolutePath())) { failInstall(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(info.absolutePath()))); return; @@ -522,7 +480,6 @@ void VersionInstaller::startTask(DownloadTask task) QNetworkRequest request{task.url}; request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - // Без таймаута зависший сокет подвесил бы установку навсегда. request.setTransferTimeout(kTransferTimeoutMs); context->reply = m_network->get(request); @@ -603,8 +560,6 @@ void VersionInstaller::handleReplyFinished(const std::shared_ptr pump(); } -// Возвращает true, если задача поставлена на повтор, и false, если установка -// уже провалена. bool VersionInstaller::retryOrFail(DownloadTask task, const QString &reason) { if (task.attempts + 1 >= kMaxAttempts) { @@ -617,7 +572,6 @@ bool VersionInstaller::retryOrFail(DownloadTask task, const QString &reason) QTimer::singleShot(delay, this, [this, task] { if (!m_running || m_canceling) return; - // В начало очереди: иначе повтор ждал бы за тысячами ресурсов. m_queue.prepend(task); pump(); }); @@ -661,15 +615,11 @@ void VersionInstaller::expandAssetIndex(const QString &indexPath) enqueue(std::move(task)); } - // enqueue уже прибавил вес ресурсов к итогу — снимаем заявленный заранее, - // чтобы он не учитывался дважды. m_bytesTotal -= m_assetsDeclaredTotal; m_assetsDeclaredTotal = actualTotal; scheduleProgress(); } -// ── Завершение ───────────────────────────────────────────────────────────── - void VersionInstaller::afterDownloads() { const bool legacy = m_assetsVirtual || m_assetsMapToResources @@ -742,8 +692,6 @@ void VersionInstaller::legacyTick() void VersionInstaller::complete() { - // Приёмка той же проверкой, которой пользуется запуск: успешная установка - // тогда по построению запускаема. LaunchOptions probe; probe.gameDir = m_gameDir; probe.versionId = m_versionId; @@ -784,8 +732,6 @@ void VersionInstaller::failInstall(const QString &message) if (!m_warnings.isEmpty()) text += u'\n' + m_warnings.mid(0, 3).join(u'\n'); - // Остальные версии из очереди уже некуда ставить — их пути могли зависеть - // от этой, да и пользователю нужен один внятный ответ. m_pendingInstalls.clear(); m_inheritDepth = 0; for (const auto &context : std::as_const(m_active)) { @@ -799,7 +745,6 @@ void VersionInstaller::failInstall(const QString &message) void VersionInstaller::abandonCurrent() { - // Версия уступает очередь родителю: сигналов нет, установка продолжается. m_queue.clear(); m_active.clear(); m_versionId.clear(); @@ -817,14 +762,11 @@ void VersionInstaller::cancel() m_inheritDepth = 0; m_canceling = true; - // abort() может доставить finished синхронно, а обработчик стирает элемент - // из m_active — поэтому обходим копию. const auto active = m_active; for (const auto &context : active) { if (context->reply) context->reply->abort(); } - // abort() мог довести отмену до конца прямо здесь — тогда m_canceling уже снят. if (m_canceling && m_active.isEmpty()) finalizeCancel(); } diff --git a/versionmanifestservice.cpp b/versionmanifestservice.cpp index a43b359..0b8c23e 100644 --- a/versionmanifestservice.cpp +++ b/versionmanifestservice.cpp @@ -21,9 +21,6 @@ namespace { const QString kManifestUrl = QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"); -// Снапшоты выходят примерно раз в неделю, манифест весит около 250 КБ: шесть -// часов — это не больше четырёх запросов в сутки, новая версия видна в тот же -// день, а десяток открытий списка за сессию не стоит ни одного обращения к сети. constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60; constexpr int kTransferTimeoutMs = 20000; @@ -45,13 +42,11 @@ QList parseManifest(const QJsonObject &root) entry.releaseTime = QDateTime::fromString( object.value(QStringLiteral("releaseTime")).toString(), Qt::ISODate); - // Описание версии качается по этой ссылке, поэтому чужие схемы не берём. if (entry.id.isEmpty() || entry.url.scheme() != QLatin1String("https")) continue; list << entry; } - // Mojang и так отдаёт новые сверху, но зависеть от этого не стоит. std::stable_sort(list.begin(), list.end(), [](const RemoteVersionEntry &a, const RemoteVersionEntry &b) { if (a.releaseTime != b.releaseTime) @@ -61,7 +56,7 @@ QList parseManifest(const QJsonObject &root) return list; } -} // namespace +} VersionManifestService::VersionManifestService(QObject *parent) : QObject(parent) @@ -97,8 +92,6 @@ void VersionManifestService::ensureLoaded(Callback callback, bool forceRefresh) const bool fresh = hasData() && m_fetchedAt.isValid() && m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds; if (!forceRefresh && fresh) { - // Колбэк, который то синхронный, то нет, ломает вызывающий код — - // на попадании в кэш отвечаем через событийный цикл. if (callback) QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); return; @@ -133,7 +126,6 @@ void VersionManifestService::startRefresh() QList parsed = parseManifest(QJsonDocument::fromJson(reply->readAll()).object()); - // Пустой разбор — испорченный ответ; хороший кэш им не затираем. if (parsed.isEmpty()) { finishRefresh(false, Loc::text("version.error.manifestUnparsable")); return; @@ -152,10 +144,8 @@ void VersionManifestService::finishRefresh(bool ok, const QString &warning) m_refreshing = false; emit refreshingChanged(); - // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. const QList pending = std::exchange(m_pending, {}); for (const Callback &callback : pending) { - // Сеть недоступна, но старые данные есть — работаем на них. if (!ok && hasData()) callback(true, warning); else @@ -182,7 +172,6 @@ bool VersionManifestService::readCache() void VersionManifestService::writeCache() { - // Папку кэша могли удалить уже после запуска лаунчера. if (!LauncherPaths::ensureRootExists()) return; @@ -196,13 +185,9 @@ void VersionManifestService::writeCache() entry.releaseTime.toString(Qt::ISODate)}}; } - // Своя обёртка: в манифесте Mojang нет отметки времени, по которой можно - // судить о свежести кэша. const QJsonObject root{{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)}, {QStringLiteral("versions"), versions}}; - // QSaveFile: оборванная запись не оставит обрезанный кэш, который потом - // пришлось бы отличать от настоящего. QSaveFile file(LauncherPaths::versionManifestFile()); if (!file.open(QIODevice::WriteOnly)) return; diff --git a/zlibreference.cpp b/zlibreference.cpp index 7305e46..3ed15f2 100644 --- a/zlibreference.cpp +++ b/zlibreference.cpp @@ -41,7 +41,7 @@ QString ZlibReference::bundledPath() const QString appDir = QCoreApplication::applicationDirPath(); if (!appDir.isEmpty()) { candidates << appDir + QStringLiteral("/zlib/libz.so.1") // дерево сборки - << appDir + QStringLiteral("/../lib/galeonLauncher/libz.so.1"); + << appDir + QStringLiteral("/../lib/BiLauncher/libz.so.1"); } #ifdef LAUNCHER_ZLIB_INSTALL_DIR candidates << QStringLiteral(LAUNCHER_ZLIB_INSTALL_DIR) + QStringLiteral("/libz.so.1");