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