#include "javainstaller.h" #include "localization.h" #include "launcherpaths.h" #include #include #include #include #include #include #include #include #include #include #include #include #include // Читатель архива вместе с разобранным оглавлением: fileInfoList() каждый раз // заново разбирает центральный каталог zip, а записей в JDK десятки тысяч. struct ZipExtraction { explicit ZipExtraction(const QString &path) : reader(path) { } QZipReader reader; QList 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, Loc::text("java.error.buildUnavailable")); return; } QString error; if (!LauncherPaths::ensureRootExists(&error)) { emit failed(entry.version, Loc::text("java.error.launcherDirUnavailable").arg(error)); return; } m_entry = entry; m_label = Loc::text("java.status.nameAndVersion").arg(javaKindTitle(entry.kind), entry.version); m_targetDir = JavaRuntimeStore::dirFor(entry.id); if (m_targetDir.isEmpty()) { emit failed(m_label, Loc::text("java.error.invalidBuildId")); return; } // Переустановка поверх недокачанной прошлой попытки: старое дерево мешает // и размером, и тем, что locateBinary нашла бы в нём чужой java. if (!JavaRuntimeStore::remove(entry.id, &error)) { emit failed(m_label, error); return; } if (!QDir().mkpath(m_targetDir)) { emit failed(m_label, Loc::text("common.error.createFailed").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(Loc::text("java.error.unknownArchiveFormat").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(Loc::text("java.progress.downloadingBuild").arg(m_entry.version)); downloadArchive(); } void JavaInstaller::downloadArchive() { m_archiveFile = std::make_unique(m_archivePath); if (!m_archiveFile->open(QIODevice::WriteOnly)) { fail(Loc::text("common.error.openForWriteFailed").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(Loc::text("java.error.downloadFailed").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(Loc::text("java.error.archiveChecksum")); return; } } if (!m_archiveFile->commit()) { m_archiveFile.reset(); fail(Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(m_archivePath))); return; } m_archiveFile.reset(); startExtraction(); } void JavaInstaller::startExtraction() { // Байты кончились, дальше считаем шаги: панель по нулевому итогу перестанет // показывать мегабайты, а проценты продолжит. m_bytesTotal = 0; m_bytesSettled = 0; setStage(Loc::text("java.progress.extracting").arg(m_entry.version)); if (m_entry.archive == QLatin1String("tar.gz")) { startTarExtraction(); return; } m_zip = std::make_unique(m_archivePath); if (!m_zip->reader.isReadable()) { m_zip.reset(); fail(Loc::text("java.error.archiveReadFailed").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 &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(Loc::text("java.error.archiveBadPath").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(Loc::text("common.error.createFailed") .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(Loc::text("java.error.extractEntryFailed").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(Loc::text("java.error.extractFailed").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(Loc::text("java.error.extractCrashed") .arg(QString::fromLocal8Bit(errorOutput).trimmed())); return; } complete(); }); m_tar->start(); } // ── Mojang: дерево файлов ─────────────────────────────────────────────────── void JavaInstaller::beginMojang() { setStage(Loc::text("java.progress.fileList").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(Loc::text("java.error.manifestFetchFailed").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(Loc::text("java.error.manifestUnparsable")); return; } if (!prepareTree(files)) return; setStage(Loc::text("java.progress.downloadingBuild").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(Loc::text("java.error.manifestBadPath").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(Loc::text("common.error.createFailed").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(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(Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(info.absolutePath()))); return; } auto context = std::make_shared(); context->task = task; context->file = std::make_unique(task.path); if (!context->file->open(QIODevice::WriteOnly)) { fail(Loc::text("common.error.openForWriteFailed").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 &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(Loc::text("common.error.downloadFailed") .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, Loc::text("common.error.checksumMismatch"))) return; pump(); return; } } if (!context->file->commit()) { fail(Loc::text("common.error.saveFailed").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(Loc::text("common.error.downloadFailed") .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(Loc::text("java.error.linkFailed") .arg(QDir(m_targetDir).relativeFilePath(linkPath))); return false; } } } return true; } // ── Завершение ────────────────────────────────────────────────────────────── void JavaInstaller::complete() { setStage(Loc::text("java.progress.verifying")); const QString javaPath = JavaRuntimeStore::locateBinary(m_targetDir); if (javaPath.isEmpty()) { fail(Loc::text("java.error.noExecutable")); 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(); }