From dddc0261d4702511e1aa94517a621ab46a3dabb3 Mon Sep 17 00:00:00 2001 From: galeon Date: Thu, 27 Aug 2026 19:35:11 +0300 Subject: [PATCH] java implementation and downloading --- CMakeLists.txt | 7 + JavaPickerDialog.qml | 520 +++++++++++++++++++++++++++ Main.qml | 109 ++++++ javainstaller.cpp | 783 +++++++++++++++++++++++++++++++++++++++++ javainstaller.h | 148 ++++++++ javalocator.cpp | 10 + javaruntime.cpp | 270 ++++++++++++++ javaruntime.h | 86 +++++ javaruntimeservice.cpp | 528 +++++++++++++++++++++++++++ javaruntimeservice.h | 88 +++++ launcherbackend.cpp | 301 +++++++++++++++- launcherbackend.h | 37 ++ launcherpaths.cpp | 20 +- launcherpaths.h | 10 + 14 files changed, 2907 insertions(+), 10 deletions(-) create mode 100644 JavaPickerDialog.qml create mode 100644 javainstaller.cpp create mode 100644 javainstaller.h create mode 100644 javaruntime.cpp create mode 100644 javaruntime.h create mode 100644 javaruntimeservice.cpp create mode 100644 javaruntimeservice.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f84c59d..f00aeec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,12 @@ qt_add_qml_module(appMinecraft_launcher minecraftversion.cpp javalocator.h javalocator.cpp + javaruntime.h + javaruntime.cpp + javaruntimeservice.h + javaruntimeservice.cpp + javainstaller.h + javainstaller.cpp authservice.h authservice.cpp gamelauncher.h @@ -45,6 +51,7 @@ qt_add_qml_module(appMinecraft_launcher Main.qml DarkCombo.qml VersionPickerDialog.qml + JavaPickerDialog.qml LabelledField.qml BuildsDialog.qml LoaderRow.qml diff --git a/JavaPickerDialog.qml b/JavaPickerDialog.qml new file mode 100644 index 0000000..f930e94 --- /dev/null +++ b/JavaPickerDialog.qml @@ -0,0 +1,520 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 2.15 + +// Выбор сборки Java отдельным окном: слева типы, справа сами версии с поиском. +// Устроено так же, как выбор версии Minecraft, но выбранное здесь ещё и +// качается — в отличие от версий игры, где загрузку начинает сама сборка. +Dialog { + id: picker + + required property var backend + + // Сборка, с которой окно открылось: по «Отмене» выбор к ней и возвращается. + property string selectedId: "" + property string category: "java" + property string filterText: "" + property bool installedOnly: false + + // Java, ниже которой выбранной версии игры не запуститься. 0 — версия игры + // не выбрана, предупреждать не о чем. + property int requiredMajor: 0 + + signal runtimeChosen(string runtimeId) + + modal: true + padding: 0 + width: 720 + height: 480 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#1e1e1e" + radius: 10 + border.color: "#91B315" + border.width: 1 + } + + readonly property var categories: [ + { key: "java", title: qsTr("Java"), hint: qsTr("сборки Mojang") }, + { key: "jdk", title: qsTr("JDK"), hint: qsTr("Temurin, с инструментами") }, + { key: "jre", title: qsTr("JRE"), hint: qsTr("Temurin, только запуск") } + ] + + // Каталог приходит из C++ уже отсортированным (новые сверху, скачанные + // первыми), поэтому здесь только отбор — порядок не трогаем. + readonly property var visibleEntries: { + const query = picker.filterText.trim().toLowerCase() + const source = picker.backend.javaCatalog + const out = [] + for (var i = 0; i < source.length; ++i) { + const entry = source[i] + if (entry.kind !== picker.category) + continue + if (picker.installedOnly && !entry.installed) + continue + if (query !== "" && entry.search.indexOf(query) === -1) + continue + out.push(entry) + } + return out + } + + readonly property var selectedEntry: { + const list = picker.backend.javaCatalog + for (var i = 0; i < list.length; ++i) { + if (list[i].id === picker.selectedId) + return list[i] + } + return null + } + + function openFor(runtimeId, required) { + picker.selectedId = runtimeId + picker.requiredMajor = required || 0 + picker.filterText = "" + searchField.text = "" + picker.category = picker.categoryOf(runtimeId) + picker.backend.refreshJavaCatalog() + picker.open() + picker.revealSelected() + } + + function categoryOf(runtimeId) { + const source = picker.backend.javaCatalog + for (var i = 0; i < source.length; ++i) { + if (source[i].id === runtimeId) + return source[i].kind + } + return "java" + } + + function indexOfSelected() { + const list = picker.visibleEntries + for (var i = 0; i < list.length; ++i) { + if (list[i].id === picker.selectedId) + return i + } + return -1 + } + + function revealSelected() { + const index = picker.indexOfSelected() + runtimeList.currentIndex = index + if (index >= 0) + runtimeList.positionViewAtIndex(index, ListView.Center) + } + + // «Выбрать» и скачивает: сборка, которой нет на диске, запуску не поможет. + function acceptSelection() { + const entry = picker.selectedEntry + if (!entry) + return + picker.runtimeChosen(entry.id) + if (!entry.installed && entry.downloadable) + picker.backend.installJavaRuntime(entry.id) + picker.close() + } + + header: Item { + implicitHeight: 52 + Text { + anchors.centerIn: parent + text: qsTr("Версия Java") + color: "#ffffff" + font.pixelSize: 17 + font.bold: true + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#333333" + } + } + + contentItem: RowLayout { + spacing: 0 + + // ── Типы сборок ───────────────────────────────────────────────── + Rectangle { + Layout.preferredWidth: 170 + Layout.fillHeight: true + color: "transparent" + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + + Repeater { + model: picker.categories + + delegate: Rectangle { + id: categoryRow + required property var modelData + + width: parent.width + height: 44 + radius: 5 + color: picker.category === categoryRow.modelData.key ? "#91B315" + : categoryArea.containsMouse ? "#2d2d2d" : "transparent" + + Column { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: categoryRow.modelData.title + color: "#ffffff" + font.pixelSize: 13 + } + Text { + text: categoryRow.modelData.hint + color: picker.category === categoryRow.modelData.key + ? "#1e1e1e" : "#777777" + font.pixelSize: 10 + } + } + + MouseArea { + id: categoryArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + picker.category = categoryRow.modelData.key + picker.revealSelected() + } + } + } + } + + Item { width: 1; height: 8 } + + // Требование выбранной сборки — чтобы не пришлось держать в + // голове, какая Java нужна какой версии игры. + Text { + width: parent.width - 4 + visible: picker.requiredMajor > 0 + wrapMode: Text.Wrap + color: "#888888" + font.pixelSize: 10 + text: qsTr("Выбранной версии игры нужна Java %1 или новее") + .arg(picker.requiredMajor) + } + } + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: "#333333" + } + + // ── Версии ────────────────────────────────────────────────────── + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + TextField { + id: searchField + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 8 + height: 32 + placeholderText: qsTr("Поиск версии…") + color: "#ffffff" + placeholderTextColor: "#666666" + background: Rectangle { + color: "#232323" + radius: 5 + border.color: searchField.activeFocus ? "#91B315" : "#444444" + border.width: 1 + } + onTextChanged: { + picker.filterText = text + runtimeList.currentIndex = picker.indexOfSelected() + } + Keys.onEscapePressed: picker.close() + Keys.onReturnPressed: picker.acceptSelection() + Keys.onEnterPressed: picker.acceptSelection() + Keys.onDownPressed: runtimeList.step(1) + Keys.onUpPressed: runtimeList.step(-1) + } + + CheckBox { + id: installedOnlyBox + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + height: 28 + checked: picker.installedOnly + onToggled: { + picker.installedOnly = checked + picker.revealSelected() + } + + indicator: Rectangle { + implicitWidth: 16 + implicitHeight: 16 + x: 0 + y: (installedOnlyBox.height - height) / 2 + radius: 4 + color: installedOnlyBox.checked ? "#91B315" : "#2a2a2a" + border.color: installedOnlyBox.checked ? "#91B315" : "#444444" + border.width: 1 + + Text { + anchors.centerIn: parent + visible: installedOnlyBox.checked + text: "✓" + color: "#1e1e1e" + font.pixelSize: 12 + font.bold: true + } + } + contentItem: Text { + leftPadding: installedOnlyBox.indicator.width + 8 + text: qsTr("Только скачанные") + color: "#aaaaaa" + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + } + } + + ListView { + id: runtimeList + anchors.top: searchField.bottom + anchors.topMargin: 6 + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: installedOnlyBox.top + anchors.leftMargin: 8 + anchors.rightMargin: 8 + clip: true + spacing: 1 + model: picker.visibleEntries + ScrollIndicator.vertical: ScrollIndicator {} + + function step(delta) { + const next = runtimeList.currentIndex + delta + if (next < 0 || next >= runtimeList.count) + return + runtimeList.currentIndex = next + runtimeList.positionViewAtIndex(next, ListView.Contain) + picker.selectedId = picker.visibleEntries[next].id + } + + delegate: Rectangle { + id: runtimeRow + required property var modelData + required property int index + + readonly property bool chosen: picker.selectedId === runtimeRow.modelData.id + // Сборка ниже требования игры выбирается, но помечается: она + // может пригодиться для другой сборки. + readonly property bool tooOld: picker.requiredMajor > 0 + && runtimeRow.modelData.major < picker.requiredMajor + + width: runtimeList.width + height: 44 + radius: 4 + color: runtimeRow.chosen ? "#91B315" + : runtimeArea.containsMouse ? "#2d2d2d" : "transparent" + + Column { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: rightMarks.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Row { + spacing: 6 + Text { + text: runtimeRow.modelData.label + color: "#ffffff" + font.pixelSize: 13 + } + Text { + visible: runtimeRow.modelData.lts + text: "LTS" + color: runtimeRow.chosen ? "#1e1e1e" : "#91B315" + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Text { + // Колонка растянута якорями, поэтому её ширина + // задана, а не выведена из детей — цикла тут нет. + width: parent.width + elide: Text.ElideRight + color: runtimeRow.chosen ? "#1e1e1e" + : runtimeRow.tooOld ? "#aa7777" : "#888888" + font.pixelSize: 10 + text: { + const parts = [runtimeRow.modelData.detail, + runtimeRow.modelData.coverage] + if (runtimeRow.modelData.sizeMb > 0) + parts.push(qsTr("%1 МБ").arg(runtimeRow.modelData.sizeMb)) + if (runtimeRow.tooOld) + parts.push(qsTr("не хватит для выбранной версии игры")) + return parts.join(" · ") + } + } + } + + Row { + id: rightMarks + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + visible: runtimeRow.modelData.installed + text: "✓" + color: runtimeRow.chosen ? "#1e1e1e" : "#91B315" + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + // Сборки весят по двести мегабайт — удалять их нужно + // прямо здесь, иначе папка лаунчера растёт молча. + Image { + visible: runtimeRow.modelData.installed + source: "images/Trash.svg" + width: 14 + height: 14 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + opacity: trashArea.containsMouse ? 1.0 : 0.55 + + MouseArea { + id: trashArea + anchors.fill: parent + anchors.margins: -5 + hoverEnabled: true + onClicked: { + picker.backend.removeJavaRuntime(runtimeRow.modelData.id) + if (picker.selectedId === runtimeRow.modelData.id) + picker.selectedId = "" + } + } + } + } + + MouseArea { + id: runtimeArea + anchors.fill: parent + anchors.rightMargin: 40 + hoverEnabled: true + onClicked: { + picker.selectedId = runtimeRow.modelData.id + runtimeList.currentIndex = runtimeRow.index + } + onDoubleClicked: { + picker.selectedId = runtimeRow.modelData.id + picker.acceptSelection() + } + } + } + } + + // Пустая категория, ничего не нашлось или каталог ещё не пришёл. + Text { + anchors.centerIn: runtimeList + width: runtimeList.width - 20 + visible: picker.visibleEntries.length === 0 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: "#888888" + font.pixelSize: 12 + font.italic: true + text: picker.backend.javaCatalogLoading + ? qsTr("Загрузка списка сборок Java…") + : picker.backend.javaCatalog.length === 0 + ? qsTr("Список сборок Java недоступен — проверьте соединение") + : qsTr("Ничего не найдено") + } + } + } + + footer: Item { + implicitHeight: 60 + Rectangle { + anchors.top: parent.top + width: parent.width + height: 1 + color: "#333333" + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 20 + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 320 + elide: Text.ElideRight + color: picker.selectedEntry === null ? "#666666" : "#91B315" + font.pixelSize: 13 + text: picker.selectedEntry === null + ? qsTr("Сборка не выбрана") + : picker.selectedEntry.installed + ? picker.selectedEntry.label + : qsTr("%1 — будет скачана").arg(picker.selectedEntry.label) + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 20 + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Button { + text: qsTr("Отмена") + width: 110; height: 36 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#444444" : "#333333" + radius: 6 + } + onClicked: picker.close() + } + + Button { + text: picker.selectedEntry !== null && !picker.selectedEntry.installed + ? qsTr("Скачать") : qsTr("Выбрать") + width: 110; height: 36 + enabled: picker.selectedEntry !== null + opacity: enabled ? 1.0 : 0.45 + contentItem: Text { + text: parent.text + color: "#ffffff" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#6a8510" : "#91B315" + radius: 6 + } + onClicked: picker.acceptSelection() + } + } + } + + onOpened: searchField.forceActiveFocus() +} diff --git a/Main.qml b/Main.qml index 6af8c23..fe3b191 100644 --- a/Main.qml +++ b/Main.qml @@ -913,10 +913,30 @@ Window { y: (window.height - height) / 2 padding: 0 + // Выбранная сборка Java живёт в свойстве, а не в поле ввода: её выбирают + // в отдельном окне, а записывается она только по «Сохранить». + property string javaRuntimeId: "" + // Не привязка: javaRuntimeInfo() — обычный вызов, и сам он не + // пересчитается, когда сборка докачается. Обновляем по событиям. + property var javaRuntimeInfo: null + onJavaRuntimeIdChanged: settingsDialog.refreshJavaInfo() + + function refreshJavaInfo() { + javaRuntimeInfo = javaRuntimeId === "" ? null + : backend.javaRuntimeInfo(javaRuntimeId) + } + + Connections { + target: backend + function onJavaRuntimeInstalled(runtimeId) { settingsDialog.refreshJavaInfo() } + } + function load() { const s = backend.settings() stGameDir.text = s.gameDir || "" stJavaPath.text = s.javaPath || "" + settingsDialog.javaRuntimeId = s.javaRuntime || "" + settingsDialog.refreshJavaInfo() stMinMemory.text = String(s.minMemoryMb) stMaxMemory.text = String(s.maxMemoryMb) stJvmArgs.text = s.jvmArgs || "" @@ -972,6 +992,84 @@ Window { placeholder: "автоопределение" } + // Сборка Java из папки лаунчера. Выбрана — запуск идёт ей, а не тем, + // что нашлось в системе; путь выше остаётся запасным вариантом. + Column { + x: 20 + width: parent.width - 40 + spacing: 3 + + Text { + text: "Версия Java из папки лаунчера" + color: "#aaaaaa" + font.pixelSize: 11 + } + + Rectangle { + width: parent.width + height: 32 + radius: 6 + color: "#2a2a2a" + border.color: javaFieldArea.containsMouse ? "#91B315" : "#444444" + border.width: 1 + + Text { + anchors.fill: parent + leftPadding: 10 + rightPadding: 150 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + color: settingsDialog.javaRuntimeId === "" ? "#666666" : "#ffffff" + text: { + if (settingsDialog.javaRuntimeId === "") + return qsTr("не выбрана — искать в системе") + const info = settingsDialog.javaRuntimeInfo + if (!info) + return settingsDialog.javaRuntimeId + return info.installed ? info.label + : qsTr("%1 — скачивается").arg(info.label) + } + } + + Text { + anchors.right: clearJava.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Выбрать…") + color: "#91B315" + font.pixelSize: 12 + } + + Text { + id: clearJava + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + visible: settingsDialog.javaRuntimeId !== "" + text: "✕" + color: clearJavaArea.containsMouse ? "#cc6666" : "#666666" + font.pixelSize: 12 + + MouseArea { + id: clearJavaArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + onClicked: settingsDialog.javaRuntimeId = "" + } + } + + MouseArea { + id: javaFieldArea + anchors.fill: parent + anchors.rightMargin: 26 + hoverEnabled: true + onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId, + backend.requiredJavaMajor(backend.activeBuildIndex)) + } + } + } + Row { x: 20 spacing: 10 @@ -1110,6 +1208,7 @@ Window { onAccepted: backend.updateSettings({ "gameDir": stGameDir.text.trim(), "javaPath": stJavaPath.text.trim(), + "javaRuntime": settingsDialog.javaRuntimeId, "minMemoryMb": parseInt(stMinMemory.text) || 0, "maxMemoryMb": parseInt(stMaxMemory.text) || 0, "jvmArgs": stJvmArgs.text.trim(), @@ -1118,4 +1217,14 @@ Window { "fullscreen": stFullscreen.checked }) } + + // Окно выбора Java. Живёт рядом с настройками, а не внутри: оно шире их и + // центрируется по самому окну лаунчера. + JavaPickerDialog { + id: javaPicker + backend: backend + x: (window.width - width) / 2 + y: (window.height - height) / 2 + onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId + } } diff --git a/javainstaller.cpp b/javainstaller.cpp new file mode 100644 index 0000000..85500f1 --- /dev/null +++ b/javainstaller.cpp @@ -0,0 +1,783 @@ +#include "javainstaller.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, 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(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(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 &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(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(); + context->task = task; + context->file = std::make_unique(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 &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(); +} diff --git a/javainstaller.h b/javainstaller.h new file mode 100644 index 0000000..db77dfd --- /dev/null +++ b/javainstaller.h @@ -0,0 +1,148 @@ +#pragma once + +#include "javaruntime.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +class QJsonObject; +class QNetworkAccessManager; +class QNetworkReply; +class QProcess; +class QSaveFile; +class QTimer; + +// Состояние распаковки zip. Спрятано в .cpp, чтобы приватный заголовок +// QZipReader не расходился по проекту вместе с этим. +struct ZipExtraction; + +// Один файл рантайма Mojang. +struct JavaFileTask +{ + QUrl url; + QString path; // абсолютный путь назначения + QString sha1; + qint64 size = 0; + bool executable = false; + int attempts = 0; +}; + +// Файл в процессе скачивания. Пишем потоком: рантайм — это две сотни мегабайт. +struct JavaActiveDownload +{ + JavaFileTask task; + QNetworkReply *reply = nullptr; + std::unique_ptr file; + QCryptographicHash hash{QCryptographicHash::Sha1}; + qint64 received = 0; +}; + +// Установка сборки Java в /java/. Два разных пути под одним фасадом: +// +// * Temurin отдаёт один архив — качаем, сверяем sha256 и распаковываем; +// +// * Mojang отдаёт манифест с деревом файлов — качаем файлы по отдельности, +// как это делает официальный лаунчер. +// +// Набор геттеров повторяет VersionInstaller: панель загрузки в интерфейсе +// читает их одинаково, независимо от того, кто сейчас работает. +class JavaInstaller : public QObject +{ + Q_OBJECT + +public: + explicit JavaInstaller(QObject *parent = nullptr); + ~JavaInstaller() override; + + bool isRunning() const { return m_running; } + QString runtimeId() const { return m_entry.id; } + QString label() const { return m_label; } + QString stage() const { return m_stage; } + QString currentFile() const { return m_currentFile; } + + // Байты считаются только на загрузке: на распаковке считать нечего, и панель + // по нулевому итогу сама прячет мегабайты. + qint64 bytesDone() const; + qint64 bytesTotal() const { return m_bytesTotal; } + double fraction() const; // 0..1, либо -1 пока итог неизвестен + + void install(const JavaRuntimeEntry &entry); + void cancel(); + +signals: + void started(const QString &label); + void progressChanged(); + void finished(const QString &runtimeId, const QString &javaPath); + void failed(const QString &label, const QString &message); + void canceled(const QString &label); + +private: + // ── Общее ─────────────────────────────────────────────────────────── + void beginArchive(); + void beginMojang(); + void complete(); + void fail(const QString &message); + void reset(); + void setStage(const QString &stage); + void scheduleProgress(); + void flushProgress(); + + // ── Temurin ───────────────────────────────────────────────────────── + void downloadArchive(); + void handleArchiveFinished(QNetworkReply *reply); + void startExtraction(); + void extractZipChunk(); + void startTarExtraction(); + + // ── Mojang ────────────────────────────────────────────────────────── + void handleManifest(const QByteArray &payload); + bool prepareTree(const QJsonObject &files); + void pump(); + void startTask(JavaFileTask task); + void handleFileFinished(const std::shared_ptr &context); + bool retryOrFail(JavaFileTask task, const QString &reason); + bool applyLinks(); + + QNetworkAccessManager *m_network; + + JavaRuntimeEntry m_entry; + QString m_label; + QString m_targetDir; + QString m_archivePath; + + QString m_stage; + QString m_currentFile; + qint64 m_bytesSettled = 0; + qint64 m_bytesTotal = 0; + + // Шаги распаковки: у архива — записи внутри него, у Mojang — файлы дерева. + int m_stepsDone = 0; + int m_stepsTotal = 0; + + QNetworkReply *m_reply = nullptr; // архив или манифест + std::unique_ptr m_archiveFile; + QCryptographicHash m_archiveHash{QCryptographicHash::Sha256}; + + QProcess *m_tar = nullptr; + QTimer *m_extractTimer; + QTimer *m_progressTimer; + bool m_progressDirty = false; + + // Распаковка zip по кускам: держать GUI-поток занятым на всю сотню + // мегабайт нельзя, а поток ради одной операции заводить незачем. + std::unique_ptr m_zip; + + QQueue m_queue; + QList> m_active; + QList> m_links; // {абсолютный путь ссылки, цель} + + bool m_running = false; + bool m_canceling = false; +}; diff --git a/javalocator.cpp b/javalocator.cpp index 2c0005d..ffb4369 100644 --- a/javalocator.cpp +++ b/javalocator.cpp @@ -1,5 +1,7 @@ #include "javalocator.h" +#include "javaruntime.h" + #include #include #include @@ -72,6 +74,14 @@ QStringList JavaLocator::findAll(const QString &gameDir) { QStringList result; + // 0. Сборки, скачанные самим лаунчером. Идут первыми: пользователь выбирал + // их осознанно, в отличие от того, что нашлось в системе. + const QList managed = JavaRuntimeStore::installed(); + for (const InstalledJavaRuntime &entry : managed) { + if (!result.contains(entry.javaPath)) + result << entry.javaPath; + } + // 1. Рантайм, скачанный самим Minecraft: .minecraft/runtime////bin const QString runtime = gameDir + QStringLiteral("/runtime"); if (QDir(runtime).exists()) { diff --git a/javaruntime.cpp b/javaruntime.cpp new file mode 100644 index 0000000..f5d428c --- /dev/null +++ b/javaruntime.cpp @@ -0,0 +1,270 @@ +#include "javaruntime.h" + +#include "launcherpaths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +#if defined(Q_OS_WIN) +constexpr auto kExecutable = "java.exe"; +#else +constexpr auto kExecutable = "java"; +#endif + +const QString kMetaFile = QStringLiteral("runtime.json"); + +QString translate(const char *text) +{ + return QCoreApplication::translate("JavaRuntime", text); +} + +} // namespace + +QString javaKindKey(JavaRuntimeKind kind) +{ + switch (kind) { + case JavaRuntimeKind::Mojang: return QStringLiteral("java"); + case JavaRuntimeKind::Jdk: return QStringLiteral("jdk"); + case JavaRuntimeKind::Jre: return QStringLiteral("jre"); + } + return QStringLiteral("jdk"); +} + +std::optional javaKindFromKey(const QString &key) +{ + if (key == QLatin1String("java")) + return JavaRuntimeKind::Mojang; + if (key == QLatin1String("jdk")) + return JavaRuntimeKind::Jdk; + if (key == QLatin1String("jre")) + return JavaRuntimeKind::Jre; + return std::nullopt; +} + +QString javaKindTitle(JavaRuntimeKind kind) +{ + switch (kind) { + case JavaRuntimeKind::Mojang: return translate("Java"); + case JavaRuntimeKind::Jdk: return translate("JDK"); + case JavaRuntimeKind::Jre: return translate("JRE"); + } + return {}; +} + +QString JavaRuntimeStore::sanitizeId(const QString &id) +{ + QString out; + out.reserve(id.size()); + for (const QChar ch : id) { + const bool safe = ch.isLetterOrNumber() || ch == u'.' || ch == u'-' || ch == u'_'; + out += safe ? ch : u'_'; + } + // Точки в начале превратили бы папку в скрытую, а «..» — в путь наружу. + while (out.startsWith(u'.')) + out.remove(0, 1); + return out; +} + +QString JavaRuntimeStore::dirFor(const QString &id) +{ + const QString safe = sanitizeId(id); + if (safe.isEmpty()) + return {}; + return LauncherPaths::javaDir() + u'/' + safe; +} + +QString JavaRuntimeStore::locateBinary(const QString &rootDir) +{ + if (rootDir.isEmpty() || !QDir(rootDir).exists()) + return {}; + + // Кандидатов бывает несколько: в JDK рядом с bin/java лежит ещё и + // jre/bin/java. Берём самый короткий путь — это корневой интерпретатор. + QString best; + QDirIterator it(rootDir, QStringList{QString::fromLatin1(kExecutable)}, + QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + const QFileInfo info = it.fileInfo(); + if (info.absolutePath().section(u'/', -1) != QLatin1String("bin")) + continue; + const QString path = info.absoluteFilePath(); + if (best.isEmpty() || path.length() < best.length()) + best = path; + } + return best; +} + +std::optional JavaRuntimeStore::find(const QString &id) +{ + const QString dir = dirFor(id); + if (dir.isEmpty()) + return std::nullopt; + + QFile file(dir + u'/' + kMetaFile); + if (!file.open(QIODevice::ReadOnly)) + return std::nullopt; + + const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object(); + InstalledJavaRuntime runtime; + runtime.id = object.value(QStringLiteral("id")).toString(); + runtime.kind = object.value(QStringLiteral("kind")).toString(); + runtime.major = object.value(QStringLiteral("major")).toInt(); + runtime.version = object.value(QStringLiteral("version")).toString(); + runtime.size = static_cast(object.value(QStringLiteral("size")).toDouble()); + + // Путь хранится относительным: папку лаунчера могли перенести вместе с + // профилем пользователя, а абсолютный путь этого не переживает. + const QString relative = object.value(QStringLiteral("javaPath")).toString(); + runtime.javaPath = relative.isEmpty() ? QString() : QDir(dir).absoluteFilePath(relative); + + if (runtime.id != id || runtime.javaPath.isEmpty() || !QFileInfo::exists(runtime.javaPath)) + return std::nullopt; + return runtime; +} + +QList JavaRuntimeStore::installed() +{ + QList result; + const QDir root(LauncherPaths::javaDir()); + const QStringList entries = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + for (const QString &entry : entries) { + const auto runtime = find(entry); + if (runtime) + result << *runtime; + } + + std::stable_sort(result.begin(), result.end(), + [](const InstalledJavaRuntime &a, const InstalledJavaRuntime &b) { + if (a.major != b.major) + return a.major > b.major; + return a.version > b.version; + }); + return result; +} + +bool JavaRuntimeStore::writeMeta(const InstalledJavaRuntime &runtime, QString *error) +{ + const QString dir = dirFor(runtime.id); + if (dir.isEmpty()) { + if (error) + *error = translate("недопустимый идентификатор сборки Java"); + return false; + } + + const QJsonObject object{ + {QStringLiteral("id"), runtime.id}, + {QStringLiteral("kind"), runtime.kind}, + {QStringLiteral("major"), runtime.major}, + {QStringLiteral("version"), runtime.version}, + {QStringLiteral("javaPath"), QDir(dir).relativeFilePath(runtime.javaPath)}, + {QStringLiteral("size"), static_cast(runtime.size)}}; + + QSaveFile file(dir + u'/' + kMetaFile); + if (!file.open(QIODevice::WriteOnly)) { + if (error) + *error = translate("не удалось записать %1").arg(QDir::toNativeSeparators(file.fileName())); + return false; + } + const QByteArray payload = QJsonDocument(object).toJson(QJsonDocument::Compact); + if (file.write(payload) != payload.size() || !file.commit()) { + file.cancelWriting(); + if (error) + *error = translate("не удалось записать %1").arg(QDir::toNativeSeparators(file.fileName())); + return false; + } + return true; +} + +bool JavaRuntimeStore::remove(const QString &id, QString *error) +{ + const QString dir = dirFor(id); + // Удаляем только внутри /java: sanitizeId не пропустит «..», но + // проверка стоит одной строки, а цена ошибки — чужая папка. + if (dir.isEmpty() || !dir.startsWith(LauncherPaths::javaDir() + u'/')) { + if (error) + *error = translate("недопустимый идентификатор сборки Java"); + return false; + } + if (!QDir(dir).exists()) + return true; + if (!QDir(dir).removeRecursively()) { + if (error) + *error = translate("не удалось удалить %1").arg(QDir::toNativeSeparators(dir)); + return false; + } + return true; +} + +int JavaRequirement::minimumFor(const QString &minecraftVersionId) +{ + const QString id = minecraftVersionId.trimmed(); + if (id.isEmpty()) + return 0; + + // Снапшоты — YYwWWa. Ровных границ по неделям нет, но год отвечает на + // вопрос «какая Java» точнее, чем ничего: 1.17 (Java 16) вышла в 21-м году, + // 1.18 (Java 17) в конце того же года, 1.20.5 (Java 21) — в 24-м. + static const QRegularExpression snapshot(QStringLiteral("^(\\d{2})w\\d{2}[a-z]$")); + const QRegularExpressionMatch snapshotMatch = snapshot.match(id); + if (snapshotMatch.hasMatch()) { + const int year = snapshotMatch.captured(1).toInt(); + if (year >= 24) + return 21; + if (year >= 22) + return 17; + if (year == 21) + return 16; + return 8; + } + + // Релизы и их пре-релизы: 1.20.5, 1.21, 1.21.4-pre1, 26.2. + static const QRegularExpression release( + QStringLiteral("^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?")); + const QRegularExpressionMatch releaseMatch = release.match(id); + if (!releaseMatch.hasMatch()) + return 8; // a1.2.6, b1.7.3, rd-132211 и прочая доисторическая ваниль + + const int epoch = releaseMatch.captured(1).toInt(); + const int major = releaseMatch.captured(2).toInt(); + const int patch = releaseMatch.captured(3).toInt(); + + // Нумерация после 1.21.x пошла по годам (26.2) — это заведомо новее 1.20.5. + if (epoch != 1) + return 21; + if (major >= 21) + return 21; + if (major == 20) + return patch >= 5 ? 21 : 17; + if (major >= 18) + return 17; + if (major == 17) + return 16; + return 8; +} + +QString JavaRequirement::coverage(int javaMajor) +{ + if (javaMajor >= 21) + return translate("Minecraft 1.20.5 и новее"); + if (javaMajor >= 17) + return translate("Minecraft 1.18 — 1.20.4"); + if (javaMajor == 16) + return translate("Minecraft 1.17 — 1.17.1"); + if (javaMajor >= 9) + return translate("для Minecraft не подходит"); + if (javaMajor > 0) + return translate("Minecraft 1.16.5 и старше"); + return {}; +} diff --git a/javaruntime.h b/javaruntime.h new file mode 100644 index 0000000..32ac0b2 --- /dev/null +++ b/javaruntime.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include + +#include + +// Откуда взялась сборка Java и что именно в ней лежит. +enum class JavaRuntimeKind { + Mojang, // «Java» — тот же рантайм, которым игру запускает официальный лаунчер + Jdk, // Eclipse Temurin JDK: компилятор и инструменты в комплекте + Jre // Eclipse Temurin JRE: только то, что нужно для запуска +}; + +QString javaKindKey(JavaRuntimeKind kind); // java | jdk | jre +std::optional javaKindFromKey(const QString &key); +QString javaKindTitle(JavaRuntimeKind kind); + +// Строка каталога — то, что можно скачать. +struct JavaRuntimeEntry +{ + QString id; // temurin-jdk-21.0.12.1_1 | mojang-java-runtime-delta + JavaRuntimeKind kind = JavaRuntimeKind::Jdk; + int major = 0; // 8, 17, 21… + QString version; // 21.0.12.1+1 | 21.0.7 + QString component; // java-runtime-delta — только у сборок Mojang + QDateTime released; + QUrl url; // архив Temurin либо manifest.json компонента + QString checksum; // sha256 архива Temurin, sha1 манифеста Mojang + qint64 size = 0; // размер загрузки, 0 — неизвестен + QString archive; // zip | tar.gz | mojang + QString architecture; // x64 | aarch64 — на macOS бывает и не родная + bool lts = false; + + bool isValid() const { return !id.isEmpty() && major > 0 && url.isValid(); } +}; + +// Сборка, уже распакованная в папке лаунчера. +struct InstalledJavaRuntime +{ + QString id; + QString kind; // java | jdk | jre + int major = 0; + QString version; + QString javaPath; // абсолютный путь к java/java.exe + qint64 size = 0; // сколько заняло на диске по итогам установки +}; + +// Папка /java: одна подпапка на сборку плюс её описание внутри. Отдельного +// индекса нет намеренно — удалённую вручную папку не пришлось бы вычищать ещё и +// из общего файла. +namespace JavaRuntimeStore { + +QString dirFor(const QString &id); + +// Всё, что лежит в /java и на что нашлась java. Новые версии сверху. +QList installed(); +std::optional find(const QString &id); + +// Ищет bin/java в распакованном дереве: у Temurin на macOS это +// Contents/Home/bin, у Mojang — jre.bundle/Contents/Home/bin. +QString locateBinary(const QString &rootDir); + +bool writeMeta(const InstalledJavaRuntime &runtime, QString *error); +bool remove(const QString &id, QString *error); + +// Имя папки из идентификатора каталога: всё, кроме букв, цифр, точки, дефиса и +// подчёркивания, заменяется — идентификатор приходит из сети. +QString sanitizeId(const QString &id); + +} // namespace JavaRuntimeStore + +// Какая Java нужна какой версии игры. Точный ответ лежит в client.json +// (javaVersion.majorVersion) и берётся оттуда при запуске; эта таблица нужна +// раньше — когда версия ещё не скачана, а подсказку в каталоге показать надо. +namespace JavaRequirement { + +// Минимальная мажорная версия Java для версии игры. 0 — разобрать не удалось. +int minimumFor(const QString &minecraftVersionId); + +// «Minecraft 1.20.5 и новее» — подпись к строке каталога. +QString coverage(int javaMajor); + +} // namespace JavaRequirement diff --git a/javaruntimeservice.cpp b/javaruntimeservice.cpp new file mode 100644 index 0000000..43a2934 --- /dev/null +++ b/javaruntimeservice.cpp @@ -0,0 +1,528 @@ +#include "javaruntimeservice.h" + +#include "launcherpaths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +const QString kAdoptiumApi = QStringLiteral("https://api.adoptium.net/v3"); + +// Список компонентов рантайма Mojang. Ссылка зашита в официальный лаунчер и +// меняется вместе с ним; сами компоненты обновляются по этому же адресу. +const QString kMojangRuntimesUrl = QStringLiteral( + "https://piston-meta.mojang.com/v1/products/java-runtime/" + "2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"); + +// Сборки Java выходят раз в квартал, а не раз в неделю: суток кэша достаточно, +// чтобы новая появилась в тот же день, и хватает на десяток открытий окна. +constexpr qint64 kCacheTtlSeconds = 24 * 60 * 60; + +constexpr int kTransferTimeoutMs = 20000; + +// Сколько последних сборок каждой мажорной версии показывать. Больше трёх +// смысла нет: старые патчи отличаются только исправлениями безопасности. +constexpr int kReleasesPerMajor = 3; + +QString adoptiumOs() +{ +#if defined(Q_OS_WIN) + return QStringLiteral("windows"); +#elif defined(Q_OS_MACOS) + return QStringLiteral("mac"); +#else + return QStringLiteral("linux"); +#endif +} + +QString adoptiumArch() +{ + const QString arch = QSysInfo::currentCpuArchitecture(); + if (arch == QLatin1String("arm64")) + return QStringLiteral("aarch64"); + if (arch == QLatin1String("x86_64")) + return QStringLiteral("x64"); + if (arch == QLatin1String("i386")) + return QStringLiteral("x86"); + if (arch == QLatin1String("arm")) + return QStringLiteral("arm"); + return arch; +} + +// Ключ платформы в манифесте Mojang. +QString mojangPlatform() +{ + const QString arch = QSysInfo::currentCpuArchitecture(); +#if defined(Q_OS_WIN) + if (arch == QLatin1String("arm64")) + return QStringLiteral("windows-arm64"); + return arch == QLatin1String("i386") ? QStringLiteral("windows-x86") + : QStringLiteral("windows-x64"); +#elif defined(Q_OS_MACOS) + return arch == QLatin1String("arm64") ? QStringLiteral("mac-os-arm64") + : QStringLiteral("mac-os"); +#else + return arch == QLatin1String("i386") ? QStringLiteral("linux-i386") + : QStringLiteral("linux"); +#endif +} + +QNetworkRequest makeRequest(const QUrl &url) +{ + QNetworkRequest request{url}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kTransferTimeoutMs); + return request; +} + +// «1.8.0_504-b01» никто не узнаёт, а «8u504» — общепринятое имя той же сборки. +QString displayVersion(const QJsonObject &versionData) +{ + const int major = versionData.value(QStringLiteral("major")).toInt(); + if (major == 8) { + return QStringLiteral("8u%1").arg(versionData.value(QStringLiteral("security")).toInt()); + } + + QString openjdk = versionData.value(QStringLiteral("openjdk_version")).toString(); + if (openjdk.endsWith(QLatin1String("-LTS"))) + openjdk.chop(4); + if (!openjdk.isEmpty()) + return openjdk; + return versionData.value(QStringLiteral("semver")).toString(); +} + +QString archiveKind(const QString &fileName) +{ + if (fileName.endsWith(QLatin1String(".zip"), Qt::CaseInsensitive)) + return QStringLiteral("zip"); + if (fileName.endsWith(QLatin1String(".tar.gz"), Qt::CaseInsensitive)) + return QStringLiteral("tar.gz"); + return {}; +} + +} // namespace + +JavaRuntimeService::JavaRuntimeService(QObject *parent) + : QObject(parent) + , m_network(new QNetworkAccessManager(this)) +{ +} + +QList JavaRuntimeService::entries(JavaRuntimeKind kind) const +{ + QList result; + for (const JavaRuntimeEntry &entry : m_entries) { + if (entry.kind == kind) + result << entry; + } + return result; +} + +std::optional JavaRuntimeService::find(const QString &id) const +{ + const auto it = m_indexById.constFind(id); + if (it == m_indexById.constEnd()) + return std::nullopt; + return m_entries.at(it.value()); +} + +void JavaRuntimeService::ensureLoaded(Callback callback, bool forceRefresh) +{ + if (!m_cacheRead) { + m_cacheRead = true; + if (readCache()) + emit catalogChanged(); + } + + const bool fresh = hasData() && m_fetchedAt.isValid() + && m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds; + if (!forceRefresh && fresh) { + // Колбэк, который то синхронный, то нет, ломает вызывающий код — + // на попадании в кэш отвечаем через событийный цикл. + if (callback) + QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); }); + return; + } + + if (callback) + m_pending << std::move(callback); + if (isRefreshing()) + return; + startRefresh(); +} + +void JavaRuntimeService::startRefresh() +{ + m_incoming.clear(); + m_warnings.clear(); + m_ltsVersions.clear(); + + // Счётчик поднимаем на оба стартовых запроса разом: иначе ответ первого + // успел бы обнулить его и завершить обновление раньше времени. + m_outstanding = 2; + emit refreshingChanged(); + + requestAdoptiumReleases(); + requestMojangRuntimes(); +} + +void JavaRuntimeService::requestAdoptiumReleases() +{ + QNetworkReply *reply = m_network->get( + makeRequest(QUrl(kAdoptiumApi + QStringLiteral("/info/available_releases")))); + + connect(reply, &QNetworkReply::finished, this, [this, reply] { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + requestFinished(tr("Не удалось получить список версий Java: %1") + .arg(reply->errorString())); + return; + } + + const QJsonObject root = QJsonDocument::fromJson(reply->readAll()).object(); + const QJsonArray lts = root.value(QStringLiteral("available_lts_releases")).toArray(); + for (const QJsonValue &value : lts) + m_ltsVersions.insert(value.toInt()); + + const QJsonArray available = root.value(QStringLiteral("available_releases")).toArray(); + QList majors; + for (const QJsonValue &value : available) { + const int major = value.toInt(); + if (major > 0) + majors << major; + } + if (majors.isEmpty()) { + requestFinished(tr("Список версий Java не удалось разобрать")); + return; + } + + // Запросы добавляем до того, как отчитается этот: иначе счётчик уйдёт + // в ноль между двумя строчками. + m_outstanding += majors.size(); + const QString arch = adoptiumArch(); + for (const int major : std::as_const(majors)) + requestFeatureRelease(major, arch, arch == QLatin1String("aarch64")); + + requestFinished({}); + }); +} + +void JavaRuntimeService::requestFeatureRelease(int major, + const QString &architecture, + bool allowFallback) +{ + QUrl url(kAdoptiumApi + QStringLiteral("/assets/feature_releases/%1/ga").arg(major)); + QUrlQuery query; + query.addQueryItem(QStringLiteral("architecture"), architecture); + query.addQueryItem(QStringLiteral("heap_size"), QStringLiteral("normal")); + query.addQueryItem(QStringLiteral("jvm_impl"), QStringLiteral("hotspot")); + query.addQueryItem(QStringLiteral("os"), adoptiumOs()); + query.addQueryItem(QStringLiteral("page"), QStringLiteral("0")); + query.addQueryItem(QStringLiteral("page_size"), QString::number(kReleasesPerMajor)); + query.addQueryItem(QStringLiteral("project"), QStringLiteral("jdk")); + query.addQueryItem(QStringLiteral("sort_method"), QStringLiteral("DEFAULT")); + query.addQueryItem(QStringLiteral("sort_order"), QStringLiteral("DESC")); + query.addQueryItem(QStringLiteral("vendor"), QStringLiteral("eclipse")); + url.setQuery(query); + + QNetworkReply *reply = m_network->get(makeRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply, major, allowFallback] { + reply->deleteLater(); + + // 404 значит «под эту платформу такой сборки нет» — это не ошибка сети, + // а нормальный ответ: Java 8 и 16 под Apple Silicon просто не собирали. + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + QList parsed; + if (reply->error() == QNetworkReply::NoError) + parsed = parseAdoptium(reply->readAll(), m_ltsVersions); + + if (parsed.isEmpty() && allowFallback && (status == 404 || status == 200)) { + // На Apple Silicon старые сборки берём под x64: Rosetta их запускает. + m_outstanding += 1; + requestFeatureRelease(major, QStringLiteral("x64"), false); + requestFinished({}); + return; + } + + QString warning; + if (reply->error() != QNetworkReply::NoError && status != 404) { + warning = tr("Не удалось получить сборки Java %1: %2") + .arg(major) + .arg(reply->errorString()); + } + m_incoming += parsed; + requestFinished(warning); + }); +} + +void JavaRuntimeService::requestMojangRuntimes() +{ + QNetworkReply *reply = m_network->get(makeRequest(QUrl(kMojangRuntimesUrl))); + connect(reply, &QNetworkReply::finished, this, [this, reply] { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + requestFinished(tr("Не удалось получить рантаймы Mojang: %1") + .arg(reply->errorString())); + return; + } + + m_incoming += parseMojang(reply->readAll()); + requestFinished({}); + }); +} + +void JavaRuntimeService::requestFinished(const QString &warning) +{ + if (!warning.isEmpty()) + m_warnings << warning; + if (--m_outstanding > 0) + return; + finishRefresh(); +} + +void JavaRuntimeService::finishRefresh() +{ + m_outstanding = 0; + const QString warning = m_warnings.join(QStringLiteral("; ")); + // Пустой разбор — испорченные ответы; хороший кэш ими не затираем. + const bool ok = !m_incoming.isEmpty(); + if (ok) { + applyEntries(std::exchange(m_incoming, {})); + m_fetchedAt = QDateTime::currentDateTimeUtc(); + writeCache(); + emit catalogChanged(); + } + m_incoming.clear(); + m_warnings.clear(); + emit refreshingChanged(); + + // Колбэк может снова позвать ensureLoaded — список забираем себе заранее. + const QList pending = std::exchange(m_pending, {}); + for (const Callback &callback : pending) { + // Сеть подвела, но старые данные есть — работаем на них. + if (!ok && hasData()) + callback(true, warning.isEmpty() ? tr("Список сборок Java взят из кэша") : warning); + else + callback(ok, warning); + } +} + +QList JavaRuntimeService::parseAdoptium(const QByteArray &payload, + const QSet <sVersions) +{ + QList result; + const QJsonArray releases = QJsonDocument::fromJson(payload).array(); + + for (const QJsonValue &releaseValue : releases) { + const QJsonObject release = releaseValue.toObject(); + const QJsonObject versionData = release.value(QStringLiteral("version_data")).toObject(); + const int major = versionData.value(QStringLiteral("major")).toInt(); + const QString releaseName = release.value(QStringLiteral("release_name")).toString(); + if (major <= 0 || releaseName.isEmpty()) + continue; + + const QString version = displayVersion(versionData); + const QDateTime released = QDateTime::fromString( + release.value(QStringLiteral("timestamp")).toString(), Qt::ISODate); + + const QJsonArray binaries = release.value(QStringLiteral("binaries")).toArray(); + for (const QJsonValue &binaryValue : binaries) { + const QJsonObject binary = binaryValue.toObject(); + const auto kind = javaKindFromKey(binary.value(QStringLiteral("image_type")).toString()); + if (!kind || *kind == JavaRuntimeKind::Mojang) + continue; // testimage, debugimage, sources и прочее нам не нужно + + const QJsonObject package = binary.value(QStringLiteral("package")).toObject(); + const QUrl url(package.value(QStringLiteral("link")).toString()); + const QString archive = archiveKind(package.value(QStringLiteral("name")).toString()); + // Распаковать умеем только zip и tar.gz; https — потому что ссылку + // мы получили из сети и открываем её же. + if (archive.isEmpty() || url.scheme() != QLatin1String("https")) + continue; + + JavaRuntimeEntry entry; + entry.kind = *kind; + entry.major = major; + entry.version = version; + entry.released = released; + entry.url = url; + entry.checksum = package.value(QStringLiteral("checksum")).toString(); + entry.size = static_cast(package.value(QStringLiteral("size")).toDouble()); + entry.archive = archive; + entry.architecture = binary.value(QStringLiteral("architecture")).toString(); + entry.lts = ltsVersions.contains(major); + entry.id = JavaRuntimeStore::sanitizeId(QStringLiteral("temurin-%1-%2-%3") + .arg(javaKindKey(*kind), + version, + entry.architecture)); + result << entry; + } + } + return result; +} + +QList JavaRuntimeService::parseMojang(const QByteArray &payload) +{ + QList result; + const QJsonObject root = QJsonDocument::fromJson(payload).object(); + const QJsonObject platform = root.value(mojangPlatform()).toObject(); + + for (auto it = platform.constBegin(); it != platform.constEnd(); ++it) { + // minecraft-java-exe — это лаунчер игры, а не Java. + if (it.key() == QLatin1String("minecraft-java-exe")) + continue; + + const QJsonArray variants = it.value().toArray(); + if (variants.isEmpty()) + continue; // компонента под эту платформу нет + + const QJsonObject variant = variants.first().toObject(); + const QJsonObject manifest = variant.value(QStringLiteral("manifest")).toObject(); + const QUrl url(manifest.value(QStringLiteral("url")).toString()); + if (url.scheme() != QLatin1String("https")) + continue; + + const QJsonObject versionObject = variant.value(QStringLiteral("version")).toObject(); + const QString version = versionObject.value(QStringLiteral("name")).toString(); + if (version.isEmpty()) + continue; + + JavaRuntimeEntry entry; + entry.kind = JavaRuntimeKind::Mojang; + entry.component = it.key(); + entry.version = version; + // «8u51-cacert462b08» у jre-legacy, «21.0.7» у остальных. + entry.major = version.startsWith(QLatin1String("8u")) + ? 8 + : version.section(u'.', 0, 0).toInt(); + entry.released = QDateTime::fromString( + versionObject.value(QStringLiteral("released")).toString(), Qt::ISODate); + entry.url = url; + entry.checksum = manifest.value(QStringLiteral("sha1")).toString(); + entry.size = 0; // манифест знает только свой размер, не размер рантайма + entry.archive = QStringLiteral("mojang"); + entry.architecture = QSysInfo::currentCpuArchitecture(); + entry.lts = false; + entry.id = JavaRuntimeStore::sanitizeId(QStringLiteral("mojang-") + it.key()); + + if (entry.major > 0) + result << entry; + } + return result; +} + +void JavaRuntimeService::applyEntries(QList list) +{ + std::stable_sort(list.begin(), list.end(), + [](const JavaRuntimeEntry &a, const JavaRuntimeEntry &b) { + if (a.major != b.major) + return a.major > b.major; + if (a.released != b.released) + return a.released > b.released; + return a.version > b.version; + }); + + m_entries = std::move(list); + m_indexById.clear(); + m_indexById.reserve(m_entries.size()); + for (int i = 0; i < m_entries.size(); ++i) + m_indexById.insert(m_entries.at(i).id, i); +} + +bool JavaRuntimeService::readCache() +{ + QFile file(LauncherPaths::javaCatalogFile()); + if (!file.open(QIODevice::ReadOnly)) + return false; + + const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + // Кэш платформозависим: папку лаунчера могли принести с другой машины. + if (root.value(QStringLiteral("platform")).toString() != mojangPlatform()) + return false; + + QList parsed; + const QJsonArray entries = root.value(QStringLiteral("entries")).toArray(); + for (const QJsonValue &value : entries) { + const QJsonObject object = value.toObject(); + const auto kind = javaKindFromKey(object.value(QStringLiteral("kind")).toString()); + if (!kind) + continue; + + JavaRuntimeEntry entry; + entry.id = object.value(QStringLiteral("id")).toString(); + entry.kind = *kind; + entry.major = object.value(QStringLiteral("major")).toInt(); + entry.version = object.value(QStringLiteral("version")).toString(); + entry.component = object.value(QStringLiteral("component")).toString(); + entry.released = QDateTime::fromString( + object.value(QStringLiteral("released")).toString(), Qt::ISODate); + entry.url = QUrl(object.value(QStringLiteral("url")).toString()); + entry.checksum = object.value(QStringLiteral("checksum")).toString(); + entry.size = static_cast(object.value(QStringLiteral("size")).toDouble()); + entry.archive = object.value(QStringLiteral("archive")).toString(); + entry.architecture = object.value(QStringLiteral("architecture")).toString(); + entry.lts = object.value(QStringLiteral("lts")).toBool(); + + if (entry.isValid() && entry.url.scheme() == QLatin1String("https")) + parsed << entry; + } + if (parsed.isEmpty()) + return false; + + applyEntries(std::move(parsed)); + m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(), + Qt::ISODate); + return true; +} + +void JavaRuntimeService::writeCache() +{ + // Папку кэша могли удалить уже после запуска лаунчера. + if (!LauncherPaths::ensureRootExists()) + return; + + QJsonArray entries; + for (const JavaRuntimeEntry &entry : std::as_const(m_entries)) { + entries << QJsonObject{{QStringLiteral("id"), entry.id}, + {QStringLiteral("kind"), javaKindKey(entry.kind)}, + {QStringLiteral("major"), entry.major}, + {QStringLiteral("version"), entry.version}, + {QStringLiteral("component"), entry.component}, + {QStringLiteral("released"), entry.released.toString(Qt::ISODate)}, + {QStringLiteral("url"), entry.url.toString()}, + {QStringLiteral("checksum"), entry.checksum}, + {QStringLiteral("size"), static_cast(entry.size)}, + {QStringLiteral("archive"), entry.archive}, + {QStringLiteral("architecture"), entry.architecture}, + {QStringLiteral("lts"), entry.lts}}; + } + + const QJsonObject root{{QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)}, + {QStringLiteral("platform"), mojangPlatform()}, + {QStringLiteral("entries"), entries}}; + + QSaveFile file(LauncherPaths::javaCatalogFile()); + if (!file.open(QIODevice::WriteOnly)) + return; + const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact); + if (file.write(payload) != payload.size()) { + file.cancelWriting(); + return; + } + file.commit(); +} diff --git a/javaruntimeservice.h b/javaruntimeservice.h new file mode 100644 index 0000000..04a1062 --- /dev/null +++ b/javaruntimeservice.h @@ -0,0 +1,88 @@ +#pragma once + +#include "javaruntime.h" + +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; + +// Каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в папке +// лаунчера и отдаёт из кэша, пока тот не устарел. Устроен как +// VersionManifestService и работает в GUI-потоке. +// +// Источников два и они дополняют друг друга: +// +// * Mojang (категория «Java») — ровно тот рантайм, которым запускает игру +// официальный лаунчер. Версий немного, зато они заведомо совместимы. +// +// * Eclipse Temurin (категории JDK и JRE) — свежие сборки всех мажорных +// версий, включая те, до которых Mojang ещё не дошёл. +// +// Отдаются только сборки под текущие ОС и архитектуру: выбрать заведомо +// неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 +// сборок aarch64 нет вовсе и приходится брать x64 (работает через Rosetta). +class JavaRuntimeService : public QObject +{ + Q_OBJECT + +public: + // ok = true и непустой warning — данные отдали из устаревшего кэша. + using Callback = std::function; + + explicit JavaRuntimeService(QObject *parent = nullptr); + + void ensureLoaded(Callback callback, bool forceRefresh = false); + + // Новые версии сверху. + QList entries(JavaRuntimeKind kind) const; + std::optional find(const QString &id) const; + + bool isRefreshing() const { return m_outstanding > 0; } + bool hasData() const { return !m_entries.isEmpty(); } + +signals: + void catalogChanged(); + void refreshingChanged(); + +private: + void startRefresh(); + void requestAdoptiumReleases(); + // allowFallback — на macOS/aarch64 разрешено переспросить сборку под x64. + void requestFeatureRelease(int major, const QString &architecture, bool allowFallback); + void requestMojangRuntimes(); + + // Общий хвост любого запроса: считает незавершённые и, когда их не + // осталось, применяет накопленное. + void requestFinished(const QString &warning); + void finishRefresh(); + + static QList parseAdoptium(const QByteArray &payload, + const QSet <sVersions); + static QList parseMojang(const QByteArray &payload); + + void applyEntries(QList list); + bool readCache(); + void writeCache(); + + QNetworkAccessManager *m_network; + + QList m_entries; + QHash m_indexById; + QDateTime m_fetchedAt; + bool m_cacheRead = false; + + // Накопитель одного обновления: ответы приходят вразнобой, каталог + // подменяется целиком и только когда пришли все. + QList m_incoming; + QStringList m_warnings; + QSet m_ltsVersions; + int m_outstanding = 0; + + QList m_pending; +}; diff --git a/launcherbackend.cpp b/launcherbackend.cpp index 78e47a2..c36fdb3 100644 --- a/launcherbackend.cpp +++ b/launcherbackend.cpp @@ -2,7 +2,10 @@ #include "authservice.h" #include "gamelauncher.h" +#include "javainstaller.h" #include "javalocator.h" +#include "javaruntime.h" +#include "javaruntimeservice.h" #include "launcherpaths.h" #include "versioninstaller.h" #include "versionmanifestservice.h" @@ -47,6 +50,8 @@ LauncherBackend::LauncherBackend(QObject *parent) , m_loaderMeta(new ModLoaderVersionService(this)) , m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this)) , m_switcher(new BuildSwitcher(this)) + , m_javaMeta(new JavaRuntimeService(this)) + , m_javaInstaller(new JavaInstaller(this)) { // Папка лаунчера создаётся при первом запуске и проверяется при каждом // следующем — если её удалили, она появится снова. @@ -88,6 +93,41 @@ LauncherBackend::LauncherBackend(QObject *parent) connect(m_manifest, &VersionManifestService::refreshingChanged, this, &LauncherBackend::catalogLoadingChanged); + connect(m_javaMeta, &JavaRuntimeService::catalogChanged, + this, &LauncherBackend::invalidateJavaCatalog); + connect(m_javaMeta, &JavaRuntimeService::refreshingChanged, + this, &LauncherBackend::javaCatalogLoadingChanged); + + connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) { + emit downloadChanged(); + emit busyChanged(); + emit launchProgress(tr("Загрузка Java «%1»…").arg(label)); + }); + connect(m_javaInstaller, &JavaInstaller::progressChanged, + this, &LauncherBackend::downloadChanged); + connect(m_javaInstaller, &JavaInstaller::finished, this, + [this](const QString &runtimeId, const QString &javaPath) { + invalidateJavaCatalog(); + emit downloadChanged(); + emit busyChanged(); + emit javaRuntimeInstalled(runtimeId); + emit launchProgress(tr("Java установлена: %1") + .arg(QDir::toNativeSeparators(javaPath))); + }); + connect(m_javaInstaller, &JavaInstaller::failed, this, + [this](const QString &label, const QString &message) { + invalidateJavaCatalog(); + emit downloadChanged(); + emit busyChanged(); + emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message)); + }); + connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) { + invalidateJavaCatalog(); + emit downloadChanged(); + emit busyChanged(); + emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label)); + }); + connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) { emit downloadChanged(); emit busyChanged(); @@ -653,19 +693,32 @@ void LauncherBackend::cancelDownload() // Установщик лоадера сам остановит вложенную установку версии. if (m_loaderInstaller->isRunning()) m_loaderInstaller->cancel(); - else + else if (m_installer->isRunning()) m_installer->cancel(); + else + m_javaInstaller->cancel(); } -// Панель загрузки одна на два установщика: пока идёт установка модлоадера, она -// показывает его стадии, включая вложенную загрузку файлов версии. +// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она +// показывает его стадии, включая вложенную загрузку файлов версии. Установка +// Java начинается только когда остальные не заняты, поэтому за панель они не спорят. bool LauncherBackend::downloading() const { - return m_loaderInstaller->isRunning() || m_installer->isRunning(); + return m_loaderInstaller->isRunning() || m_installer->isRunning() + || m_javaInstaller->isRunning(); +} + +// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок. +bool LauncherBackend::javaDownloadShown() const +{ + return m_javaInstaller->isRunning() && !m_installer->isRunning() + && !m_loaderInstaller->isRunning(); } double LauncherBackend::downloadProgress() const { + if (javaDownloadShown()) + return m_javaInstaller->fraction(); if (!m_loaderInstaller->isRunning()) return m_installer->fraction(); // Внутри установки лоадера файлы качает VersionInstaller — его доля точнее. @@ -674,6 +727,8 @@ double LauncherBackend::downloadProgress() const QString LauncherBackend::downloadVersion() const { + if (javaDownloadShown()) + return m_javaInstaller->label(); if (m_loaderInstaller->isRunning()) return m_loaderInstaller->label(); return m_installer->versionId(); @@ -683,7 +738,10 @@ QString LauncherBackend::downloadStatus() const { QString stage = m_installer->stage(); QString file = m_installer->currentFile(); - if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) { + if (javaDownloadShown()) { + stage = m_javaInstaller->stage(); + file = m_javaInstaller->currentFile(); + } else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) { stage = m_loaderInstaller->stage(); file = m_loaderInstaller->currentFile(); } @@ -696,6 +754,8 @@ QString LauncherBackend::downloadStatus() const qint64 LauncherBackend::downloadBytesDone() const { + if (javaDownloadShown()) + return m_javaInstaller->bytesDone(); if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) return m_loaderInstaller->bytesDone(); return m_installer->bytesDone(); @@ -703,6 +763,8 @@ qint64 LauncherBackend::downloadBytesDone() const qint64 LauncherBackend::downloadBytesTotal() const { + if (javaDownloadShown()) + return m_javaInstaller->bytesTotal(); if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) return m_loaderInstaller->bytesTotal(); return m_installer->bytesTotal(); @@ -791,7 +853,9 @@ void LauncherBackend::installLoaderForBuild(int index) *loader, build.minecraftVersion, build.loaderVersion, - m_settings.value(QStringLiteral("javaPath")).toString()); + managedJavaPath().isEmpty() + ? m_settings.value(QStringLiteral("javaPath")).toString() + : managedJavaPath()); } void LauncherBackend::launchGame(int profileIndex, int buildIndex) @@ -806,6 +870,13 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex) .arg(m_installer->versionId())); return; } + // Игра ищет java при запуске: стартовать посреди её установки — значит + // найти половину распакованного дерева. + if (m_javaInstaller->isRunning()) { + emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания") + .arg(m_javaInstaller->label())); + return; + } if (m_busy) { emit launchError(tr("Запуск уже выполняется")); return; @@ -1013,7 +1084,15 @@ void LauncherBackend::continueLaunch(const AuthResult &auth) options.clientToken = auth.clientToken; options.userType = auth.userType; - options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString(); + // Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в + // окне выбора версии Java. + options.javaPath = managedJavaPath(); + if (options.javaPath.isEmpty()) { + if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty()) + emit launchProgress(tr("Выбранная сборка Java не найдена в папке лаунчера — " + "ищем установленную в системе")); + options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString(); + } options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512); options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096); options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0); @@ -1081,7 +1160,7 @@ QString LauncherBackend::switchStatus() const bool LauncherBackend::busy() const { return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning() - || m_switcher->isRunning(); + || m_javaInstaller->isRunning() || m_switcher->isRunning(); } void LauncherBackend::setBusy(bool busy) @@ -1133,6 +1212,210 @@ QStringList LauncherBackend::detectedJava() const return result; } +// ── Сборки Java ──────────────────────────────────────────────────────────── + +void LauncherBackend::invalidateJavaCatalog() +{ + m_javaCatalogDirty = true; + emit javaCatalogChanged(); +} + +bool LauncherBackend::javaCatalogLoading() const +{ + return m_javaMeta->isRefreshing(); +} + +void LauncherBackend::refreshJavaCatalog(bool force) +{ + // Список установленного мог поменяться и без нас (папку удалили руками). + invalidateJavaCatalog(); + m_javaMeta->ensureLoaded([this](bool ok, const QString &warning) { + if (!ok && !warning.isEmpty()) + emit launchError(warning); + }, force); +} + +QVariantList LauncherBackend::javaCatalog() const +{ + if (m_javaCatalogDirty) { + rebuildJavaCatalog(); + m_javaCatalogDirty = false; + } + return m_javaCatalog; +} + +void LauncherBackend::rebuildJavaCatalog() const +{ + m_javaCatalog.clear(); + + const QList installed = JavaRuntimeStore::installed(); + QHash installedSizes; + for (const InstalledJavaRuntime &runtime : installed) + installedSizes.insert(runtime.id, runtime.size); + + const auto megabytes = [](qint64 bytes) { + return bytes > 0 ? int((bytes + 512 * 1024) / (1024 * 1024)) : 0; + }; + + const auto makeRow = [&megabytes](const QString &id, + const QString &kind, + int major, + const QString &version, + const QString &detail, + qint64 size, + bool isInstalled, + bool lts, + bool downloadable) { + return QVariantMap{ + {QStringLiteral("id"), id}, + {QStringLiteral("kind"), kind}, + {QStringLiteral("major"), major}, + {QStringLiteral("version"), version}, + {QStringLiteral("label"), QStringLiteral("Java %1").arg(version)}, + {QStringLiteral("detail"), detail}, + {QStringLiteral("coverage"), JavaRequirement::coverage(major)}, + {QStringLiteral("sizeMb"), megabytes(size)}, + {QStringLiteral("installed"), isInstalled}, + {QStringLiteral("lts"), lts}, + {QStringLiteral("downloadable"), downloadable}, + {QStringLiteral("search"), + QStringLiteral("%1 %2 %3 %4").arg(version, QString::number(major), kind, detail).toLower()}}; + }; + + // Скачанное — сверху: этим уже можно пользоваться прямо сейчас. + QSet listed; + for (const InstalledJavaRuntime &runtime : installed) { + listed.insert(runtime.id); + const auto remote = m_javaMeta->find(runtime.id); + const QString detail = remote ? (remote->kind == JavaRuntimeKind::Mojang + ? tr("Mojang · %1").arg(remote->component) + : tr("Temurin · %1").arg(remote->architecture)) + : tr("скачана лаунчером"); + m_javaCatalog.append(makeRow(runtime.id, + runtime.kind, + runtime.major, + runtime.version, + detail, + runtime.size, + true, + remote && remote->lts, + remote.has_value())); + } + + const QList kinds{JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk, + JavaRuntimeKind::Jre}; + for (const JavaRuntimeKind kind : kinds) { + const QList entries = m_javaMeta->entries(kind); + for (const JavaRuntimeEntry &entry : entries) { + if (listed.contains(entry.id)) + continue; + const QString detail = kind == JavaRuntimeKind::Mojang + ? tr("Mojang · %1").arg(entry.component) + : tr("Temurin · %1").arg(entry.architecture); + m_javaCatalog.append(makeRow(entry.id, + javaKindKey(kind), + entry.major, + entry.version, + detail, + entry.size, + false, + entry.lts, + true)); + } + } +} + +void LauncherBackend::installJavaRuntime(const QString &runtimeId) +{ + if (m_javaInstaller->isRunning()) { + emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания") + .arg(m_javaInstaller->label())); + return; + } + if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) { + emit launchError(tr("Дождитесь окончания текущей операции")); + return; + } + + const auto entry = m_javaMeta->find(runtimeId); + if (!entry) { + emit launchError(tr("Сборка Java «%1» не найдена в каталоге").arg(runtimeId)); + return; + } + m_javaInstaller->install(*entry); +} + +void LauncherBackend::removeJavaRuntime(const QString &runtimeId) +{ + if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) { + emit launchError(tr("Эта сборка Java сейчас скачивается")); + return; + } + + QString error; + if (!JavaRuntimeStore::remove(runtimeId, &error)) { + emit launchError(tr("Не удалось удалить сборку Java: %1").arg(error)); + return; + } + + // Удалили выбранную — настройка указывала бы в пустоту. + if (m_settings.value(QStringLiteral("javaRuntime")).toString() == runtimeId) { + m_settings.insert(QStringLiteral("javaRuntime"), QString()); + saveSettings(); + emit settingsChanged(); + } + invalidateJavaCatalog(); + emit launchProgress(tr("Сборка Java удалена")); +} + +QVariantMap LauncherBackend::javaRuntimeInfo(const QString &runtimeId) const +{ + if (runtimeId.trimmed().isEmpty()) + return {}; + + const auto runtime = JavaRuntimeStore::find(runtimeId); + if (runtime) { + return QVariantMap{{QStringLiteral("id"), runtime->id}, + {QStringLiteral("kind"), runtime->kind}, + {QStringLiteral("major"), runtime->major}, + {QStringLiteral("version"), runtime->version}, + {QStringLiteral("label"), QStringLiteral("Java %1").arg(runtime->version)}, + {QStringLiteral("javaPath"), QDir::toNativeSeparators(runtime->javaPath)}, + {QStringLiteral("installed"), true}}; + } + + // Выбрана, но ещё не скачана: подпись всё равно нужна. + const auto entry = m_javaMeta->find(runtimeId); + if (!entry) + return QVariantMap{{QStringLiteral("id"), runtimeId}, + {QStringLiteral("label"), runtimeId}, + {QStringLiteral("installed"), false}}; + + return QVariantMap{{QStringLiteral("id"), entry->id}, + {QStringLiteral("kind"), javaKindKey(entry->kind)}, + {QStringLiteral("major"), entry->major}, + {QStringLiteral("version"), entry->version}, + {QStringLiteral("label"), QStringLiteral("Java %1").arg(entry->version)}, + {QStringLiteral("javaPath"), QString()}, + {QStringLiteral("installed"), false}}; +} + +int LauncherBackend::requiredJavaMajor(int buildIndex) const +{ + if (buildIndex < 0 || buildIndex >= m_builds.size()) + return 0; + return JavaRequirement::minimumFor(m_builds.at(buildIndex).minecraftVersion); +} + +QString LauncherBackend::managedJavaPath() const +{ + const QString id = m_settings.value(QStringLiteral("javaRuntime")).toString(); + if (id.trimmed().isEmpty()) + return {}; + const auto runtime = JavaRuntimeStore::find(id); + return runtime ? runtime->javaPath : QString(); +} + void LauncherBackend::openMinecraftFolder() { const QString modsDir = gameDir() + QStringLiteral("/mods"); @@ -1396,6 +1679,8 @@ void LauncherBackend::loadSettings() { m_settings = {{QStringLiteral("gameDir"), QString()}, {QStringLiteral("javaPath"), QString()}, + // id сборки Java из /java. Пусто — java ищется сама. + {QStringLiteral("javaRuntime"), QString()}, {QStringLiteral("minMemoryMb"), 512}, {QStringLiteral("maxMemoryMb"), 4096}, {QStringLiteral("jvmArgs"), QString()}, diff --git a/launcherbackend.h b/launcherbackend.h index d18b556..7fce715 100644 --- a/launcherbackend.h +++ b/launcherbackend.h @@ -18,6 +18,8 @@ class VersionInstaller; class ModLoaderVersionService; class ModLoaderInstaller; class BuildSwitcher; +class JavaRuntimeService; +class JavaInstaller; class LauncherBackend : public QObject { @@ -48,6 +50,11 @@ class LauncherBackend : public QObject Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged) Q_PROPERTY(bool catalogLoading READ catalogLoading NOTIFY catalogLoadingChanged) + // Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат + // в папке лаунчера. + Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged) + Q_PROPERTY(bool javaCatalogLoading READ javaCatalogLoading NOTIFY javaCatalogLoadingChanged) + // Одного сигнала на все свойства загрузки достаточно: установщик уже // ограничивает частоту, а QML всё равно перечитывает их разом. Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged) @@ -78,6 +85,9 @@ public: QVariantList versionCatalog() const; bool catalogLoading() const; + QVariantList javaCatalog() const; + bool javaCatalogLoading() const; + bool downloading() const; double downloadProgress() const; QString downloadVersion() const; @@ -145,6 +155,16 @@ public: Q_INVOKABLE void updateSettings(const QVariantMap &values); Q_INVOKABLE QStringList detectedJava() const; + // ── Сборки Java ───────────────────────────────────────────────────── + // Вызывается при открытии окна выбора: свежий кэш — без сети. + Q_INVOKABLE void refreshJavaCatalog(bool force = false); + Q_INVOKABLE void installJavaRuntime(const QString &runtimeId); + Q_INVOKABLE void removeJavaRuntime(const QString &runtimeId); + // Пусто — сборки с таким id в папке лаунчера нет. + Q_INVOKABLE QVariantMap javaRuntimeInfo(const QString &runtimeId) const; + // Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана. + Q_INVOKABLE int requiredJavaMajor(int buildIndex) const; + Q_INVOKABLE void openMinecraftFolder(); Q_INVOKABLE void openGameFolder(); @@ -160,8 +180,13 @@ signals: void busyChanged(); void versionCatalogChanged(); void catalogLoadingChanged(); + void javaCatalogChanged(); + void javaCatalogLoadingChanged(); void downloadChanged(); + // Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь. + void javaRuntimeInstalled(const QString &runtimeId); + void launchProgress(const QString &message); void launched(const QString &profileName, const QString &buildName, const QString &serverUrl); void launchError(const QString &message); @@ -216,6 +241,13 @@ private: void invalidateCatalog(); void rebuildCatalog() const; + void invalidateJavaCatalog(); + void rebuildJavaCatalog() const; + // Панель загрузки занята установкой Java, а не версии или модлоадера. + bool javaDownloadShown() const; + // Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана + // или ещё не скачана; тогда в дело идёт javaPath и автопоиск. + QString managedJavaPath() const; // Версия выбрана, но её нет в .minecraft — ставим в фоне. void maybeInstall(const QString &versionId); @@ -240,6 +272,8 @@ private: ModLoaderVersionService *m_loaderMeta; ModLoaderInstaller *m_loaderInstaller; BuildSwitcher *m_switcher; + JavaRuntimeService *m_javaMeta; + JavaInstaller *m_javaInstaller; // Сборка, для которой сейчас ставится модлоадер: по её id находим запись, // когда установщик отчитается. @@ -250,6 +284,9 @@ private: mutable QVariantList m_catalog; mutable bool m_catalogDirty = true; + mutable QVariantList m_javaCatalog; + mutable bool m_javaCatalogDirty = true; + bool m_busy = false; // Состояние запуска, начатого до асинхронной авторизации. diff --git a/launcherpaths.cpp b/launcherpaths.cpp index bbf1fa1..4f558dd 100644 --- a/launcherpaths.cpp +++ b/launcherpaths.cpp @@ -71,6 +71,21 @@ QString LauncherPaths::runtimeDir() return rootDir() + QStringLiteral("/runtime"); } +QString LauncherPaths::javaDir() +{ + return rootDir() + QStringLiteral("/java"); +} + +QString LauncherPaths::javaCatalogFile() +{ + return cacheDir() + QStringLiteral("/java_runtimes.json"); +} + +QString LauncherPaths::javaDownloadDir() +{ + return cacheDir() + QStringLiteral("/java"); +} + QString LauncherPaths::cacheDir() { return rootDir() + QStringLiteral("/cache"); @@ -93,8 +108,9 @@ QString LauncherPaths::loaderDownloadDir() bool LauncherPaths::ensureRootExists(QString *error) { - const QStringList required{rootDir(), runtimeDir(), cacheDir(), - loaderDownloadDir(), buildStorageDir()}; + const QStringList required{rootDir(), runtimeDir(), cacheDir(), + loaderDownloadDir(), buildStorageDir(), javaDir(), + javaDownloadDir()}; for (const QString &path : required) { if (!QDir().mkpath(path)) { if (error) diff --git a/launcherpaths.h b/launcherpaths.h index 9d3d47b..ebe61af 100644 --- a/launcherpaths.h +++ b/launcherpaths.h @@ -31,6 +31,16 @@ QString buildStorageDir(); // Сюда качается authlib-injector. QString runtimeDir(); +// /java — сборки Java, скачанные лаунчером. Каждая в своей подпапке, +// имя подпапки — идентификатор сборки из каталога. +QString javaDir(); + +// Слепок каталога доступных сборок Java с отметкой времени. +QString javaCatalogFile(); + +// Сюда качаются архивы Temurin до распаковки. +QString javaDownloadDir(); + // /cache — данные, которые можно удалить без потерь. QString cacheDir();