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