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