java implementation and downloading

This commit is contained in:
2026-08-27 19:35:11 +03:00
parent 08e0dc4965
commit dddc0261d4
14 changed files with 2907 additions and 10 deletions
+7
View File
@@ -24,6 +24,12 @@ qt_add_qml_module(appMinecraft_launcher
minecraftversion.cpp minecraftversion.cpp
javalocator.h javalocator.h
javalocator.cpp javalocator.cpp
javaruntime.h
javaruntime.cpp
javaruntimeservice.h
javaruntimeservice.cpp
javainstaller.h
javainstaller.cpp
authservice.h authservice.h
authservice.cpp authservice.cpp
gamelauncher.h gamelauncher.h
@@ -45,6 +51,7 @@ qt_add_qml_module(appMinecraft_launcher
Main.qml Main.qml
DarkCombo.qml DarkCombo.qml
VersionPickerDialog.qml VersionPickerDialog.qml
JavaPickerDialog.qml
LabelledField.qml LabelledField.qml
BuildsDialog.qml BuildsDialog.qml
LoaderRow.qml LoaderRow.qml
+520
View File
@@ -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()
}
+109
View File
@@ -913,10 +913,30 @@ Window {
y: (window.height - height) / 2 y: (window.height - height) / 2
padding: 0 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() { function load() {
const s = backend.settings() const s = backend.settings()
stGameDir.text = s.gameDir || "" stGameDir.text = s.gameDir || ""
stJavaPath.text = s.javaPath || "" stJavaPath.text = s.javaPath || ""
settingsDialog.javaRuntimeId = s.javaRuntime || ""
settingsDialog.refreshJavaInfo()
stMinMemory.text = String(s.minMemoryMb) stMinMemory.text = String(s.minMemoryMb)
stMaxMemory.text = String(s.maxMemoryMb) stMaxMemory.text = String(s.maxMemoryMb)
stJvmArgs.text = s.jvmArgs || "" stJvmArgs.text = s.jvmArgs || ""
@@ -972,6 +992,84 @@ Window {
placeholder: "автоопределение" 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 { Row {
x: 20 x: 20
spacing: 10 spacing: 10
@@ -1110,6 +1208,7 @@ Window {
onAccepted: backend.updateSettings({ onAccepted: backend.updateSettings({
"gameDir": stGameDir.text.trim(), "gameDir": stGameDir.text.trim(),
"javaPath": stJavaPath.text.trim(), "javaPath": stJavaPath.text.trim(),
"javaRuntime": settingsDialog.javaRuntimeId,
"minMemoryMb": parseInt(stMinMemory.text) || 0, "minMemoryMb": parseInt(stMinMemory.text) || 0,
"maxMemoryMb": parseInt(stMaxMemory.text) || 0, "maxMemoryMb": parseInt(stMaxMemory.text) || 0,
"jvmArgs": stJvmArgs.text.trim(), "jvmArgs": stJvmArgs.text.trim(),
@@ -1118,4 +1217,14 @@ Window {
"fullscreen": stFullscreen.checked "fullscreen": stFullscreen.checked
}) })
} }
// Окно выбора Java. Живёт рядом с настройками, а не внутри: оно шире их и
// центрируется по самому окну лаунчера.
JavaPickerDialog {
id: javaPicker
backend: backend
x: (window.width - width) / 2
y: (window.height - height) / 2
onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId
}
} }
+783
View File
@@ -0,0 +1,783 @@
#include "javainstaller.h"
#include "launcherpaths.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcess>
#include <QSaveFile>
#include <QTimer>
#include <QtCore/private/qzipreader_p.h>
#include <utility>
// Читатель архива вместе с разобранным оглавлением: fileInfoList() каждый раз
// заново разбирает центральный каталог zip, а записей в JDK десятки тысяч.
struct ZipExtraction
{
explicit ZipExtraction(const QString &path)
: reader(path)
{
}
QZipReader reader;
QList<QZipReader::FileInfo> entries;
int index = 0;
};
namespace {
constexpr int kTransferTimeoutMs = 30000;
constexpr int kMaxParallel = 4;
constexpr int kMaxAttempts = 3;
constexpr int kProgressIntervalMs = 100;
// Записей zip за один проход таймера. Сотня файлов по паре мегабайт — это
// заметная, но ещё не видимая глазом задержка кадра.
constexpr int kZipChunk = 24;
bool isTransient(QNetworkReply::NetworkError error, int status)
{
if (status >= 500)
return true;
switch (error) {
case QNetworkReply::TimeoutError:
case QNetworkReply::TemporaryNetworkFailureError:
case QNetworkReply::NetworkSessionFailedError:
case QNetworkReply::ProxyTimeoutError:
case QNetworkReply::UnknownNetworkError:
return true;
default:
return false;
}
}
// Путь из манифеста или архива не должен уводить за пределы папки сборки:
// «../../» в имени записи — известный способ подменить чужой файл.
bool isInside(const QString &root, const QString &path)
{
const QString normalizedRoot = QDir::cleanPath(root) + u'/';
const QString normalized = QDir::cleanPath(path);
return normalized.startsWith(normalizedRoot);
}
void makeExecutable(const QString &path)
{
const QFileDevice::Permissions current = QFile::permissions(path);
QFile::setPermissions(path,
current | QFileDevice::ExeOwner | QFileDevice::ExeGroup
| QFileDevice::ExeOther);
}
} // namespace
JavaInstaller::JavaInstaller(QObject *parent)
: QObject(parent)
, m_network(new QNetworkAccessManager(this))
, m_extractTimer(new QTimer(this))
, m_progressTimer(new QTimer(this))
{
m_extractTimer->setInterval(0);
connect(m_extractTimer, &QTimer::timeout, this, &JavaInstaller::extractZipChunk);
m_progressTimer->setInterval(kProgressIntervalMs);
connect(m_progressTimer, &QTimer::timeout, this, &JavaInstaller::flushProgress);
}
JavaInstaller::~JavaInstaller() = default;
qint64 JavaInstaller::bytesDone() const
{
qint64 total = m_bytesSettled;
for (const auto &context : m_active)
total += context->received;
return total;
}
double JavaInstaller::fraction() const
{
if (m_bytesTotal > 0)
return qBound(0.0, double(bytesDone()) / double(m_bytesTotal), 1.0);
if (m_stepsTotal > 0)
return qBound(0.0, double(m_stepsDone) / double(m_stepsTotal), 1.0);
return -1.0;
}
void JavaInstaller::install(const JavaRuntimeEntry &entry)
{
if (m_running)
return;
if (!entry.isValid()) {
emit failed(entry.version, tr("Сборка Java недоступна для загрузки"));
return;
}
QString error;
if (!LauncherPaths::ensureRootExists(&error)) {
emit failed(entry.version, tr("Папка лаунчера недоступна: %1").arg(error));
return;
}
m_entry = entry;
m_label = tr("%1 %2").arg(javaKindTitle(entry.kind), entry.version);
m_targetDir = JavaRuntimeStore::dirFor(entry.id);
if (m_targetDir.isEmpty()) {
emit failed(m_label, tr("Недопустимый идентификатор сборки Java"));
return;
}
// Переустановка поверх недокачанной прошлой попытки: старое дерево мешает
// и размером, и тем, что locateBinary нашла бы в нём чужой java.
if (!JavaRuntimeStore::remove(entry.id, &error)) {
emit failed(m_label, error);
return;
}
if (!QDir().mkpath(m_targetDir)) {
emit failed(m_label, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(m_targetDir)));
return;
}
m_running = true;
m_canceling = false;
m_bytesSettled = 0;
m_bytesTotal = 0;
m_stepsDone = 0;
m_stepsTotal = 0;
m_currentFile.clear();
m_links.clear();
m_queue.clear();
emit started(m_label);
if (entry.archive == QLatin1String("mojang"))
beginMojang();
else
beginArchive();
}
void JavaInstaller::cancel()
{
if (!m_running || m_canceling)
return;
m_canceling = true;
m_extractTimer->stop();
if (m_reply)
m_reply->abort();
for (const auto &context : std::as_const(m_active))
context->reply->abort();
if (m_tar)
m_tar->kill();
// Прерванная установка оставляет дерево наполовину собранным: запускать из
// него нечего, а locateBinary могла бы найти в нём java.
if (m_active.isEmpty() && !m_reply && !m_tar) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
}
}
// ── Temurin: один архив ─────────────────────────────────────────────────────
void JavaInstaller::beginArchive()
{
if (m_entry.archive != QLatin1String("zip")
&& m_entry.archive != QLatin1String("tar.gz")) {
fail(tr("Неизвестный формат архива: %1").arg(m_entry.archive));
return;
}
const QString name = m_entry.url.fileName();
m_archivePath = LauncherPaths::javaDownloadDir() + u'/'
+ (name.isEmpty() ? m_entry.id + QStringLiteral(".archive") : name);
m_bytesTotal = m_entry.size;
setStage(tr("Загрузка Java %1").arg(m_entry.version));
downloadArchive();
}
void JavaInstaller::downloadArchive()
{
m_archiveFile = std::make_unique<QSaveFile>(m_archivePath);
if (!m_archiveFile->open(QIODevice::WriteOnly)) {
fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(m_archivePath)));
return;
}
m_archiveHash.reset();
m_currentFile = QFileInfo(m_archivePath).fileName();
QNetworkRequest request{m_entry.url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
m_reply = m_network->get(request);
connect(m_reply, &QNetworkReply::readyRead, this, [this] {
const QByteArray chunk = m_reply->readAll();
if (chunk.isEmpty())
return;
m_archiveHash.addData(chunk);
if (m_archiveFile->write(chunk) != chunk.size())
m_reply->abort();
});
connect(m_reply, &QNetworkReply::downloadProgress, this,
[this](qint64 received, qint64 total) {
m_bytesSettled = received;
if (m_bytesTotal <= 0 && total > 0)
m_bytesTotal = total;
scheduleProgress();
});
connect(m_reply, &QNetworkReply::finished, this,
[this] { handleArchiveFinished(m_reply); });
scheduleProgress();
}
void JavaInstaller::handleArchiveFinished(QNetworkReply *reply)
{
reply->deleteLater();
m_reply = nullptr;
if (m_canceling) {
m_archiveFile->cancelWriting();
m_archiveFile.reset();
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
if (!m_running)
return;
if (reply->error() != QNetworkReply::NoError) {
m_archiveFile->cancelWriting();
m_archiveFile.reset();
fail(tr("Не удалось скачать Java %1: %2").arg(m_entry.version, reply->errorString()));
return;
}
if (!m_entry.checksum.isEmpty()) {
const QString actual = QString::fromLatin1(m_archiveHash.result().toHex());
if (actual.compare(m_entry.checksum, Qt::CaseInsensitive) != 0) {
m_archiveFile->cancelWriting();
m_archiveFile.reset();
fail(tr("Контрольная сумма архива Java не совпала — загрузка повреждена"));
return;
}
}
if (!m_archiveFile->commit()) {
m_archiveFile.reset();
fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(m_archivePath)));
return;
}
m_archiveFile.reset();
startExtraction();
}
void JavaInstaller::startExtraction()
{
// Байты кончились, дальше считаем шаги: панель по нулевому итогу перестанет
// показывать мегабайты, а проценты продолжит.
m_bytesTotal = 0;
m_bytesSettled = 0;
setStage(tr("Распаковка Java %1").arg(m_entry.version));
if (m_entry.archive == QLatin1String("tar.gz")) {
startTarExtraction();
return;
}
m_zip = std::make_unique<ZipExtraction>(m_archivePath);
if (!m_zip->reader.isReadable()) {
m_zip.reset();
fail(tr("Не удалось прочитать архив %1").arg(QDir::toNativeSeparators(m_archivePath)));
return;
}
m_zip->entries = m_zip->reader.fileInfoList();
m_stepsDone = 0;
m_stepsTotal = m_zip->entries.size();
m_extractTimer->start();
}
void JavaInstaller::extractZipChunk()
{
if (m_canceling || !m_running || !m_zip) {
m_extractTimer->stop();
return;
}
const QList<QZipReader::FileInfo> &entries = m_zip->entries;
const int end = qMin(m_zip->index + kZipChunk, entries.size());
for (; m_zip->index < end; ++m_zip->index) {
const QZipReader::FileInfo &info = entries.at(m_zip->index);
const QString target = m_targetDir + u'/' + info.filePath;
if (!isInside(m_targetDir, target)) {
m_extractTimer->stop();
fail(tr("Архив Java содержит недопустимый путь: %1").arg(info.filePath));
return;
}
if (info.isDir) {
QDir().mkpath(target);
continue;
}
// Ссылок в zip-сборках Temurin не бывает — под Unix они отдают tar.gz.
if (!info.isFile)
continue;
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
m_extractTimer->stop();
fail(tr("Не удалось создать %1")
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
return;
}
const QByteArray data = m_zip->reader.fileData(info.filePath);
QFile file(target);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
m_extractTimer->stop();
fail(tr("Не удалось распаковать %1").arg(info.filePath));
return;
}
file.close();
if (info.permissions != QFileDevice::Permissions())
file.setPermissions(info.permissions);
}
m_stepsDone = m_zip->index;
m_currentFile = m_zip->index > 0 ? entries.at(m_zip->index - 1).filePath : QString();
scheduleProgress();
if (m_zip->index >= entries.size()) {
m_extractTimer->stop();
m_zip.reset();
complete();
}
}
void JavaInstaller::startTarExtraction()
{
// tar есть в любой macOS и практически в любом дистрибутиве Linux; своего
// распаковщика gzip-tar в Qt нет, а тянуть ради него зависимость незачем.
m_tar = new QProcess(this);
m_tar->setWorkingDirectory(m_targetDir);
m_tar->setProgram(QStringLiteral("tar"));
m_tar->setArguments({QStringLiteral("-xzf"), m_archivePath,
QStringLiteral("-C"), m_targetDir});
connect(m_tar, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
if (!m_tar)
return;
const QString message = m_tar->errorString();
m_tar->deleteLater();
m_tar = nullptr;
if (m_canceling) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
fail(tr("Не удалось распаковать архив Java: %1").arg(message));
});
connect(m_tar, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
if (!m_tar)
return;
const QByteArray errorOutput = m_tar->readAllStandardError();
m_tar->deleteLater();
m_tar = nullptr;
if (m_canceling) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
if (status != QProcess::NormalExit || exitCode != 0) {
fail(tr("Распаковка архива Java не удалась: %1")
.arg(QString::fromLocal8Bit(errorOutput).trimmed()));
return;
}
complete();
});
m_tar->start();
}
// ── Mojang: дерево файлов ───────────────────────────────────────────────────
void JavaInstaller::beginMojang()
{
setStage(tr("Список файлов Java %1").arg(m_entry.version));
m_currentFile = QStringLiteral("manifest.json");
QNetworkRequest request{m_entry.url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
m_reply = m_network->get(request);
connect(m_reply, &QNetworkReply::finished, this, [this] {
QNetworkReply *reply = m_reply;
reply->deleteLater();
m_reply = nullptr;
if (m_canceling) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
return;
}
if (!m_running)
return;
if (reply->error() != QNetworkReply::NoError) {
fail(tr("Не удалось получить состав рантайма: %1").arg(reply->errorString()));
return;
}
handleManifest(reply->readAll());
});
}
void JavaInstaller::handleManifest(const QByteArray &payload)
{
const QJsonObject files = QJsonDocument::fromJson(payload)
.object()
.value(QStringLiteral("files"))
.toObject();
if (files.isEmpty()) {
fail(tr("Состав рантайма Java не удалось разобрать"));
return;
}
if (!prepareTree(files))
return;
setStage(tr("Загрузка Java %1").arg(m_entry.version));
if (m_queue.isEmpty()) {
if (applyLinks())
complete();
return;
}
pump();
}
bool JavaInstaller::prepareTree(const QJsonObject &files)
{
m_bytesTotal = 0;
for (auto it = files.constBegin(); it != files.constEnd(); ++it) {
const QString target = m_targetDir + u'/' + it.key();
if (!isInside(m_targetDir, target)) {
fail(tr("Состав рантайма содержит недопустимый путь: %1").arg(it.key()));
return false;
}
const QJsonObject node = it.value().toObject();
const QString type = node.value(QStringLiteral("type")).toString();
if (type == QLatin1String("directory")) {
if (!QDir().mkpath(target)) {
fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(target)));
return false;
}
continue;
}
if (type == QLatin1String("link")) {
// Ссылки создаём в конце: их цели ещё не скачаны.
m_links << qMakePair(target, node.value(QStringLiteral("target")).toString());
continue;
}
if (type != QLatin1String("file"))
continue;
const QJsonObject raw = node.value(QStringLiteral("downloads"))
.toObject()
.value(QStringLiteral("raw"))
.toObject();
const QUrl url(raw.value(QStringLiteral("url")).toString());
if (url.scheme() != QLatin1String("https"))
continue;
JavaFileTask task;
task.url = url;
task.path = target;
task.sha1 = raw.value(QStringLiteral("sha1")).toString();
task.size = static_cast<qint64>(raw.value(QStringLiteral("size")).toDouble());
task.executable = node.value(QStringLiteral("executable")).toBool();
m_bytesTotal += task.size;
m_queue.enqueue(task);
}
m_stepsTotal = m_queue.size();
m_stepsDone = 0;
return true;
}
void JavaInstaller::pump()
{
if (!m_running || m_canceling)
return;
while (m_active.size() < kMaxParallel && !m_queue.isEmpty())
startTask(m_queue.dequeue());
if (m_active.isEmpty() && m_queue.isEmpty()) {
if (applyLinks())
complete();
}
}
void JavaInstaller::startTask(JavaFileTask task)
{
const QFileInfo info(task.path);
if (!QDir().mkpath(info.absolutePath())) {
fail(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath())));
return;
}
auto context = std::make_shared<JavaActiveDownload>();
context->task = task;
context->file = std::make_unique<QSaveFile>(task.path);
if (!context->file->open(QIODevice::WriteOnly)) {
fail(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path)));
return;
}
QNetworkRequest request{task.url};
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
request.setTransferTimeout(kTransferTimeoutMs);
context->reply = m_network->get(request);
m_active.append(context);
m_currentFile = QDir(m_targetDir).relativeFilePath(task.path);
connect(context->reply, &QNetworkReply::readyRead, this, [this, context] {
const QByteArray chunk = context->reply->readAll();
if (chunk.isEmpty())
return;
context->hash.addData(chunk);
if (context->file->write(chunk) != chunk.size())
context->reply->abort();
});
connect(context->reply, &QNetworkReply::downloadProgress, this,
[this, context](qint64 received, qint64) {
context->received = received;
scheduleProgress();
});
connect(context->reply, &QNetworkReply::finished, this,
[this, context] { handleFileFinished(context); });
scheduleProgress();
}
void JavaInstaller::handleFileFinished(const std::shared_ptr<JavaActiveDownload> &context)
{
context->reply->deleteLater();
m_active.removeOne(context);
if (m_canceling) {
context->file->cancelWriting();
if (m_active.isEmpty()) {
const QString label = m_label;
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit canceled(label);
}
return;
}
if (!m_running)
return;
const int status =
context->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (context->reply->error() != QNetworkReply::NoError) {
context->file->cancelWriting();
const QString reason = context->reply->errorString();
if (isTransient(context->reply->error(), status)) {
if (!retryOrFail(context->task, reason))
return;
} else {
fail(tr("Не удалось скачать %1: %2")
.arg(QDir(m_targetDir).relativeFilePath(context->task.path), reason));
return;
}
pump();
return;
}
if (!context->task.sha1.isEmpty()) {
const QString actual = QString::fromLatin1(context->hash.result().toHex());
if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) {
context->file->cancelWriting();
if (!retryOrFail(context->task, tr("контрольная сумма не совпала")))
return;
pump();
return;
}
}
if (!context->file->commit()) {
fail(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path)));
return;
}
if (context->task.executable)
makeExecutable(context->task.path);
m_bytesSettled += context->received;
++m_stepsDone;
scheduleProgress();
pump();
}
bool JavaInstaller::retryOrFail(JavaFileTask task, const QString &reason)
{
if (++task.attempts >= kMaxAttempts) {
fail(tr("Не удалось скачать %1: %2")
.arg(QDir(m_targetDir).relativeFilePath(task.path), reason));
return false;
}
m_queue.enqueue(task);
return true;
}
bool JavaInstaller::applyLinks()
{
for (const auto &link : std::as_const(m_links)) {
const QString linkPath = link.first;
if (QFileInfo::exists(linkPath))
continue;
if (!QDir().mkpath(QFileInfo(linkPath).absolutePath()))
continue;
// Цель относительна самой ссылке — QFile::link кладёт её как есть.
if (!QFile::link(link.second, linkPath)) {
// Под Windows символических ссылок в рантаймах Mojang нет, но если
// они появятся, копия работает не хуже.
const QString source =
QDir::cleanPath(QFileInfo(linkPath).absolutePath() + u'/' + link.second);
if (!QFile::copy(source, linkPath)) {
fail(tr("Не удалось создать ссылку %1")
.arg(QDir(m_targetDir).relativeFilePath(linkPath)));
return false;
}
}
}
return true;
}
// ── Завершение ──────────────────────────────────────────────────────────────
void JavaInstaller::complete()
{
setStage(tr("Проверка сборки Java"));
const QString javaPath = JavaRuntimeStore::locateBinary(m_targetDir);
if (javaPath.isEmpty()) {
fail(tr("В скачанной сборке не нашлось исполняемого файла java"));
return;
}
#if !defined(Q_OS_WIN)
// tar сохраняет права, а вот zip и файлы Mojang без флага executable — нет.
makeExecutable(javaPath);
#endif
InstalledJavaRuntime runtime;
runtime.id = m_entry.id;
runtime.kind = javaKindKey(m_entry.kind);
runtime.major = m_entry.major;
runtime.version = m_entry.version;
runtime.javaPath = javaPath;
runtime.size = m_entry.size > 0 ? m_entry.size : m_bytesTotal;
QString error;
if (!JavaRuntimeStore::writeMeta(runtime, &error)) {
fail(error);
return;
}
// Архив нужен был только на распаковку — двести мегабайт в кэше ни к чему.
if (!m_archivePath.isEmpty())
QFile::remove(m_archivePath);
const QString id = m_entry.id;
reset();
emit finished(id, javaPath);
}
void JavaInstaller::fail(const QString &message)
{
const QString label = m_label;
m_extractTimer->stop();
m_zip.reset();
if (!m_archivePath.isEmpty())
QFile::remove(m_archivePath);
// Половина рантайма хуже, чем его отсутствие: следующая попытка начнётся с чистой папки.
JavaRuntimeStore::remove(m_entry.id, nullptr);
reset();
emit failed(label, message);
}
void JavaInstaller::reset()
{
m_running = false;
m_canceling = false;
m_progressTimer->stop();
m_extractTimer->stop();
m_zip.reset();
m_queue.clear();
m_active.clear();
m_links.clear();
m_archiveFile.reset();
m_archivePath.clear();
m_bytesSettled = 0;
m_bytesTotal = 0;
m_stepsDone = 0;
m_stepsTotal = 0;
m_stage.clear();
m_currentFile.clear();
m_entry = {};
m_label.clear();
emit progressChanged();
}
void JavaInstaller::setStage(const QString &stage)
{
if (m_stage == stage)
return;
m_stage = stage;
emit progressChanged();
}
void JavaInstaller::scheduleProgress()
{
m_progressDirty = true;
if (!m_progressTimer->isActive())
m_progressTimer->start();
}
void JavaInstaller::flushProgress()
{
if (!m_progressDirty) {
m_progressTimer->stop();
return;
}
m_progressDirty = false;
emit progressChanged();
}
+148
View File
@@ -0,0 +1,148 @@
#pragma once
#include "javaruntime.h"
#include <QCryptographicHash>
#include <QList>
#include <QObject>
#include <QPair>
#include <QQueue>
#include <QString>
#include <QStringList>
#include <memory>
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<QSaveFile> file;
QCryptographicHash hash{QCryptographicHash::Sha1};
qint64 received = 0;
};
// Установка сборки Java в <root>/java/<id>. Два разных пути под одним фасадом:
//
// * 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<JavaActiveDownload> &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<QSaveFile> 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<ZipExtraction> m_zip;
QQueue<JavaFileTask> m_queue;
QList<std::shared_ptr<JavaActiveDownload>> m_active;
QList<QPair<QString, QString>> m_links; // {абсолютный путь ссылки, цель}
bool m_running = false;
bool m_canceling = false;
};
+10
View File
@@ -1,5 +1,7 @@
#include "javalocator.h" #include "javalocator.h"
#include "javaruntime.h"
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QFileInfo> #include <QFileInfo>
@@ -72,6 +74,14 @@ QStringList JavaLocator::findAll(const QString &gameDir)
{ {
QStringList result; QStringList result;
// 0. Сборки, скачанные самим лаунчером. Идут первыми: пользователь выбирал
// их осознанно, в отличие от того, что нашлось в системе.
const QList<InstalledJavaRuntime> managed = JavaRuntimeStore::installed();
for (const InstalledJavaRuntime &entry : managed) {
if (!result.contains(entry.javaPath))
result << entry.javaPath;
}
// 1. Рантайм, скачанный самим Minecraft: .minecraft/runtime/<component>/<os>/<component>/bin // 1. Рантайм, скачанный самим Minecraft: .minecraft/runtime/<component>/<os>/<component>/bin
const QString runtime = gameDir + QStringLiteral("/runtime"); const QString runtime = gameDir + QStringLiteral("/runtime");
if (QDir(runtime).exists()) { if (QDir(runtime).exists()) {
+270
View File
@@ -0,0 +1,270 @@
#include "javaruntime.h"
#include "launcherpaths.h"
#include <QCoreApplication>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QSaveFile>
#include <algorithm>
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<JavaRuntimeKind> 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<InstalledJavaRuntime> 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<qint64>(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<InstalledJavaRuntime> JavaRuntimeStore::installed()
{
QList<InstalledJavaRuntime> 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<double>(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);
// Удаляем только внутри <root>/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 {};
}
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include <QDateTime>
#include <QList>
#include <QString>
#include <QUrl>
#include <optional>
// Откуда взялась сборка Java и что именно в ней лежит.
enum class JavaRuntimeKind {
Mojang, // «Java» — тот же рантайм, которым игру запускает официальный лаунчер
Jdk, // Eclipse Temurin JDK: компилятор и инструменты в комплекте
Jre // Eclipse Temurin JRE: только то, что нужно для запуска
};
QString javaKindKey(JavaRuntimeKind kind); // java | jdk | jre
std::optional<JavaRuntimeKind> 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; // сколько заняло на диске по итогам установки
};
// Папка <root>/java: одна подпапка на сборку плюс её описание внутри. Отдельного
// индекса нет намеренно — удалённую вручную папку не пришлось бы вычищать ещё и
// из общего файла.
namespace JavaRuntimeStore {
QString dirFor(const QString &id);
// Всё, что лежит в <root>/java и на что нашлась java. Новые версии сверху.
QList<InstalledJavaRuntime> installed();
std::optional<InstalledJavaRuntime> 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
+528
View File
@@ -0,0 +1,528 @@
#include "javaruntimeservice.h"
#include "launcherpaths.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QTimer>
#include <QUrlQuery>
#include <algorithm>
#include <utility>
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<JavaRuntimeEntry> JavaRuntimeService::entries(JavaRuntimeKind kind) const
{
QList<JavaRuntimeEntry> result;
for (const JavaRuntimeEntry &entry : m_entries) {
if (entry.kind == kind)
result << entry;
}
return result;
}
std::optional<JavaRuntimeEntry> 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<int> 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<JavaRuntimeEntry> 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<Callback> 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<JavaRuntimeEntry> JavaRuntimeService::parseAdoptium(const QByteArray &payload,
const QSet<int> &ltsVersions)
{
QList<JavaRuntimeEntry> 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<qint64>(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<JavaRuntimeEntry> JavaRuntimeService::parseMojang(const QByteArray &payload)
{
QList<JavaRuntimeEntry> 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<JavaRuntimeEntry> 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<JavaRuntimeEntry> 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<qint64>(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<double>(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();
}
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include "javaruntime.h"
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QObject>
#include <QSet>
#include <functional>
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<void(bool ok, const QString &warning)>;
explicit JavaRuntimeService(QObject *parent = nullptr);
void ensureLoaded(Callback callback, bool forceRefresh = false);
// Новые версии сверху.
QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const;
std::optional<JavaRuntimeEntry> 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<JavaRuntimeEntry> parseAdoptium(const QByteArray &payload,
const QSet<int> &ltsVersions);
static QList<JavaRuntimeEntry> parseMojang(const QByteArray &payload);
void applyEntries(QList<JavaRuntimeEntry> list);
bool readCache();
void writeCache();
QNetworkAccessManager *m_network;
QList<JavaRuntimeEntry> m_entries;
QHash<QString, int> m_indexById;
QDateTime m_fetchedAt;
bool m_cacheRead = false;
// Накопитель одного обновления: ответы приходят вразнобой, каталог
// подменяется целиком и только когда пришли все.
QList<JavaRuntimeEntry> m_incoming;
QStringList m_warnings;
QSet<int> m_ltsVersions;
int m_outstanding = 0;
QList<Callback> m_pending;
};
+292 -7
View File
@@ -2,7 +2,10 @@
#include "authservice.h" #include "authservice.h"
#include "gamelauncher.h" #include "gamelauncher.h"
#include "javainstaller.h"
#include "javalocator.h" #include "javalocator.h"
#include "javaruntime.h"
#include "javaruntimeservice.h"
#include "launcherpaths.h" #include "launcherpaths.h"
#include "versioninstaller.h" #include "versioninstaller.h"
#include "versionmanifestservice.h" #include "versionmanifestservice.h"
@@ -47,6 +50,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
, m_loaderMeta(new ModLoaderVersionService(this)) , m_loaderMeta(new ModLoaderVersionService(this))
, m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this)) , m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
, m_switcher(new BuildSwitcher(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, connect(m_manifest, &VersionManifestService::refreshingChanged,
this, &LauncherBackend::catalogLoadingChanged); 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) { connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
emit downloadChanged(); emit downloadChanged();
emit busyChanged(); emit busyChanged();
@@ -653,19 +693,32 @@ void LauncherBackend::cancelDownload()
// Установщик лоадера сам остановит вложенную установку версии. // Установщик лоадера сам остановит вложенную установку версии.
if (m_loaderInstaller->isRunning()) if (m_loaderInstaller->isRunning())
m_loaderInstaller->cancel(); m_loaderInstaller->cancel();
else else if (m_installer->isRunning())
m_installer->cancel(); m_installer->cancel();
else
m_javaInstaller->cancel();
} }
// Панель загрузки одна на два установщика: пока идёт установка модлоадера, она // Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
// показывает его стадии, включая вложенную загрузку файлов версии. // показывает его стадии, включая вложенную загрузку файлов версии. Установка
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
bool LauncherBackend::downloading() const 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 double LauncherBackend::downloadProgress() const
{ {
if (javaDownloadShown())
return m_javaInstaller->fraction();
if (!m_loaderInstaller->isRunning()) if (!m_loaderInstaller->isRunning())
return m_installer->fraction(); return m_installer->fraction();
// Внутри установки лоадера файлы качает VersionInstaller — его доля точнее. // Внутри установки лоадера файлы качает VersionInstaller — его доля точнее.
@@ -674,6 +727,8 @@ double LauncherBackend::downloadProgress() const
QString LauncherBackend::downloadVersion() const QString LauncherBackend::downloadVersion() const
{ {
if (javaDownloadShown())
return m_javaInstaller->label();
if (m_loaderInstaller->isRunning()) if (m_loaderInstaller->isRunning())
return m_loaderInstaller->label(); return m_loaderInstaller->label();
return m_installer->versionId(); return m_installer->versionId();
@@ -683,7 +738,10 @@ QString LauncherBackend::downloadStatus() const
{ {
QString stage = m_installer->stage(); QString stage = m_installer->stage();
QString file = m_installer->currentFile(); 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(); stage = m_loaderInstaller->stage();
file = m_loaderInstaller->currentFile(); file = m_loaderInstaller->currentFile();
} }
@@ -696,6 +754,8 @@ QString LauncherBackend::downloadStatus() const
qint64 LauncherBackend::downloadBytesDone() const qint64 LauncherBackend::downloadBytesDone() const
{ {
if (javaDownloadShown())
return m_javaInstaller->bytesDone();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
return m_loaderInstaller->bytesDone(); return m_loaderInstaller->bytesDone();
return m_installer->bytesDone(); return m_installer->bytesDone();
@@ -703,6 +763,8 @@ qint64 LauncherBackend::downloadBytesDone() const
qint64 LauncherBackend::downloadBytesTotal() const qint64 LauncherBackend::downloadBytesTotal() const
{ {
if (javaDownloadShown())
return m_javaInstaller->bytesTotal();
if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
return m_loaderInstaller->bytesTotal(); return m_loaderInstaller->bytesTotal();
return m_installer->bytesTotal(); return m_installer->bytesTotal();
@@ -791,7 +853,9 @@ void LauncherBackend::installLoaderForBuild(int index)
*loader, *loader,
build.minecraftVersion, build.minecraftVersion,
build.loaderVersion, build.loaderVersion,
m_settings.value(QStringLiteral("javaPath")).toString()); managedJavaPath().isEmpty()
? m_settings.value(QStringLiteral("javaPath")).toString()
: managedJavaPath());
} }
void LauncherBackend::launchGame(int profileIndex, int buildIndex) void LauncherBackend::launchGame(int profileIndex, int buildIndex)
@@ -806,6 +870,13 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex)
.arg(m_installer->versionId())); .arg(m_installer->versionId()));
return; return;
} }
// Игра ищет java при запуске: стартовать посреди её установки — значит
// найти половину распакованного дерева.
if (m_javaInstaller->isRunning()) {
emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания")
.arg(m_javaInstaller->label()));
return;
}
if (m_busy) { if (m_busy) {
emit launchError(tr("Запуск уже выполняется")); emit launchError(tr("Запуск уже выполняется"));
return; return;
@@ -1013,7 +1084,15 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
options.clientToken = auth.clientToken; options.clientToken = auth.clientToken;
options.userType = auth.userType; options.userType = auth.userType;
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
// окне выбора версии 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.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
}
options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512); options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512);
options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096); options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096);
options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0); options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
@@ -1081,7 +1160,7 @@ QString LauncherBackend::switchStatus() const
bool LauncherBackend::busy() const bool LauncherBackend::busy() const
{ {
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning() return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|| m_switcher->isRunning(); || m_javaInstaller->isRunning() || m_switcher->isRunning();
} }
void LauncherBackend::setBusy(bool busy) void LauncherBackend::setBusy(bool busy)
@@ -1133,6 +1212,210 @@ QStringList LauncherBackend::detectedJava() const
return result; 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<InstalledJavaRuntime> installed = JavaRuntimeStore::installed();
QHash<QString, qint64> 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<QString> 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<JavaRuntimeKind> kinds{JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk,
JavaRuntimeKind::Jre};
for (const JavaRuntimeKind kind : kinds) {
const QList<JavaRuntimeEntry> 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() void LauncherBackend::openMinecraftFolder()
{ {
const QString modsDir = gameDir() + QStringLiteral("/mods"); const QString modsDir = gameDir() + QStringLiteral("/mods");
@@ -1396,6 +1679,8 @@ void LauncherBackend::loadSettings()
{ {
m_settings = {{QStringLiteral("gameDir"), QString()}, m_settings = {{QStringLiteral("gameDir"), QString()},
{QStringLiteral("javaPath"), QString()}, {QStringLiteral("javaPath"), QString()},
// id сборки Java из <root>/java. Пусто — java ищется сама.
{QStringLiteral("javaRuntime"), QString()},
{QStringLiteral("minMemoryMb"), 512}, {QStringLiteral("minMemoryMb"), 512},
{QStringLiteral("maxMemoryMb"), 4096}, {QStringLiteral("maxMemoryMb"), 4096},
{QStringLiteral("jvmArgs"), QString()}, {QStringLiteral("jvmArgs"), QString()},
+37
View File
@@ -18,6 +18,8 @@ class VersionInstaller;
class ModLoaderVersionService; class ModLoaderVersionService;
class ModLoaderInstaller; class ModLoaderInstaller;
class BuildSwitcher; class BuildSwitcher;
class JavaRuntimeService;
class JavaInstaller;
class LauncherBackend : public QObject class LauncherBackend : public QObject
{ {
@@ -48,6 +50,11 @@ class LauncherBackend : public QObject
Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged) Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged)
Q_PROPERTY(bool catalogLoading READ catalogLoading NOTIFY catalogLoadingChanged) 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 всё равно перечитывает их разом. // ограничивает частоту, а QML всё равно перечитывает их разом.
Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged) Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged)
@@ -78,6 +85,9 @@ public:
QVariantList versionCatalog() const; QVariantList versionCatalog() const;
bool catalogLoading() const; bool catalogLoading() const;
QVariantList javaCatalog() const;
bool javaCatalogLoading() const;
bool downloading() const; bool downloading() const;
double downloadProgress() const; double downloadProgress() const;
QString downloadVersion() const; QString downloadVersion() const;
@@ -145,6 +155,16 @@ public:
Q_INVOKABLE void updateSettings(const QVariantMap &values); Q_INVOKABLE void updateSettings(const QVariantMap &values);
Q_INVOKABLE QStringList detectedJava() const; 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 openMinecraftFolder();
Q_INVOKABLE void openGameFolder(); Q_INVOKABLE void openGameFolder();
@@ -160,8 +180,13 @@ signals:
void busyChanged(); void busyChanged();
void versionCatalogChanged(); void versionCatalogChanged();
void catalogLoadingChanged(); void catalogLoadingChanged();
void javaCatalogChanged();
void javaCatalogLoadingChanged();
void downloadChanged(); void downloadChanged();
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
void javaRuntimeInstalled(const QString &runtimeId);
void launchProgress(const QString &message); void launchProgress(const QString &message);
void launched(const QString &profileName, const QString &buildName, const QString &serverUrl); void launched(const QString &profileName, const QString &buildName, const QString &serverUrl);
void launchError(const QString &message); void launchError(const QString &message);
@@ -216,6 +241,13 @@ private:
void invalidateCatalog(); void invalidateCatalog();
void rebuildCatalog() const; void rebuildCatalog() const;
void invalidateJavaCatalog();
void rebuildJavaCatalog() const;
// Панель загрузки занята установкой Java, а не версии или модлоадера.
bool javaDownloadShown() const;
// Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана
// или ещё не скачана; тогда в дело идёт javaPath и автопоиск.
QString managedJavaPath() const;
// Версия выбрана, но её нет в .minecraft — ставим в фоне. // Версия выбрана, но её нет в .minecraft — ставим в фоне.
void maybeInstall(const QString &versionId); void maybeInstall(const QString &versionId);
@@ -240,6 +272,8 @@ private:
ModLoaderVersionService *m_loaderMeta; ModLoaderVersionService *m_loaderMeta;
ModLoaderInstaller *m_loaderInstaller; ModLoaderInstaller *m_loaderInstaller;
BuildSwitcher *m_switcher; BuildSwitcher *m_switcher;
JavaRuntimeService *m_javaMeta;
JavaInstaller *m_javaInstaller;
// Сборка, для которой сейчас ставится модлоадер: по её id находим запись, // Сборка, для которой сейчас ставится модлоадер: по её id находим запись,
// когда установщик отчитается. // когда установщик отчитается.
@@ -250,6 +284,9 @@ private:
mutable QVariantList m_catalog; mutable QVariantList m_catalog;
mutable bool m_catalogDirty = true; mutable bool m_catalogDirty = true;
mutable QVariantList m_javaCatalog;
mutable bool m_javaCatalogDirty = true;
bool m_busy = false; bool m_busy = false;
// Состояние запуска, начатого до асинхронной авторизации. // Состояние запуска, начатого до асинхронной авторизации.
+17 -1
View File
@@ -71,6 +71,21 @@ QString LauncherPaths::runtimeDir()
return rootDir() + QStringLiteral("/runtime"); 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() QString LauncherPaths::cacheDir()
{ {
return rootDir() + QStringLiteral("/cache"); return rootDir() + QStringLiteral("/cache");
@@ -94,7 +109,8 @@ QString LauncherPaths::loaderDownloadDir()
bool LauncherPaths::ensureRootExists(QString *error) bool LauncherPaths::ensureRootExists(QString *error)
{ {
const QStringList required{rootDir(), runtimeDir(), cacheDir(), const QStringList required{rootDir(), runtimeDir(), cacheDir(),
loaderDownloadDir(), buildStorageDir()}; loaderDownloadDir(), buildStorageDir(), javaDir(),
javaDownloadDir()};
for (const QString &path : required) { for (const QString &path : required) {
if (!QDir().mkpath(path)) { if (!QDir().mkpath(path)) {
if (error) if (error)
+10
View File
@@ -31,6 +31,16 @@ QString buildStorageDir();
// Сюда качается authlib-injector. // Сюда качается authlib-injector.
QString runtimeDir(); QString runtimeDir();
// <root>/java — сборки Java, скачанные лаунчером. Каждая в своей подпапке,
// имя подпапки — идентификатор сборки из каталога.
QString javaDir();
// Слепок каталога доступных сборок Java с отметкой времени.
QString javaCatalogFile();
// Сюда качаются архивы Temurin до распаковки.
QString javaDownloadDir();
// <root>/cache — данные, которые можно удалить без потерь. // <root>/cache — данные, которые можно удалить без потерь.
QString cacheDir(); QString cacheDir();