version tracking and auto installing
This commit is contained in:
@@ -0,0 +1,809 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
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) {
|
||||
failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto entry = m_manifest->find(id);
|
||||
if (!entry) {
|
||||
// Обычно это вручную вписанный id сборки — сообщение не должно
|
||||
// выглядеть как сетевая ошибка.
|
||||
failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — "
|
||||
"установить её автоматически нельзя")
|
||||
.arg(id));
|
||||
return;
|
||||
}
|
||||
fetchVersionJson(entry->url, entry->sha1);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
m_versionJson = QJsonDocument::fromJson(payload).object();
|
||||
if (m_versionJson.isEmpty()) {
|
||||
failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id));
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Ванильные версии ничего не наследуют, но сборка могла бы — тогда
|
||||
// сначала ставим родителя, а потом возвращаемся к этой версии.
|
||||
const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||||
if (!parentId.isEmpty() && isSafeRelativeSegment(parentId)
|
||||
&& !QFile::exists(m_gameDir + QStringLiteral("/versions/") + parentId + u'/' + parentId
|
||||
+ QStringLiteral(".json"))) {
|
||||
if (m_inheritDepth >= kMaxInheritDepth) {
|
||||
failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
|
||||
return;
|
||||
}
|
||||
++m_inheritDepth;
|
||||
m_pendingInstalls.prepend({m_gameDir, id});
|
||||
m_pendingInstalls.prepend({m_gameDir, parentId});
|
||||
abandonCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
m_inheritDepth = 0;
|
||||
if (prepareTasks())
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Составление списка файлов ──────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user