cleanups
This commit is contained in:
@@ -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
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 ¤tPath);
|
void progress(int done, int total, const QString ¤tPath);
|
||||||
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
@@ -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
@@ -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 ¬e,
|
const QJsonObject ¬e,
|
||||||
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;
|
||||||
|
|||||||
@@ -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` | Версия лаунчера |
|
||||||
|
|
||||||
## Публичные методы
|
## Публичные методы
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
const QString kFolderName = QStringLiteral("galeonLauncher");
|
const QString kFolderName = QStringLiteral("BiLauncher");
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|||||||
+1
-45
@@ -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
@@ -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
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 =
|
||||||
|
|||||||
@@ -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()},
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user