java implementation and downloading

This commit is contained in:
2026-08-27 19:35:11 +03:00
parent 08e0dc4965
commit dddc0261d4
14 changed files with 2907 additions and 10 deletions
+783
View File
@@ -0,0 +1,783 @@
#include "javainstaller.h"
#include "launcherpaths.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcess>
#include <QSaveFile>
#include <QTimer>
#include <QtCore/private/qzipreader_p.h>
#include <utility>
// Читатель архива вместе с разобранным оглавлением: fileInfoList() каждый раз
// заново разбирает центральный каталог zip, а записей в JDK десятки тысяч.
struct ZipExtraction
{
explicit ZipExtraction(const QString &path)
: reader(path)
{
}
QZipReader reader;
QList<QZipReader::FileInfo> entries;
int index = 0;
};
namespace {
constexpr int kTransferTimeoutMs = 30000;
constexpr int kMaxParallel = 4;
constexpr int kMaxAttempts = 3;
constexpr int kProgressIntervalMs = 100;
// Записей zip за один проход таймера. Сотня файлов по паре мегабайт — это
// заметная, но ещё не видимая глазом задержка кадра.
constexpr int kZipChunk = 24;
bool isTransient(QNetworkReply::NetworkError error, int status)
{
if (status >= 500)
return true;
switch (error) {
case QNetworkReply::TimeoutError:
case QNetworkReply::TemporaryNetworkFailureError:
case QNetworkReply::NetworkSessionFailedError:
case QNetworkReply::ProxyTimeoutError:
case QNetworkReply::UnknownNetworkError:
return true;
default:
return false;
}
}
// Путь из манифеста или архива не должен уводить за пределы папки сборки:
// «../../» в имени записи — известный способ подменить чужой файл.
bool isInside(const QString &root, const QString &path)
{
const QString normalizedRoot = QDir::cleanPath(root) + u'/';
const QString normalized = QDir::cleanPath(path);
return normalized.startsWith(normalizedRoot);
}
void makeExecutable(const QString &path)
{
const QFileDevice::Permissions current = QFile::permissions(path);
QFile::setPermissions(path,
current | QFileDevice::ExeOwner | QFileDevice::ExeGroup
| QFileDevice::ExeOther);
}
} // namespace
JavaInstaller::JavaInstaller(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
, m_extractTimer(new QTimer(this))
, m_progressTimer(new QTimer(this))
{
m_extractTimer->setInterval(0);
connect(m_extractTimer, &QTimer::timeout, this, &JavaInstaller::extractZipChunk);
m_progressTimer->setInterval(kProgressIntervalMs);
connect(m_progressTimer, &QTimer::timeout, this, &JavaInstaller::flushProgress);
}
JavaInstaller::~JavaInstaller() = default;
qint64 JavaInstaller::bytesDone() const
{
qint64 total = m_bytesSettled;
for (const auto &context : m_active)
total += context->received;
return total;
}
double JavaInstaller::fraction() const
{
if (m_bytesTotal > 0)
return qBound(0.0, double(bytesDone()) / double(m_bytesTotal), 1.0);
if (m_stepsTotal > 0)
return qBound(0.0, double(m_stepsDone) / double(m_stepsTotal), 1.0);
return -1.0;
}
void JavaInstaller::install(const JavaRuntimeEntry &entry)
{
if (m_running)
return;
if (!entry.isValid()) {
emit failed(entry.version, tr("Сборка Java недоступна для загрузки"));
return;
}
QString error;
if (!LauncherPaths::ensureRootExists(&error)) {
emit failed(entry.version, tr("Папка лаунчера недоступна: %1").arg(error));
return;
}
m_entry = entry;
m_label = tr("%1 %2").arg(javaKindTitle(entry.kind), entry.version);
m_targetDir = JavaRuntimeStore::dirFor(entry.id);
if (m_targetDir.isEmpty()) {
emit failed(m_label, tr("Недопустимый идентификатор сборки Java"));
return;
}
// Переустановка поверх недокачанной прошлой попытки: старое дерево мешает
// и размером, и тем, что locateBinary нашла бы в нём чужой java.
if (!JavaRuntimeStore::remove(entry.id, &error)) {
emit failed(m_label, error);
return;
}
if (!QDir().mkpath(m_targetDir)) {
emit failed(m_label, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(m_targetDir)));
return;
}
m_running = true;
m_canceling = false;
m_bytesSettled = 0;
m_bytesTotal = 0;
m_stepsDone = 0;
m_stepsTotal = 0;
m_currentFile.clear();
m_links.clear();
m_queue.clear();
emit started(m_label);
if (entry.archive == QLatin1String("mojang"))
beginMojang();
else
beginArchive();
}
void JavaInstaller::cancel()
{
if (!m_running || m_canceling)
return;
m_canceling = true;
m_extractTimer->stop();
if (m_reply)
m_reply->abort();
for (const auto &context : std::as_const(m_active))
context->reply->abort();
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);
reset();
emit canceled(label);
}
}
// ── Temurin: один архив ─────────────────────────────────────────────────────
void JavaInstaller::beginArchive()
{
if (m_entry.archive != QLatin1String("zip")
&& m_entry.archive != QLatin1String("tar.gz")) {
fail(tr("Неизвестный формат архива: %1").arg(m_entry.archive));
return;
}
const QString name = m_entry.url.fileName();
m_archivePath = LauncherPaths::javaDownloadDir() + u'/'
+ (name.isEmpty() ? m_entry.id + QStringLiteral(".archive") : name);
m_bytesTotal = m_entry.size;
setStage(tr("Загрузка Java %1").arg(m_entry.version));
downloadArchive();
}
void JavaInstaller::downloadArchive()
{
m_archiveFile = std::make_unique<QSaveFile>(m_archivePath);
if (!m_archiveFile->open(QIODevice::WriteOnly)) {
fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(m_archivePath)));
return;
}
m_archiveHash.reset();
m_currentFile = QFileInfo(m_archivePath).fileName();
QNetworkRequest request{m_entry.url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
m_reply = m_network->get(request);
connect(m_reply, &QNetworkReply::readyRead, this, [this] {
const QByteArray chunk = m_reply->readAll();
if (chunk.isEmpty())
return;
m_archiveHash.addData(chunk);
if (m_archiveFile->write(chunk) != chunk.size())
m_reply->abort();
});
connect(m_reply, &QNetworkReply::downloadProgress, this,
[this](qint64 received, qint64 total) {
m_bytesSettled = received;
if (m_bytesTotal <= 0 && total > 0)
m_bytesTotal = total;
scheduleProgress();
});
connect(m_reply, &QNetworkReply::finished, this,
[this] { handleArchiveFinished(m_reply); });
scheduleProgress();
}
void JavaInstaller::handleArchiveFinished(QNetworkReply *reply)
{
reply->deleteLater();
m_reply = nullptr;
if (m_canceling) {
m_archiveFile->cancelWriting();
m_archiveFile.reset();
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
if (!m_running)
return;
if (reply->error() != QNetworkReply::NoError) {
m_archiveFile->cancelWriting();
m_archiveFile.reset();
fail(tr("Не удалось скачать Java %1: %2").arg(m_entry.version, reply->errorString()));
return;
}
if (!m_entry.checksum.isEmpty()) {
const QString actual = QString::fromLatin1(m_archiveHash.result().toHex());
if (actual.compare(m_entry.checksum, Qt::CaseInsensitive) != 0) {
m_archiveFile->cancelWriting();
m_archiveFile.reset();
fail(tr("Контрольная сумма архива Java не совпала — загрузка повреждена"));
return;
}
}
if (!m_archiveFile->commit()) {
m_archiveFile.reset();
fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(m_archivePath)));
return;
}
m_archiveFile.reset();
startExtraction();
}
void JavaInstaller::startExtraction()
{
// Байты кончились, дальше считаем шаги: панель по нулевому итогу перестанет
// показывать мегабайты, а проценты продолжит.
m_bytesTotal = 0;
m_bytesSettled = 0;
setStage(tr("Распаковка Java %1").arg(m_entry.version));
if (m_entry.archive == QLatin1String("tar.gz")) {
startTarExtraction();
return;
}
m_zip = std::make_unique<ZipExtraction>(m_archivePath);
if (!m_zip->reader.isReadable()) {
m_zip.reset();
fail(tr("Не удалось прочитать архив %1").arg(QDir::toNativeSeparators(m_archivePath)));
return;
}
m_zip->entries = m_zip->reader.fileInfoList();
m_stepsDone = 0;
m_stepsTotal = m_zip->entries.size();
m_extractTimer->start();
}
void JavaInstaller::extractZipChunk()
{
if (m_canceling || !m_running || !m_zip) {
m_extractTimer->stop();
return;
}
const QList<QZipReader::FileInfo> &entries = m_zip->entries;
const int end = qMin(m_zip->index + kZipChunk, entries.size());
for (; m_zip->index < end; ++m_zip->index) {
const QZipReader::FileInfo &info = entries.at(m_zip->index);
const QString target = m_targetDir + u'/' + info.filePath;
if (!isInside(m_targetDir, target)) {
m_extractTimer->stop();
fail(tr("Архив Java содержит недопустимый путь: %1").arg(info.filePath));
return;
}
if (info.isDir) {
QDir().mkpath(target);
continue;
}
// Ссылок в zip-сборках Temurin не бывает — под Unix они отдают tar.gz.
if (!info.isFile)
continue;
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
m_extractTimer->stop();
fail(tr("Не удалось создать %1")
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
return;
}
const QByteArray data = m_zip->reader.fileData(info.filePath);
QFile file(target);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
m_extractTimer->stop();
fail(tr("Не удалось распаковать %1").arg(info.filePath));
return;
}
file.close();
if (info.permissions != QFileDevice::Permissions())
file.setPermissions(info.permissions);
}
m_stepsDone = m_zip->index;
m_currentFile = m_zip->index > 0 ? entries.at(m_zip->index - 1).filePath : QString();
scheduleProgress();
if (m_zip->index >= entries.size()) {
m_extractTimer->stop();
m_zip.reset();
complete();
}
}
void JavaInstaller::startTarExtraction()
{
// tar есть в любой macOS и практически в любом дистрибутиве Linux; своего
// распаковщика gzip-tar в Qt нет, а тянуть ради него зависимость незачем.
m_tar = new QProcess(this);
m_tar->setWorkingDirectory(m_targetDir);
m_tar->setProgram(QStringLiteral("tar"));
m_tar->setArguments({QStringLiteral("-xzf"), m_archivePath,
QStringLiteral("-C"), m_targetDir});
connect(m_tar, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
if (!m_tar)
return;
const QString message = m_tar->errorString();
m_tar->deleteLater();
m_tar = nullptr;
if (m_canceling) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
fail(tr("Не удалось распаковать архив Java: %1").arg(message));
});
connect(m_tar, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
if (!m_tar)
return;
const QByteArray errorOutput = m_tar->readAllStandardError();
m_tar->deleteLater();
m_tar = nullptr;
if (m_canceling) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
if (status != QProcess::NormalExit || exitCode != 0) {
fail(tr("Распаковка архива Java не удалась: %1")
.arg(QString::fromLocal8Bit(errorOutput).trimmed()));
return;
}
complete();
});
m_tar->start();
}
// ── Mojang: дерево файлов ───────────────────────────────────────────────────
void JavaInstaller::beginMojang()
{
setStage(tr("Список файлов Java %1").arg(m_entry.version));
m_currentFile = QStringLiteral("manifest.json");
QNetworkRequest request{m_entry.url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
m_reply = m_network->get(request);
connect(m_reply, &QNetworkReply::finished, this, [this] {
QNetworkReply *reply = m_reply;
reply->deleteLater();
m_reply = nullptr;
if (m_canceling) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
if (!m_running)
return;
if (reply->error() != QNetworkReply::NoError) {
fail(tr("Не удалось получить состав рантайма: %1").arg(reply->errorString()));
return;
}
handleManifest(reply->readAll());
});
}
void JavaInstaller::handleManifest(const QByteArray &payload)
{
const QJsonObject files = QJsonDocument::fromJson(payload)
.object()
.value(QStringLiteral("files"))
.toObject();
if (files.isEmpty()) {
fail(tr("Состав рантайма Java не удалось разобрать"));
return;
}
if (!prepareTree(files))
return;
setStage(tr("Загрузка Java %1").arg(m_entry.version));
if (m_queue.isEmpty()) {
if (applyLinks())
complete();
return;
}
pump();
}
bool JavaInstaller::prepareTree(const QJsonObject &files)
{
m_bytesTotal = 0;
for (auto it = files.constBegin(); it != files.constEnd(); ++it) {
const QString target = m_targetDir + u'/' + it.key();
if (!isInside(m_targetDir, target)) {
fail(tr("Состав рантайма содержит недопустимый путь: %1").arg(it.key()));
return false;
}
const QJsonObject node = it.value().toObject();
const QString type = node.value(QStringLiteral("type")).toString();
if (type == QLatin1String("directory")) {
if (!QDir().mkpath(target)) {
fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(target)));
return false;
}
continue;
}
if (type == QLatin1String("link")) {
// Ссылки создаём в конце: их цели ещё не скачаны.
m_links << qMakePair(target, node.value(QStringLiteral("target")).toString());
continue;
}
if (type != QLatin1String("file"))
continue;
const QJsonObject raw = node.value(QStringLiteral("downloads"))
.toObject()
.value(QStringLiteral("raw"))
.toObject();
const QUrl url(raw.value(QStringLiteral("url")).toString());
if (url.scheme() != QLatin1String("https"))
continue;
JavaFileTask task;
task.url = url;
task.path = target;
task.sha1 = raw.value(QStringLiteral("sha1")).toString();
task.size = static_cast<qint64>(raw.value(QStringLiteral("size")).toDouble());
task.executable = node.value(QStringLiteral("executable")).toBool();
m_bytesTotal += task.size;
m_queue.enqueue(task);
}
m_stepsTotal = m_queue.size();
m_stepsDone = 0;
return true;
}
void JavaInstaller::pump()
{
if (!m_running || m_canceling)
return;
while (m_active.size() < kMaxParallel && !m_queue.isEmpty())
startTask(m_queue.dequeue());
if (m_active.isEmpty() && m_queue.isEmpty()) {
if (applyLinks())
complete();
}
}
void JavaInstaller::startTask(JavaFileTask task)
{
const QFileInfo info(task.path);
if (!QDir().mkpath(info.absolutePath())) {
fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath())));
return;
}
auto context = std::make_shared<JavaActiveDownload>();
context->task = task;
context->file = std::make_unique<QSaveFile>(task.path);
if (!context->file->open(QIODevice::WriteOnly)) {
fail(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_currentFile = QDir(m_targetDir).relativeFilePath(task.path);
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] { handleFileFinished(context); });
scheduleProgress();
}
void JavaInstaller::handleFileFinished(const std::shared_ptr<JavaActiveDownload> &context)
{
context->reply->deleteLater();
m_active.removeOne(context);
if (m_canceling) {
context->file->cancelWriting();
if (m_active.isEmpty()) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
}
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 {
fail(tr("Не удалось скачать %1: %2")
.arg(QDir(m_targetDir).relativeFilePath(context->task.path), 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()) {
fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path)));
return;
}
if (context->task.executable)
makeExecutable(context->task.path);
m_bytesSettled += context->received;
++m_stepsDone;
scheduleProgress();
pump();
}
bool JavaInstaller::retryOrFail(JavaFileTask task, const QString &reason)
{
if (++task.attempts >= kMaxAttempts) {
fail(tr("Не удалось скачать %1: %2")
.arg(QDir(m_targetDir).relativeFilePath(task.path), reason));
return false;
}
m_queue.enqueue(task);
return true;
}
bool JavaInstaller::applyLinks()
{
for (const auto &link : std::as_const(m_links)) {
const QString linkPath = link.first;
if (QFileInfo::exists(linkPath))
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)) {
fail(tr("Не удалось создать ссылку %1")
.arg(QDir(m_targetDir).relativeFilePath(linkPath)));
return false;
}
}
}
return true;
}
// ── Завершение ──────────────────────────────────────────────────────────────
void JavaInstaller::complete()
{
setStage(tr("Проверка сборки Java"));
const QString javaPath = JavaRuntimeStore::locateBinary(m_targetDir);
if (javaPath.isEmpty()) {
fail(tr("В скачанной сборке не нашлось исполняемого файла java"));
return;
}
#if !defined(Q_OS_WIN)
// tar сохраняет права, а вот zip и файлы Mojang без флага executable — нет.
makeExecutable(javaPath);
#endif
InstalledJavaRuntime runtime;
runtime.id = m_entry.id;
runtime.kind = javaKindKey(m_entry.kind);
runtime.major = m_entry.major;
runtime.version = m_entry.version;
runtime.javaPath = javaPath;
runtime.size = m_entry.size > 0 ? m_entry.size : m_bytesTotal;
QString error;
if (!JavaRuntimeStore::writeMeta(runtime, &error)) {
fail(error);
return;
}
// Архив нужен был только на распаковку — двести мегабайт в кэше ни к чему.
if (!m_archivePath.isEmpty())
QFile::remove(m_archivePath);
const QString id = m_entry.id;
reset();
emit finished(id, javaPath);
}
void JavaInstaller::fail(const QString &message)
{
const QString label = m_label;
m_extractTimer->stop();
m_zip.reset();
if (!m_archivePath.isEmpty())
QFile::remove(m_archivePath);
// Половина рантайма хуже, чем его отсутствие: следующая попытка начнётся с чистой папки.
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit failed(label, message);
}
void JavaInstaller::reset()
{
m_running = false;
m_canceling = false;
m_progressTimer->stop();
m_extractTimer->stop();
m_zip.reset();
m_queue.clear();
m_active.clear();
m_links.clear();
m_archiveFile.reset();
m_archivePath.clear();
m_bytesSettled = 0;
m_bytesTotal = 0;
m_stepsDone = 0;
m_stepsTotal = 0;
m_stage.clear();
m_currentFile.clear();
m_entry = {};
m_label.clear();
emit progressChanged();
}
void JavaInstaller::setStage(const QString &stage)
{
if (m_stage == stage)
return;
m_stage = stage;
emit progressChanged();
}
void JavaInstaller::scheduleProgress()
{
m_progressDirty = true;
if (!m_progressTimer->isActive())
m_progressTimer->start();
}
void JavaInstaller::flushProgress()
{
if (!m_progressDirty) {
m_progressTimer->stop();
return;
}
m_progressDirty = false;
emit progressChanged();
}