java implementation and downloading
This commit is contained in:
@@ -24,6 +24,12 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
minecraftversion.cpp
|
||||
javalocator.h
|
||||
javalocator.cpp
|
||||
javaruntime.h
|
||||
javaruntime.cpp
|
||||
javaruntimeservice.h
|
||||
javaruntimeservice.cpp
|
||||
javainstaller.h
|
||||
javainstaller.cpp
|
||||
authservice.h
|
||||
authservice.cpp
|
||||
gamelauncher.h
|
||||
@@ -45,6 +51,7 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
Main.qml
|
||||
DarkCombo.qml
|
||||
VersionPickerDialog.qml
|
||||
JavaPickerDialog.qml
|
||||
LabelledField.qml
|
||||
BuildsDialog.qml
|
||||
LoaderRow.qml
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -913,10 +913,30 @@ Window {
|
||||
y: (window.height - height) / 2
|
||||
padding: 0
|
||||
|
||||
// Выбранная сборка Java живёт в свойстве, а не в поле ввода: её выбирают
|
||||
// в отдельном окне, а записывается она только по «Сохранить».
|
||||
property string javaRuntimeId: ""
|
||||
// Не привязка: javaRuntimeInfo() — обычный вызов, и сам он не
|
||||
// пересчитается, когда сборка докачается. Обновляем по событиям.
|
||||
property var javaRuntimeInfo: null
|
||||
onJavaRuntimeIdChanged: settingsDialog.refreshJavaInfo()
|
||||
|
||||
function refreshJavaInfo() {
|
||||
javaRuntimeInfo = javaRuntimeId === "" ? null
|
||||
: backend.javaRuntimeInfo(javaRuntimeId)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
function onJavaRuntimeInstalled(runtimeId) { settingsDialog.refreshJavaInfo() }
|
||||
}
|
||||
|
||||
function load() {
|
||||
const s = backend.settings()
|
||||
stGameDir.text = s.gameDir || ""
|
||||
stJavaPath.text = s.javaPath || ""
|
||||
settingsDialog.javaRuntimeId = s.javaRuntime || ""
|
||||
settingsDialog.refreshJavaInfo()
|
||||
stMinMemory.text = String(s.minMemoryMb)
|
||||
stMaxMemory.text = String(s.maxMemoryMb)
|
||||
stJvmArgs.text = s.jvmArgs || ""
|
||||
@@ -972,6 +992,84 @@ Window {
|
||||
placeholder: "автоопределение"
|
||||
}
|
||||
|
||||
// Сборка Java из папки лаунчера. Выбрана — запуск идёт ей, а не тем,
|
||||
// что нашлось в системе; путь выше остаётся запасным вариантом.
|
||||
Column {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
text: "Версия Java из папки лаунчера"
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 32
|
||||
radius: 6
|
||||
color: "#2a2a2a"
|
||||
border.color: javaFieldArea.containsMouse ? "#91B315" : "#444444"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
leftPadding: 10
|
||||
rightPadding: 150
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
color: settingsDialog.javaRuntimeId === "" ? "#666666" : "#ffffff"
|
||||
text: {
|
||||
if (settingsDialog.javaRuntimeId === "")
|
||||
return qsTr("не выбрана — искать в системе")
|
||||
const info = settingsDialog.javaRuntimeInfo
|
||||
if (!info)
|
||||
return settingsDialog.javaRuntimeId
|
||||
return info.installed ? info.label
|
||||
: qsTr("%1 — скачивается").arg(info.label)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: clearJava.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: qsTr("Выбрать…")
|
||||
color: "#91B315"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
id: clearJava
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: settingsDialog.javaRuntimeId !== ""
|
||||
text: "✕"
|
||||
color: clearJavaArea.containsMouse ? "#cc6666" : "#666666"
|
||||
font.pixelSize: 12
|
||||
|
||||
MouseArea {
|
||||
id: clearJavaArea
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
onClicked: settingsDialog.javaRuntimeId = ""
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: javaFieldArea
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 26
|
||||
hoverEnabled: true
|
||||
onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId,
|
||||
backend.requiredJavaMajor(backend.activeBuildIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
x: 20
|
||||
spacing: 10
|
||||
@@ -1110,6 +1208,7 @@ Window {
|
||||
onAccepted: backend.updateSettings({
|
||||
"gameDir": stGameDir.text.trim(),
|
||||
"javaPath": stJavaPath.text.trim(),
|
||||
"javaRuntime": settingsDialog.javaRuntimeId,
|
||||
"minMemoryMb": parseInt(stMinMemory.text) || 0,
|
||||
"maxMemoryMb": parseInt(stMaxMemory.text) || 0,
|
||||
"jvmArgs": stJvmArgs.text.trim(),
|
||||
@@ -1118,4 +1217,14 @@ Window {
|
||||
"fullscreen": stFullscreen.checked
|
||||
})
|
||||
}
|
||||
|
||||
// Окно выбора Java. Живёт рядом с настройками, а не внутри: оно шире их и
|
||||
// центрируется по самому окну лаунчера.
|
||||
JavaPickerDialog {
|
||||
id: javaPicker
|
||||
backend: backend
|
||||
x: (window.width - width) / 2
|
||||
y: (window.height - height) / 2
|
||||
onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "javalocator.h"
|
||||
|
||||
#include "javaruntime.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFileInfo>
|
||||
@@ -72,6 +74,14 @@ QStringList JavaLocator::findAll(const QString &gameDir)
|
||||
{
|
||||
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
|
||||
const QString runtime = gameDir + QStringLiteral("/runtime");
|
||||
if (QDir(runtime).exists()) {
|
||||
|
||||
+270
@@ -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 {};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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> <sVersions)
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -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> <sVersions);
|
||||
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
@@ -2,7 +2,10 @@
|
||||
|
||||
#include "authservice.h"
|
||||
#include "gamelauncher.h"
|
||||
#include "javainstaller.h"
|
||||
#include "javalocator.h"
|
||||
#include "javaruntime.h"
|
||||
#include "javaruntimeservice.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
@@ -47,6 +50,8 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
, m_loaderMeta(new ModLoaderVersionService(this))
|
||||
, m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
|
||||
, m_switcher(new BuildSwitcher(this))
|
||||
, m_javaMeta(new JavaRuntimeService(this))
|
||||
, m_javaInstaller(new JavaInstaller(this))
|
||||
{
|
||||
// Папка лаунчера создаётся при первом запуске и проверяется при каждом
|
||||
// следующем — если её удалили, она появится снова.
|
||||
@@ -88,6 +93,41 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
connect(m_manifest, &VersionManifestService::refreshingChanged,
|
||||
this, &LauncherBackend::catalogLoadingChanged);
|
||||
|
||||
connect(m_javaMeta, &JavaRuntimeService::catalogChanged,
|
||||
this, &LauncherBackend::invalidateJavaCatalog);
|
||||
connect(m_javaMeta, &JavaRuntimeService::refreshingChanged,
|
||||
this, &LauncherBackend::javaCatalogLoadingChanged);
|
||||
|
||||
connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка Java «%1»…").arg(label));
|
||||
});
|
||||
connect(m_javaInstaller, &JavaInstaller::progressChanged,
|
||||
this, &LauncherBackend::downloadChanged);
|
||||
connect(m_javaInstaller, &JavaInstaller::finished, this,
|
||||
[this](const QString &runtimeId, const QString &javaPath) {
|
||||
invalidateJavaCatalog();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit javaRuntimeInstalled(runtimeId);
|
||||
emit launchProgress(tr("Java установлена: %1")
|
||||
.arg(QDir::toNativeSeparators(javaPath)));
|
||||
});
|
||||
connect(m_javaInstaller, &JavaInstaller::failed, this,
|
||||
[this](const QString &label, const QString &message) {
|
||||
invalidateJavaCatalog();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message));
|
||||
});
|
||||
connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
|
||||
invalidateJavaCatalog();
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
|
||||
});
|
||||
|
||||
connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
|
||||
emit downloadChanged();
|
||||
emit busyChanged();
|
||||
@@ -653,19 +693,32 @@ void LauncherBackend::cancelDownload()
|
||||
// Установщик лоадера сам остановит вложенную установку версии.
|
||||
if (m_loaderInstaller->isRunning())
|
||||
m_loaderInstaller->cancel();
|
||||
else
|
||||
else if (m_installer->isRunning())
|
||||
m_installer->cancel();
|
||||
else
|
||||
m_javaInstaller->cancel();
|
||||
}
|
||||
|
||||
// Панель загрузки одна на два установщика: пока идёт установка модлоадера, она
|
||||
// показывает его стадии, включая вложенную загрузку файлов версии.
|
||||
// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
|
||||
// показывает его стадии, включая вложенную загрузку файлов версии. Установка
|
||||
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
|
||||
bool LauncherBackend::downloading() const
|
||||
{
|
||||
return m_loaderInstaller->isRunning() || m_installer->isRunning();
|
||||
return m_loaderInstaller->isRunning() || m_installer->isRunning()
|
||||
|| m_javaInstaller->isRunning();
|
||||
}
|
||||
|
||||
// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
|
||||
bool LauncherBackend::javaDownloadShown() const
|
||||
{
|
||||
return m_javaInstaller->isRunning() && !m_installer->isRunning()
|
||||
&& !m_loaderInstaller->isRunning();
|
||||
}
|
||||
|
||||
double LauncherBackend::downloadProgress() const
|
||||
{
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->fraction();
|
||||
if (!m_loaderInstaller->isRunning())
|
||||
return m_installer->fraction();
|
||||
// Внутри установки лоадера файлы качает VersionInstaller — его доля точнее.
|
||||
@@ -674,6 +727,8 @@ double LauncherBackend::downloadProgress() const
|
||||
|
||||
QString LauncherBackend::downloadVersion() const
|
||||
{
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->label();
|
||||
if (m_loaderInstaller->isRunning())
|
||||
return m_loaderInstaller->label();
|
||||
return m_installer->versionId();
|
||||
@@ -683,7 +738,10 @@ QString LauncherBackend::downloadStatus() const
|
||||
{
|
||||
QString stage = m_installer->stage();
|
||||
QString file = m_installer->currentFile();
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
|
||||
if (javaDownloadShown()) {
|
||||
stage = m_javaInstaller->stage();
|
||||
file = m_javaInstaller->currentFile();
|
||||
} else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
|
||||
stage = m_loaderInstaller->stage();
|
||||
file = m_loaderInstaller->currentFile();
|
||||
}
|
||||
@@ -696,6 +754,8 @@ QString LauncherBackend::downloadStatus() const
|
||||
|
||||
qint64 LauncherBackend::downloadBytesDone() const
|
||||
{
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->bytesDone();
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
||||
return m_loaderInstaller->bytesDone();
|
||||
return m_installer->bytesDone();
|
||||
@@ -703,6 +763,8 @@ qint64 LauncherBackend::downloadBytesDone() const
|
||||
|
||||
qint64 LauncherBackend::downloadBytesTotal() const
|
||||
{
|
||||
if (javaDownloadShown())
|
||||
return m_javaInstaller->bytesTotal();
|
||||
if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
|
||||
return m_loaderInstaller->bytesTotal();
|
||||
return m_installer->bytesTotal();
|
||||
@@ -791,7 +853,9 @@ void LauncherBackend::installLoaderForBuild(int index)
|
||||
*loader,
|
||||
build.minecraftVersion,
|
||||
build.loaderVersion,
|
||||
m_settings.value(QStringLiteral("javaPath")).toString());
|
||||
managedJavaPath().isEmpty()
|
||||
? m_settings.value(QStringLiteral("javaPath")).toString()
|
||||
: managedJavaPath());
|
||||
}
|
||||
|
||||
void LauncherBackend::launchGame(int profileIndex, int buildIndex)
|
||||
@@ -806,6 +870,13 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex)
|
||||
.arg(m_installer->versionId()));
|
||||
return;
|
||||
}
|
||||
// Игра ищет java при запуске: стартовать посреди её установки — значит
|
||||
// найти половину распакованного дерева.
|
||||
if (m_javaInstaller->isRunning()) {
|
||||
emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания")
|
||||
.arg(m_javaInstaller->label()));
|
||||
return;
|
||||
}
|
||||
if (m_busy) {
|
||||
emit launchError(tr("Запуск уже выполняется"));
|
||||
return;
|
||||
@@ -1013,7 +1084,15 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
options.clientToken = auth.clientToken;
|
||||
options.userType = auth.userType;
|
||||
|
||||
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
|
||||
// окне выбора версии Java.
|
||||
options.javaPath = managedJavaPath();
|
||||
if (options.javaPath.isEmpty()) {
|
||||
if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty())
|
||||
emit launchProgress(tr("Выбранная сборка Java не найдена в папке лаунчера — "
|
||||
"ищем установленную в системе"));
|
||||
options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
|
||||
}
|
||||
options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512);
|
||||
options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096);
|
||||
options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
|
||||
@@ -1081,7 +1160,7 @@ QString LauncherBackend::switchStatus() const
|
||||
bool LauncherBackend::busy() const
|
||||
{
|
||||
return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
|
||||
|| m_switcher->isRunning();
|
||||
|| m_javaInstaller->isRunning() || m_switcher->isRunning();
|
||||
}
|
||||
|
||||
void LauncherBackend::setBusy(bool busy)
|
||||
@@ -1133,6 +1212,210 @@ QStringList LauncherBackend::detectedJava() const
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Сборки Java ────────────────────────────────────────────────────────────
|
||||
|
||||
void LauncherBackend::invalidateJavaCatalog()
|
||||
{
|
||||
m_javaCatalogDirty = true;
|
||||
emit javaCatalogChanged();
|
||||
}
|
||||
|
||||
bool LauncherBackend::javaCatalogLoading() const
|
||||
{
|
||||
return m_javaMeta->isRefreshing();
|
||||
}
|
||||
|
||||
void LauncherBackend::refreshJavaCatalog(bool force)
|
||||
{
|
||||
// Список установленного мог поменяться и без нас (папку удалили руками).
|
||||
invalidateJavaCatalog();
|
||||
m_javaMeta->ensureLoaded([this](bool ok, const QString &warning) {
|
||||
if (!ok && !warning.isEmpty())
|
||||
emit launchError(warning);
|
||||
}, force);
|
||||
}
|
||||
|
||||
QVariantList LauncherBackend::javaCatalog() const
|
||||
{
|
||||
if (m_javaCatalogDirty) {
|
||||
rebuildJavaCatalog();
|
||||
m_javaCatalogDirty = false;
|
||||
}
|
||||
return m_javaCatalog;
|
||||
}
|
||||
|
||||
void LauncherBackend::rebuildJavaCatalog() const
|
||||
{
|
||||
m_javaCatalog.clear();
|
||||
|
||||
const QList<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()
|
||||
{
|
||||
const QString modsDir = gameDir() + QStringLiteral("/mods");
|
||||
@@ -1396,6 +1679,8 @@ void LauncherBackend::loadSettings()
|
||||
{
|
||||
m_settings = {{QStringLiteral("gameDir"), QString()},
|
||||
{QStringLiteral("javaPath"), QString()},
|
||||
// id сборки Java из <root>/java. Пусто — java ищется сама.
|
||||
{QStringLiteral("javaRuntime"), QString()},
|
||||
{QStringLiteral("minMemoryMb"), 512},
|
||||
{QStringLiteral("maxMemoryMb"), 4096},
|
||||
{QStringLiteral("jvmArgs"), QString()},
|
||||
|
||||
@@ -18,6 +18,8 @@ class VersionInstaller;
|
||||
class ModLoaderVersionService;
|
||||
class ModLoaderInstaller;
|
||||
class BuildSwitcher;
|
||||
class JavaRuntimeService;
|
||||
class JavaInstaller;
|
||||
|
||||
class LauncherBackend : public QObject
|
||||
{
|
||||
@@ -48,6 +50,11 @@ class LauncherBackend : public QObject
|
||||
Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged)
|
||||
Q_PROPERTY(bool catalogLoading READ catalogLoading NOTIFY catalogLoadingChanged)
|
||||
|
||||
// Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат
|
||||
// в папке лаунчера.
|
||||
Q_PROPERTY(QVariantList javaCatalog READ javaCatalog NOTIFY javaCatalogChanged)
|
||||
Q_PROPERTY(bool javaCatalogLoading READ javaCatalogLoading NOTIFY javaCatalogLoadingChanged)
|
||||
|
||||
// Одного сигнала на все свойства загрузки достаточно: установщик уже
|
||||
// ограничивает частоту, а QML всё равно перечитывает их разом.
|
||||
Q_PROPERTY(bool downloading READ downloading NOTIFY downloadChanged)
|
||||
@@ -78,6 +85,9 @@ public:
|
||||
QVariantList versionCatalog() const;
|
||||
bool catalogLoading() const;
|
||||
|
||||
QVariantList javaCatalog() const;
|
||||
bool javaCatalogLoading() const;
|
||||
|
||||
bool downloading() const;
|
||||
double downloadProgress() const;
|
||||
QString downloadVersion() const;
|
||||
@@ -145,6 +155,16 @@ public:
|
||||
Q_INVOKABLE void updateSettings(const QVariantMap &values);
|
||||
Q_INVOKABLE QStringList detectedJava() const;
|
||||
|
||||
// ── Сборки Java ─────────────────────────────────────────────────────
|
||||
// Вызывается при открытии окна выбора: свежий кэш — без сети.
|
||||
Q_INVOKABLE void refreshJavaCatalog(bool force = false);
|
||||
Q_INVOKABLE void installJavaRuntime(const QString &runtimeId);
|
||||
Q_INVOKABLE void removeJavaRuntime(const QString &runtimeId);
|
||||
// Пусто — сборки с таким id в папке лаунчера нет.
|
||||
Q_INVOKABLE QVariantMap javaRuntimeInfo(const QString &runtimeId) const;
|
||||
// Минимальная Java для версии игры выбранной сборки; 0 — версия не выбрана.
|
||||
Q_INVOKABLE int requiredJavaMajor(int buildIndex) const;
|
||||
|
||||
Q_INVOKABLE void openMinecraftFolder();
|
||||
Q_INVOKABLE void openGameFolder();
|
||||
|
||||
@@ -160,8 +180,13 @@ signals:
|
||||
void busyChanged();
|
||||
void versionCatalogChanged();
|
||||
void catalogLoadingChanged();
|
||||
void javaCatalogChanged();
|
||||
void javaCatalogLoadingChanged();
|
||||
void downloadChanged();
|
||||
|
||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||
void javaRuntimeInstalled(const QString &runtimeId);
|
||||
|
||||
void launchProgress(const QString &message);
|
||||
void launched(const QString &profileName, const QString &buildName, const QString &serverUrl);
|
||||
void launchError(const QString &message);
|
||||
@@ -216,6 +241,13 @@ private:
|
||||
|
||||
void invalidateCatalog();
|
||||
void rebuildCatalog() const;
|
||||
void invalidateJavaCatalog();
|
||||
void rebuildJavaCatalog() const;
|
||||
// Панель загрузки занята установкой Java, а не версии или модлоадера.
|
||||
bool javaDownloadShown() const;
|
||||
// Путь к java выбранной сборки из папки лаунчера. Пусто — сборка не выбрана
|
||||
// или ещё не скачана; тогда в дело идёт javaPath и автопоиск.
|
||||
QString managedJavaPath() const;
|
||||
// Версия выбрана, но её нет в .minecraft — ставим в фоне.
|
||||
void maybeInstall(const QString &versionId);
|
||||
|
||||
@@ -240,6 +272,8 @@ private:
|
||||
ModLoaderVersionService *m_loaderMeta;
|
||||
ModLoaderInstaller *m_loaderInstaller;
|
||||
BuildSwitcher *m_switcher;
|
||||
JavaRuntimeService *m_javaMeta;
|
||||
JavaInstaller *m_javaInstaller;
|
||||
|
||||
// Сборка, для которой сейчас ставится модлоадер: по её id находим запись,
|
||||
// когда установщик отчитается.
|
||||
@@ -250,6 +284,9 @@ private:
|
||||
mutable QVariantList m_catalog;
|
||||
mutable bool m_catalogDirty = true;
|
||||
|
||||
mutable QVariantList m_javaCatalog;
|
||||
mutable bool m_javaCatalogDirty = true;
|
||||
|
||||
bool m_busy = false;
|
||||
|
||||
// Состояние запуска, начатого до асинхронной авторизации.
|
||||
|
||||
+17
-1
@@ -71,6 +71,21 @@ QString LauncherPaths::runtimeDir()
|
||||
return rootDir() + QStringLiteral("/runtime");
|
||||
}
|
||||
|
||||
QString LauncherPaths::javaDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/java");
|
||||
}
|
||||
|
||||
QString LauncherPaths::javaCatalogFile()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/java_runtimes.json");
|
||||
}
|
||||
|
||||
QString LauncherPaths::javaDownloadDir()
|
||||
{
|
||||
return cacheDir() + QStringLiteral("/java");
|
||||
}
|
||||
|
||||
QString LauncherPaths::cacheDir()
|
||||
{
|
||||
return rootDir() + QStringLiteral("/cache");
|
||||
@@ -94,7 +109,8 @@ QString LauncherPaths::loaderDownloadDir()
|
||||
bool LauncherPaths::ensureRootExists(QString *error)
|
||||
{
|
||||
const QStringList required{rootDir(), runtimeDir(), cacheDir(),
|
||||
loaderDownloadDir(), buildStorageDir()};
|
||||
loaderDownloadDir(), buildStorageDir(), javaDir(),
|
||||
javaDownloadDir()};
|
||||
for (const QString &path : required) {
|
||||
if (!QDir().mkpath(path)) {
|
||||
if (error)
|
||||
|
||||
@@ -31,6 +31,16 @@ QString buildStorageDir();
|
||||
// Сюда качается authlib-injector.
|
||||
QString runtimeDir();
|
||||
|
||||
// <root>/java — сборки Java, скачанные лаунчером. Каждая в своей подпапке,
|
||||
// имя подпапки — идентификатор сборки из каталога.
|
||||
QString javaDir();
|
||||
|
||||
// Слепок каталога доступных сборок Java с отметкой времени.
|
||||
QString javaCatalogFile();
|
||||
|
||||
// Сюда качаются архивы Temurin до распаковки.
|
||||
QString javaDownloadDir();
|
||||
|
||||
// <root>/cache — данные, которые можно удалить без потерь.
|
||||
QString cacheDir();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user