2026-08-24 21:09:52 +03:00
|
|
|
#include "versioninstaller.h"
|
|
|
|
|
|
|
|
|
|
#include "gamelauncher.h"
|
|
|
|
|
#include "versionmanifestservice.h"
|
|
|
|
|
|
|
|
|
|
#include <QDir>
|
|
|
|
|
#include <QFile>
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
#include <QJsonArray>
|
|
|
|
|
#include <QJsonDocument>
|
|
|
|
|
#include <QNetworkAccessManager>
|
|
|
|
|
#include <QNetworkReply>
|
|
|
|
|
#include <QNetworkRequest>
|
|
|
|
|
#include <QSaveFile>
|
|
|
|
|
#include <QTimer>
|
|
|
|
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#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)
|
|
|
|
|
return false;
|
|
|
|
|
for (const QChar c : value) {
|
|
|
|
|
if (!((c >= u'0' && c <= u'9') || (c >= u'a' && c <= u'f') || (c >= u'A' && c <= u'F')))
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Относительный путь из json нельзя подставлять как есть: «../..» увёл бы
|
|
|
|
|
// запись за пределы .minecraft.
|
|
|
|
|
bool isSafeRelativePath(const QString &path)
|
|
|
|
|
{
|
|
|
|
|
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
|
|
|
|
|
return false;
|
|
|
|
|
const QStringList parts = path.split(u'/');
|
|
|
|
|
for (const QString &part : parts) {
|
|
|
|
|
if (part.isEmpty() || part == QLatin1String(".") || part == QLatin1String(".."))
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Одиночный сегмент: id версии, id индекса ресурсов, имя конфига логирования.
|
|
|
|
|
bool isSafeRelativeSegment(const QString &value)
|
|
|
|
|
{
|
|
|
|
|
return !value.contains(u'/') && isSafeRelativePath(value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
QString versionJsonPath(const QString &gameDir, const QString &id)
|
|
|
|
|
{
|
|
|
|
|
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 21:09:52 +03:00
|
|
|
bool isTransient(QNetworkReply::NetworkError error, int httpStatus)
|
|
|
|
|
{
|
|
|
|
|
if (httpStatus == 429 || httpStatus >= 500)
|
|
|
|
|
return true;
|
|
|
|
|
switch (error) {
|
|
|
|
|
case QNetworkReply::RemoteHostClosedError:
|
|
|
|
|
case QNetworkReply::TimeoutError:
|
|
|
|
|
case QNetworkReply::TemporaryNetworkFailureError:
|
|
|
|
|
case QNetworkReply::NetworkSessionFailedError:
|
|
|
|
|
case QNetworkReply::UnknownNetworkError:
|
|
|
|
|
case QNetworkReply::ProxyTimeoutError:
|
|
|
|
|
case QNetworkReply::ContentReSendError:
|
|
|
|
|
return true;
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QString fileNameOf(const QString &path)
|
|
|
|
|
{
|
|
|
|
|
return QFileInfo(path).fileName();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
VersionInstaller::VersionInstaller(VersionManifestService *manifest, QObject *parent)
|
|
|
|
|
: QObject(parent)
|
|
|
|
|
, m_manifest(manifest)
|
|
|
|
|
, m_network(new QNetworkAccessManager(this))
|
|
|
|
|
, m_progressTimer(new QTimer(this))
|
|
|
|
|
, m_legacyTimer(new QTimer(this))
|
|
|
|
|
{
|
|
|
|
|
// Восемь параллельных ответов шлют downloadProgress на каждый кусок данных:
|
|
|
|
|
// без склейки привязки в QML пересчитывались бы сотни раз в секунду.
|
|
|
|
|
m_progressTimer->setInterval(100);
|
|
|
|
|
connect(m_progressTimer, &QTimer::timeout, this, &VersionInstaller::flushProgress);
|
|
|
|
|
|
|
|
|
|
m_legacyTimer->setInterval(0);
|
|
|
|
|
connect(m_legacyTimer, &QTimer::timeout, this, &VersionInstaller::legacyTick);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VersionInstaller::~VersionInstaller()
|
|
|
|
|
{
|
|
|
|
|
// Из деструктора сигналы уже некому слушать, а QSaveFile сам выбросит
|
|
|
|
|
// недописанные временные файлы.
|
|
|
|
|
blockSignals(true);
|
|
|
|
|
cancel();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Прогресс ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
qint64 VersionInstaller::bytesDone() const
|
|
|
|
|
{
|
|
|
|
|
qint64 total = m_bytesSettled;
|
|
|
|
|
for (const auto &context : m_active)
|
|
|
|
|
total += context->received;
|
|
|
|
|
return total;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double VersionInstaller::fraction() const
|
|
|
|
|
{
|
|
|
|
|
if (m_bytesTotal <= 0)
|
|
|
|
|
return -1.0;
|
|
|
|
|
const double value = double(bytesDone()) / double(m_bytesTotal);
|
|
|
|
|
return std::clamp(value, 0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::setStage(const QString &stage)
|
|
|
|
|
{
|
|
|
|
|
m_stage = stage;
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::scheduleProgress()
|
|
|
|
|
{
|
|
|
|
|
m_progressDirty = true;
|
|
|
|
|
if (!m_progressTimer->isActive())
|
|
|
|
|
m_progressTimer->start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::flushProgress()
|
|
|
|
|
{
|
|
|
|
|
if (!m_progressDirty) {
|
|
|
|
|
if (!m_running)
|
|
|
|
|
m_progressTimer->stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
m_progressDirty = false;
|
|
|
|
|
emit progressChanged();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Очередь установок ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
bool VersionInstaller::isQueued(const QString &versionId) const
|
|
|
|
|
{
|
|
|
|
|
for (const auto &entry : m_pendingInstalls) {
|
|
|
|
|
if (entry.second == versionId)
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::install(const QString &gameDir, const QString &versionId)
|
|
|
|
|
{
|
|
|
|
|
const QString id = versionId.trimmed();
|
|
|
|
|
if (id.isEmpty())
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
if (!isSafeRelativeSegment(id)) {
|
|
|
|
|
emit failed(id, tr("Недопустимое имя версии «%1»").arg(id));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_canceling) {
|
|
|
|
|
emit failed(id, tr("Идёт отмена предыдущей загрузки — попробуйте ещё раз"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (m_versionId == id || isQueued(id))
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
m_pendingInstalls.enqueue({gameDir, id});
|
|
|
|
|
if (!m_running)
|
|
|
|
|
startNext();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::startNext()
|
|
|
|
|
{
|
|
|
|
|
if (m_pendingInstalls.isEmpty()) {
|
|
|
|
|
m_running = false;
|
|
|
|
|
m_versionId.clear();
|
|
|
|
|
m_stage.clear();
|
|
|
|
|
m_currentLabel.clear();
|
|
|
|
|
m_progressTimer->stop();
|
|
|
|
|
emit progressChanged();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto entry = m_pendingInstalls.dequeue();
|
|
|
|
|
m_gameDir = entry.first;
|
|
|
|
|
m_versionId = entry.second;
|
|
|
|
|
m_versionJson = {};
|
|
|
|
|
m_version = {};
|
|
|
|
|
m_queue.clear();
|
|
|
|
|
m_warnings.clear();
|
|
|
|
|
m_assetObjects = {};
|
|
|
|
|
m_assetsVirtual = false;
|
|
|
|
|
m_assetsMapToResources = false;
|
|
|
|
|
m_legacyCopies.clear();
|
|
|
|
|
m_legacyIndex = 0;
|
|
|
|
|
m_bytesSettled = 0;
|
|
|
|
|
m_bytesTotal = 0;
|
|
|
|
|
m_assetsDeclaredTotal = 0;
|
|
|
|
|
|
|
|
|
|
const bool wasRunning = m_running;
|
|
|
|
|
m_running = true;
|
|
|
|
|
if (!wasRunning)
|
|
|
|
|
emit started(m_versionId);
|
|
|
|
|
setStage(tr("Получение списка версий"));
|
|
|
|
|
emit progressChanged();
|
|
|
|
|
|
|
|
|
|
resolveManifestEntry();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::resolveManifestEntry()
|
|
|
|
|
{
|
|
|
|
|
const QString id = m_versionId;
|
|
|
|
|
m_manifest->ensureLoaded([this, id](bool ok, const QString &warning) {
|
|
|
|
|
if (!m_running || m_canceling || m_versionId != id)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
if (!ok) {
|
2026-08-27 09:21:51 +03:00
|
|
|
QFile local(versionJsonPath(m_gameDir, id));
|
|
|
|
|
if (local.open(QIODevice::ReadOnly)) {
|
|
|
|
|
adoptVersionJson(local.readAll(), /*alreadySaved=*/true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-24 21:09:52 +03:00
|
|
|
failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto entry = m_manifest->find(id);
|
2026-08-27 09:21:51 +03:00
|
|
|
if (entry) {
|
|
|
|
|
fetchVersionJson(entry->url, entry->sha1);
|
2026-08-24 21:09:52 +03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
|
|
|
|
|
// Версии нет в манифесте, но описание могло уже лечь на диск: так
|
|
|
|
|
// ставятся сборки модлоадеров, чьи id Mojang не знает.
|
|
|
|
|
QFile local(versionJsonPath(m_gameDir, id));
|
|
|
|
|
if (local.open(QIODevice::ReadOnly)) {
|
|
|
|
|
adoptVersionJson(local.readAll(), /*alreadySaved=*/true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Иначе это вручную вписанный id сборки — сообщение не должно
|
|
|
|
|
// выглядеть как сетевая ошибка.
|
|
|
|
|
failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — "
|
|
|
|
|
"установить её автоматически нельзя")
|
|
|
|
|
.arg(id));
|
2026-08-24 21:09:52 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expectedSha1)
|
|
|
|
|
{
|
|
|
|
|
setStage(tr("Описание версии"));
|
|
|
|
|
m_currentLabel = m_versionId + QStringLiteral(".json");
|
|
|
|
|
|
|
|
|
|
QNetworkRequest request{url};
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
request.setTransferTimeout(kTransferTimeoutMs);
|
|
|
|
|
|
|
|
|
|
const QString id = m_versionId;
|
|
|
|
|
QNetworkReply *reply = m_network->get(request);
|
|
|
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply, expectedSha1, id] {
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
if (!m_running || m_canceling || m_versionId != id)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
|
|
|
failInstall(tr("Не удалось получить описание версии: %1").arg(reply->errorString()));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QByteArray payload = reply->readAll();
|
|
|
|
|
if (!expectedSha1.isEmpty()) {
|
|
|
|
|
const QString actual = QString::fromLatin1(
|
|
|
|
|
QCryptographicHash::hash(payload, QCryptographicHash::Sha1).toHex());
|
|
|
|
|
if (actual.compare(expectedSha1, Qt::CaseInsensitive) != 0) {
|
|
|
|
|
failInstall(tr("Контрольная сумма описания версии «%1» не совпала").arg(id));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
adoptVersionJson(payload, /*alreadySaved=*/false);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-08-24 21:09:52 +03:00
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
// Общий хвост для описания версии, откуда бы оно ни пришло: из сети или уже
|
|
|
|
|
// лежащим на диске (сборки модлоадеров кладут его туда сами).
|
|
|
|
|
void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadySaved)
|
|
|
|
|
{
|
|
|
|
|
const QString id = m_versionId;
|
|
|
|
|
|
|
|
|
|
m_versionJson = QJsonDocument::fromJson(payload).object();
|
|
|
|
|
if (m_versionJson.isEmpty()) {
|
|
|
|
|
failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!alreadySaved) {
|
2026-08-24 21:09:52 +03:00
|
|
|
const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
|
|
|
|
|
if (!QDir().mkpath(directory)) {
|
|
|
|
|
failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QSaveFile file(directory + u'/' + id + QStringLiteral(".json"));
|
|
|
|
|
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
|
|
|
|
|
|| !file.commit()) {
|
|
|
|
|
file.cancelWriting();
|
|
|
|
|
failInstall(tr("Не удалось сохранить описание версии «%1»").arg(id));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
}
|
2026-08-24 21:09:52 +03:00
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
// Ванильные версии ничего не наследуют, но сборка могла бы — тогда
|
|
|
|
|
// сначала ставим родителя, а потом возвращаемся к этой версии.
|
|
|
|
|
const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
|
|
|
|
if (!parentId.isEmpty() && isSafeRelativeSegment(parentId)
|
|
|
|
|
&& !QFile::exists(versionJsonPath(m_gameDir, parentId))) {
|
|
|
|
|
if (m_inheritDepth >= kMaxInheritDepth) {
|
|
|
|
|
failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
|
2026-08-24 21:09:52 +03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-27 09:21:51 +03:00
|
|
|
++m_inheritDepth;
|
|
|
|
|
m_pendingInstalls.prepend({m_gameDir, id});
|
|
|
|
|
m_pendingInstalls.prepend({m_gameDir, parentId});
|
|
|
|
|
abandonCurrent();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-24 21:09:52 +03:00
|
|
|
|
2026-08-27 09:21:51 +03:00
|
|
|
m_inheritDepth = 0;
|
|
|
|
|
if (prepareTasks())
|
|
|
|
|
pump();
|
2026-08-24 21:09:52 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Составление списка файлов ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::enqueue(DownloadTask task)
|
|
|
|
|
{
|
|
|
|
|
if (task.url.scheme() != QLatin1String("https")) {
|
|
|
|
|
// Подменённое описание версии не должно уводить загрузку на чужой адрес.
|
|
|
|
|
m_warnings << tr("Пропущен %1: недопустимая ссылка").arg(task.label);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
m_bytesTotal += task.size;
|
|
|
|
|
m_queue.enqueue(std::move(task));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool VersionInstaller::prepareTasks()
|
|
|
|
|
{
|
|
|
|
|
setStage(tr("Подготовка"));
|
|
|
|
|
|
|
|
|
|
QString error;
|
|
|
|
|
// features пустые намеренно: Mojang отбирает библиотеки только по ОС,
|
|
|
|
|
// а feature-правила живут в arguments, которые установщику не нужны.
|
|
|
|
|
m_version = VersionLoader::load(m_gameDir, m_versionId, {}, &error);
|
|
|
|
|
if (!m_version.isValid()) {
|
|
|
|
|
failInstall(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(m_versionId)
|
|
|
|
|
: error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QJsonObject client = m_versionJson.value(QStringLiteral("downloads"))
|
|
|
|
|
.toObject()
|
|
|
|
|
.value(QStringLiteral("client"))
|
|
|
|
|
.toObject();
|
|
|
|
|
if (!client.isEmpty()) {
|
|
|
|
|
DownloadTask task;
|
|
|
|
|
task.kind = DownloadTask::ClientJar;
|
|
|
|
|
task.url = QUrl(client.value(QStringLiteral("url")).toString());
|
|
|
|
|
task.path = m_version.clientJarPath;
|
|
|
|
|
task.sha1 = client.value(QStringLiteral("sha1")).toString();
|
|
|
|
|
task.size = client.value(QStringLiteral("size")).toInteger();
|
|
|
|
|
task.label = fileNameOf(task.path);
|
|
|
|
|
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 << tr("Нет ссылки на библиотеку %1").arg(library.name);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
DownloadTask task;
|
|
|
|
|
task.kind = DownloadTask::Library;
|
|
|
|
|
task.url = QUrl(library.url);
|
|
|
|
|
task.path = m_gameDir + QStringLiteral("/libraries/") + library.path;
|
|
|
|
|
task.sha1 = library.sha1;
|
|
|
|
|
task.size = library.size;
|
|
|
|
|
task.label = library.name;
|
|
|
|
|
enqueue(std::move(task));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QJsonObject assetIndex = m_versionJson.value(QStringLiteral("assetIndex")).toObject();
|
|
|
|
|
if (!assetIndex.isEmpty() && isSafeRelativeSegment(m_version.assetIndexId)) {
|
|
|
|
|
DownloadTask task;
|
|
|
|
|
task.kind = DownloadTask::AssetIndex;
|
|
|
|
|
task.url = QUrl(assetIndex.value(QStringLiteral("url")).toString());
|
|
|
|
|
task.path = m_gameDir + QStringLiteral("/assets/indexes/") + m_version.assetIndexId
|
|
|
|
|
+ QStringLiteral(".json");
|
|
|
|
|
task.sha1 = assetIndex.value(QStringLiteral("sha1")).toString();
|
|
|
|
|
task.size = assetIndex.value(QStringLiteral("size")).toInteger();
|
|
|
|
|
task.label = tr("индекс ресурсов");
|
|
|
|
|
enqueue(std::move(task));
|
|
|
|
|
|
|
|
|
|
// totalSize — суммарный вес всех ресурсов, известный ещё до скачивания
|
|
|
|
|
// индекса: без него полоса первые секунды стояла бы и потом прыгнула.
|
|
|
|
|
m_assetsDeclaredTotal = assetIndex.value(QStringLiteral("totalSize")).toInteger();
|
|
|
|
|
m_bytesTotal += m_assetsDeclaredTotal;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QJsonObject logging = m_versionJson.value(QStringLiteral("logging"))
|
|
|
|
|
.toObject()
|
|
|
|
|
.value(QStringLiteral("client"))
|
|
|
|
|
.toObject()
|
|
|
|
|
.value(QStringLiteral("file"))
|
|
|
|
|
.toObject();
|
|
|
|
|
const QString loggingId = logging.value(QStringLiteral("id")).toString();
|
|
|
|
|
if (!logging.isEmpty() && isSafeRelativeSegment(loggingId)) {
|
|
|
|
|
DownloadTask task;
|
|
|
|
|
task.kind = DownloadTask::LoggingConfig;
|
|
|
|
|
task.url = QUrl(logging.value(QStringLiteral("url")).toString());
|
|
|
|
|
task.path = m_gameDir + QStringLiteral("/assets/log_configs/") + loggingId;
|
|
|
|
|
task.sha1 = logging.value(QStringLiteral("sha1")).toString();
|
|
|
|
|
task.size = logging.value(QStringLiteral("size")).toInteger();
|
|
|
|
|
task.label = loggingId;
|
|
|
|
|
enqueue(std::move(task));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStage(tr("Файлы игры"));
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Движок загрузки ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::schedulePump()
|
|
|
|
|
{
|
|
|
|
|
if (m_pumpScheduled)
|
|
|
|
|
return;
|
|
|
|
|
m_pumpScheduled = true;
|
|
|
|
|
QTimer::singleShot(0, this, [this] {
|
|
|
|
|
m_pumpScheduled = false;
|
|
|
|
|
pump();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::pump()
|
|
|
|
|
{
|
|
|
|
|
if (!m_running || m_canceling)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
int skipped = 0;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (m_queue.isEmpty() && m_active.isEmpty())
|
|
|
|
|
afterDownloads();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::startTask(DownloadTask task)
|
|
|
|
|
{
|
|
|
|
|
const QFileInfo info(task.path);
|
|
|
|
|
if (info.exists() && !m_verifyExisting && (task.size == 0 || info.size() == task.size)) {
|
|
|
|
|
m_bytesSettled += task.size;
|
|
|
|
|
if (task.kind == DownloadTask::AssetIndex)
|
|
|
|
|
expandAssetIndex(task.path);
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Временный файл QSaveFile ложится рядом с целевым, поэтому папка нужна
|
|
|
|
|
// даже до открытия.
|
|
|
|
|
if (!QDir().mkpath(info.absolutePath())) {
|
|
|
|
|
failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath())));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto context = std::make_shared<ActiveDownload>();
|
|
|
|
|
context->task = task;
|
|
|
|
|
context->file = std::make_unique<QSaveFile>(task.path);
|
|
|
|
|
if (!context->file->open(QIODevice::WriteOnly)) {
|
|
|
|
|
failInstall(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QNetworkRequest request{task.url};
|
|
|
|
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
|
|
|
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
|
|
|
|
// Без таймаута зависший сокет подвесил бы установку навсегда.
|
|
|
|
|
request.setTransferTimeout(kTransferTimeoutMs);
|
|
|
|
|
|
|
|
|
|
context->reply = m_network->get(request);
|
|
|
|
|
m_active.append(context);
|
|
|
|
|
m_currentLabel = task.label;
|
|
|
|
|
|
|
|
|
|
connect(context->reply, &QNetworkReply::readyRead, this, [this, context] {
|
|
|
|
|
const QByteArray chunk = context->reply->readAll();
|
|
|
|
|
if (chunk.isEmpty())
|
|
|
|
|
return;
|
|
|
|
|
context->hash.addData(chunk);
|
|
|
|
|
if (context->file->write(chunk) != chunk.size())
|
|
|
|
|
context->reply->abort();
|
|
|
|
|
});
|
|
|
|
|
connect(context->reply, &QNetworkReply::downloadProgress, this,
|
|
|
|
|
[this, context](qint64 received, qint64) {
|
|
|
|
|
context->received = received;
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
});
|
|
|
|
|
connect(context->reply, &QNetworkReply::finished, this,
|
|
|
|
|
[this, context] { handleReplyFinished(context); });
|
|
|
|
|
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::handleReplyFinished(const std::shared_ptr<ActiveDownload> &context)
|
|
|
|
|
{
|
|
|
|
|
context->reply->deleteLater();
|
|
|
|
|
m_active.removeOne(context);
|
|
|
|
|
|
|
|
|
|
if (m_canceling) {
|
|
|
|
|
context->file->cancelWriting();
|
|
|
|
|
if (m_active.isEmpty())
|
|
|
|
|
finalizeCancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!m_running)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
const int status =
|
|
|
|
|
context->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
|
|
|
|
|
|
|
|
if (context->reply->error() != QNetworkReply::NoError) {
|
|
|
|
|
context->file->cancelWriting();
|
|
|
|
|
const QString reason = context->reply->errorString();
|
|
|
|
|
if (isTransient(context->reply->error(), status)) {
|
|
|
|
|
if (!retryOrFail(context->task, reason))
|
|
|
|
|
return;
|
|
|
|
|
} else {
|
|
|
|
|
failInstall(tr("Не удалось скачать %1: %2").arg(context->task.label, reason));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pump();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!context->task.sha1.isEmpty()) {
|
|
|
|
|
const QString actual = QString::fromLatin1(context->hash.result().toHex());
|
|
|
|
|
if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) {
|
|
|
|
|
context->file->cancelWriting();
|
|
|
|
|
if (!retryOrFail(context->task, tr("контрольная сумма не совпала")))
|
|
|
|
|
return;
|
|
|
|
|
pump();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!context->file->commit()) {
|
|
|
|
|
failInstall(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_bytesSettled += context->received;
|
|
|
|
|
if (context->task.kind == DownloadTask::AssetIndex)
|
|
|
|
|
expandAssetIndex(context->task.path);
|
|
|
|
|
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
pump();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Возвращает true, если задача поставлена на повтор, и false, если установка
|
|
|
|
|
// уже провалена.
|
|
|
|
|
bool VersionInstaller::retryOrFail(DownloadTask task, const QString &reason)
|
|
|
|
|
{
|
|
|
|
|
if (task.attempts + 1 >= kMaxAttempts) {
|
|
|
|
|
failInstall(tr("Не удалось скачать %1: %2").arg(task.label, reason));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
++task.attempts;
|
|
|
|
|
const int delay = 400 * task.attempts;
|
|
|
|
|
QTimer::singleShot(delay, this, [this, task] {
|
|
|
|
|
if (!m_running || m_canceling)
|
|
|
|
|
return;
|
|
|
|
|
// В начало очереди: иначе повтор ждал бы за тысячами ресурсов.
|
|
|
|
|
m_queue.prepend(task);
|
|
|
|
|
pump();
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::expandAssetIndex(const QString &indexPath)
|
|
|
|
|
{
|
|
|
|
|
QFile file(indexPath);
|
|
|
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
|
|
|
failInstall(tr("Не удалось прочитать индекс ресурсов %1")
|
|
|
|
|
.arg(QDir::toNativeSeparators(indexPath)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
|
|
|
|
m_assetObjects = root.value(QStringLiteral("objects")).toObject();
|
|
|
|
|
m_assetsVirtual = root.value(QStringLiteral("virtual")).toBool();
|
|
|
|
|
m_assetsMapToResources = root.value(QStringLiteral("map_to_resources")).toBool();
|
|
|
|
|
|
|
|
|
|
setStage(tr("Ресурсы"));
|
|
|
|
|
|
|
|
|
|
qint64 actualTotal = 0;
|
|
|
|
|
for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) {
|
|
|
|
|
const QJsonObject object = it.value().toObject();
|
|
|
|
|
const QString hash = object.value(QStringLiteral("hash")).toString();
|
|
|
|
|
if (!isSha1Hex(hash)) {
|
|
|
|
|
m_warnings << tr("Пропущен ресурс %1: некорректный хэш").arg(it.key());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString prefix = hash.left(2);
|
|
|
|
|
DownloadTask task;
|
|
|
|
|
task.kind = DownloadTask::AssetObject;
|
|
|
|
|
task.url = QUrl(kResourcesUrl + prefix + u'/' + hash);
|
|
|
|
|
task.path = m_gameDir + QStringLiteral("/assets/objects/") + prefix + u'/' + hash;
|
|
|
|
|
task.sha1 = hash;
|
|
|
|
|
task.size = object.value(QStringLiteral("size")).toInteger();
|
|
|
|
|
task.label = it.key();
|
|
|
|
|
actualTotal += task.size;
|
|
|
|
|
enqueue(std::move(task));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// enqueue уже прибавил вес ресурсов к итогу — снимаем заявленный заранее,
|
|
|
|
|
// чтобы он не учитывался дважды.
|
|
|
|
|
m_bytesTotal -= m_assetsDeclaredTotal;
|
|
|
|
|
m_assetsDeclaredTotal = actualTotal;
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Завершение ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::afterDownloads()
|
|
|
|
|
{
|
|
|
|
|
const bool legacy = m_assetsVirtual || m_assetsMapToResources
|
|
|
|
|
|| m_version.assetsKind == QLatin1String("legacy")
|
|
|
|
|
|| m_version.assetsKind == QLatin1String("pre-1.6");
|
|
|
|
|
if (legacy && !m_assetObjects.isEmpty() && m_legacyCopies.isEmpty() && m_legacyIndex == 0) {
|
|
|
|
|
startLegacyCopy();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
complete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::startLegacyCopy()
|
|
|
|
|
{
|
|
|
|
|
setStage(tr("Подготовка ресурсов"));
|
|
|
|
|
|
|
|
|
|
const QString targetRoot = m_assetsMapToResources
|
|
|
|
|
? m_gameDir + QStringLiteral("/resources/")
|
|
|
|
|
: m_gameDir + QStringLiteral("/assets/virtual/legacy/");
|
|
|
|
|
|
|
|
|
|
for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) {
|
|
|
|
|
const QString name = it.key();
|
|
|
|
|
const QString hash = it.value().toObject().value(QStringLiteral("hash")).toString();
|
|
|
|
|
if (!isSha1Hex(hash) || !isSafeRelativePath(name))
|
|
|
|
|
continue;
|
|
|
|
|
m_legacyCopies.append({m_gameDir + QStringLiteral("/assets/objects/") + hash.left(2) + u'/'
|
|
|
|
|
+ hash,
|
|
|
|
|
targetRoot + name});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_legacyIndex = 0;
|
|
|
|
|
if (m_legacyCopies.isEmpty()) {
|
|
|
|
|
complete();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
m_legacyTimer->start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::legacyTick()
|
|
|
|
|
{
|
|
|
|
|
if (!m_running || m_canceling) {
|
|
|
|
|
m_legacyTimer->stop();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const int end = std::min(m_legacyIndex + kLegacyCopiesPerTick, int(m_legacyCopies.size()));
|
|
|
|
|
for (; m_legacyIndex < end; ++m_legacyIndex) {
|
|
|
|
|
const auto &pair = m_legacyCopies.at(m_legacyIndex);
|
|
|
|
|
const QFileInfo source(pair.first);
|
|
|
|
|
const QFileInfo target(pair.second);
|
|
|
|
|
if (!source.exists())
|
|
|
|
|
continue;
|
|
|
|
|
if (target.exists() && target.size() == source.size())
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
QDir().mkpath(target.absolutePath());
|
|
|
|
|
QFile::remove(pair.second);
|
|
|
|
|
if (!QFile::copy(pair.first, pair.second))
|
|
|
|
|
m_warnings << tr("Не удалось подготовить ресурс %1").arg(target.fileName());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_currentLabel = tr("%1 из %2").arg(m_legacyIndex).arg(m_legacyCopies.size());
|
|
|
|
|
scheduleProgress();
|
|
|
|
|
|
|
|
|
|
if (m_legacyIndex >= m_legacyCopies.size()) {
|
|
|
|
|
m_legacyTimer->stop();
|
|
|
|
|
complete();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::complete()
|
|
|
|
|
{
|
|
|
|
|
// Приёмка той же проверкой, которой пользуется запуск: успешная установка
|
|
|
|
|
// тогда по построению запускаема.
|
|
|
|
|
LaunchOptions probe;
|
|
|
|
|
probe.gameDir = m_gameDir;
|
|
|
|
|
probe.versionId = m_versionId;
|
|
|
|
|
const QStringList missing = GameLauncher::missingFiles(probe, m_version);
|
|
|
|
|
if (!missing.isEmpty()) {
|
|
|
|
|
failInstall(tr("После загрузки не хватает файлов (%1):\n%2")
|
|
|
|
|
.arg(missing.size())
|
|
|
|
|
.arg(missing.join(u'\n')));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const QString id = m_versionId;
|
|
|
|
|
resetInstallState();
|
|
|
|
|
emit finished(id);
|
|
|
|
|
startNext();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::resetInstallState()
|
|
|
|
|
{
|
|
|
|
|
m_queue.clear();
|
|
|
|
|
m_active.clear();
|
|
|
|
|
m_assetObjects = {};
|
|
|
|
|
m_legacyCopies.clear();
|
|
|
|
|
m_legacyIndex = 0;
|
|
|
|
|
m_legacyTimer->stop();
|
|
|
|
|
m_versionId.clear();
|
|
|
|
|
m_stage.clear();
|
|
|
|
|
m_currentLabel.clear();
|
|
|
|
|
m_running = false;
|
|
|
|
|
m_progressDirty = false;
|
|
|
|
|
m_progressTimer->stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::failInstall(const QString &message)
|
|
|
|
|
{
|
|
|
|
|
const QString id = m_versionId;
|
|
|
|
|
QString text = 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)) {
|
|
|
|
|
if (context->reply)
|
|
|
|
|
context->reply->abort();
|
|
|
|
|
context->file->cancelWriting();
|
|
|
|
|
}
|
|
|
|
|
resetInstallState();
|
|
|
|
|
emit failed(id, text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::abandonCurrent()
|
|
|
|
|
{
|
|
|
|
|
// Версия уступает очередь родителю: сигналов нет, установка продолжается.
|
|
|
|
|
m_queue.clear();
|
|
|
|
|
m_active.clear();
|
|
|
|
|
m_versionId.clear();
|
|
|
|
|
startNext();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::cancel()
|
|
|
|
|
{
|
|
|
|
|
if (!m_running)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
m_pendingInstalls.clear();
|
|
|
|
|
m_queue.clear();
|
|
|
|
|
m_legacyTimer->stop();
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void VersionInstaller::finalizeCancel()
|
|
|
|
|
{
|
|
|
|
|
const QString id = m_versionId;
|
|
|
|
|
m_canceling = false;
|
|
|
|
|
resetInstallState();
|
|
|
|
|
emit canceled(id);
|
|
|
|
|
}
|