Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11da87908 | ||
|
|
6567e731d1 | ||
|
|
441021b9a6 | ||
|
|
5dba4e89cb | ||
|
|
01af372ef2 | ||
|
|
00e7c957e4 | ||
|
|
f1a840174b | ||
|
|
d6986057d1 | ||
|
|
c25e071b29 | ||
|
|
4689e59b5a | ||
|
|
616840fda9 | ||
|
|
bbc8633257 | ||
|
|
af41017446 | ||
|
|
dddc0261d4 | ||
|
|
08e0dc4965 | ||
|
|
168884127b | ||
|
|
5bc59f8018 | ||
|
|
671fbe169d | ||
|
|
2c51c4736a | ||
|
|
af4230500e | ||
|
|
2c4b594c90 | ||
|
|
56e56a370f | ||
|
|
2b67938385 | ||
|
|
966cd46049 | ||
|
|
bbbed6aa89 | ||
|
|
2d35c613a9 | ||
|
|
4bf16a183f | ||
|
|
af2139c671 | ||
|
|
ca2a6bdbff | ||
|
|
d3bee159bc | ||
|
|
2b8119c64e | ||
|
|
5dd96fc4dc | ||
|
|
81877df00f | ||
|
|
49cbded003 | ||
|
|
3b3d9e7af0 | ||
|
|
0d18e48783 | ||
|
|
b5b964713e | ||
|
|
8ea52d0d70 | ||
|
|
c5e16bc295 | ||
|
|
a22dca1093 | ||
|
|
a485621830 | ||
|
|
249476a7ff | ||
|
|
0832ebc0a7 | ||
|
|
3be6cca929 | ||
|
|
7903775959 | ||
|
|
26ab5acb15 | ||
|
|
8dcc3de486 | ||
|
|
e9e1885d17 | ||
|
|
11fea3785b | ||
|
|
eb4695d75f |
@@ -58,3 +58,9 @@
|
|||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|
||||||
|
build/
|
||||||
|
.qtcreator/
|
||||||
|
.DS_Store
|
||||||
|
CMakeLists.txt.user*
|
||||||
|
stage/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 2.15
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: buildsDialog
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
property int editIndex: -1
|
||||||
|
property bool loading: false
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 880
|
||||||
|
height: 600
|
||||||
|
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||||
|
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBuild(index) {
|
||||||
|
const data = buildsDialog.backend.customBuildAt(index)
|
||||||
|
buildsDialog.loading = true
|
||||||
|
buildsDialog.editIndex = index
|
||||||
|
bdName.text = data.name || ""
|
||||||
|
bdServer.text = data.serverUrl || ""
|
||||||
|
bdMinecraft.selectedId = data.minecraftVersion || ""
|
||||||
|
loaderPanel.applyBuild(data.loader || "", data.loaderVersion || "")
|
||||||
|
buildsDialog.loading = false
|
||||||
|
buildsDialog.refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemove(index) {
|
||||||
|
const info = buildsDialog.backend.customBuildRemovalInfo(index)
|
||||||
|
if (!info || info.name === undefined)
|
||||||
|
return
|
||||||
|
removeConfirm.buildIndex = index
|
||||||
|
removeConfirm.buildName = info.name
|
||||||
|
removeConfirm.hasArchive = info.hasArchive === true
|
||||||
|
removeConfirm.active = info.active === true
|
||||||
|
removeConfirm.lastOne = info.lastOne === true
|
||||||
|
removeConfirm.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRemove(index) {
|
||||||
|
buildsDialog.backend.removeCustomBuild(index)
|
||||||
|
const left = buildsDialog.backend.customBuildNames.length
|
||||||
|
if (left === 0)
|
||||||
|
buildsDialog.editIndex = -1
|
||||||
|
else
|
||||||
|
buildsDialog.selectBuild(Math.min(index, left - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(fields) {
|
||||||
|
if (buildsDialog.loading || buildsDialog.editIndex < 0)
|
||||||
|
return
|
||||||
|
buildsDialog.backend.updateCustomBuild(buildsDialog.editIndex, fields)
|
||||||
|
buildsDialog.refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatus() {
|
||||||
|
if (buildsDialog.editIndex < 0) {
|
||||||
|
bdStatus.text = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const problems = buildsDialog.backend.checkInstallation(buildsDialog.editIndex)
|
||||||
|
bdStatus.ok = problems.length === 0
|
||||||
|
bdStatus.text = problems.length === 0
|
||||||
|
? Loc.t.build.status.ready
|
||||||
|
: Loc.t.build.error.missingFiles.arg(problems.length).arg(problems[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function newBuildName() {
|
||||||
|
const taken = buildsDialog.backend.customBuildNames
|
||||||
|
for (var n = 1; ; ++n) {
|
||||||
|
const candidate = Loc.t.build.title.numbered.arg(n)
|
||||||
|
if (taken.indexOf(candidate) === -1)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: removeConfirm
|
||||||
|
|
||||||
|
property int buildIndex: -1
|
||||||
|
property string buildName: ""
|
||||||
|
property bool hasArchive: false
|
||||||
|
property bool active: false
|
||||||
|
property bool lastOne: false
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 420
|
||||||
|
parent: buildsDialog.parent
|
||||||
|
x: (buildsDialog.parent.width - width) / 2
|
||||||
|
y: (buildsDialog.parent.height - height) / 2
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.build.title.removeConfirm
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Column {
|
||||||
|
spacing: 10
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 18
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: Loc.t.build.hint.removeIrreversible
|
||||||
|
.arg(removeConfirm.buildName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.hasArchive
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.build.warning.archiveGoesToo
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.active && !removeConfirm.lastOne
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.build.warning.removingActive
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.lastOne
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.build.hint.removingLast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: removeConfirm.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.remove
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#8a3a3a" : "#663333"
|
||||||
|
radius: 6
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
const index = removeConfirm.buildIndex
|
||||||
|
removeConfirm.close()
|
||||||
|
buildsDialog.performRemove(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosed: removeConfirm.buildIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionPickerDialog {
|
||||||
|
id: versionPicker
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
parent: buildsDialog.parent
|
||||||
|
x: (buildsDialog.parent.width - width) / 2
|
||||||
|
y: (buildsDialog.parent.height - height) / 2
|
||||||
|
onVersionChosen: (versionId) => bdMinecraft.selectedId = versionId
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: buildsDialog.backend
|
||||||
|
function onInstalledVersionsChanged() { buildsDialog.refreshStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
onAboutToShow: {
|
||||||
|
buildsDialog.backend.refreshVersionCatalog()
|
||||||
|
const names = buildsDialog.backend.customBuildNames
|
||||||
|
if (names.length === 0)
|
||||||
|
buildsDialog.editIndex = -1
|
||||||
|
else
|
||||||
|
buildsDialog.selectBuild(Math.max(0, buildsDialog.backend.activeBuildIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.title.customBuilds
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.preferredWidth: 260
|
||||||
|
Layout.fillHeight: true
|
||||||
|
enabled: !buildsDialog.backend.switching
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: buildList
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: addBuildRow.top
|
||||||
|
anchors.margins: 8
|
||||||
|
clip: true
|
||||||
|
spacing: 2
|
||||||
|
model: buildsDialog.backend.customBuildNames
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
id: buildRow
|
||||||
|
required property string modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: buildList.width
|
||||||
|
height: 40
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.right: activeBadge.left
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: buildRow.modelData
|
||||||
|
color: "#ffffff"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: activeBadge
|
||||||
|
anchors.right: buildTrash.left
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||||
|
text: Loc.t.build.label.active
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
Image {
|
||||||
|
id: buildTrash
|
||||||
|
width: 16; height: 16
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: buildRow.hovered
|
||||||
|
opacity: buildTrashArea.containsMouse ? 1.0 : 0.7
|
||||||
|
source: "images/Trash.svg"
|
||||||
|
sourceSize.width: 16; sourceSize.height: 16
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: buildTrashArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -6
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: buildsDialog.askRemove(buildRow.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||||
|
? "#91B315" : "#232323"
|
||||||
|
radius: 5
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: buildsDialog.selectBuild(buildRow.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: addBuildRow
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 8
|
||||||
|
height: 34
|
||||||
|
radius: 5
|
||||||
|
color: addBuildArea.containsPress ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.build.button.new
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: addBuildArea
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: {
|
||||||
|
buildsDialog.backend.addCustomBuild(buildsDialog.newBuildName(), "", "")
|
||||||
|
buildsDialog.selectBuild(buildsDialog.backend.customBuildNames.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 1
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.switching
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 20
|
||||||
|
contentHeight: detailsColumn.height
|
||||||
|
clip: true
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: detailsColumn
|
||||||
|
width: parent.width
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
LabelledField {
|
||||||
|
id: bdName
|
||||||
|
width: parent.width
|
||||||
|
label: Loc.t.build.label.name
|
||||||
|
placeholder: Loc.t.build.placeholder.name
|
||||||
|
onEditingFinished: buildsDialog.commit({"name": bdName.text.trim()})
|
||||||
|
}
|
||||||
|
|
||||||
|
LabelledField {
|
||||||
|
id: bdServer
|
||||||
|
width: parent.width
|
||||||
|
label: Loc.t.build.label.serverAddress
|
||||||
|
placeholder: Loc.t.build.placeholder.serverAddress
|
||||||
|
onEditingFinished: buildsDialog.commit({"serverUrl": bdServer.text.trim()})
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: Loc.t.common.label.minecraftVersion
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: bdMinecraft
|
||||||
|
property string selectedId: ""
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
|
height: 36
|
||||||
|
radius: 6
|
||||||
|
color: "#2a2a2a"
|
||||||
|
border.color: versionFieldArea.containsMouse ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
leftPadding: 10
|
||||||
|
rightPadding: 110
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: bdMinecraft.selectedId === "" ? "#666666" : "#ffffff"
|
||||||
|
text: bdMinecraft.selectedId === "" ? Loc.t.build.button.pickVersion
|
||||||
|
: bdMinecraft.selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: Loc.t.common.button.choose
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: versionFieldArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: versionPicker.openFor(bdMinecraft.selectedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectedIdChanged: buildsDialog.commit({"minecraftVersion":
|
||||||
|
bdMinecraft.selectedId})
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: Loc.t.build.label.modLoader
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: loaderPanel
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
property var rows: [bdForge, bdFabric, bdNeoForge, bdQuilt]
|
||||||
|
|
||||||
|
function applyBuild(loader, loaderVersion) {
|
||||||
|
for (var i = 0; i < loaderPanel.rows.length; ++i)
|
||||||
|
loaderPanel.rows[i].applyBuild(loader, loaderVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedRow() {
|
||||||
|
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||||
|
if (loaderPanel.rows[i].checked)
|
||||||
|
return loaderPanel.rows[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepOnly(row) {
|
||||||
|
for (var i = 0; i < loaderPanel.rows.length; ++i) {
|
||||||
|
if (loaderPanel.rows[i] === row)
|
||||||
|
continue
|
||||||
|
loaderPanel.rows[i].applyBuild("", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitSelection() {
|
||||||
|
const row = loaderPanel.selectedRow()
|
||||||
|
buildsDialog.commit({
|
||||||
|
"loader": row ? row.loaderKey : "",
|
||||||
|
"loaderVersion": row ? row.selectedVersion : "",
|
||||||
|
"resolvedVersionId": ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
LoaderRow {
|
||||||
|
id: bdForge
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "forge"
|
||||||
|
title: Loc.t.loader.name.forge
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdForge)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
LoaderRow {
|
||||||
|
id: bdFabric
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "fabric"
|
||||||
|
title: Loc.t.loader.name.fabric
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdFabric)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
LoaderRow {
|
||||||
|
id: bdNeoForge
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "neoforge"
|
||||||
|
title: Loc.t.loader.name.neoforge
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdNeoForge)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
LoaderRow {
|
||||||
|
id: bdQuilt
|
||||||
|
width: parent.width
|
||||||
|
backend: buildsDialog.backend
|
||||||
|
loaderKey: "quilt"
|
||||||
|
title: Loc.t.loader.name.quilt
|
||||||
|
gameVersion: bdMinecraft.selectedId
|
||||||
|
onUserChecked: loaderPanel.keepOnly(bdQuilt)
|
||||||
|
onChanged: loaderPanel.commitSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: bdStatus
|
||||||
|
property bool ok: false
|
||||||
|
width: parent.width
|
||||||
|
color: bdStatus.ok ? "#8fbf5a" : "#cc6666"
|
||||||
|
font.pixelSize: 11
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.install
|
||||||
|
width: 140; height: 36
|
||||||
|
enabled: buildsDialog.editIndex >= 0 && !buildsDialog.backend.busy
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: buildsDialog.backend.installCustomBuild(buildsDialog.editIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.build.button.makeActive
|
||||||
|
width: 170; height: 36
|
||||||
|
enabled: buildsDialog.editIndex >= 0
|
||||||
|
&& buildsDialog.editIndex !== buildsDialog.backend.activeBuildIndex
|
||||||
|
&& !buildsDialog.backend.busy
|
||||||
|
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: buildsDialog.backend.activeBuildIndex = buildsDialog.editIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.close
|
||||||
|
width: 120; height: 36
|
||||||
|
enabled: !buildsDialog.backend.switching
|
||||||
|
opacity: enabled ? 1.0 : 0.45
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: buildsDialog.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
project(BiLauncher VERSION 0.1 LANGUAGES CXX C)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||||
|
find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Core CorePrivate Gui Network)
|
||||||
|
find_package(Qt6 QUIET COMPONENTS WebEngineQuick)
|
||||||
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
|
message(STATUS "Qt WebEngine найден: вход через аккаунт Microsoft будет доступен")
|
||||||
|
set(LAUNCHER_MSA_QML_FILES MicrosoftLoginDialog.qml)
|
||||||
|
else()
|
||||||
|
message(WARNING
|
||||||
|
"Qt WebEngine не найден — лаунчер соберётся без входа через аккаунт Microsoft. "
|
||||||
|
"Чтобы его включить, доустановите в MaintenanceTool для своей версии Qt сразу три "
|
||||||
|
"компонента: Qt WebEngine, Qt WebChannel и Qt Positioning.")
|
||||||
|
set(LAUNCHER_MSA_QML_FILES "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_standard_project_setup(REQUIRES 6.8)
|
||||||
|
|
||||||
|
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
|
||||||
|
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
|
||||||
|
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
|
||||||
|
# который ставится ниже вместе с темой hicolor.
|
||||||
|
#
|
||||||
|
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
|
||||||
|
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
|
||||||
|
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
|
||||||
|
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
|
||||||
|
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
set(LAUNCHER_APP_ICON "${LAUNCHER_ICON_DIR}/icon.rc")
|
||||||
|
elseif(APPLE)
|
||||||
|
set(LAUNCHER_APP_ICON "${LAUNCHER_ICON_DIR}/icon.icns")
|
||||||
|
set_source_files_properties(${LAUNCHER_APP_ICON} PROPERTIES
|
||||||
|
MACOSX_PACKAGE_LOCATION "Resources")
|
||||||
|
else()
|
||||||
|
set(LAUNCHER_APP_ICON "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
qt_add_executable(BiLauncher
|
||||||
|
main.cpp
|
||||||
|
${LAUNCHER_APP_ICON}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
|
||||||
|
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
|
||||||
|
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
|
||||||
|
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
|
||||||
|
qt_add_resources(BiLauncher "appicon"
|
||||||
|
PREFIX "/icons"
|
||||||
|
BASE "icons"
|
||||||
|
FILES
|
||||||
|
icons/icon_16x16.png
|
||||||
|
icons/icon_16x16@2x.png
|
||||||
|
icons/icon_24x24.png
|
||||||
|
icons/icon_32x32.png
|
||||||
|
icons/icon_32x32@2x.png
|
||||||
|
icons/icon_48x48.png
|
||||||
|
icons/icon_64x64.png
|
||||||
|
icons/icon_128x128.png
|
||||||
|
icons/icon_128x128@2x.png
|
||||||
|
icons/icon_256x256.png
|
||||||
|
icons/icon_256x256@2x.png
|
||||||
|
icons/icon_512x512.png
|
||||||
|
icons/icon_512x512@2x.png
|
||||||
|
)
|
||||||
|
|
||||||
|
qt_add_qml_module(BiLauncher
|
||||||
|
URI BiLauncher
|
||||||
|
SOURCES
|
||||||
|
localization.h
|
||||||
|
localization.cpp
|
||||||
|
launcherbackend.h
|
||||||
|
launcherbackend.cpp
|
||||||
|
launcherpaths.h
|
||||||
|
launcherpaths.cpp
|
||||||
|
minecraftversion.h
|
||||||
|
minecraftversion.cpp
|
||||||
|
javalocator.h
|
||||||
|
javalocator.cpp
|
||||||
|
zlibreference.h
|
||||||
|
zlibreference.cpp
|
||||||
|
javaruntime.h
|
||||||
|
javaruntime.cpp
|
||||||
|
javaruntimeservice.h
|
||||||
|
javaruntimeservice.cpp
|
||||||
|
javainstaller.h
|
||||||
|
javainstaller.cpp
|
||||||
|
authservice.h
|
||||||
|
authservice.cpp
|
||||||
|
msaauthservice.h
|
||||||
|
msaauthservice.cpp
|
||||||
|
gamelauncher.h
|
||||||
|
gamelauncher.cpp
|
||||||
|
versionmanifestservice.h
|
||||||
|
versionmanifestservice.cpp
|
||||||
|
versioninstaller.h
|
||||||
|
versioninstaller.cpp
|
||||||
|
modloader.h
|
||||||
|
modloaderversionservice.h
|
||||||
|
modloaderversionservice.cpp
|
||||||
|
modloaderinstaller.h
|
||||||
|
modloaderinstaller.cpp
|
||||||
|
buildarchiveworker.h
|
||||||
|
buildarchiveworker.cpp
|
||||||
|
buildswitcher.h
|
||||||
|
buildswitcher.cpp
|
||||||
|
seasonalbuildservice.h
|
||||||
|
seasonalbuildservice.cpp
|
||||||
|
seasonalpackdownloader.h
|
||||||
|
seasonalpackdownloader.cpp
|
||||||
|
QML_FILES
|
||||||
|
Main.qml
|
||||||
|
DarkCombo.qml
|
||||||
|
VersionPickerDialog.qml
|
||||||
|
JavaPickerDialog.qml
|
||||||
|
LabelledField.qml
|
||||||
|
BuildsDialog.qml
|
||||||
|
SeasonalBuildsDialog.qml
|
||||||
|
LoaderRow.qml
|
||||||
|
ProgressPanel.qml
|
||||||
|
${LAUNCHER_MSA_QML_FILES}
|
||||||
|
RESOURCES images/Folder/Folder_Active.svg images/Folder/Folder_Idle.svg images/Folder/Folder_Pressed.svg images/Options/Options_active.svg images/Options/Options_Idle.svg images/Options/Options_Pressed.svg images/Play_Button/Play_Active.svg images/Play_Button/Play_Idle.svg images/Play_Button/Play_pressed.svg images/Profile_Box/Asset_23.jpg images/Profile_Box/Asset_23.svg images/Profile_Box/Asset_24.jpg images/Profile_Box/Asset_24.svg images/Profile_Box/Kishka_Profile_Active.svg images/Profile_Box/Kishka_Profile_Idle.svg images/Profile_Box/Kishka_Profile_open.svg images/Scroll/Scroll_Lever.svg images/Scroll/Scroll_palka.svg images/Version_box/Kishka_Profile_Open.svg images/Version_box/Kishka_Ver_Active.svg images/Version_box/Kishka_Ver_idle.svg images/Trash.svg images/Pencil.svg images/GovuztTW8AAHqBf.jpeg images/photo_2025-12-16_15-04-17.jpg images/photo_2025-12-21_02-30-09.jpg i18n/translations.json
|
||||||
|
RESOURCES images/launcherBackground.png
|
||||||
|
RESOURCES images/launcherBackground2.mp4 images/launcherBackground2static.jpg
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
|
||||||
|
# If you are developing for iOS or macOS you should consider setting an
|
||||||
|
# explicit, fixed bundle identifier manually though.
|
||||||
|
set_target_properties(BiLauncher PROPERTIES
|
||||||
|
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
||||||
|
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||||
|
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
|
||||||
|
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
|
||||||
|
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
|
||||||
|
MACOSX_BUNDLE_ICON_FILE icon.icns
|
||||||
|
MACOSX_BUNDLE TRUE
|
||||||
|
WIN32_EXECUTABLE TRUE
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(BiLauncher
|
||||||
|
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||||
|
)
|
||||||
|
|
||||||
|
if(Qt6WebEngineQuick_FOUND)
|
||||||
|
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
||||||
|
target_compile_definitions(BiLauncher PRIVATE LAUNCHER_HAS_WEBENGINE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
|
if(UNIX AND NOT APPLE)
|
||||||
|
set(LAUNCHER_ZLIB_DIR "${CMAKE_INSTALL_LIBDIR}/BiLauncher")
|
||||||
|
set(LAUNCHER_ZLIB_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/zlib")
|
||||||
|
|
||||||
|
add_library(launcher_zlib_reference SHARED
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/adler32.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/compress.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/crc32.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/deflate.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzclose.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzlib.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzread.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/gzwrite.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/infback.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/inffast.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/inflate.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/inftrees.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/trees.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/uncompr.c
|
||||||
|
${LAUNCHER_ZLIB_SOURCES}/zutil.c
|
||||||
|
)
|
||||||
|
target_include_directories(launcher_zlib_reference PRIVATE ${LAUNCHER_ZLIB_SOURCES})
|
||||||
|
|
||||||
|
target_compile_definitions(launcher_zlib_reference PRIVATE
|
||||||
|
HAVE_UNISTD_H=1 HAVE_STDARG_H=1 _LARGEFILE64_SOURCE=1)
|
||||||
|
|
||||||
|
set_target_properties(launcher_zlib_reference PROPERTIES
|
||||||
|
OUTPUT_NAME z
|
||||||
|
VERSION 1.3.1
|
||||||
|
SOVERSION 1
|
||||||
|
C_VISIBILITY_PRESET default
|
||||||
|
LIBRARY_OUTPUT_DIRECTORY $<TARGET_FILE_DIR:BiLauncher>/zlib
|
||||||
|
)
|
||||||
|
add_dependencies(BiLauncher launcher_zlib_reference)
|
||||||
|
|
||||||
|
target_compile_definitions(BiLauncher PRIVATE
|
||||||
|
LAUNCHER_ZLIB_INSTALL_DIR="${CMAKE_INSTALL_FULL_LIBDIR}/BiLauncher"
|
||||||
|
)
|
||||||
|
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
||||||
|
|
||||||
|
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
|
||||||
|
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
|
||||||
|
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
||||||
|
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
||||||
|
RENAME bilauncher.png)
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
install(FILES "${LAUNCHER_ICON_DIR}/bilauncher.desktop"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
install(TARGETS BiLauncher
|
||||||
|
BUNDLE DESTINATION .
|
||||||
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
)
|
||||||
|
find_package(Qt6 REQUIRED COMPONENTS Qml)
|
||||||
|
set(deploy_opts "")
|
||||||
|
if(APPLE)
|
||||||
|
set(deploy_opts --hardened-runtime)
|
||||||
|
endif()
|
||||||
|
qt_generate_deploy_qml_app_script(
|
||||||
|
TARGET BiLauncher
|
||||||
|
OUTPUT_SCRIPT deploy_script
|
||||||
|
MACOS_BUNDLE_POST_BUILD
|
||||||
|
NO_UNSUPPORTED_PLATFORM_ERROR
|
||||||
|
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
||||||
|
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
||||||
|
install(SCRIPT ${deploy_script})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
// Комбобокс в общем тёмном стиле окна.
|
||||||
|
ComboBox {
|
||||||
|
id: darkCombo
|
||||||
|
|
||||||
|
// Та же стрелка, что у комбобоксов профиля и версии на главном окне,
|
||||||
|
// вместо двойного шеврона Basic-стиля.
|
||||||
|
indicator: Image {
|
||||||
|
width: 10; height: 10
|
||||||
|
x: darkCombo.width - width - 12
|
||||||
|
y: (darkCombo.height - height) / 2
|
||||||
|
source: darkCombo.down ? "images/Profile_Box/Asset_23.svg"
|
||||||
|
: "images/Profile_Box/Asset_24.svg"
|
||||||
|
rotation: 180
|
||||||
|
sourceSize.width: 10; sourceSize.height: 10
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
leftPadding: 10
|
||||||
|
rightPadding: darkCombo.indicator.width + 22
|
||||||
|
text: darkCombo.displayText
|
||||||
|
color: "#ffffff"
|
||||||
|
font: darkCombo.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#2a2a2a"
|
||||||
|
radius: 6
|
||||||
|
border.color: darkCombo.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: darkCombo.width
|
||||||
|
highlighted: darkCombo.highlightedIndex === index
|
||||||
|
contentItem: Text {
|
||||||
|
text: modelData
|
||||||
|
color: "#ffffff"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle { color: highlighted ? "#3a3a3a" : "#2a2a2a" }
|
||||||
|
}
|
||||||
|
|
||||||
|
popup: Popup {
|
||||||
|
y: darkCombo.height
|
||||||
|
width: darkCombo.width
|
||||||
|
implicitHeight: Math.min(contentItem.implicitHeight, 220)
|
||||||
|
padding: 1
|
||||||
|
|
||||||
|
contentItem: ListView {
|
||||||
|
clip: true
|
||||||
|
implicitHeight: contentHeight
|
||||||
|
model: darkCombo.delegateModel
|
||||||
|
currentIndex: darkCombo.highlightedIndex
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#2a2a2a"
|
||||||
|
radius: 6
|
||||||
|
border.color: "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: Loc.t.common.java.kind.java, hint: Loc.t.java.filter.mojang },
|
||||||
|
{ key: "jdk", title: Loc.t.common.java.kind.jdk, hint: Loc.t.java.filter.temurinJdk },
|
||||||
|
{ key: "jre", title: Loc.t.common.java.kind.jre, hint: Loc.t.java.filter.temurinJre }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Каталог приходит из 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: Loc.t.java.label.version
|
||||||
|
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: Loc.t.java.hint.required
|
||||||
|
.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: Loc.t.common.placeholder.searchVersion
|
||||||
|
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: Loc.t.java.filter.downloadedOnly
|
||||||
|
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(Loc.t.common.status.megabytes.arg(runtimeRow.modelData.sizeMb))
|
||||||
|
if (runtimeRow.tooOld)
|
||||||
|
parts.push(Loc.t.java.warning.tooOldForVersion)
|
||||||
|
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
|
||||||
|
? Loc.t.java.progress.loadingCatalog
|
||||||
|
: picker.backend.javaCatalog.length === 0
|
||||||
|
? Loc.t.java.error.catalogUnavailable
|
||||||
|
: Loc.t.common.status.nothingFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? Loc.t.common.status.noBuildSelected
|
||||||
|
: picker.selectedEntry.installed
|
||||||
|
? picker.selectedEntry.label
|
||||||
|
: Loc.t.java.status.willDownload.arg(picker.selectedEntry.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
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
|
||||||
|
? Loc.t.common.button.download : Loc.t.common.button.select
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
// Подпись + поле ввода одной колонкой — используется в настройках.
|
||||||
|
Column {
|
||||||
|
id: labelled
|
||||||
|
|
||||||
|
property alias text: field.text
|
||||||
|
property alias validator: field.validator
|
||||||
|
property string label: ""
|
||||||
|
property string placeholder: ""
|
||||||
|
|
||||||
|
// Правка закончена: поле потеряло фокус или пользователь нажал Enter.
|
||||||
|
signal editingFinished()
|
||||||
|
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: labelled.label
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: field
|
||||||
|
width: labelled.width
|
||||||
|
onEditingFinished: labelled.editingFinished()
|
||||||
|
height: 32
|
||||||
|
placeholderText: labelled.placeholder
|
||||||
|
color: "#ffffff"
|
||||||
|
placeholderTextColor: "#666666"
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#2a2a2a"
|
||||||
|
radius: 6
|
||||||
|
border.color: field.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
// Один модлоадер в карточке сборки: чекбокс и, когда он отмечен, список версий
|
||||||
|
// именно под выбранную версию Minecraft.
|
||||||
|
//
|
||||||
|
// Совместимость проверять не нужно: backend.loaderVersions() отдаёт список уже
|
||||||
|
// под конкретную версию игры, поэтому несовместимой строки в нём не бывает.
|
||||||
|
// Пустой список — это «лоадер не поддерживает эту версию игры», и он тоже
|
||||||
|
// показывается словами, а не пустым выпадающим списком.
|
||||||
|
Column {
|
||||||
|
id: loaderRow
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
required property string loaderKey
|
||||||
|
required property string title
|
||||||
|
|
||||||
|
// Версия Minecraft, выбранная в карточке. Её смена сбрасывает выбор.
|
||||||
|
property string gameVersion: ""
|
||||||
|
|
||||||
|
property alias checked: box.checked
|
||||||
|
property string selectedVersion: ""
|
||||||
|
property var entries: []
|
||||||
|
|
||||||
|
// Пользователь щёлкнул чекбокс — карточка снимет отметки с остальных
|
||||||
|
// лоадеров: одновременно в .minecraft может жить только один.
|
||||||
|
signal userChecked()
|
||||||
|
// Отметка или версия изменились — карточке пора сохранить сборку.
|
||||||
|
signal changed()
|
||||||
|
|
||||||
|
// Ставится, пока карточка заполняет строку данными сборки: в это время
|
||||||
|
// обработчики не должны считать изменения пользовательскими.
|
||||||
|
property bool applying: false
|
||||||
|
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
function versionIndex(version) {
|
||||||
|
for (var i = 0; i < loaderRow.entries.length; ++i) {
|
||||||
|
if (loaderRow.entries[i].version === version)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единственное место, где меняются entries и selectedVersion. Через него
|
||||||
|
// проходят все три пути — кэш, ответ сети и заполнение из сборки, — потому
|
||||||
|
// что выбранная версия обязана существовать в списке под текущую версию
|
||||||
|
// игры. Раньше проверял только путь из сети, и версия лоадера от прошлой
|
||||||
|
// версии игры доживала до сохранения сборки.
|
||||||
|
function applyEntries(list) {
|
||||||
|
loaderRow.entries = list
|
||||||
|
|
||||||
|
// Список отсортирован новыми вперёд, поэтому максимально доступная
|
||||||
|
// версия для этой версии игры — всегда первая строка.
|
||||||
|
if (loaderRow.versionIndex(loaderRow.selectedVersion) < 0) {
|
||||||
|
const fallback = list.length > 0 ? list[0].version : ""
|
||||||
|
const changedValue = loaderRow.selectedVersion !== fallback
|
||||||
|
loaderRow.selectedVersion = fallback
|
||||||
|
if (changedValue && !loaderRow.applying)
|
||||||
|
loaderRow.changed()
|
||||||
|
}
|
||||||
|
|
||||||
|
combo.currentIndex = loaderRow.versionIndex(loaderRow.selectedVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
if (!box.checked || loaderRow.gameVersion === "") {
|
||||||
|
loaderRow.applyEntries([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Сначала из кэша — список появляется мгновенно, — потом обновление сети.
|
||||||
|
loaderRow.applyEntries(loaderRow.backend.loaderVersions(loaderRow.loaderKey,
|
||||||
|
loaderRow.gameVersion))
|
||||||
|
loaderRow.backend.refreshLoaderVersions(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполнение из сохранённой сборки: без сигналов changed().
|
||||||
|
function applyBuild(loader, loaderVersion) {
|
||||||
|
loaderRow.applying = true
|
||||||
|
box.checked = loader === loaderRow.loaderKey
|
||||||
|
// Версию из сборки берём как пожелание: если под выбранную версию игры
|
||||||
|
// её нет, applyEntries() заменит её на максимально доступную.
|
||||||
|
loaderRow.selectedVersion = box.checked ? loaderVersion : ""
|
||||||
|
loaderRow.reload()
|
||||||
|
loaderRow.applying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onGameVersionChanged: {
|
||||||
|
// Сборка лоадера привязана к версии игры: под новой версией прежний
|
||||||
|
// выбор недействителен, и список перезапрашивается заново.
|
||||||
|
loaderRow.selectedVersion = ""
|
||||||
|
loaderRow.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: loaderRow.backend
|
||||||
|
function onLoaderVersionsChanged(key, game) {
|
||||||
|
if (key !== loaderRow.loaderKey || game !== loaderRow.gameVersion || !box.checked)
|
||||||
|
return
|
||||||
|
loaderRow.applyEntries(loaderRow.backend.loaderVersions(key, game))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
id: box
|
||||||
|
text: loaderRow.title
|
||||||
|
enabled: loaderRow.gameVersion !== ""
|
||||||
|
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
x: 0
|
||||||
|
y: (box.height - height) / 2
|
||||||
|
radius: 4
|
||||||
|
color: box.checked ? "#91B315" : "#2a2a2a"
|
||||||
|
border.color: box.checked ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: box.checked
|
||||||
|
text: "✓"
|
||||||
|
color: "#1e1e1e"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
leftPadding: box.indicator.width + 8
|
||||||
|
text: box.text
|
||||||
|
color: box.enabled ? "#ffffff" : "#666666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggled: {
|
||||||
|
if (box.checked)
|
||||||
|
loaderRow.userChecked()
|
||||||
|
else
|
||||||
|
loaderRow.selectedVersion = ""
|
||||||
|
loaderRow.reload()
|
||||||
|
loaderRow.changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DarkCombo {
|
||||||
|
id: combo
|
||||||
|
visible: box.checked && loaderRow.entries.length > 0
|
||||||
|
width: loaderRow.width - 26
|
||||||
|
height: 32
|
||||||
|
x: 26
|
||||||
|
model: loaderRow.entries.map(entry => entry.label)
|
||||||
|
|
||||||
|
onActivated: (index) => {
|
||||||
|
if (index < 0 || index >= loaderRow.entries.length)
|
||||||
|
return
|
||||||
|
loaderRow.selectedVersion = loaderRow.entries[index].version
|
||||||
|
loaderRow.changed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: box.checked && loaderRow.entries.length === 0
|
||||||
|
x: 26
|
||||||
|
width: loaderRow.width - 26
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
font.pixelSize: 11
|
||||||
|
color: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||||
|
? "#888888" : "#cc6666"
|
||||||
|
text: loaderRow.backend.loaderVersionsLoading(loaderRow.loaderKey, loaderRow.gameVersion)
|
||||||
|
? Loc.t.loader.progress.loadingVersions.arg(loaderRow.title)
|
||||||
|
: Loc.t.loader.status.unsupported.arg(loaderRow.title)
|
||||||
|
.arg(loaderRow.gameVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtWebEngine
|
||||||
|
|
||||||
|
// Вход в аккаунт Microsoft — то же самое, что делает официальный лаунчер:
|
||||||
|
// страница входа показывается внутри приложения, а лаунчер ждёт, когда браузер
|
||||||
|
// уйдёт на redirect_uri с кодом авторизации в адресе.
|
||||||
|
Dialog {
|
||||||
|
id: msLogin
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
|
||||||
|
// Окно ничего не знает про тосты главного окна: о неудаче сообщает сигналом.
|
||||||
|
signal failed(string message)
|
||||||
|
|
||||||
|
// Код уже отдан backend — второй раз по тому же адресу не реагируем:
|
||||||
|
// WebEngineView успевает сообщить об urlChanged несколько раз.
|
||||||
|
property bool codeTaken: false
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
modal: true
|
||||||
|
width: 560
|
||||||
|
height: 680
|
||||||
|
padding: 0
|
||||||
|
closePolicy: Popup.NoAutoClose
|
||||||
|
|
||||||
|
function openAt(url) {
|
||||||
|
codeTaken = false
|
||||||
|
web.url = url
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разбор адреса живёт в C++: правила совпадения должны совпадать с теми,
|
||||||
|
// по которым сервис строит сам redirect_uri.
|
||||||
|
function handleUrl(url) {
|
||||||
|
if (codeTaken)
|
||||||
|
return
|
||||||
|
const info = backend.inspectMicrosoftRedirect(String(url))
|
||||||
|
if (!info.matched)
|
||||||
|
return
|
||||||
|
|
||||||
|
codeTaken = true
|
||||||
|
close()
|
||||||
|
if (info.code !== "") {
|
||||||
|
backend.finishMicrosoftLogin(info.code)
|
||||||
|
} else {
|
||||||
|
backend.cancelMicrosoftLogin()
|
||||||
|
msLogin.failed(info.error !== "" ? info.error : Loc.t.auth.msa.error.incomplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.auth.msa.title.signIn
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
// Профиль без storageName — значит без диска: куки живут только пока
|
||||||
|
// работает лаунчер и в общий браузер не попадают. За выбор аккаунта в
|
||||||
|
// пределах сессии отвечает prompt=select_account в адресе входа.
|
||||||
|
WebEngineProfilePrototype {
|
||||||
|
id: msProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
WebEngineView {
|
||||||
|
id: web
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
|
||||||
|
profile: msProfile.instance()
|
||||||
|
|
||||||
|
onUrlChanged: msLogin.handleUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
running: web.loading
|
||||||
|
visible: running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
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: {
|
||||||
|
msLogin.codeTaken = true
|
||||||
|
msLogin.close()
|
||||||
|
msLogin.backend.cancelMicrosoftLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
// Плашка хода долгой операции в левом нижнем углу: не задевает кнопку запуска,
|
||||||
|
// сообщение по центру и кнопки папки с настройками.
|
||||||
|
Rectangle {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
property string title: ""
|
||||||
|
property string status: ""
|
||||||
|
// -1 — итог ещё неизвестен, показываем «…» вместо процентов.
|
||||||
|
property double fraction: -1
|
||||||
|
// Необязательная вторая строка: мегабайты у загрузки, путь у архивации.
|
||||||
|
property string detail: ""
|
||||||
|
property bool cancellable: true
|
||||||
|
|
||||||
|
signal cancelRequested()
|
||||||
|
|
||||||
|
width: 320
|
||||||
|
height: 72
|
||||||
|
radius: 8
|
||||||
|
color: "#1e1e1e"
|
||||||
|
opacity: 0.94
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 12; y: 8
|
||||||
|
width: parent.width - 70
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.bold: true
|
||||||
|
text: panel.title
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
y: 8
|
||||||
|
color: "#91B315"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: panel.fraction < 0 ? "…" : Math.round(panel.fraction * 100) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: track
|
||||||
|
x: 12; y: 30
|
||||||
|
width: parent.width - 24
|
||||||
|
height: 6
|
||||||
|
radius: 3
|
||||||
|
color: "#2a2a2a"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: panel.fraction > 0 ? track.width * panel.fraction : 0
|
||||||
|
height: parent.height
|
||||||
|
radius: 3
|
||||||
|
color: "#91B315"
|
||||||
|
Behavior on width { NumberAnimation { duration: 120 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 12; y: 44
|
||||||
|
width: parent.width - 40
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 10
|
||||||
|
text: panel.detail !== "" ? panel.detail : panel.status
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.bottomMargin: 6
|
||||||
|
visible: panel.cancellable
|
||||||
|
text: "✕"
|
||||||
|
color: cancelArea.containsMouse ? "#cc6666" : "#666666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: cancelArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -6
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: panel.cancelRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# minecraft-launcher
|
|
||||||
|
|
||||||
Первая итерация самописного лаунчера для запуска сборок Minecraft
|
|
||||||
|
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
|
||||||
|
// Готовые сезонные сборки с сервера лаунчера: таблица со всем, что нужно
|
||||||
|
// решить, ставить её или нет, и одна кнопка, которая делает всё остальное —
|
||||||
|
// заводит сборку, ставит версию игры, модлоадер, Java и раскладывает файлы.
|
||||||
|
//
|
||||||
|
// Таблица собрана из Row фиксированных колонок, а не из TableView: в проекте
|
||||||
|
// нет ни одной модели QAbstractItemModel, а строки приходят готовыми
|
||||||
|
// QVariantMap — заводить ради семи колонок отдельную модель незачем.
|
||||||
|
Dialog {
|
||||||
|
id: seasons
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
|
||||||
|
// Выбранная строка. Хранится по id, а не по индексу: список обновляется
|
||||||
|
// под руками, и индекс после обновления указывал бы на другую сборку.
|
||||||
|
property string selectedId: ""
|
||||||
|
|
||||||
|
// Ширины колонок в одном месте: их повторяют и шапка, и делегат.
|
||||||
|
readonly property var columns: [
|
||||||
|
{ key: "name", title: Loc.t.common.header.name, width: 250, align: Text.AlignLeft },
|
||||||
|
{ key: "minecraftVersion", title: Loc.t.common.header.version, width: 90, align: Text.AlignLeft },
|
||||||
|
{ key: "loaderLabel", title: Loc.t.common.header.loader, width: 150, align: Text.AlignLeft },
|
||||||
|
{ key: "modCount", title: Loc.t.seasonal.header.mods, width: 70, align: Text.AlignRight },
|
||||||
|
{ key: "seasonStart", title: Loc.t.seasonal.header.start, width: 110, align: Text.AlignLeft },
|
||||||
|
{ key: "seasonEnd", title: Loc.t.seasonal.header.end, width: 110, align: Text.AlignLeft },
|
||||||
|
{ key: "status", title: Loc.t.common.header.status, width: 140, align: Text.AlignLeft }
|
||||||
|
]
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 960
|
||||||
|
height: 560
|
||||||
|
// Пока идёт установка, окно закрывается только кнопкой: случайный щелчок
|
||||||
|
// мимо не должен спрятать единственную видимую отмену.
|
||||||
|
closePolicy: seasons.backend.seasonalInstalling
|
||||||
|
? Popup.NoAutoClose
|
||||||
|
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#91B315"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строки приходят из C++ уже отсортированными и сведёнными с локальными
|
||||||
|
// записями — здесь только показ.
|
||||||
|
readonly property var entries: seasons.backend.seasonalCatalog
|
||||||
|
|
||||||
|
readonly property var selectedEntry: {
|
||||||
|
const list = seasons.entries
|
||||||
|
for (var i = 0; i < list.length; ++i) {
|
||||||
|
if (list[i].id === seasons.selectedId)
|
||||||
|
return list[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCatalog() {
|
||||||
|
seasons.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMb(bytes) {
|
||||||
|
if (!bytes || bytes <= 0)
|
||||||
|
return ""
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(entry, key) {
|
||||||
|
if (key === "loaderLabel") {
|
||||||
|
return entry.loaderVersion === ""
|
||||||
|
? entry.loaderTitle
|
||||||
|
: entry.loaderTitle + " " + entry.loaderVersion
|
||||||
|
}
|
||||||
|
return String(entry[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свежий кэш отвечает без сети, поэтому дёргаем при каждом открытии.
|
||||||
|
onAboutToShow: seasons.backend.refreshSeasonalCatalog(false)
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.title.seasonalBuilds
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
|
||||||
|
// ── Шапка таблицы ───────────────────────────────────────────────
|
||||||
|
Row {
|
||||||
|
id: tableHeader
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 18
|
||||||
|
height: 30
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: seasons.columns
|
||||||
|
|
||||||
|
delegate: Text {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: modelData.width
|
||||||
|
height: tableHeader.height
|
||||||
|
text: modelData.title
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 12
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: modelData.align
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
rightPadding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: headerLine
|
||||||
|
anchors.top: tableHeader.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Сообщение вместо таблицы ────────────────────────────────────
|
||||||
|
// Пустой список и ошибка выглядят одинаково пустыми, поэтому причина
|
||||||
|
// показывается прямо на месте строк.
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 80
|
||||||
|
visible: seasons.entries.length === 0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
color: seasons.backend.seasonalCatalogError === "" ? "#888888" : "#cc6666"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: seasons.backend.seasonalCatalogLoading
|
||||||
|
? Loc.t.seasonal.progress.loadingCatalog
|
||||||
|
: (seasons.backend.seasonalCatalogError === ""
|
||||||
|
? Loc.t.seasonal.status.catalogEmptyYet
|
||||||
|
: seasons.backend.seasonalCatalogError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Строки ──────────────────────────────────────────────────────
|
||||||
|
ListView {
|
||||||
|
id: buildList
|
||||||
|
anchors.top: headerLine.bottom
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
clip: true
|
||||||
|
spacing: 1
|
||||||
|
model: seasons.entries
|
||||||
|
ScrollIndicator.vertical: ScrollIndicator {}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: buildRow
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: buildList.width
|
||||||
|
height: 36
|
||||||
|
radius: 4
|
||||||
|
color: seasons.selectedId === buildRow.modelData.id ? "#91B315"
|
||||||
|
: rowArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
readonly property bool highlighted: seasons.selectedId === buildRow.modelData.id
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: seasons.columns
|
||||||
|
|
||||||
|
delegate: Text {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: modelData.width
|
||||||
|
text: seasons.cellText(buildRow.modelData, modelData.key)
|
||||||
|
// Подсвеченная строка залита зелёным — тёмный текст
|
||||||
|
// на ней читается, светлый нет.
|
||||||
|
color: buildRow.highlighted
|
||||||
|
? "#1e1e1e"
|
||||||
|
: (modelData.key === "status"
|
||||||
|
&& buildRow.modelData.updateAvailable
|
||||||
|
? "#91B315" : "#ffffff")
|
||||||
|
font.pixelSize: 13
|
||||||
|
elide: Text.ElideRight
|
||||||
|
horizontalAlignment: modelData.align
|
||||||
|
rightPadding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: seasons.selectedId = buildRow.modelData.id
|
||||||
|
onDoubleClicked: {
|
||||||
|
seasons.selectedId = buildRow.modelData.id
|
||||||
|
seasons.installSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSelected() {
|
||||||
|
const entry = seasons.selectedEntry
|
||||||
|
if (!entry || seasons.backend.busy)
|
||||||
|
return
|
||||||
|
seasons.backend.installSeasonalBuild(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 76
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Описание и размер выбранной сборки: они длинные и в таблицу не
|
||||||
|
// помещаются, а решение принимается по ним.
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 18
|
||||||
|
anchors.right: footerButtons.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: seasons.selectedEntry ? seasons.selectedEntry.description : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 11
|
||||||
|
text: {
|
||||||
|
if (!seasons.selectedEntry)
|
||||||
|
return Loc.t.seasonal.hint.pickBuild
|
||||||
|
const entry = seasons.selectedEntry
|
||||||
|
var line = Loc.t.seasonal.status.revision.arg(entry.revision)
|
||||||
|
const size = seasons.formatMb(entry.sizeBytes)
|
||||||
|
if (size !== "")
|
||||||
|
line += Loc.t.seasonal.status.sizeSuffix.arg(size)
|
||||||
|
if (entry.serverUrl !== "")
|
||||||
|
line += " · " + entry.serverUrl
|
||||||
|
if (entry.updateAvailable)
|
||||||
|
line += Loc.t.seasonal.status.installedRevisionSuffix.arg(entry.installedRevision)
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: footerButtons
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 18
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: refreshButton
|
||||||
|
width: 150
|
||||||
|
height: 36
|
||||||
|
enabled: !seasons.backend.seasonalCatalogLoading
|
||||||
|
contentItem: Text {
|
||||||
|
text: seasons.backend.seasonalCatalogLoading ? Loc.t.common.status.updating
|
||||||
|
: Loc.t.common.button.refreshList
|
||||||
|
color: refreshButton.enabled ? "#ffffff" : "#888888"
|
||||||
|
font.pixelSize: 13
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: refreshButton.pressed ? "#444444" : "#333333"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: seasons.backend.refreshSeasonalCatalog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: installButton
|
||||||
|
width: 150
|
||||||
|
height: 36
|
||||||
|
// Установка занимает и панель загрузки, и .minecraft целиком:
|
||||||
|
// пока лаунчер занят чем угодно, вторую начинать нельзя.
|
||||||
|
enabled: seasons.selectedEntry !== null && !seasons.backend.busy
|
||||||
|
contentItem: Text {
|
||||||
|
text: seasons.selectedEntry && seasons.selectedEntry.updateAvailable
|
||||||
|
? Loc.t.common.button.update
|
||||||
|
: Loc.t.common.button.install
|
||||||
|
color: installButton.enabled ? "#ffffff" : "#888888"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: !installButton.enabled ? "#2a2a2a"
|
||||||
|
: installButton.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: seasons.installSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls 2.15
|
||||||
|
import QtQuick.Layouts 2.15
|
||||||
|
|
||||||
|
// Выбор версии Minecraft отдельным окном: слева категории, справа сами версии
|
||||||
|
// с поиском сверху.
|
||||||
|
//
|
||||||
|
// Раньше это был выпадающий список на всю тысячу версий: найти в нём бету 1.7
|
||||||
|
// можно было только поиском по точному номеру. Категории делят список на
|
||||||
|
// обозримые части, а поиск работает внутри выбранной.
|
||||||
|
Dialog {
|
||||||
|
id: picker
|
||||||
|
|
||||||
|
required property var backend
|
||||||
|
|
||||||
|
// Версия, с которой окно открылось: по «Отмене» выбор к ней и возвращается.
|
||||||
|
property string selectedId: ""
|
||||||
|
property string category: "release"
|
||||||
|
property string filterText: ""
|
||||||
|
property bool installedOnly: false
|
||||||
|
|
||||||
|
// Выбранная версия подтверждена кнопкой или двойным щелчком. Имя не
|
||||||
|
// accepted(): такой сигнал у Dialog уже есть, и переопределить его нельзя.
|
||||||
|
signal versionChosen(string versionId)
|
||||||
|
|
||||||
|
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: "release", title: Loc.t.version.filter.releases },
|
||||||
|
{ key: "snapshot", title: Loc.t.version.filter.snapshots },
|
||||||
|
{ key: "old_beta", title: Loc.t.version.filter.betas },
|
||||||
|
{ key: "old_alpha", title: Loc.t.version.filter.alphas },
|
||||||
|
{ key: "other", title: Loc.t.version.filter.other }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Каталог приходит из C++ уже отсортированным (новые сверху), поэтому здесь
|
||||||
|
// только отбор — порядок не трогаем.
|
||||||
|
readonly property var visibleEntries: {
|
||||||
|
const query = picker.filterText.trim().toLowerCase()
|
||||||
|
const source = picker.backend.versionCatalog
|
||||||
|
const out = []
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
const entry = source[i]
|
||||||
|
if (entry.category !== picker.category)
|
||||||
|
continue
|
||||||
|
if (picker.installedOnly && !entry.installed)
|
||||||
|
continue
|
||||||
|
if (query !== "" && entry.search.indexOf(query) === -1)
|
||||||
|
continue
|
||||||
|
out.push(entry)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFor(versionId) {
|
||||||
|
picker.selectedId = versionId
|
||||||
|
picker.filterText = ""
|
||||||
|
searchField.text = ""
|
||||||
|
// Открываемся на категории текущей версии, а не всегда на релизах.
|
||||||
|
picker.category = picker.categoryOf(versionId)
|
||||||
|
picker.backend.refreshVersionCatalog()
|
||||||
|
picker.open()
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryOf(versionId) {
|
||||||
|
const source = picker.backend.versionCatalog
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
if (source[i].id === versionId)
|
||||||
|
return source[i].category
|
||||||
|
}
|
||||||
|
return "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
versionList.currentIndex = index
|
||||||
|
if (index >= 0)
|
||||||
|
versionList.positionViewAtIndex(index, ListView.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSelection() {
|
||||||
|
if (picker.selectedId === "")
|
||||||
|
return
|
||||||
|
picker.versionChosen(picker.selectedId)
|
||||||
|
picker.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogHas(versionId) {
|
||||||
|
const source = picker.backend.versionCatalog
|
||||||
|
for (var i = 0; i < source.length; ++i) {
|
||||||
|
if (source[i].id === versionId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спрашиваем до удаления: версию придётся качать заново, а профили
|
||||||
|
// модлоадеров поверх неё — переустанавливать. Id запоминаем здесь:
|
||||||
|
// к моменту ответа строка под курсором может быть уже другой.
|
||||||
|
function askRemove(versionId) {
|
||||||
|
const info = picker.backend.versionRemovalInfo(versionId)
|
||||||
|
if (!info || info.installed !== true)
|
||||||
|
return
|
||||||
|
removeConfirm.versionId = versionId
|
||||||
|
removeConfirm.sizeMb = info.sizeMb || 0
|
||||||
|
removeConfirm.dependents = info.dependents || []
|
||||||
|
removeConfirm.builds = info.builds || []
|
||||||
|
removeConfirm.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function performRemove(versionId) {
|
||||||
|
picker.backend.removeVersion(versionId)
|
||||||
|
// Профиль модлоадера был в каталоге только потому, что установлен:
|
||||||
|
// после удаления строка исчезает совсем, и выбор надо снять.
|
||||||
|
if (picker.selectedId === versionId && !picker.catalogHas(versionId))
|
||||||
|
picker.selectedId = ""
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: removeConfirm
|
||||||
|
|
||||||
|
property string versionId: ""
|
||||||
|
property int sizeMb: 0
|
||||||
|
property var dependents: []
|
||||||
|
property var builds: []
|
||||||
|
|
||||||
|
modal: true
|
||||||
|
padding: 0
|
||||||
|
width: 420
|
||||||
|
parent: picker.parent
|
||||||
|
x: (picker.parent.width - width) / 2
|
||||||
|
y: (picker.parent.height - height) / 2
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#1e1e1e"
|
||||||
|
radius: 10
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.version.title.removeConfirm
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 17
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Column {
|
||||||
|
spacing: 10
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 18
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: removeConfirm.sizeMb > 0
|
||||||
|
? Loc.t.version.hint.removeWithSize
|
||||||
|
.arg(removeConfirm.versionId).arg(removeConfirm.sizeMb)
|
||||||
|
: Loc.t.version.hint.remove
|
||||||
|
.arg(removeConfirm.versionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.dependents.length > 0
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.version.warning.loaderProfilesDepend
|
||||||
|
.arg(removeConfirm.dependents.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
visible: removeConfirm.builds.length > 0
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#cc6666"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.version.warning.buildsDepend
|
||||||
|
.arg(removeConfirm.builds.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
x: 20
|
||||||
|
width: parent.width - 40
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
text: Loc.t.version.hint.sharedFilesStay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer: Item {
|
||||||
|
implicitHeight: 60
|
||||||
|
Rectangle {
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: "#333333"
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#6a8510" : "#91B315"
|
||||||
|
radius: 6
|
||||||
|
}
|
||||||
|
onClicked: removeConfirm.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.remove
|
||||||
|
width: 130; height: 36
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: "#ffffff"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? "#8a3a3a" : "#663333"
|
||||||
|
radius: 6
|
||||||
|
border.color: "#cc6666"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
const id = removeConfirm.versionId
|
||||||
|
removeConfirm.close()
|
||||||
|
picker.performRemove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClosed: removeConfirm.versionId = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
header: Item {
|
||||||
|
implicitHeight: 52
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Loc.t.common.label.minecraftVersion
|
||||||
|
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: 150
|
||||||
|
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: 34
|
||||||
|
radius: 5
|
||||||
|
color: picker.category === categoryRow.modelData.key ? "#91B315"
|
||||||
|
: categoryArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: categoryRow.modelData.title
|
||||||
|
color: "#ffffff"
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: categoryArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.category = categoryRow.modelData.key
|
||||||
|
picker.revealSelected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: Loc.t.common.placeholder.searchVersion
|
||||||
|
color: "#ffffff"
|
||||||
|
placeholderTextColor: "#666666"
|
||||||
|
background: Rectangle {
|
||||||
|
color: "#232323"
|
||||||
|
radius: 5
|
||||||
|
border.color: searchField.activeFocus ? "#91B315" : "#444444"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
onTextChanged: {
|
||||||
|
picker.filterText = text
|
||||||
|
versionList.currentIndex = picker.indexOfSelected()
|
||||||
|
}
|
||||||
|
Keys.onEscapePressed: picker.close()
|
||||||
|
Keys.onReturnPressed: picker.acceptSelection()
|
||||||
|
Keys.onEnterPressed: picker.acceptSelection()
|
||||||
|
Keys.onDownPressed: versionList.step(1)
|
||||||
|
Keys.onUpPressed: versionList.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: Loc.t.version.filter.installedOnly
|
||||||
|
color: "#aaaaaa"
|
||||||
|
font.pixelSize: 11
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: versionList
|
||||||
|
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 = versionList.currentIndex + delta
|
||||||
|
if (next < 0 || next >= versionList.count)
|
||||||
|
return
|
||||||
|
versionList.currentIndex = next
|
||||||
|
versionList.positionViewAtIndex(next, ListView.Contain)
|
||||||
|
picker.selectedId = picker.visibleEntries[next].id
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: versionRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
width: versionList.width
|
||||||
|
height: 32
|
||||||
|
radius: 4
|
||||||
|
color: picker.selectedId === versionRow.modelData.id ? "#91B315"
|
||||||
|
: versionArea.containsMouse ? "#2d2d2d" : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: rightMarks.left
|
||||||
|
anchors.rightMargin: 6
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: versionRow.modelData.label
|
||||||
|
color: "#ffffff"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: rightMarks
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: versionRow.modelData.installed
|
||||||
|
text: "✓"
|
||||||
|
color: picker.selectedId === versionRow.modelData.id ? "#1e1e1e" : "#91B315"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Версия весит от десятков мегабайт, а галочка рядом —
|
||||||
|
// единственное место, где видно, что именно скачано.
|
||||||
|
Image {
|
||||||
|
visible: versionRow.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.askRemove(versionRow.modelData.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: versionArea
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.rightMargin: 40
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
picker.selectedId = versionRow.modelData.id
|
||||||
|
versionList.currentIndex = versionRow.index
|
||||||
|
}
|
||||||
|
onDoubleClicked: {
|
||||||
|
picker.selectedId = versionRow.modelData.id
|
||||||
|
picker.acceptSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустая категория, ничего не нашлось или манифест ещё не пришёл.
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: versionList
|
||||||
|
width: versionList.width - 20
|
||||||
|
visible: picker.visibleEntries.length === 0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
color: "#888888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.italic: true
|
||||||
|
text: picker.backend.catalogLoading
|
||||||
|
? Loc.t.version.progress.loadingList
|
||||||
|
: picker.backend.versionCatalog.length === 0
|
||||||
|
? Loc.t.version.error.listUnreachable
|
||||||
|
: Loc.t.common.status.nothingFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 - 300
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: picker.selectedId === "" ? "#666666" : "#91B315"
|
||||||
|
font.pixelSize: 13
|
||||||
|
text: picker.selectedId === "" ? Loc.t.version.status.notSelected : picker.selectedId
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 20
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: Loc.t.common.button.cancel
|
||||||
|
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: Loc.t.common.button.select
|
||||||
|
width: 110; height: 36
|
||||||
|
enabled: picker.selectedId !== ""
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
#include "authservice.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QString kAuthServer = QStringLiteral("https://authserver.ely.by");
|
||||||
|
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
||||||
|
const QString kInjectorFileName = QStringLiteral("authlib-injector.jar");
|
||||||
|
|
||||||
|
QString normalizeUuid(const QString &uuid)
|
||||||
|
{
|
||||||
|
return QString(uuid).remove(u'-');
|
||||||
|
}
|
||||||
|
|
||||||
|
QString describeElyError(const QJsonObject &response, int status)
|
||||||
|
{
|
||||||
|
const QString message = response.value(QStringLiteral("errorMessage")).toString();
|
||||||
|
if (!message.isEmpty())
|
||||||
|
return message;
|
||||||
|
const QString error = response.value(QStringLiteral("error")).toString();
|
||||||
|
if (!error.isEmpty())
|
||||||
|
return error;
|
||||||
|
return Loc::text("auth.error.serverCode").arg(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthService::AuthService(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_network(new QNetworkAccessManager(this))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthResult AuthService::offline(const QString &nickname)
|
||||||
|
{
|
||||||
|
AuthResult result;
|
||||||
|
if (nickname.isEmpty()) {
|
||||||
|
result.error = Loc::text("auth.offline.error.noNickname");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray digest = QCryptographicHash::hash(
|
||||||
|
QStringLiteral("OfflinePlayer:%1").arg(nickname).toUtf8(), QCryptographicHash::Md5);
|
||||||
|
digest[6] = static_cast<char>((digest[6] & 0x0f) | 0x30); // версия UUID = 3
|
||||||
|
digest[8] = static_cast<char>((digest[8] & 0x3f) | 0x80); // вариант RFC 4122
|
||||||
|
|
||||||
|
result.ok = true;
|
||||||
|
result.playerName = nickname;
|
||||||
|
result.uuid = QString::fromLatin1(digest.toHex());
|
||||||
|
result.accessToken = QStringLiteral("0");
|
||||||
|
result.clientToken = QStringLiteral("0");
|
||||||
|
result.userType = QStringLiteral("legacy");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AuthService::generateClientToken()
|
||||||
|
{
|
||||||
|
return QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::postJson(
|
||||||
|
const QString &endpoint,
|
||||||
|
const QJsonObject &body,
|
||||||
|
std::function<void(int, const QJsonObject &, const QString &)> handler)
|
||||||
|
{
|
||||||
|
QNetworkRequest request{QUrl(kAuthServer + endpoint)};
|
||||||
|
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||||
|
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
|
||||||
|
QNetworkReply *reply = m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||||
|
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
||||||
|
reply->deleteLater();
|
||||||
|
|
||||||
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
const QByteArray payload = reply->readAll();
|
||||||
|
|
||||||
|
// Сетевую ошибку отличаем от ответа сервера: при 401 статус есть, а error — есть всегда.
|
||||||
|
QString transportError;
|
||||||
|
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
||||||
|
transportError = reply->errorString();
|
||||||
|
|
||||||
|
handler(status, QJsonDocument::fromJson(payload).object(), transportError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::loginElyBy(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
const QString &accessToken,
|
||||||
|
Callback callback)
|
||||||
|
{
|
||||||
|
if (login.isEmpty()) {
|
||||||
|
AuthResult result;
|
||||||
|
result.error = Loc::text("auth.ely.error.noLogin");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString token = clientToken.isEmpty() ? generateClientToken() : clientToken;
|
||||||
|
|
||||||
|
if (accessToken.isEmpty()) {
|
||||||
|
authenticate(login, password, token, std::move(callback));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("auth.ely.progress.checkingToken"));
|
||||||
|
postJson(QStringLiteral("/auth/validate"),
|
||||||
|
{{QStringLiteral("accessToken"), accessToken}},
|
||||||
|
[this, login, password, token, accessToken, callback](
|
||||||
|
int status, const QJsonObject &, const QString &transportError) {
|
||||||
|
if (!transportError.isEmpty()) {
|
||||||
|
AuthResult result;
|
||||||
|
result.error = Loc::text("auth.ely.error.unreachable").arg(transportError);
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == 200) {
|
||||||
|
AuthResult result;
|
||||||
|
result.ok = true;
|
||||||
|
result.accessToken = accessToken;
|
||||||
|
result.clientToken = token;
|
||||||
|
result.userType = QStringLiteral("ELYBY");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Токен протух — пробуем продлить его, не спрашивая пароль.
|
||||||
|
emit progress(Loc::text("auth.ely.progress.refreshingToken"));
|
||||||
|
postJson(QStringLiteral("/auth/refresh"),
|
||||||
|
{{QStringLiteral("accessToken"), accessToken},
|
||||||
|
{QStringLiteral("clientToken"), token},
|
||||||
|
{QStringLiteral("requestUser"), true}},
|
||||||
|
[this, login, password, token, callback](
|
||||||
|
int refreshStatus, const QJsonObject &response, const QString &error) {
|
||||||
|
if (error.isEmpty() && refreshStatus == 200) {
|
||||||
|
AuthResult result;
|
||||||
|
result.ok = true;
|
||||||
|
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
||||||
|
result.clientToken = token;
|
||||||
|
const QJsonObject profile =
|
||||||
|
response.value(QStringLiteral("selectedProfile")).toObject();
|
||||||
|
result.playerName = profile.value(QStringLiteral("name")).toString();
|
||||||
|
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
||||||
|
result.userType = QStringLiteral("ELYBY");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authenticate(login, password, token, callback);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::loginElyByWithTotp(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &totp,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback)
|
||||||
|
{
|
||||||
|
authenticate(login,
|
||||||
|
password + u':' + totp,
|
||||||
|
clientToken.isEmpty() ? generateClientToken() : clientToken,
|
||||||
|
std::move(callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::authenticate(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback)
|
||||||
|
{
|
||||||
|
if (password.isEmpty()) {
|
||||||
|
AuthResult result;
|
||||||
|
result.error = Loc::text("auth.ely.error.noPassword");
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("auth.ely.progress.signingIn"));
|
||||||
|
postJson(QStringLiteral("/auth/authenticate"),
|
||||||
|
{{QStringLiteral("username"), login},
|
||||||
|
{QStringLiteral("password"), password},
|
||||||
|
{QStringLiteral("clientToken"), clientToken},
|
||||||
|
{QStringLiteral("requestUser"), true}},
|
||||||
|
[clientToken, callback](int status, const QJsonObject &response, const QString &transportError) {
|
||||||
|
AuthResult result;
|
||||||
|
result.clientToken = clientToken;
|
||||||
|
|
||||||
|
if (!transportError.isEmpty()) {
|
||||||
|
result.error = Loc::text("auth.ely.error.unreachable").arg(transportError);
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != 200) {
|
||||||
|
const QString message = describeElyError(response, status);
|
||||||
|
result.twoFactorRequired = message.contains(QStringLiteral("two factor"),
|
||||||
|
Qt::CaseInsensitive);
|
||||||
|
result.error = result.twoFactorRequired
|
||||||
|
? Loc::text("auth.ely.error.twoFactor")
|
||||||
|
: message;
|
||||||
|
callback(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject profile = response.value(QStringLiteral("selectedProfile")).toObject();
|
||||||
|
result.playerName = profile.value(QStringLiteral("name")).toString();
|
||||||
|
result.uuid = normalizeUuid(profile.value(QStringLiteral("id")).toString());
|
||||||
|
result.accessToken = response.value(QStringLiteral("accessToken")).toString();
|
||||||
|
result.userType = QStringLiteral("ELYBY");
|
||||||
|
result.ok = !result.accessToken.isEmpty() && !result.playerName.isEmpty();
|
||||||
|
if (!result.ok)
|
||||||
|
result.error = Loc::text("auth.ely.error.noGameProfile");
|
||||||
|
callback(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthService::ensureAuthlibInjector(
|
||||||
|
const QString &targetDir,
|
||||||
|
std::function<void(const QString &, const QString &)> callback)
|
||||||
|
{
|
||||||
|
QDir().mkpath(targetDir);
|
||||||
|
const QString jarPath = targetDir + u'/' + kInjectorFileName;
|
||||||
|
if (QFileInfo::exists(jarPath)) {
|
||||||
|
callback(jarPath, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("auth.ely.progress.downloadingInjector"));
|
||||||
|
QNetworkRequest manifestRequest{QUrl(kInjectorManifest)};
|
||||||
|
manifestRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
|
||||||
|
QNetworkReply *manifestReply = m_network->get(manifestRequest);
|
||||||
|
connect(manifestReply, &QNetworkReply::finished, this, [this, manifestReply, jarPath, callback]() {
|
||||||
|
manifestReply->deleteLater();
|
||||||
|
if (manifestReply->error() != QNetworkReply::NoError) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorInfoFailed")
|
||||||
|
.arg(manifestReply->errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject manifest = QJsonDocument::fromJson(manifestReply->readAll()).object();
|
||||||
|
const QString url = manifest.value(QStringLiteral("download_url")).toString();
|
||||||
|
const QString expectedSha256 = manifest.value(QStringLiteral("checksums"))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("sha256"))
|
||||||
|
.toString();
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorNoUrl"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QNetworkRequest jarRequest{QUrl(url)};
|
||||||
|
jarRequest.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||||
|
QNetworkReply *jarReply = m_network->get(jarRequest);
|
||||||
|
connect(jarReply, &QNetworkReply::finished, this, [jarReply, jarPath, expectedSha256, callback]() {
|
||||||
|
jarReply->deleteLater();
|
||||||
|
if (jarReply->error() != QNetworkReply::NoError) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorDownloadFailed").arg(jarReply->errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray payload = jarReply->readAll();
|
||||||
|
if (!expectedSha256.isEmpty()) {
|
||||||
|
const QString actual = QString::fromLatin1(
|
||||||
|
QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex());
|
||||||
|
if (actual != expectedSha256) {
|
||||||
|
callback({}, Loc::text("auth.ely.error.injectorChecksum"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(jarPath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
|
||||||
|
callback({}, Loc::text("common.error.saveFailed").arg(QDir::toNativeSeparators(jarPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
callback(jarPath, {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class QNetworkAccessManager;
|
||||||
|
|
||||||
|
struct AuthResult
|
||||||
|
{
|
||||||
|
bool ok = false;
|
||||||
|
bool twoFactorRequired = false;
|
||||||
|
bool licenseMissing = false;
|
||||||
|
QString error;
|
||||||
|
|
||||||
|
QString playerName; // ${auth_player_name}
|
||||||
|
QString uuid; // ${auth_uuid}, hex без дефисов
|
||||||
|
QString accessToken; // ${auth_access_token}
|
||||||
|
QString clientToken; // ${clientid}
|
||||||
|
QString userType; // ${user_type}: legacy / msa / ELYBY
|
||||||
|
|
||||||
|
// Только для аккаунтов Microsoft.
|
||||||
|
QString refreshToken; // продлевает сессию без ввода пароля
|
||||||
|
QString xuid; // ${auth_xuid}
|
||||||
|
QDateTime expiresAt; // UTC-время, когда протухает accessToken
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthService : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
using Callback = std::function<void(const AuthResult &)>;
|
||||||
|
|
||||||
|
explicit AuthService(QObject *parent = nullptr);
|
||||||
|
static AuthResult offline(const QString &nickname);
|
||||||
|
static QString generateClientToken();
|
||||||
|
|
||||||
|
void loginElyBy(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
const QString &accessToken,
|
||||||
|
Callback callback);
|
||||||
|
|
||||||
|
void loginElyByWithTotp(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &totp,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback);
|
||||||
|
|
||||||
|
void ensureAuthlibInjector(const QString &targetDir,
|
||||||
|
std::function<void(const QString &path, const QString &error)> callback);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(const QString &message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void postJson(const QString &endpoint,
|
||||||
|
const QJsonObject &body,
|
||||||
|
std::function<void(int status, const QJsonObject &response, const QString &transportError)> handler);
|
||||||
|
|
||||||
|
void authenticate(const QString &login,
|
||||||
|
const QString &password,
|
||||||
|
const QString &clientToken,
|
||||||
|
Callback callback);
|
||||||
|
|
||||||
|
QNetworkAccessManager *m_network;
|
||||||
|
};
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
#include "buildarchiveworker.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QtCore/private/qzipreader_p.h>
|
||||||
|
#include <QtCore/private/qzipwriter_p.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
bool isSafeRelativePath(const QString &path)
|
||||||
|
{
|
||||||
|
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
|
||||||
|
return false;
|
||||||
|
const QStringList parts = path.split(u'/');
|
||||||
|
for (const QString &part : parts) {
|
||||||
|
if (part == QLatin1String(".") || part == QLatin1String(".."))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool isInside(const QDir &root, const QString &absolutePath)
|
||||||
|
{
|
||||||
|
const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
|
||||||
|
return QDir::cleanPath(absolutePath).startsWith(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString topSegment(const QString &path)
|
||||||
|
{
|
||||||
|
const int slash = path.indexOf(u'/');
|
||||||
|
return slash < 0 ? path : path.left(slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pruneEmptyDirs(const QDir &root, QStringList dirs)
|
||||||
|
{
|
||||||
|
std::sort(dirs.begin(), dirs.end(),
|
||||||
|
[](const QString &a, const QString &b) { return a.size() > b.size(); });
|
||||||
|
for (const QString &relative : std::as_const(dirs)) {
|
||||||
|
if (relative.isEmpty() || relative == QLatin1String("."))
|
||||||
|
continue;
|
||||||
|
QDir dir(root.absoluteFilePath(relative));
|
||||||
|
if (dir.exists() && dir.isEmpty())
|
||||||
|
root.rmdir(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
const QDir root(gameDir);
|
||||||
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||||
|
| QDir::Hidden | QDir::System);
|
||||||
|
for (const QFileInfo &entry : top) {
|
||||||
|
if (excludeTop.contains(entry.fileName()))
|
||||||
|
continue;
|
||||||
|
if (entry.isSymLink())
|
||||||
|
continue;
|
||||||
|
if (entry.isFile()) {
|
||||||
|
result << entry.fileName();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isDir())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QDirIterator it(entry.absoluteFilePath(),
|
||||||
|
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||||
|
QDirIterator::Subdirectories);
|
||||||
|
while (it.hasNext())
|
||||||
|
result << root.relativeFilePath(it.next());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::archive(const QString &gameDir,
|
||||||
|
const QString &tempZipPath,
|
||||||
|
const QStringList &excludeTop)
|
||||||
|
{
|
||||||
|
const QDir root(gameDir);
|
||||||
|
if (!root.exists()) {
|
||||||
|
emit finished(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(gameDir)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList files = collectFiles(gameDir, excludeTop);
|
||||||
|
emit progress(0, files.size(), {});
|
||||||
|
|
||||||
|
{
|
||||||
|
QZipWriter writer(tempZipPath);
|
||||||
|
if (writer.status() != QZipWriter::NoError) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveCreateFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(tempZipPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
||||||
|
|
||||||
|
int done = 0;
|
||||||
|
for (const QString &relative : files) {
|
||||||
|
if (canceled()) {
|
||||||
|
writer.close();
|
||||||
|
QFile::remove(tempZipPath);
|
||||||
|
emit finished(false, Loc::text("switch.status.archivingCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(root.absoluteFilePath(relative));
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
writer.close();
|
||||||
|
QFile::remove(tempZipPath);
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.readFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer.addFile(relative, &file);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (writer.status() != QZipWriter::NoError) {
|
||||||
|
writer.close();
|
||||||
|
QFile::remove(tempZipPath);
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveWriteFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(relative)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit progress(++done, files.size(), relative);
|
||||||
|
}
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
|
||||||
|
{
|
||||||
|
QDir root(gameDir);
|
||||||
|
if (!root.exists()) {
|
||||||
|
emit finished(true, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFileInfoList victims;
|
||||||
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||||
|
| QDir::Hidden | QDir::System);
|
||||||
|
for (const QFileInfo &entry : top) {
|
||||||
|
if (!keepTop.contains(entry.fileName()))
|
||||||
|
victims << entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(0, victims.size(), {});
|
||||||
|
int done = 0;
|
||||||
|
for (const QFileInfo &entry : victims) {
|
||||||
|
const bool ok = entry.isDir() && !entry.isSymLink()
|
||||||
|
? QDir(entry.absoluteFilePath()).removeRecursively()
|
||||||
|
: QFile::remove(entry.absoluteFilePath());
|
||||||
|
if (!ok) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.removeFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit progress(++done, victims.size(), entry.fileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
|
||||||
|
{
|
||||||
|
QZipReader reader(zipPath);
|
||||||
|
if (!reader.isReadable()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||||
|
emit progress(0, entries.size(), {});
|
||||||
|
|
||||||
|
const QDir root(gameDir);
|
||||||
|
if (!QDir().mkpath(gameDir)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int done = 0;
|
||||||
|
for (const QZipReader::FileInfo &entry : entries) {
|
||||||
|
if (canceled()) {
|
||||||
|
emit finished(false, Loc::text("switch.status.extractCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry.isFile) {
|
||||||
|
emit progress(++done, entries.size(), entry.filePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isSafeRelativePath(entry.filePath)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveBadPath").arg(entry.filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString target = root.absoluteFilePath(entry.filePath);
|
||||||
|
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.createFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(target);
|
||||||
|
const QByteArray data = reader.fileData(entry.filePath);
|
||||||
|
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.writeFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
emit progress(++done, entries.size(), entry.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildArchiveWorker::applyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QStringList &forbiddenTop)
|
||||||
|
{
|
||||||
|
QZipReader reader(zipPath);
|
||||||
|
if (!reader.isReadable()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||||
|
QStringList applied;
|
||||||
|
applied.reserve(entries.size());
|
||||||
|
for (const QZipReader::FileInfo &entry : entries) {
|
||||||
|
if (!entry.isFile)
|
||||||
|
continue;
|
||||||
|
if (!isSafeRelativePath(entry.filePath)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.packBadPath").arg(entry.filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (forbiddenTop.contains(topSegment(entry.filePath))) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.packTouchesLauncher")
|
||||||
|
.arg(entry.filePath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applied << entry.filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir root(gameDir);
|
||||||
|
if (!QDir().mkpath(gameDir)) {
|
||||||
|
emit finished(false, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int total = removeRelative.size() + applied.size();
|
||||||
|
emit progress(0, total, {});
|
||||||
|
int done = 0;
|
||||||
|
QStringList touchedDirs;
|
||||||
|
for (const QString &relative : removeRelative) {
|
||||||
|
if (canceled()) {
|
||||||
|
emit finished(false, Loc::text("switch.status.installCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString target = root.absoluteFilePath(relative);
|
||||||
|
if (!isInside(root, target)) {
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (QFileInfo::exists(target) && !QFile::remove(target)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.removeFailed").arg(QDir::toNativeSeparators(target)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString parent = QFileInfo(relative).path();
|
||||||
|
if (!touchedDirs.contains(parent))
|
||||||
|
touchedDirs << parent;
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
}
|
||||||
|
pruneEmptyDirs(root, touchedDirs);
|
||||||
|
for (const QString &relative : std::as_const(applied)) {
|
||||||
|
if (canceled()) {
|
||||||
|
emit finished(false, Loc::text("switch.status.installCancelled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString target = root.absoluteFilePath(relative);
|
||||||
|
if (!isInside(root, target)) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.packEscapesGameDir")
|
||||||
|
.arg(relative));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("common.error.createFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(target);
|
||||||
|
const QByteArray data = reader.fileData(relative);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|
||||||
|
|| file.write(data) != data.size()) {
|
||||||
|
emit finished(false,
|
||||||
|
Loc::text("switch.error.writeFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(relative), file.errorString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
emit progress(++done, total, relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit packEntries(applied);
|
||||||
|
emit finished(true, {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAtomicInt>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class BuildArchiveWorker : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit BuildArchiveWorker(QObject *parent = nullptr);
|
||||||
|
void requestCancel() { m_cancel.storeRelaxed(1); }
|
||||||
|
void clearCancel() { m_cancel.storeRelaxed(0); }
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop);
|
||||||
|
|
||||||
|
void clear(const QString &gameDir, const QStringList &keepTop);
|
||||||
|
|
||||||
|
void restore(const QString &zipPath, const QString &gameDir);
|
||||||
|
|
||||||
|
void applyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QStringList &forbiddenTop);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(int done, int total, const QString ¤tPath);
|
||||||
|
void finished(bool ok, const QString &error);
|
||||||
|
void packEntries(const QStringList &entries);
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool canceled() const { return m_cancel.loadRelaxed() != 0; }
|
||||||
|
|
||||||
|
QAtomicInt m_cancel;
|
||||||
|
};
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
#include "buildswitcher.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include "buildarchiveworker.h"
|
||||||
|
#include "launcherpaths.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDirIterator>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QStorageInfo>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QString kIndexFile = QStringLiteral("index.json");
|
||||||
|
const QString kStateSwitching = QStringLiteral("switching");
|
||||||
|
const QString kStateApplying = QStringLiteral("applying");
|
||||||
|
const QString kStateIdle = QStringLiteral("idle");
|
||||||
|
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;
|
||||||
|
|
||||||
|
QString slug(const QString &name)
|
||||||
|
{
|
||||||
|
QString result;
|
||||||
|
result.reserve(name.size());
|
||||||
|
for (const QChar c : name) {
|
||||||
|
if (c.isLetterOrNumber() || c == u'-' || c == u'_')
|
||||||
|
result.append(c);
|
||||||
|
else if (c.isSpace() || c == u'.')
|
||||||
|
result.append(u'_');
|
||||||
|
}
|
||||||
|
while (result.startsWith(u'_'))
|
||||||
|
result.remove(0, 1);
|
||||||
|
return result.isEmpty() ? QStringLiteral("build") : result.left(48);
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 directorySize(const QString &path, const QStringList &excludeTop)
|
||||||
|
{
|
||||||
|
qint64 total = 0;
|
||||||
|
const QDir root(path);
|
||||||
|
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
|
||||||
|
| QDir::Hidden | QDir::System);
|
||||||
|
for (const QFileInfo &entry : top) {
|
||||||
|
if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
|
||||||
|
continue;
|
||||||
|
if (entry.isFile()) {
|
||||||
|
total += entry.size();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QDirIterator it(entry.absoluteFilePath(),
|
||||||
|
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
|
||||||
|
QDirIterator::Subdirectories);
|
||||||
|
while (it.hasNext()) {
|
||||||
|
it.next();
|
||||||
|
total += it.fileInfo().size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList BuildSwitcher::sharedTopLevel()
|
||||||
|
{
|
||||||
|
return {QStringLiteral("assets"),
|
||||||
|
QStringLiteral("libraries"),
|
||||||
|
QStringLiteral("versions"),
|
||||||
|
QStringLiteral("launcher_profiles.json"),
|
||||||
|
QStringLiteral("runtime")};
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildSwitcher::BuildSwitcher(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_thread(new QThread(this))
|
||||||
|
, m_worker(new BuildArchiveWorker)
|
||||||
|
{
|
||||||
|
m_worker->moveToThread(m_thread);
|
||||||
|
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
|
||||||
|
connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
|
||||||
|
connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
|
||||||
|
connect(m_worker, &BuildArchiveWorker::packEntries, this,
|
||||||
|
[this](const QStringList &entries) { m_lastPackEntries = entries; });
|
||||||
|
m_thread->start();
|
||||||
|
|
||||||
|
sweepTempArchives();
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildSwitcher::~BuildSwitcher()
|
||||||
|
{
|
||||||
|
m_worker->requestCancel();
|
||||||
|
m_thread->quit();
|
||||||
|
m_thread->wait(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
double BuildSwitcher::fraction() const
|
||||||
|
{
|
||||||
|
if (m_state == Idle || m_total <= 0)
|
||||||
|
return -1.0;
|
||||||
|
return double(m_done) / double(m_total);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BuildSwitcher::buildDir(int buildId) const
|
||||||
|
{
|
||||||
|
return LauncherPaths::buildDir(buildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BuildSwitcher::archivePathOf(int buildId) const
|
||||||
|
{
|
||||||
|
const QDir dir(buildDir(buildId));
|
||||||
|
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
|
||||||
|
for (const QString &name : zips) {
|
||||||
|
if (!name.startsWith(u'.'))
|
||||||
|
return dir.absoluteFilePath(name);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::setStage(const QString &stage)
|
||||||
|
{
|
||||||
|
m_stage = stage;
|
||||||
|
emit progressChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::switchTo(int fromBuildId,
|
||||||
|
const QString &fromName,
|
||||||
|
int toBuildId,
|
||||||
|
const QString &toName,
|
||||||
|
const QString &gameDir)
|
||||||
|
{
|
||||||
|
if (m_state != Idle) {
|
||||||
|
emit failed(toBuildId, Loc::text("common.error.switchInProgress"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString rootError;
|
||||||
|
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||||
|
emit failed(toBuildId, rootError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_fromId = fromBuildId;
|
||||||
|
m_toId = toBuildId;
|
||||||
|
m_fromName = fromName;
|
||||||
|
m_toName = toName;
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_done = 0;
|
||||||
|
m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
|
||||||
|
if (fromBuildId < 0 || !QDir(gameDir).exists()) {
|
||||||
|
m_state = Clearing;
|
||||||
|
beginClearing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QDir().mkpath(buildDir(fromBuildId))) {
|
||||||
|
emit failed(toBuildId,
|
||||||
|
Loc::text("common.error.createFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
|
||||||
|
const QStorageInfo storage(LauncherPaths::buildStorageDir());
|
||||||
|
if (storage.isValid() && storage.bytesAvailable() < needed) {
|
||||||
|
emit failed(toBuildId,
|
||||||
|
Loc::text("switch.error.notEnoughSpace")
|
||||||
|
.arg(fromName)
|
||||||
|
.arg(needed / (1024 * 1024)),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
|
||||||
|
+ QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");
|
||||||
|
|
||||||
|
m_state = Archiving;
|
||||||
|
setStage(Loc::text("switch.progress.saving").arg(fromName));
|
||||||
|
requestArchive(gameDir, m_tempZip, sharedTopLevel());
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::cancel()
|
||||||
|
{
|
||||||
|
if (m_state == Archiving)
|
||||||
|
m_worker->requestCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestArchive(const QString &gameDir,
|
||||||
|
const QString &tempZip,
|
||||||
|
const QStringList &exclude)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
|
||||||
|
Q_ARG(QStringList, exclude));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::requestApplyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &remove)
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(m_worker, "applyPack", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, zipPath), Q_ARG(QString, gameDir),
|
||||||
|
Q_ARG(QStringList, remove), Q_ARG(QStringList, sharedTopLevel()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::onWorkerProgress(int done, int total, const QString ¤tPath)
|
||||||
|
{
|
||||||
|
m_done = done;
|
||||||
|
m_total = total;
|
||||||
|
m_status = currentPath;
|
||||||
|
emit progressChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
|
||||||
|
{
|
||||||
|
switch (m_state) {
|
||||||
|
case Idle:
|
||||||
|
return;
|
||||||
|
case Archiving:
|
||||||
|
if (!ok) {
|
||||||
|
QFile::remove(m_tempZip);
|
||||||
|
fail(error, /*gameDirIntact=*/true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitArchive();
|
||||||
|
return;
|
||||||
|
case Clearing:
|
||||||
|
if (!ok) {
|
||||||
|
fail(error, /*gameDirIntact=*/false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
beginRestoring();
|
||||||
|
return;
|
||||||
|
case Restoring:
|
||||||
|
if (!ok) {
|
||||||
|
fail(error, /*gameDirIntact=*/false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
case Applying:
|
||||||
|
if (!ok) {
|
||||||
|
fail(error, /*gameDirIntact=*/m_done == 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishApplying();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::commitArchive()
|
||||||
|
{
|
||||||
|
const QString target = buildDir(m_fromId) + u'/'
|
||||||
|
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
|
||||||
|
+ u'_' + slug(m_fromName) + QStringLiteral(".zip");
|
||||||
|
|
||||||
|
if (QFile::exists(target) && !QFile::remove(target)) {
|
||||||
|
QFile::remove(m_tempZip);
|
||||||
|
fail(Loc::text("switch.error.archiveReplaceFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!QFile::rename(m_tempZip, target)) {
|
||||||
|
QFile::remove(m_tempZip);
|
||||||
|
fail(Loc::text("switch.error.archiveSaveFailed").arg(QDir::toNativeSeparators(target)), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir dir(buildDir(m_fromId));
|
||||||
|
const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
|
||||||
|
for (const QString &name : zips) {
|
||||||
|
if (!name.startsWith(u'.') && dir.absoluteFilePath(name) != target)
|
||||||
|
QFile::remove(dir.absoluteFilePath(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateSwitching);
|
||||||
|
index.insert(QStringLiteral("from"), m_fromId);
|
||||||
|
index.insert(QStringLiteral("to"), m_toId);
|
||||||
|
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||||
|
builds.insert(QString::number(m_fromId),
|
||||||
|
QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
|
||||||
|
{QStringLiteral("name"), m_fromName},
|
||||||
|
{QStringLiteral("savedAt"),
|
||||||
|
QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
|
||||||
|
index.insert(QStringLiteral("builds"), builds);
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
m_state = Clearing;
|
||||||
|
beginClearing();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::beginClearing()
|
||||||
|
{
|
||||||
|
setStage(Loc::text("switch.progress.preparing").arg(m_toName));
|
||||||
|
requestClear(m_gameDir, sharedTopLevel());
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::beginRestoring()
|
||||||
|
{
|
||||||
|
const QString archive = archivePathOf(m_toId);
|
||||||
|
if (archive.isEmpty()) {
|
||||||
|
m_state = Restoring;
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_state = Restoring;
|
||||||
|
setStage(Loc::text("switch.progress.restoring").arg(m_toName));
|
||||||
|
requestRestore(archive, m_gameDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::applyPack(int buildId,
|
||||||
|
const QString &buildName,
|
||||||
|
const QString &packZipPath,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QJsonObject ¬e,
|
||||||
|
const QString &gameDir)
|
||||||
|
{
|
||||||
|
if (m_state != Idle) {
|
||||||
|
emit failed(buildId, Loc::text("switch.error.busy"), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString rootError;
|
||||||
|
if (!LauncherPaths::ensureRootExists(&rootError)) {
|
||||||
|
emit failed(buildId, rootError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!QFile::exists(packZipPath)) {
|
||||||
|
emit failed(buildId,
|
||||||
|
Loc::text("switch.error.archiveMissing")
|
||||||
|
.arg(QDir::toNativeSeparators(packZipPath)),
|
||||||
|
true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_fromId = -1;
|
||||||
|
m_toId = buildId;
|
||||||
|
m_toName = buildName;
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_packPath = packZipPath;
|
||||||
|
m_packRemove = removeRelative;
|
||||||
|
m_packNote = note;
|
||||||
|
m_lastPackEntries.clear();
|
||||||
|
m_lastPackNote = {};
|
||||||
|
m_done = 0;
|
||||||
|
m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateApplying);
|
||||||
|
index.insert(QStringLiteral("to"), buildId);
|
||||||
|
index.insert(QStringLiteral("pack"), QFileInfo(packZipPath).fileName());
|
||||||
|
index.insert(QStringLiteral("packName"), buildName);
|
||||||
|
index.insert(QStringLiteral("packRemove"), QJsonArray::fromStringList(removeRelative));
|
||||||
|
index.insert(QStringLiteral("packNote"), note);
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
m_state = Applying;
|
||||||
|
beginApplying();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::beginApplying()
|
||||||
|
{
|
||||||
|
setStage(Loc::text("switch.progress.installing").arg(m_toName));
|
||||||
|
requestApplyPack(m_packPath, m_gameDir, m_packRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::finishApplying()
|
||||||
|
{
|
||||||
|
QFile::remove(m_packPath);
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateIdle);
|
||||||
|
index.insert(QStringLiteral("active"), m_toId);
|
||||||
|
index.remove(QStringLiteral("from"));
|
||||||
|
index.remove(QStringLiteral("to"));
|
||||||
|
index.remove(QStringLiteral("pack"));
|
||||||
|
index.remove(QStringLiteral("packName"));
|
||||||
|
index.remove(QStringLiteral("packRemove"));
|
||||||
|
index.remove(QStringLiteral("packNote"));
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
m_lastPackNote = m_packNote;
|
||||||
|
m_packPath.clear();
|
||||||
|
m_packRemove.clear();
|
||||||
|
m_packNote = {};
|
||||||
|
|
||||||
|
const int toId = m_toId;
|
||||||
|
m_state = Idle;
|
||||||
|
m_stage.clear();
|
||||||
|
m_status.clear();
|
||||||
|
m_done = m_total = 0;
|
||||||
|
emit progressChanged();
|
||||||
|
emit finished(toId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::finish()
|
||||||
|
{
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
index.insert(QStringLiteral("state"), kStateIdle);
|
||||||
|
index.insert(QStringLiteral("active"), m_toId);
|
||||||
|
index.remove(QStringLiteral("from"));
|
||||||
|
index.remove(QStringLiteral("to"));
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
const int toId = m_toId;
|
||||||
|
m_state = Idle;
|
||||||
|
m_stage.clear();
|
||||||
|
m_status.clear();
|
||||||
|
m_done = m_total = 0;
|
||||||
|
emit progressChanged();
|
||||||
|
emit finished(toId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
|
||||||
|
{
|
||||||
|
const int toId = m_toId;
|
||||||
|
m_state = Idle;
|
||||||
|
m_stage.clear();
|
||||||
|
m_status.clear();
|
||||||
|
m_done = m_total = 0;
|
||||||
|
emit progressChanged();
|
||||||
|
emit failed(toId, message, gameDirIntact);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildSwitcher::forgetBuild(int buildId)
|
||||||
|
{
|
||||||
|
if (buildId < 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
QJsonObject index = readIndex();
|
||||||
|
QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||||
|
builds.remove(QString::number(buildId));
|
||||||
|
index.insert(QStringLiteral("builds"), builds);
|
||||||
|
if (index.value(QStringLiteral("active")).toInt(-1) == buildId)
|
||||||
|
index.remove(QStringLiteral("active"));
|
||||||
|
writeIndex(index);
|
||||||
|
|
||||||
|
QDir dir(buildDir(buildId));
|
||||||
|
return !dir.exists() || dir.removeRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BuildSwitcher::interruptedSwitchWarning() const
|
||||||
|
{
|
||||||
|
const QJsonObject index = readIndex();
|
||||||
|
const QString state = index.value(QStringLiteral("state")).toString();
|
||||||
|
|
||||||
|
if (state == kStateApplying) {
|
||||||
|
const QString name = index.value(QStringLiteral("packName")).toString();
|
||||||
|
return Loc::text("switch.status.applyInterrupted")
|
||||||
|
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.nameSuffix").arg(name));
|
||||||
|
}
|
||||||
|
if (state != kStateSwitching)
|
||||||
|
return {};
|
||||||
|
|
||||||
|
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||||
|
const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
|
||||||
|
const QString name = builds.value(QString::number(toId))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("name"))
|
||||||
|
.toString();
|
||||||
|
return Loc::text("switch.status.switchInterrupted")
|
||||||
|
.arg(name.isEmpty() ? QString() : Loc::text("switch.status.targetSuffix").arg(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::resumeInterrupted(const QString &gameDir)
|
||||||
|
{
|
||||||
|
const QJsonObject index = readIndex();
|
||||||
|
const QString state = index.value(QStringLiteral("state")).toString();
|
||||||
|
|
||||||
|
if (state == kStateApplying) {
|
||||||
|
resumeApplying(index, gameDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state != kStateSwitching)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||||
|
if (toId < 0 || m_state != Idle)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_fromId = -1;
|
||||||
|
m_toId = toId;
|
||||||
|
m_toName = index.value(QStringLiteral("builds"))
|
||||||
|
.toObject()
|
||||||
|
.value(QString::number(toId))
|
||||||
|
.toObject()
|
||||||
|
.value(QStringLiteral("name"))
|
||||||
|
.toString();
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_done = m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
m_state = Clearing;
|
||||||
|
beginClearing();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::resumeApplying(const QJsonObject &index, const QString &gameDir)
|
||||||
|
{
|
||||||
|
const int toId = index.value(QStringLiteral("to")).toInt(-1);
|
||||||
|
if (toId < 0 || m_state != Idle)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const QString packName = index.value(QStringLiteral("pack")).toString();
|
||||||
|
const QString packPath = buildDir(toId) + u'/' + packName;
|
||||||
|
if (packName.isEmpty() || packName.contains(u'/') || !QFile::exists(packPath)) {
|
||||||
|
QJsonObject cleaned = index;
|
||||||
|
cleaned.insert(QStringLiteral("state"), kStateIdle);
|
||||||
|
cleaned.remove(QStringLiteral("pack"));
|
||||||
|
cleaned.remove(QStringLiteral("packName"));
|
||||||
|
cleaned.remove(QStringLiteral("packRemove"));
|
||||||
|
cleaned.remove(QStringLiteral("packNote"));
|
||||||
|
cleaned.remove(QStringLiteral("to"));
|
||||||
|
writeIndex(cleaned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList remove;
|
||||||
|
const QJsonArray removeArray = index.value(QStringLiteral("packRemove")).toArray();
|
||||||
|
for (const QJsonValue &value : removeArray)
|
||||||
|
remove << value.toString();
|
||||||
|
|
||||||
|
m_fromId = -1;
|
||||||
|
m_toId = toId;
|
||||||
|
m_toName = index.value(QStringLiteral("packName")).toString();
|
||||||
|
m_gameDir = gameDir;
|
||||||
|
m_packPath = packPath;
|
||||||
|
m_packRemove = remove;
|
||||||
|
m_packNote = index.value(QStringLiteral("packNote")).toObject();
|
||||||
|
m_lastPackEntries.clear();
|
||||||
|
m_lastPackNote = {};
|
||||||
|
m_done = m_total = 0;
|
||||||
|
m_worker->clearCancel();
|
||||||
|
m_state = Applying;
|
||||||
|
beginApplying();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject BuildSwitcher::readIndex() const
|
||||||
|
{
|
||||||
|
QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return {};
|
||||||
|
return QJsonDocument::fromJson(file.readAll()).object();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::writeIndex(const QJsonObject &index) const
|
||||||
|
{
|
||||||
|
if (!LauncherPaths::ensureRootExists())
|
||||||
|
return;
|
||||||
|
QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
|
||||||
|
if (!file.open(QIODevice::WriteOnly))
|
||||||
|
return;
|
||||||
|
const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
|
||||||
|
if (file.write(payload) != payload.size()) {
|
||||||
|
file.cancelWriting();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildSwitcher::sweepTempArchives() const
|
||||||
|
{
|
||||||
|
QDir root(LauncherPaths::buildStorageDir());
|
||||||
|
const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||||
|
for (const QString &build : builds) {
|
||||||
|
QDir dir(root.absoluteFilePath(build));
|
||||||
|
const QStringList leftovers =
|
||||||
|
dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
|
||||||
|
for (const QString &name : leftovers)
|
||||||
|
QFile::remove(dir.absoluteFilePath(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QThread;
|
||||||
|
class BuildArchiveWorker;
|
||||||
|
class BuildSwitcher : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit BuildSwitcher(QObject *parent = nullptr);
|
||||||
|
~BuildSwitcher() override;
|
||||||
|
|
||||||
|
bool isRunning() const { return m_state != Idle; }
|
||||||
|
QString stage() const { return m_stage; }
|
||||||
|
QString status() const { return m_status; }
|
||||||
|
double fraction() const;
|
||||||
|
static QStringList sharedTopLevel();
|
||||||
|
|
||||||
|
void switchTo(int fromBuildId,
|
||||||
|
const QString &fromName,
|
||||||
|
int toBuildId,
|
||||||
|
const QString &toName,
|
||||||
|
const QString &gameDir);
|
||||||
|
|
||||||
|
void applyPack(int buildId,
|
||||||
|
const QString &buildName,
|
||||||
|
const QString &packZipPath,
|
||||||
|
const QStringList &removeRelative,
|
||||||
|
const QJsonObject ¬e,
|
||||||
|
const QString &gameDir);
|
||||||
|
|
||||||
|
QStringList lastPackEntries() const { return m_lastPackEntries; }
|
||||||
|
QJsonObject lastPackNote() const { return m_lastPackNote; }
|
||||||
|
|
||||||
|
void cancel();
|
||||||
|
bool forgetBuild(int buildId);
|
||||||
|
bool hasArchive(int buildId) const { return !archivePathOf(buildId).isEmpty(); }
|
||||||
|
QString interruptedSwitchWarning() const;
|
||||||
|
void resumeInterrupted(const QString &gameDir);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progressChanged();
|
||||||
|
void finished(int toBuildId);
|
||||||
|
void failed(int toBuildId, const QString &message, bool gameDirIntact);
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum State { Idle, Archiving, Clearing, Restoring, Applying };
|
||||||
|
void requestArchive(const QString &gameDir, const QString &tempZip, const QStringList &exclude);
|
||||||
|
void requestClear(const QString &gameDir, const QStringList &keep);
|
||||||
|
void requestRestore(const QString &zipPath, const QString &gameDir);
|
||||||
|
void requestApplyPack(const QString &zipPath,
|
||||||
|
const QString &gameDir,
|
||||||
|
const QStringList &remove);
|
||||||
|
|
||||||
|
void onWorkerFinished(bool ok, const QString &error);
|
||||||
|
void onWorkerProgress(int done, int total, const QString ¤tPath);
|
||||||
|
|
||||||
|
void commitArchive();
|
||||||
|
void beginApplying();
|
||||||
|
void finishApplying();
|
||||||
|
void resumeApplying(const QJsonObject &index, const QString &gameDir);
|
||||||
|
void beginClearing();
|
||||||
|
void beginRestoring();
|
||||||
|
void finish();
|
||||||
|
void fail(const QString &message, bool gameDirIntact);
|
||||||
|
|
||||||
|
QString buildDir(int buildId) const;
|
||||||
|
QString archivePathOf(int buildId) const;
|
||||||
|
void setStage(const QString &stage);
|
||||||
|
|
||||||
|
QJsonObject readIndex() const;
|
||||||
|
void writeIndex(const QJsonObject &index) const;
|
||||||
|
void sweepTempArchives() const;
|
||||||
|
|
||||||
|
QThread *m_thread;
|
||||||
|
BuildArchiveWorker *m_worker;
|
||||||
|
|
||||||
|
State m_state = Idle;
|
||||||
|
QString m_stage;
|
||||||
|
QString m_status;
|
||||||
|
int m_done = 0;
|
||||||
|
int m_total = 0;
|
||||||
|
|
||||||
|
int m_fromId = -1;
|
||||||
|
int m_toId = -1;
|
||||||
|
QString m_fromName;
|
||||||
|
QString m_toName;
|
||||||
|
QString m_gameDir;
|
||||||
|
QString m_tempZip;
|
||||||
|
QString m_packPath;
|
||||||
|
QStringList m_packRemove;
|
||||||
|
QJsonObject m_packNote;
|
||||||
|
QStringList m_lastPackEntries;
|
||||||
|
QJsonObject m_lastPackNote;
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# AuthService
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`Minecraft_launcher` поддерживает три способа входа: офлайн-профиль без пароля, учётную запись
|
||||||
|
Ely.by и учётную запись Microsoft. `AuthService` закрывает первые два: это Yggdrasil-клиент Ely.by
|
||||||
|
плюс офлайн-режим. За третий отвечает [MsaAuthService](MsaAuthService.md).
|
||||||
|
|
||||||
|
Результат любого способа — структура `AuthResult`, объявленная в этом же заголовке. Она содержит
|
||||||
|
ровно то, что подставляется в аргументы запуска вида `${auth_*}`, поэтому дальше запуск игры идёт
|
||||||
|
по общему пути независимо от того, как пользователь вошёл.
|
||||||
|
|
||||||
|
Класс также умеет скачивать `authlib-injector` — библиотеку, которая перенаправляет обращения игры
|
||||||
|
к серверу авторизации на Ely.by.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); других владельцев нет.
|
||||||
|
Заголовок подключает [MsaAuthService](MsaAuthService.md) — ради общей структуры `AuthResult` — и
|
||||||
|
[GameLauncher](GameLauncher.md) через поля `LaunchOptions`, которые заполняются из результата
|
||||||
|
авторизации.
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QDateTime`, `QJsonObject`, `QString`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: даёт мета-объектную систему, сигнал `progress` и владение по родителю.
|
||||||
|
Виртуальных методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Публичные структуры
|
||||||
|
|
||||||
|
### AuthResult
|
||||||
|
|
||||||
|
Результат авторизации — то, что подставляется в `${auth_*}` аргументы запуска.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `ok` | `bool` | Авторизация удалась |
|
||||||
|
| `twoFactorRequired` | `bool` | Ely.by отклонил пароль с пометкой two factor — нужен одноразовый код |
|
||||||
|
| `licenseMissing` | `bool` | Вход в Microsoft прошёл, но копии игры на аккаунте нет. Обрабатывается отдельно от прочих ошибок, потому что чинится только покупкой |
|
||||||
|
| `error` | `QString` | Текст ошибки, когда `ok` равен `false` |
|
||||||
|
| `playerName` | `QString` | Подставляется в `${auth_player_name}` |
|
||||||
|
| `uuid` | `QString` | Подставляется в `${auth_uuid}`; hex без дефисов |
|
||||||
|
| `accessToken` | `QString` | Подставляется в `${auth_access_token}` |
|
||||||
|
| `clientToken` | `QString` | Подставляется в `${clientid}` |
|
||||||
|
| `userType` | `QString` | Подставляется в `${user_type}`; принимает значения `legacy` (офлайн), `msa` (Microsoft) и `ELYBY` |
|
||||||
|
| `refreshToken` | `QString` | Только для аккаунтов Microsoft: продлевает сессию без ввода пароля |
|
||||||
|
| `xuid` | `QString` | Только для Microsoft; подставляется в `${auth_xuid}` |
|
||||||
|
| `expiresAt` | `QDateTime` | Только для Microsoft: UTC-время, когда протухает `accessToken` |
|
||||||
|
|
||||||
|
## Псевдонимы типов
|
||||||
|
|
||||||
|
`AuthService::Callback` — `std::function<void(const AuthResult &)>`. Все сетевые методы
|
||||||
|
асинхронные: колбэк вызывается ровно один раз и всегда в потоке GUI.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit AuthService(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### static AuthResult offline(const QString &nickname)
|
||||||
|
|
||||||
|
Готовит результат для офлайн-профиля без единого сетевого запроса. UUID выводится из ника так же,
|
||||||
|
как это делает сам Minecraft в офлайне: `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name)
|
||||||
|
.getBytes(UTF_8))`. Благодаря этому один и тот же ник всегда даёт один и тот же UUID, и прогресс
|
||||||
|
на сервере не теряется.
|
||||||
|
|
||||||
|
#### static QString generateClientToken()
|
||||||
|
|
||||||
|
Случайный `clientToken` лаунчера. Генерируется один раз на профиль и хранится вместе с ним:
|
||||||
|
Yggdrasil связывает выданный `accessToken` именно с этим значением.
|
||||||
|
|
||||||
|
#### void loginElyBy(const QString &login, const QString &password, const QString &clientToken, const QString &accessToken, Callback callback)
|
||||||
|
|
||||||
|
Полный цикл входа в Ely.by: сначала проверка имеющегося токена, затем его продление, и только при
|
||||||
|
неудаче — авторизация по паролю. Пароль можно оставить пустым, если уже есть рабочий
|
||||||
|
`accessToken`, — тогда пользователю не придётся вводить его заново.
|
||||||
|
|
||||||
|
По ходу работы испускает `progress` с описанием текущего шага. Результат приходит в `callback`
|
||||||
|
один раз; при ответе с пометкой двухфакторной аутентификации в нём выставлен
|
||||||
|
`twoFactorRequired`, и вызывающий код должен спросить у пользователя код и продолжить через
|
||||||
|
`loginElyByWithTotp()`.
|
||||||
|
|
||||||
|
#### void loginElyByWithTotp(const QString &login, const QString &password, const QString &totp, const QString &clientToken, Callback callback)
|
||||||
|
|
||||||
|
Повтор авторизации с одноразовым кодом двухфакторной аутентификации. Пароль и код объединяются в
|
||||||
|
одно поле в формате «пароль:код», как того требует Ely.by.
|
||||||
|
|
||||||
|
#### void ensureAuthlibInjector(const QString &targetDir, std::function<void(const QString &path, const QString &error)> callback)
|
||||||
|
|
||||||
|
Скачивает `authlib-injector` в `targetDir`, если его там ещё нет. Колбэк получает либо путь к
|
||||||
|
готовому jar, либо текст ошибки — заполнено всегда ровно одно из двух.
|
||||||
|
|
||||||
|
Библиотека нужна только для профилей Ely.by: она подключается к JVM аргументом `-javaagent` и
|
||||||
|
перенаправляет обращения игры к серверу авторизации.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### progress(const QString &message)
|
||||||
|
|
||||||
|
Описание текущего шага авторизации. Испускается по ходу всех сетевых операций.
|
||||||
|
|
||||||
|
Обработчик показывает сообщение пользователю: в главном окне лаунчера оно попадает в плашку
|
||||||
|
статуса и держится до следующего сообщения, потому что шаг может занять заметное время.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` в конструкторе — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя и уничтожается вместе
|
||||||
|
с ним.
|
||||||
|
|
||||||
|
Колбэки захватываются по значению и живут до своего единственного вызова. Уничтожение сервиса во
|
||||||
|
время незавершённого запроса отменяет запрос вместе с менеджером сети — колбэк в этом случае не
|
||||||
|
вызывается, поэтому захватывать в него сырые указатели на объекты с меньшим временем жизни, чем у
|
||||||
|
сервиса, нельзя.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Все сетевые методы асинхронные, и их колбэки вызываются в том же потоке, в
|
||||||
|
котором создан сервис. Собственной синхронизации в классе нет.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает методы входа при запуске игры и переправляет сигнал `progress` в
|
||||||
|
интерфейс. Полученный `AuthResult` он раскладывает по полям `LaunchOptions`, которые уходят в
|
||||||
|
[GameLauncher](GameLauncher.md). Путь, возвращённый `ensureAuthlibInjector()`, попадает в поле
|
||||||
|
`authlibInjectorPath` тех же параметров запуска.
|
||||||
|
|
||||||
|
Сохранением токенов между запусками занимается `LauncherBackend`: сам сервис ничего не пишет на
|
||||||
|
диск, кроме скачанного jar.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс общается с сервером авторизации Ely.by через
|
||||||
|
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запросы инициирует всегда лаунчер. Внутренний
|
||||||
|
помощник `postJson()` разделяет три исхода: успешный ответ, ответ с кодом ошибки и транспортную
|
||||||
|
ошибку — последняя отдаётся отдельным параметром, чтобы отличить недоступную сеть от отказа
|
||||||
|
сервера.
|
||||||
|
|
||||||
|
Отдельным каналом идёт загрузка `authlib-injector` — обычная HTTPS-загрузка файла в
|
||||||
|
`targetDir`. Повторных попыток при неудаче класс не делает: решение о повторе принимает вызывающий
|
||||||
|
код.
|
||||||
|
|
||||||
|
Все сигналы и колбэки приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *auth = new AuthService(this);
|
||||||
|
connect(auth, &AuthService::progress, this, &Backend::showStatus);
|
||||||
|
|
||||||
|
auth->loginElyBy(profile.login, profile.password,
|
||||||
|
profile.clientToken, profile.accessToken,
|
||||||
|
[this](const AuthResult &result) {
|
||||||
|
if (result.twoFactorRequired) {
|
||||||
|
emit twoFactorRequired(m_pendingProfileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
emit launchError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continueLaunch(result);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# BuildArchiveWorker
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
Смена сборки в лаунчере — это перекладывание содержимого `.minecraft`: текущее упаковывается в
|
||||||
|
архив, папка вычищается, на её место распаковывается архив другой сборки. Речь о гигабайтах модов,
|
||||||
|
конфигов и миров, и гонять их в потоке GUI нельзя — окно замерзало бы на всё время смены.
|
||||||
|
|
||||||
|
`BuildArchiveWorker` — исполнитель этих операций в отдельном потоке. Он умеет четыре вещи:
|
||||||
|
упаковать, вычистить, распаковать и докатить пак сезонной сборки поверх уже разложенного
|
||||||
|
содержимого.
|
||||||
|
|
||||||
|
Класс намеренно ничего не знает ни о сборках, ни о путях лаунчера: он принимает готовые пути и
|
||||||
|
списки. Порядок шагов и восстановление после сбоя — дело [BuildSwitcher](BuildSwitcher.md).
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Создаётся и целиком управляется [BuildSwitcher](BuildSwitcher.md), который же и переносит его в
|
||||||
|
собственный `QThread`. Больше к классу никто не обращается.
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` и `Qt6::CorePrivate` — последний нужен ради `QZipReader` и
|
||||||
|
`QZipWriter`, которыми читаются и пишутся архивы сборок.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, слоты, сигналы и возможность жить в отдельном потоке
|
||||||
|
через `moveToThread()`. Виртуальных методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
Объект живёт в своём `QThread` и вызывается только через очередь сигналов — прямых вызовов его
|
||||||
|
слотов из потока GUI быть не должно.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit BuildArchiveWorker(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт исполнителя. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### void requestCancel()
|
||||||
|
|
||||||
|
Просит прервать текущую операцию. Вызывается из потока GUI прямо во время работы — это
|
||||||
|
единственный метод класса, предназначенный для вызова снаружи рабочего потока.
|
||||||
|
|
||||||
|
Отмена не прерывает операцию мгновенно: признак проверяется между файлами. Хранится он в
|
||||||
|
`QAtomicInt`, поэтому запись из одного потока и чтение из другого безопасны без блокировок.
|
||||||
|
|
||||||
|
#### void clearCancel()
|
||||||
|
|
||||||
|
Сбрасывает признак отмены перед началом новой операции.
|
||||||
|
|
||||||
|
## Публичные слоты
|
||||||
|
|
||||||
|
Все четыре слота вызываются через очередь сигналов и выполняются в рабочем потоке. Каждый
|
||||||
|
завершается сигналом `finished`.
|
||||||
|
|
||||||
|
#### void archive(const QString &gameDir, const QString &tempZipPath, const QStringList &excludeTop)
|
||||||
|
|
||||||
|
Упаковывает всё содержимое `gameDir` во временный файл `tempZipPath`. Элементы верхнего уровня,
|
||||||
|
перечисленные в `excludeTop`, в архив не попадают — так из архива сборки исключаются общие
|
||||||
|
каталоги лаунчера, которые не принадлежат ни одной сборке.
|
||||||
|
|
||||||
|
Запись идёт во временный файл, чтобы прерванная упаковка не оставила повреждённый архив на месте
|
||||||
|
настоящего.
|
||||||
|
|
||||||
|
#### void clear(const QString &gameDir, const QStringList &keepTop)
|
||||||
|
|
||||||
|
Удаляет из `gameDir` всё, кроме элементов верхнего уровня, перечисленных в `keepTop`. Выполняется
|
||||||
|
после упаковки, перед распаковкой другой сборки.
|
||||||
|
|
||||||
|
#### void restore(const QString &zipPath, const QString &gameDir)
|
||||||
|
|
||||||
|
Распаковывает архив сборки в `gameDir`.
|
||||||
|
|
||||||
|
#### void applyPack(const QString &zipPath, const QString &gameDir, const QStringList &removeRelative, const QStringList &forbiddenTop)
|
||||||
|
|
||||||
|
Докатывает пак сезонной сборки поверх уже разложенного содержимого. Сначала удаляет файлы из
|
||||||
|
`removeRelative` — те, что ушли из сборки в новой ревизии, — затем распаковывает архив с
|
||||||
|
перезаписью.
|
||||||
|
|
||||||
|
Того, чего нет ни в списке, ни в архиве, операция не касается: миры и скриншоты игрока остаются на
|
||||||
|
месте. Именно это отличает обновление сезонной сборки от её переустановки.
|
||||||
|
|
||||||
|
Параметр `forbiddenTop` — элементы верхнего уровня, которые паку трогать нельзя: общие каталоги
|
||||||
|
лаунчера. Проверяется здесь, а не только у издателя пака, потому что архив приезжает из сети.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### progress(int done, int total, const QString ¤tPath)
|
||||||
|
|
||||||
|
Ход операции: сколько файлов обработано из скольких и какой обрабатывается сейчас. Испускается по
|
||||||
|
ходу всех четырёх операций.
|
||||||
|
|
||||||
|
Обработчик — `BuildSwitcher` — пересчитывает долю выполнения и обновляет панель прогресса. Сигнал
|
||||||
|
приходит в поток GUI через очередь, поэтому прямого доступа к состоянию рабочего потока у
|
||||||
|
обработчика нет.
|
||||||
|
|
||||||
|
#### finished(bool ok, const QString &error)
|
||||||
|
|
||||||
|
Операция завершена. При `ok == false` во втором параметре — текст ошибки.
|
||||||
|
|
||||||
|
Обработчик решает, что делать дальше: перейти к следующему шагу смены сборки или откатить уже
|
||||||
|
сделанное.
|
||||||
|
|
||||||
|
#### packEntries(const QStringList &entries)
|
||||||
|
|
||||||
|
Что именно принёс пак — относительными путями. Испускается только из `applyPack()`.
|
||||||
|
|
||||||
|
Список сохраняется в описании сезонной сборки: следующему обновлению он нужен, чтобы вычислить,
|
||||||
|
какие файлы из сборки ушли, и передать их в `removeRelative`.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent`, но на практике родителя не имеет: объект,
|
||||||
|
перенесённый в другой поток через `moveToThread()`, не может иметь родителя в потоке GUI.
|
||||||
|
Ответственность за его удаление лежит на `BuildSwitcher`, который создаёт и поток, и исполнителя.
|
||||||
|
|
||||||
|
Удалять объект следует безопасным для потоков способом — не напрямую из потока GUI во время
|
||||||
|
работы. Уничтожение потока раньше исполнителя приведёт к обрыву незавершённой операции.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Класс рассчитан на жизнь в отдельном потоке. Все четыре слота выполняются в рабочем потоке;
|
||||||
|
вызывать их напрямую нельзя — только через очередь сигналов.
|
||||||
|
|
||||||
|
Единственная точка межпотокового взаимодействия — признак отмены в `QAtomicInt`:
|
||||||
|
`requestCancel()` и `clearCancel()` пишут его из потока GUI, а рабочий поток читает между файлами.
|
||||||
|
Другого разделяемого состояния у класса нет, поэтому иных блокировок не требуется.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
Единственный собеседник — [BuildSwitcher](BuildSwitcher.md). Он создаёт поток и исполнителя,
|
||||||
|
переносит второй в первый, вызывает слоты через очередь сигналов и принимает `progress`,
|
||||||
|
`finished` и `packEntries`.
|
||||||
|
|
||||||
|
Пути и списки исключений `BuildSwitcher` берёт из [launcherpaths.h](launcherpaths.md) и из
|
||||||
|
описаний сборок; сам исполнитель к ним не обращается.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Файловая система.** Класс читает, пишет и удаляет файлы в папке игры и в каталоге архивов
|
||||||
|
сборок. Сетевых обращений и дочерних процессов у него нет: архив сезонной сборки к моменту вызова
|
||||||
|
`applyPack()` уже скачан [SeasonalPackDownloader](SeasonalPackDownloader.md).
|
||||||
|
|
||||||
|
Все операции выполняются в рабочем потоке, все сигналы приходят в поток GUI через очередь.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *thread = new QThread(this);
|
||||||
|
auto *worker = new BuildArchiveWorker; // без родителя: уедет в другой поток
|
||||||
|
worker->moveToThread(thread);
|
||||||
|
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
|
||||||
|
thread->start();
|
||||||
|
|
||||||
|
connect(worker, &BuildArchiveWorker::progress, this, &Switcher::onProgress);
|
||||||
|
connect(worker, &BuildArchiveWorker::finished, this, &Switcher::onStepFinished);
|
||||||
|
|
||||||
|
worker->clearCancel();
|
||||||
|
QMetaObject::invokeMethod(worker, "archive", Qt::QueuedConnection,
|
||||||
|
Q_ARG(QString, gameDir),
|
||||||
|
Q_ARG(QString, tempZipPath),
|
||||||
|
Q_ARG(QStringList, excludeTop));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# BuildSwitcher
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`BuildSwitcher` меняет активную сборку: содержимое `.minecraft` уезжает в архив своей сборки,
|
||||||
|
папка чистится, на её место разворачивается архив выбранной. Он же докатывает пак сезонной
|
||||||
|
сборки поверх уже разложенного содержимого.
|
||||||
|
|
||||||
|
Порядок шагов подчинён одному правилу: пока новый архив не записан целиком и не переименован на
|
||||||
|
место, из `.minecraft` не удаляется ничего. Отметка о начатом переключении пишется в
|
||||||
|
`builds/index.json` до первого разрушающего действия, поэтому обрыв питания или принудительное
|
||||||
|
завершение процесса всегда обнаружим на следующем запуске.
|
||||||
|
|
||||||
|
Сами файловые операции выполняет [BuildArchiveWorker](BuildArchiveWorker.md) в отдельном потоке;
|
||||||
|
`BuildSwitcher` — это порядок шагов, учёт состояния и восстановление после сбоя.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Класс создаёт и целиком
|
||||||
|
владеет своим `QThread` и объектом [BuildArchiveWorker](BuildArchiveWorker.md).
|
||||||
|
|
||||||
|
Пути к папкам сборок и файлу состояния даёт [launcherpaths.h](launcherpaths.md)
|
||||||
|
(`buildStorageDir()`, `buildDir()`).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QThread`, `QJsonObject`) и `Qt6::CorePrivate` — опосредованно,
|
||||||
|
через исполнителя.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, три сигнала и владение по родителю. Объявлен
|
||||||
|
виртуальный деструктор — класс обязан корректно остановить свой поток.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit BuildSwitcher(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт переключатель, рабочий поток и исполнителя, переносит второй в первый и запускает поток.
|
||||||
|
Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### bool isRunning() const
|
||||||
|
|
||||||
|
Идёт ли сейчас переключение или раскатка пака.
|
||||||
|
|
||||||
|
#### QString stage() const
|
||||||
|
|
||||||
|
Текущий этап словами — заголовок панели прогресса.
|
||||||
|
|
||||||
|
#### QString status() const
|
||||||
|
|
||||||
|
Строка состояния: что обрабатывается сейчас.
|
||||||
|
|
||||||
|
#### double fraction() const
|
||||||
|
|
||||||
|
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен.
|
||||||
|
|
||||||
|
#### static QStringList sharedTopLevel()
|
||||||
|
|
||||||
|
Общие для всех сборок каталоги верхнего уровня. Они не архивируются и не чистятся: принадлежат
|
||||||
|
лаунчеру, а не какой-либо сборке. Список передаётся исполнителю как `excludeTop` при упаковке и
|
||||||
|
как `keepTop` при очистке.
|
||||||
|
|
||||||
|
#### void switchTo(int fromBuildId, const QString &fromName, int toBuildId, const QString &toName, const QString &gameDir)
|
||||||
|
|
||||||
|
Переключает активную сборку. Проходит четыре этапа: упаковка текущего содержимого во временный
|
||||||
|
файл, фиксация архива переименованием на место, очистка папки игры и распаковка архива целевой
|
||||||
|
сборки.
|
||||||
|
|
||||||
|
Имена сборок нужны для текста в панели прогресса, идентификаторы — для путей к архивам.
|
||||||
|
|
||||||
|
#### void applyPack(int buildId, const QString &buildName, const QString &packZipPath, const QStringList &removeRelative, const QJsonObject ¬e, const QString &gameDir)
|
||||||
|
|
||||||
|
Докатывает пак сезонной сборки поверх содержимого `.minecraft`. Вызывается только для уже активной
|
||||||
|
сборки: пак ложится на то, что сейчас разложено, а не внутрь чужого архива.
|
||||||
|
|
||||||
|
Параметр `removeRelative` — файлы, ушедшие из сборки в новой ревизии; они удаляются перед
|
||||||
|
распаковкой. Всего остального операция не касается, поэтому миры и скриншоты игрока переживают
|
||||||
|
обновление.
|
||||||
|
|
||||||
|
Параметр `note` — непрозрачные данные вызывающей стороны. Они переживают перезапуск вместе с
|
||||||
|
отметкой о незавершённой операции и возвращаются через `lastPackNote()`, когда раскатка доиграна.
|
||||||
|
Сам переключатель в них не заглядывает.
|
||||||
|
|
||||||
|
#### QStringList lastPackEntries() const
|
||||||
|
|
||||||
|
Что принёс последний успешно раскатанный пак — относительными путями. Список нужен следующему
|
||||||
|
обновлению, чтобы вычислить, какие файлы из сборки ушли.
|
||||||
|
|
||||||
|
#### QJsonObject lastPackNote() const
|
||||||
|
|
||||||
|
Данные, с которыми пришёл последний успешно раскатанный пак, — те самые, что передавались в
|
||||||
|
`applyPack()`.
|
||||||
|
|
||||||
|
#### void cancel()
|
||||||
|
|
||||||
|
Отменяет текущую операцию. Отмена доходит до исполнителя через признак, проверяемый между
|
||||||
|
файлами.
|
||||||
|
|
||||||
|
Отменять имеет смысл только на этапе упаковки: после очистки `.minecraft` отступать некуда, и
|
||||||
|
операцию нужно довести до конца. Именно поэтому панель прогресса смены сборки объявлена
|
||||||
|
неотменяемой.
|
||||||
|
|
||||||
|
#### bool forgetBuild(int buildId)
|
||||||
|
|
||||||
|
Сборку удалили — убирает её архив и запись о нём. Возвращает `false`, если папку архива не удалось
|
||||||
|
удалить целиком.
|
||||||
|
|
||||||
|
#### bool hasArchive(int buildId) const
|
||||||
|
|
||||||
|
Есть ли у сборки сохранённый архив. Используется в тексте предупреждения об удалении: вместе со
|
||||||
|
сборкой пропадут её моды, конфиги и миры.
|
||||||
|
|
||||||
|
#### QString interruptedSwitchWarning() const
|
||||||
|
|
||||||
|
Незавершённое переключение с прошлого запуска. Возвращает пустую строку, если всё в порядке, иначе
|
||||||
|
— готовый текст для пользователя.
|
||||||
|
|
||||||
|
Проверяется при старте лаунчера: отметка в `builds/index.json` пишется до первого разрушающего
|
||||||
|
действия, поэтому прерванная операция обнаруживается всегда.
|
||||||
|
|
||||||
|
#### void resumeInterrupted(const QString &gameDir)
|
||||||
|
|
||||||
|
Доигрывает прерванное переключение: очистку и распаковку целевой сборки. Архив исходной сборки к
|
||||||
|
этому моменту уже записан — правило порядка шагов это гарантирует.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### progressChanged()
|
||||||
|
|
||||||
|
Изменились числа прогресса. Обработчик перечитывает `stage()`, `status()` и `fraction()`.
|
||||||
|
|
||||||
|
#### finished(int toBuildId)
|
||||||
|
|
||||||
|
Переключение или раскатка завершены успешно; в параметре — идентификатор сборки, ставшей активной.
|
||||||
|
|
||||||
|
Обработчик снимает признак переключения, обновляет активную сборку и, после раскатки пака,
|
||||||
|
забирает `lastPackEntries()` и `lastPackNote()`, чтобы записать их в описание сезонной сборки.
|
||||||
|
|
||||||
|
#### failed(int toBuildId, const QString &message, bool gameDirIntact)
|
||||||
|
|
||||||
|
Операция не удалась. Третий параметр — ключевой: он говорит, цела ли папка игры. Неудача на этапе
|
||||||
|
упаковки оставляет `.minecraft` нетронутой, неудача после очистки — нет, и сообщение пользователю
|
||||||
|
должно различать эти случаи.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
|
||||||
|
Рабочий поток и исполнитель создаются в конструкторе и принадлежат переключателю. Исполнитель
|
||||||
|
родителя не имеет — объект, живущий в другом потоке, не может принадлежать объекту из потока GUI;
|
||||||
|
его удаление привязано к завершению потока. Деструктор обязан остановить поток и дождаться его
|
||||||
|
завершения, иначе рабочая операция переживёт своего владельца.
|
||||||
|
|
||||||
|
Временный архив на диске переживает аварийное завершение процесса; накопившиеся временные файлы
|
||||||
|
подчищаются при следующем запуске.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Сам переключатель живёт в потоке GUI: все его публичные методы вызываются оттуда. Тяжёлая работа
|
||||||
|
вынесена в отдельный поток к [BuildArchiveWorker](BuildArchiveWorker.md), общение с которым идёт
|
||||||
|
исключительно через очередь сигналов — прямых вызовов слотов исполнителя нет.
|
||||||
|
|
||||||
|
Единственный межпотоковый примитив — атомарный признак отмены внутри исполнителя.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `switchTo()` при смене активной сборки, `applyPack()` — при установке
|
||||||
|
или обновлении сезонной сборки, `forgetBuild()` — при удалении сборки. Сигналы переключателя он
|
||||||
|
переправляет в свойства, которые читает QML: панель прогресса главного окна показывает `stage()`,
|
||||||
|
`status()` и `fraction()`, а признак переключения блокирует список сборок и кнопки в
|
||||||
|
[BuildsDialog](../qml/BuildsDialog.md).
|
||||||
|
|
||||||
|
При старте бэкенд спрашивает `interruptedSwitchWarning()` и, если операция была прервана,
|
||||||
|
предлагает доиграть её через `resumeInterrupted()`.
|
||||||
|
|
||||||
|
Архив сезонной сборки к моменту вызова `applyPack()` уже скачан
|
||||||
|
[SeasonalPackDownloader](SeasonalPackDownloader.md).
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Файловая система.** Класс читает и пишет `builds/index.json` — отметку о состоянии переключения,
|
||||||
|
— управляет папками архивов сборок и подчищает временные файлы. Все операции с содержимым самой
|
||||||
|
папки игры делегированы исполнителю в рабочем потоке.
|
||||||
|
|
||||||
|
Сети и дочерних процессов у класса нет.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *switcher = new BuildSwitcher(this);
|
||||||
|
|
||||||
|
connect(switcher, &BuildSwitcher::progressChanged, this, &Backend::switchChanged);
|
||||||
|
connect(switcher, &BuildSwitcher::finished, this, &Backend::onSwitchFinished);
|
||||||
|
connect(switcher, &BuildSwitcher::failed, this,
|
||||||
|
[this](int, const QString &message, bool gameDirIntact) {
|
||||||
|
emit launchError(gameDirIntact
|
||||||
|
? message
|
||||||
|
: tr("%1. Папка игры осталась незавершённой.").arg(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
// при старте лаунчера
|
||||||
|
const QString warning = switcher->interruptedSwitchWarning();
|
||||||
|
if (!warning.isEmpty()) {
|
||||||
|
showStatus(warning);
|
||||||
|
switcher->resumeInterrupted(gameDir);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# GameLauncher
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`GameLauncher` — то, ради чего существует весь остальной лаунчер: он готовит и запускает JVM с
|
||||||
|
Minecraft. К моменту его вызова уже известно всё — версия разобрана, файлы скачаны, пользователь
|
||||||
|
авторизован, — и класс превращает это в командную строку и дочерний процесс.
|
||||||
|
|
||||||
|
Кроме самого запуска класс умеет проверять комплектность `.minecraft` и распаковывать нативные
|
||||||
|
библиотеки, без которых игра не стартует.
|
||||||
|
|
||||||
|
Один экземпляр — одна игра одновременно.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает [minecraftversion.h](minecraftversion.md): разобранная версия — половина входных данных
|
||||||
|
запуска, вторая половина приходит структурой `LaunchOptions` из этого же заголовка.
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md), который заполняет
|
||||||
|
`LaunchOptions` из настроек, выбранной сборки и результата авторизации.
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QProcess`, `QStringList`) и `Qt6::CorePrivate` — последний нужен
|
||||||
|
ради `QZipReader`, которым распаковываются нативные библиотеки LWJGL. Зависимость от приватного
|
||||||
|
модуля привязывает проект к конкретной версии Qt; в `CMakeLists.txt` это осознанный выбор, и
|
||||||
|
предупреждение о нём отключено.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, четыре сигнала и владение по родителю. Виртуальных
|
||||||
|
методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Публичные структуры
|
||||||
|
|
||||||
|
### LaunchOptions
|
||||||
|
|
||||||
|
Всё, что лаунчер знает к моменту нажатия кнопки запуска.
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `gameDir` | `QString` | — | Папка `.minecraft` |
|
||||||
|
| `versionId` | `QString` | — | Папка в `versions`, которую запускаем |
|
||||||
|
| `playerName` | `QString` | — | Ник игрока из результата авторизации |
|
||||||
|
| `uuid` | `QString` | — | UUID игрока |
|
||||||
|
| `accessToken` | `QString` | — | Токен доступа |
|
||||||
|
| `userType` | `QString` | — | Тип учётной записи: `legacy`, `msa` или `ELYBY` |
|
||||||
|
| `clientToken` | `QString` | — | Токен клиента |
|
||||||
|
| `xuid` | `QString` | — | Идентификатор Xbox; пустое значение заменяется на `0`, как в офлайне |
|
||||||
|
| `javaPath` | `QString` | — | Путь к java; пустое значение означает «искать самим» |
|
||||||
|
| `minMemoryMb` | `int` | `512` | Значение `-Xms` |
|
||||||
|
| `maxMemoryMb` | `int` | `4096` | Значение `-Xmx` |
|
||||||
|
| `extraJvmArgs` | `QStringList` | — | Дополнительные аргументы JVM из настроек |
|
||||||
|
| `windowWidth` | `int` | `0` | Ширина окна игры; `0` — не передавать `--width` и `--height` |
|
||||||
|
| `windowHeight` | `int` | `0` | Высота окна игры |
|
||||||
|
| `fullscreen` | `bool` | `false` | Запускать в полноэкранном режиме |
|
||||||
|
| `serverAddress` | `QString` | — | `host[:port]` для автоматического захода на сервер |
|
||||||
|
| `authlibInjectorPath` | `QString` | — | Путь к `authlib-injector`; пустое значение — не подключать |
|
||||||
|
| `authlibInjectorApi` | `QString` | `ely.by` | Сервер авторизации, на который перенаправляется игра |
|
||||||
|
| `launcherName` | `QString` | `BiLauncher` | Имя лаунчера, которое видит игра |
|
||||||
|
| `launcherVersion` | `QString` | `1.0` | Версия лаунчера |
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit GameLauncher(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт объект. Процесс игры при этом не запускается. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### bool isRunning() const
|
||||||
|
|
||||||
|
Идёт ли сейчас игра. От этого зависит доступность кнопки запуска в интерфейсе.
|
||||||
|
|
||||||
|
#### static QStringList missingFiles(const LaunchOptions &options, const MinecraftVersion &version, int limit = 12)
|
||||||
|
|
||||||
|
Проверяет `.minecraft` на комплектность и возвращает описания недостающих файлов. Пустой список
|
||||||
|
означает, что всё на месте и сборку можно запускать.
|
||||||
|
|
||||||
|
Параметр `limit` ограничивает длину списка: перечислять все отсутствующие файлы у неустановленной
|
||||||
|
версии бессмысленно, важен сам факт и пара примеров.
|
||||||
|
|
||||||
|
Метод статический, ничего не меняет и вызывается интерфейсом для строки состояния сборки.
|
||||||
|
|
||||||
|
#### static QStringList buildArguments(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir)
|
||||||
|
|
||||||
|
Собирает аргументы ровно в том порядке, в котором их ждёт JVM: сначала аргументы JVM, затем главный
|
||||||
|
класс, затем аргументы игры. Подстановки вида `${...}` из версии заменяются значениями из
|
||||||
|
параметров запуска.
|
||||||
|
|
||||||
|
Метод статический и не имеет побочных эффектов, поэтому годится и для показа собранной командной
|
||||||
|
строки без запуска.
|
||||||
|
|
||||||
|
#### static bool extractNatives(const LaunchOptions &options, const MinecraftVersion &version, const QString &nativesDir, QString \*error)
|
||||||
|
|
||||||
|
Распаковывает файлы `.dll`, `.so` и `.dylib` из нативных библиотек в `<версия>/natives`. Учитывает
|
||||||
|
поле `extractExclude` каждой библиотеки — перечисленные там префиксы не распаковываются.
|
||||||
|
|
||||||
|
Возвращает `false` и заполняет `error` при неудаче. Без этого шага игра не стартует: LWJGL ищет
|
||||||
|
нативные библиотеки именно в этой папке.
|
||||||
|
|
||||||
|
#### bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString \*error)
|
||||||
|
|
||||||
|
Полный цикл запуска: проверка комплектности, распаковка нативных библиотек, поиск java, старт
|
||||||
|
процесса.
|
||||||
|
|
||||||
|
Возвращает `false` и заполняет `error`, если что-то из перечисленного не удалось; `true` означает,
|
||||||
|
что процесс запущен — дальнейшая судьба игры приходит сигналами.
|
||||||
|
|
||||||
|
Путь к java берётся из `options.javaPath`, а при пустом значении ищется через
|
||||||
|
[JavaLocator](javalocator.md) с учётом требования версии.
|
||||||
|
|
||||||
|
#### void terminate()
|
||||||
|
|
||||||
|
Завершает процесс игры. Если игра не запущена, ничего не делает.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### progress(const QString &message)
|
||||||
|
|
||||||
|
Описание текущего шага подготовки: проверка файлов, распаковка нативных библиотек, поиск java.
|
||||||
|
Обработчик показывает сообщение пользователю — подготовка занимает заметное время.
|
||||||
|
|
||||||
|
#### output(const QString &line)
|
||||||
|
|
||||||
|
Одна строка вывода процесса игры. Обработчик пишет её в журнал; в лаунчере это вывод в консоль.
|
||||||
|
|
||||||
|
#### gameStarted(const QString &commandLine)
|
||||||
|
|
||||||
|
Процесс запущен; в параметре — собранная командная строка целиком. Удобно для диагностики: по ней
|
||||||
|
видно, с какими аргументами и какой java стартовала игра.
|
||||||
|
|
||||||
|
#### gameFinished(int exitCode, bool crashed)
|
||||||
|
|
||||||
|
Игра завершилась. `exitCode` — код выхода процесса, `crashed` отличает аварийное завершение от
|
||||||
|
обычного.
|
||||||
|
|
||||||
|
Обработчик снимает признак «игра идёт», возвращает доступность кнопки запуска и сообщает
|
||||||
|
пользователю итог: ненулевой код или выставленный `crashed` показываются как ошибка.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
|
||||||
|
Процесс игры хранится в поле `m_process` и создаётся при запуске. Один экземпляр рассчитан ровно
|
||||||
|
на одну игру одновременно: повторный вызов `launch()` при работающем процессе не предусмотрен, и
|
||||||
|
вызывающий код обязан проверять `isRunning()`.
|
||||||
|
|
||||||
|
Дочерний процесс переживает уничтожение объекта не сам по себе — завершать игру перед выходом
|
||||||
|
должен вызывающий код через `terminate()`.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. `QProcess` привязан к потоку, в котором создан, и все сигналы приходят туда же.
|
||||||
|
Статические методы (`missingFiles()`, `buildArguments()`, `extractNatives()`) состояния не имеют,
|
||||||
|
но выполняют файловый ввод-вывод и на большой версии могут заметно задержать вызывающий поток.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` собирает `LaunchOptions` из трёх источников: настроек лаунчера, описания
|
||||||
|
выбранной сборки и `AuthResult` от [AuthService](AuthService.md) или
|
||||||
|
[MsaAuthService](MsaAuthService.md). Разобранную версию он получает через
|
||||||
|
`VersionLoader::load()` из [minecraftversion.h](minecraftversion.md).
|
||||||
|
|
||||||
|
Все четыре сигнала бэкенд переправляет в QML: `progress` и `gameFinished` попадают в плашку
|
||||||
|
сообщений главного окна, `output` — в консоль, а `gameStarted` меняет признак `gameRunning`.
|
||||||
|
|
||||||
|
Статический `missingFiles()` вызывается отдельно от запуска — из метода проверки комплектности
|
||||||
|
сборки, результат которого показывает [BuildsDialog](../qml/BuildsDialog.md).
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Дочерний процесс.** Класс запускает java через `QProcess`. Аргументы собираются
|
||||||
|
`buildArguments()`; для профилей Ely.by в них добавляется `-javaagent` с путём к
|
||||||
|
`authlib-injector`. Стандартный вывод процесса читается построчно и отдаётся сигналом `output`,
|
||||||
|
завершение — сигналом `gameFinished`. Направление обмена одностороннее: лаунчер запускает процесс
|
||||||
|
и читает его вывод, ничего не передавая обратно после старта.
|
||||||
|
|
||||||
|
Все сигналы процесса приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *launcher = new GameLauncher(this);
|
||||||
|
connect(launcher, &GameLauncher::progress, this, &Backend::showStatus);
|
||||||
|
connect(launcher, &GameLauncher::gameFinished, this, &Backend::onGameFinished);
|
||||||
|
|
||||||
|
LaunchOptions options;
|
||||||
|
options.gameDir = settings.resolvedGameDir;
|
||||||
|
options.versionId = build.resolvedVersionId;
|
||||||
|
options.playerName = auth.playerName;
|
||||||
|
options.uuid = auth.uuid;
|
||||||
|
options.accessToken = auth.accessToken;
|
||||||
|
options.userType = auth.userType;
|
||||||
|
options.maxMemoryMb = settings.maxMemoryMb;
|
||||||
|
|
||||||
|
QString error;
|
||||||
|
if (!launcher->launch(options, version, &error))
|
||||||
|
emit launchError(error);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# JavaInstaller
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`JavaInstaller` ставит сборку Java в `<root>/java/<id>`. Как и
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md), он прячет за одним фасадом два разных пути.
|
||||||
|
|
||||||
|
**Temurin** отдаёт один архив: лаунчер качает его, сверяет sha256 и распаковывает. **Mojang**
|
||||||
|
отдаёт манифест с деревом файлов: лаунчер качает файлы по отдельности, как это делает официальный
|
||||||
|
лаунчер.
|
||||||
|
|
||||||
|
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md): панель загрузки в
|
||||||
|
интерфейсе читает их одинаково, независимо от того, кто сейчас работает.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает [javaruntime.h](javaruntime.md): на вход установщик принимает запись каталога
|
||||||
|
`JavaRuntimeEntry`, а результат записывает через `JavaRuntimeStore`.
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md); записи каталога приходят
|
||||||
|
от [JavaRuntimeService](JavaRuntimeService.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QProcess`, `QTimer`),
|
||||||
|
`Qt6::CorePrivate` (`QZipReader` для распаковки zip) и `Qt6::Network`.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
||||||
|
виртуальный деструктор — класс владеет незавершёнными загрузками, открытым архивом и процессом
|
||||||
|
распаковки.
|
||||||
|
|
||||||
|
## Публичные структуры
|
||||||
|
|
||||||
|
### JavaFileTask
|
||||||
|
|
||||||
|
Один файл рантайма Mojang.
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `url` | `QUrl` | — | Откуда качать |
|
||||||
|
| `path` | `QString` | — | Абсолютный путь назначения |
|
||||||
|
| `sha1` | `QString` | — | Контрольная сумма файла |
|
||||||
|
| `size` | `qint64` | `0` | Размер в байтах |
|
||||||
|
| `executable` | `bool` | `false` | Файлу нужно выставить право на исполнение — иначе `bin/java` не запустится |
|
||||||
|
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
|
||||||
|
|
||||||
|
### JavaActiveDownload
|
||||||
|
|
||||||
|
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
|
||||||
|
контрольная сумма и число принятых байт. Файлы пишутся потоком — рантайм весит около двухсот
|
||||||
|
мегабайт.
|
||||||
|
|
||||||
|
Структура `ZipExtraction`, хранящая состояние распаковки, объявлена вперёд и спрятана в
|
||||||
|
`.cpp`: так приватный заголовок `QZipReader` не расходится по проекту вместе с этим заголовком.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit JavaInstaller(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт установщик, его `QNetworkAccessManager` и два таймера. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### bool isRunning() const
|
||||||
|
|
||||||
|
Идёт ли установка прямо сейчас.
|
||||||
|
|
||||||
|
#### QString runtimeId() const
|
||||||
|
|
||||||
|
Идентификатор устанавливаемой сборки.
|
||||||
|
|
||||||
|
#### QString label() const
|
||||||
|
|
||||||
|
Подпись установки для интерфейса.
|
||||||
|
|
||||||
|
#### QString stage() const
|
||||||
|
|
||||||
|
Текущий этап словами: загрузка, проверка, распаковка.
|
||||||
|
|
||||||
|
#### QString currentFile() const
|
||||||
|
|
||||||
|
Файл, который обрабатывается сейчас.
|
||||||
|
|
||||||
|
#### qint64 bytesDone() const
|
||||||
|
|
||||||
|
Сколько байт уже получено. Байты считаются только на загрузке: на распаковке считать нечего, и
|
||||||
|
панель по нулевому итогу сама прячет мегабайты.
|
||||||
|
|
||||||
|
#### qint64 bytesTotal() const
|
||||||
|
|
||||||
|
Ожидаемый общий объём загрузки.
|
||||||
|
|
||||||
|
#### double fraction() const
|
||||||
|
|
||||||
|
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. На этапе распаковки доля считается
|
||||||
|
по числу обработанных записей архива или файлов дерева, а не по байтам.
|
||||||
|
|
||||||
|
#### void install(const JavaRuntimeEntry &entry)
|
||||||
|
|
||||||
|
Ставит сборку по записи каталога. Путь установки выбирается по полю `archive` записи: значения
|
||||||
|
`zip` и `tar.gz` ведут по пути Temurin, значение `mojang` — по пути манифеста.
|
||||||
|
|
||||||
|
По завершении установщик находит исполняемый файл java в распакованном дереве через
|
||||||
|
`JavaRuntimeStore::locateBinary()` и записывает описание сборки рядом с ней.
|
||||||
|
|
||||||
|
Одновременно ставится одна сборка; очереди у этого установщика нет.
|
||||||
|
|
||||||
|
#### void cancel()
|
||||||
|
|
||||||
|
Отменяет установку. Отмена проверяется между файлами и между кусками распаковки, поэтому
|
||||||
|
срабатывает не мгновенно, но без замораживания интерфейса.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### started(const QString &label)
|
||||||
|
|
||||||
|
Установка началась. Обработчик показывает панель прогресса.
|
||||||
|
|
||||||
|
#### progressChanged()
|
||||||
|
|
||||||
|
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер. Обработчик
|
||||||
|
перечитывает геттеры.
|
||||||
|
|
||||||
|
#### finished(const QString &runtimeId, const QString &javaPath)
|
||||||
|
|
||||||
|
Сборка установлена; во втором параметре — абсолютный путь к исполняемому файлу java.
|
||||||
|
|
||||||
|
Обработчик обновляет каталог: сборка становится помеченной как скачанная, а диалог настроек
|
||||||
|
перечитывает её описание.
|
||||||
|
|
||||||
|
#### failed(const QString &label, const QString &message)
|
||||||
|
|
||||||
|
Установка не удалась; в параметре — текст ошибки для пользователя.
|
||||||
|
|
||||||
|
#### canceled(const QString &label)
|
||||||
|
|
||||||
|
Установка отменена пользователем.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` и оба таймера создаются в конструкторе с установщиком в роли родителя.
|
||||||
|
|
||||||
|
Владение внутренними ресурсами построено на RAII: скачиваемый архив хранится как
|
||||||
|
`std::unique_ptr<QSaveFile>`, состояние распаковки — как `std::unique_ptr<ZipExtraction>`,
|
||||||
|
активные загрузки — как `std::shared_ptr<JavaActiveDownload>` с собственным `QSaveFile` внутри.
|
||||||
|
Незавершённая запись отменяется вместе с уничтожением объекта, и испорченный файл не попадает на
|
||||||
|
место назначения.
|
||||||
|
|
||||||
|
Процесс `tar`, используемый для распаковки архивов `tar.gz`, создаётся по ходу работы и
|
||||||
|
завершается в деструкторе.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Отдельного потока у класса нет намеренно: распаковка zip идёт по кускам по
|
||||||
|
таймеру — держать поток GUI занятым на всю сотню мегабайт нельзя, а заводить поток ради одной
|
||||||
|
операции незачем. Распаковка `tar.gz` отдана внешнему процессу, который работает параллельно сам.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `install()` с записью, полученной от
|
||||||
|
[JavaRuntimeService](JavaRuntimeService.md), и переправляет сигналы прогресса в те же свойства,
|
||||||
|
что и остальные установщики. Сигнал `finished` бэкенд переправляет в QML под собственным именем —
|
||||||
|
на него подписан диалог настроек, чтобы обновить строку выбранной сборки, когда та докачается.
|
||||||
|
|
||||||
|
Раскладку папки `<root>/java`, поиск исполняемого файла и запись описания обеспечивает
|
||||||
|
`JavaRuntimeStore` из [javaruntime.h](javaruntime.md).
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Загрузка по HTTPS с серверов Adoptium или Mojang. Архив Temurin
|
||||||
|
скачивается одним запросом с проверкой sha256; дерево Mojang — множеством параллельных запросов,
|
||||||
|
каждый с проверкой sha1. Неудачная задача повторяется, счётчик попыток хранится в самой задаче.
|
||||||
|
|
||||||
|
**Дочерний процесс.** Архивы `tar.gz` распаковываются системным `tar` через `QProcess` —
|
||||||
|
собственного распаковщика для этого формата в Qt нет. Обмен односторонний: лаунчер запускает
|
||||||
|
процесс и ждёт его завершения.
|
||||||
|
|
||||||
|
**Файловая система.** После распаковки дерева Mojang применяются символические ссылки из
|
||||||
|
манифеста, а файлам с признаком `executable` выставляется право на исполнение.
|
||||||
|
|
||||||
|
Все сигналы приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *javaInstaller = new JavaInstaller(this);
|
||||||
|
|
||||||
|
connect(javaInstaller, &JavaInstaller::finished, this,
|
||||||
|
[this](const QString &runtimeId, const QString &javaPath) {
|
||||||
|
m_settings.javaRuntime = runtimeId;
|
||||||
|
m_resolvedJavaPath = javaPath;
|
||||||
|
emit javaRuntimeInstalled(runtimeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const auto entry = javaCatalog->find(runtimeId);
|
||||||
|
if (entry)
|
||||||
|
javaInstaller->install(*entry);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# JavaRuntimeService
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`JavaRuntimeService` — каталог сборок Java, которые лаунчер умеет скачать: качает, кэширует в
|
||||||
|
папке лаунчера и отдаёт из кэша, пока тот не устарел. Устроен так же, как
|
||||||
|
[VersionManifestService](VersionManifestService.md).
|
||||||
|
|
||||||
|
Источников два, и они дополняют друг друга. **Mojang** (категория «Java») — ровно тот рантайм,
|
||||||
|
которым запускает игру официальный лаунчер: версий немного, зато они заведомо совместимы.
|
||||||
|
**Eclipse Temurin** (категории JDK и JRE) — свежие сборки всех мажорных версий, включая те, до
|
||||||
|
которых Mojang ещё не дошёл.
|
||||||
|
|
||||||
|
Отдаются только сборки под текущие операционную систему и архитектуру: выбрать заведомо
|
||||||
|
неработающую нечем. Исключение — macOS на Apple Silicon, где под Java 8 и 16 сборок `aarch64` нет
|
||||||
|
вовсе и приходится брать `x64`, который работает через Rosetta.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает [javaruntime.h](javaruntime.md): перечисление `JavaRuntimeKind` и структура
|
||||||
|
`JavaRuntimeEntry` приходят оттуда.
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записями каталога
|
||||||
|
пользуется [JavaInstaller](JavaInstaller.md).
|
||||||
|
|
||||||
|
Путь к файлу кэша даёт `LauncherPaths::javaCatalogFile()` из [launcherpaths.h](launcherpaths.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||||
|
методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Псевдонимы типов
|
||||||
|
|
||||||
|
`JavaRuntimeService::Callback` — `std::function<void(bool ok, const QString &warning)>`. Как и в
|
||||||
|
остальных каталогах лаунчера, `ok == true` с непустым `warning` означает данные из устаревшего
|
||||||
|
кэша.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit JavaRuntimeService(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт сервис и его `QNetworkAccessManager`. Кэш читается лениво. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
||||||
|
|
||||||
|
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
||||||
|
обновление. Параметр `forceRefresh` обходит проверку свежести — так работает кнопка обновления
|
||||||
|
каталога.
|
||||||
|
|
||||||
|
Одно обновление складывается из нескольких запросов сразу к обоим источникам. Ответы приходят
|
||||||
|
вразнобой, поэтому они накапливаются, и каталог подменяется целиком только когда пришли все.
|
||||||
|
|
||||||
|
#### QList<JavaRuntimeEntry> entries(JavaRuntimeKind kind) const
|
||||||
|
|
||||||
|
Сборки одной категории: `Mojang`, `Jdk` или `Jre`. Новые версии идут первыми.
|
||||||
|
|
||||||
|
#### std::optional<JavaRuntimeEntry> find(const QString &id) const
|
||||||
|
|
||||||
|
Запись по идентификатору сборки; `std::nullopt`, если такой нет. Поиск идёт по внутреннему
|
||||||
|
указателю.
|
||||||
|
|
||||||
|
#### std::optional<JavaRuntimeEntry> bestFor(int major) const
|
||||||
|
|
||||||
|
Что скачать, если для версии игры нужна Java указанной мажорной версии, а подходящей в системе
|
||||||
|
нет.
|
||||||
|
|
||||||
|
Правила выбора: точное совпадение мажорной версии предпочтительнее более новой, а JDK
|
||||||
|
предпочтительнее JRE — установщики Forge и NeoForge иногда требуют инструментов из полного
|
||||||
|
комплекта. `std::nullopt` означает, что предложить нечего.
|
||||||
|
|
||||||
|
#### bool isRefreshing() const
|
||||||
|
|
||||||
|
Идут ли сейчас запросы. Признак остаётся истинным, пока не завершится последний из них.
|
||||||
|
|
||||||
|
#### bool hasData() const
|
||||||
|
|
||||||
|
Есть ли в каталоге хоть что-то — из сети или из кэша.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### catalogChanged()
|
||||||
|
|
||||||
|
Каталог заменён новыми данными. Испускается один раз за обновление, когда пришли ответы от всех
|
||||||
|
источников, а не по каждому из них.
|
||||||
|
|
||||||
|
Обработчик перечитывает `entries()` для нужных категорий и обновляет интерфейс.
|
||||||
|
|
||||||
|
#### refreshingChanged()
|
||||||
|
|
||||||
|
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||||
|
|
||||||
|
Отложенные колбэки хранятся до завершения текущего обновления. Уничтожение сервиса с
|
||||||
|
незавершёнными запросами обрывает их вместе с менеджером сети, и колбэки не вызываются.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора Java и по кнопке обновления,
|
||||||
|
а сигналы переправляет в свойства, которые читает QML. Перед отдачей в интерфейс он сводит записи
|
||||||
|
каталога с уже установленными сборками из `JavaRuntimeStore` — окно выбора показывает статус, не
|
||||||
|
считая ничего само.
|
||||||
|
|
||||||
|
[JavaInstaller](JavaInstaller.md) получает запись каталога и по ней скачивает и распаковывает
|
||||||
|
сборку. `bestFor()` используется, когда для запуска не хватает Java и лаунчер должен сам
|
||||||
|
предложить, что поставить.
|
||||||
|
|
||||||
|
Требования версий игры к Java берутся из пространства имён `JavaRequirement` в
|
||||||
|
[javaruntime.h](javaruntime.md).
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс обращается к двум внешним каталогам: API Adoptium (сборки
|
||||||
|
Temurin) и списку рантаймов Mojang. Формат обоих — JSON поверх HTTPS, разбор разделён на две
|
||||||
|
функции.
|
||||||
|
|
||||||
|
Запросов на одно обновление несколько: список сборок Temurin запрашивается по мажорным версиям,
|
||||||
|
и на macOS с Apple Silicon неудачный запрос сборки `aarch64` может быть переспрошен для `x64` —
|
||||||
|
это и есть тот самый случай Java 8 и 16.
|
||||||
|
|
||||||
|
Общий счётчик незавершённых запросов сводит их воедино: пока он не обнулился, каталог не
|
||||||
|
подменяется, а предупреждения от отдельных источников накапливаются.
|
||||||
|
|
||||||
|
Все сигналы и колбэки приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *javaCatalog = new JavaRuntimeService(this);
|
||||||
|
connect(javaCatalog, &JavaRuntimeService::catalogChanged, this, &Backend::rebuildJavaCatalog);
|
||||||
|
|
||||||
|
javaCatalog->ensureLoaded([this, javaCatalog](bool ok, const QString &warning) {
|
||||||
|
if (!ok) {
|
||||||
|
emit javaCatalogError(warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto best = javaCatalog->bestFor(version.javaMajor);
|
||||||
|
if (best)
|
||||||
|
emit suggestJavaInstall(best->id, best->version);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
# LauncherBackend
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`LauncherBackend` — единственный класс проекта, видимый из QML, и центр всего приложения. Интерфейс
|
||||||
|
лаунчера ничего не знает ни о сети, ни о файлах, ни о процессах: он читает свойства этого класса,
|
||||||
|
вызывает его методы и слушает его сигналы.
|
||||||
|
|
||||||
|
Сам бэкенд почти ничего не делает руками. Он владеет двенадцатью сервисами — авторизация, запуск
|
||||||
|
игры, каталоги версий, модлоадеров, Java и сезонных сборок, три установщика, загрузчик паков и
|
||||||
|
переключатель сборок — и отвечает за то, чтобы они работали в правильном порядке. Кроме того, он
|
||||||
|
хранит состояние лаунчера: профили игрока, пользовательские сборки и настройки запуска, которые
|
||||||
|
читает и пишет в файлы папки лаунчера.
|
||||||
|
|
||||||
|
Ещё одна его задача — приводить данные к виду, удобному QML. Каталоги отдаются в интерфейс уже
|
||||||
|
сведёнными с локальным состоянием: строка версии знает, скачана ли она, строка сезонной сборки —
|
||||||
|
установлена ли и не устарела ли. Окно показывает статус, не считая ничего само.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Единственный экземпляр создаётся декларативно в [Main.qml](../qml/Main.md); в `main.cpp` он не
|
||||||
|
упоминается.
|
||||||
|
|
||||||
|
Владеет двенадцатью сервисами, каждому из которых посвящена своя страница:
|
||||||
|
|
||||||
|
| Поле | Класс | Роль |
|
||||||
|
|------|-------|------|
|
||||||
|
| `m_auth` | [AuthService](AuthService.md) | вход через Ely.by и офлайн |
|
||||||
|
| `m_msa` | [MsaAuthService](MsaAuthService.md) | вход через Microsoft |
|
||||||
|
| `m_launcher` | [GameLauncher](GameLauncher.md) | запуск JVM с игрой |
|
||||||
|
| `m_manifest` | [VersionManifestService](VersionManifestService.md) | каталог версий Mojang |
|
||||||
|
| `m_installer` | [VersionInstaller](VersionInstaller.md) | установка версии игры |
|
||||||
|
| `m_loaderMeta` | [ModLoaderVersionService](ModLoaderVersionService.md) | списки версий модлоадеров |
|
||||||
|
| `m_loaderInstaller` | [ModLoaderInstaller](ModLoaderInstaller.md) | установка модлоадера |
|
||||||
|
| `m_switcher` | [BuildSwitcher](BuildSwitcher.md) | смена активной сборки |
|
||||||
|
| `m_javaMeta` | [JavaRuntimeService](JavaRuntimeService.md) | каталог сборок Java |
|
||||||
|
| `m_javaInstaller` | [JavaInstaller](JavaInstaller.md) | установка Java |
|
||||||
|
| `m_seasonalMeta` | [SeasonalBuildService](SeasonalBuildService.md) | каталог сезонных сборок |
|
||||||
|
| `m_packDownloader` | [SeasonalPackDownloader](SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||||
|
|
||||||
|
Пути ко всем файлам состояния берутся из [launcherpaths.h](launcherpaths.md), описания версий — из
|
||||||
|
[minecraftversion.h](minecraftversion.md), словарь модлоадеров — из [modloader.h](modloader.md),
|
||||||
|
поиск системной Java — из [javalocator.md](javalocator.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core`, `Qt6::Gui`, `Qt6::Network`, `Qt6::CorePrivate` (опосредованно) и
|
||||||
|
`Qt6::Quick` — класс зарегистрирован в QML-модуле `Minecraft_launcher`, объявленном в
|
||||||
|
`CMakeLists.txt`.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, свойства, сигналы и владение по родителю. Объявлен
|
||||||
|
виртуальный деструктор. Виртуальных методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Свойства Q_PROPERTY
|
||||||
|
|
||||||
|
### Профили и сборки
|
||||||
|
|
||||||
|
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||||
|
|----------|-----|------|-------|--------|----------|
|
||||||
|
| `profileNames` | `QStringList` | `profileNames` | — | `profilesChanged` | Имена профилей игрока в порядке добавления. Только для чтения; модель выпадающего списка профилей |
|
||||||
|
| `customBuildNames` | `QStringList` | `customBuildNames` | — | `customBuildsChanged` | Имена пользовательских сборок. Только для чтения; модель списка в окне сборок |
|
||||||
|
| `activeBuildIndex` | `int` | `activeBuildIndex` | `setActiveBuildIndex` | `activeBuildChanged` | Сборка, которую запускает кнопка игры. Хранится по идентификатору сборки, а не по индексу: удаление соседней записи не должно переназначать активную. Запись в свойство запускает смену сборки |
|
||||||
|
| `activeBuildName` | `QString` | `activeBuildName` | — | `activeBuildChanged` | Имя активной сборки для подписи на кнопке. Только для чтения |
|
||||||
|
| `installedVersions` | `QStringList` | `installedVersions` | — | `installedVersionsChanged` | Версии, реально установленные в папке игры. Только для чтения |
|
||||||
|
|
||||||
|
### Занятость и смена сборки
|
||||||
|
|
||||||
|
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||||
|
|----------|-----|------|-------|--------|----------|
|
||||||
|
| `gameRunning` | `bool` | `gameRunning` | — | `gameRunningChanged` | Игра запущена. Только для чтения; выключает кнопку запуска |
|
||||||
|
| `switching` | `bool` | `switching` | — | `switchChanged` | Идёт архивация или распаковка `.minecraft` при смене сборки. Отдельно от `busy`, потому что на это время блокируется ещё и список сборок. Только для чтения |
|
||||||
|
| `switchProgress` | `double` | `switchProgress` | — | `switchChanged` | Доля выполнения смены сборки от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
|
||||||
|
| `switchStage` | `QString` | `switchStage` | — | `switchChanged` | Этап смены сборки словами. Только для чтения |
|
||||||
|
| `switchStatus` | `QString` | `switchStatus` | — | `switchChanged` | Строка состояния смены сборки. Только для чтения |
|
||||||
|
| `busy` | `bool` | `busy` | — | `busyChanged` | Лаунчер занят: пока идёт загрузка версии, кнопка запуска гаснет. Только для чтения |
|
||||||
|
| `microsoftAvailable` | `bool` | `microsoftAvailable` | — | — | Собран ли лаунчер с Qt WebEngine. Константное свойство: без WebEngine окно входа Microsoft показать нечем, и интерфейс не должен предлагать этот путь |
|
||||||
|
|
||||||
|
### Каталог версий
|
||||||
|
|
||||||
|
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||||
|
|----------|-----|------|-------|--------|----------|
|
||||||
|
| `versionCatalog` | `QVariantList` | `versionCatalog` | — | `versionCatalogChanged` | Объединённый список для выбора версии: установленные, разделитель, затем весь каталог Mojang. Строка содержит поля `id`, `label`, `category`, `installed` и `search`. Только для чтения |
|
||||||
|
| `catalogLoading` | `bool` | `catalogLoading` | — | `catalogLoadingChanged` | Идёт загрузка манифеста версий. Только для чтения |
|
||||||
|
|
||||||
|
### Каталог Java
|
||||||
|
|
||||||
|
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||||
|
|----------|-----|------|-------|--------|----------|
|
||||||
|
| `javaCatalog` | `QVariantList` | `javaCatalog` | — | `javaCatalogChanged` | Сборки Java для окна выбора: скачиваемые из сети плюс те, что уже лежат в папке лаунчера. Строка содержит поля `id`, `label`, `kind`, `major`, `installed`, `downloadable`, `lts`, `sizeMb`, `detail`, `coverage` и `search`. Только для чтения |
|
||||||
|
| `javaCatalogLoading` | `bool` | `javaCatalogLoading` | — | `javaCatalogLoadingChanged` | Идёт загрузка каталога Java. Только для чтения |
|
||||||
|
|
||||||
|
### Сезонные сборки
|
||||||
|
|
||||||
|
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||||
|
|----------|-----|------|-------|--------|----------|
|
||||||
|
| `seasonalCatalog` | `QVariantList` | `seasonalCatalog` | — | `seasonalCatalogChanged` | Готовые сборки с сервера. Строки уже сведены с локальными записями: окно показывает статус, не считая ничего само. Только для чтения |
|
||||||
|
| `seasonalCatalogLoading` | `bool` | `seasonalCatalogLoading` | — | `seasonalCatalogLoadingChanged` | Идёт загрузка каталога сезонных сборок. Только для чтения |
|
||||||
|
| `seasonalCatalogError` | `QString` | `seasonalCatalogError` | — | `seasonalCatalogChanged` | Текст ошибки обращения к серверу сборок; пусто — всё в порядке. Только для чтения |
|
||||||
|
| `seasonalInstalling` | `bool` | `seasonalInstalling` | — | `seasonalInstallingChanged` | Идёт установка сезонной сборки: окно не даёт начать вторую. Только для чтения |
|
||||||
|
|
||||||
|
### Загрузка
|
||||||
|
|
||||||
|
Одного сигнала на все свойства загрузки достаточно: установщик уже ограничивает частоту, а QML всё
|
||||||
|
равно перечитывает их разом.
|
||||||
|
|
||||||
|
| Свойство | Тип | READ | WRITE | NOTIFY | Описание |
|
||||||
|
|----------|-----|------|-------|--------|----------|
|
||||||
|
| `downloading` | `bool` | `downloading` | — | `downloadChanged` | Идёт какая-либо загрузка. Только для чтения |
|
||||||
|
| `downloadProgress` | `double` | `downloadProgress` | — | `downloadChanged` | Доля выполнения от `0` до `1`; `-1` — итог неизвестен. Только для чтения |
|
||||||
|
| `downloadVersion` | `QString` | `downloadVersion` | — | `downloadChanged` | Что именно качается — версия, модлоадер, сборка Java или пак. Только для чтения |
|
||||||
|
| `downloadStatus` | `QString` | `downloadStatus` | — | `downloadChanged` | Строка состояния загрузки. Только для чтения |
|
||||||
|
| `downloadBytesDone` | `qint64` | `downloadBytesDone` | — | `downloadChanged` | Принято байт. Только для чтения |
|
||||||
|
| `downloadBytesTotal` | `qint64` | `downloadBytesTotal` | — | `downloadChanged` | Ожидаемый объём в байтах; `0` — неизвестен. Только для чтения |
|
||||||
|
|
||||||
|
Панель загрузки одна на все четыре источника: свойства отдают числа того установщика или
|
||||||
|
загрузчика, который работает сейчас.
|
||||||
|
|
||||||
|
## Методы Q_INVOKABLE
|
||||||
|
|
||||||
|
Все перечисленные ниже методы вызываются из QML.
|
||||||
|
|
||||||
|
### Профили
|
||||||
|
|
||||||
|
#### void addProfile(const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
|
||||||
|
|
||||||
|
Добавляет профиль игрока. Параметр `authType` принимает значения `offline`, `elyby` и `microsoft`.
|
||||||
|
Для офлайн-профиля пароль не нужен, для профиля Microsoft не нужны ни логин, ни пароль. Испускает
|
||||||
|
`profilesChanged`.
|
||||||
|
|
||||||
|
#### void updateProfile(int index, const QString &name, const QString &login, const QString &password, const QString &authType = "offline")
|
||||||
|
|
||||||
|
Перезаписывает профиль по индексу теми же полями. Испускает `profilesChanged`.
|
||||||
|
|
||||||
|
#### QVariantMap profileAt(int index) const
|
||||||
|
|
||||||
|
Данные профиля для диалога редактирования: имя, логин, пароль, тип, а также признак наличия
|
||||||
|
действующей сессии Microsoft и ник, полученный при официальной авторизации.
|
||||||
|
|
||||||
|
#### void removeProfile(int index)
|
||||||
|
|
||||||
|
Удаляет профиль. Испускает `profilesChanged`.
|
||||||
|
|
||||||
|
### Вход через Microsoft
|
||||||
|
|
||||||
|
#### void startMicrosoftLogin(int profileIndex)
|
||||||
|
|
||||||
|
Начинает вход в аккаунт Microsoft: испускает `microsoftLoginUrlReady` с адресом страницы входа.
|
||||||
|
|
||||||
|
Значение `-1` в `profileIndex` означает, что вход ещё не привязан к профилю: профиль создастся по
|
||||||
|
нику, который вернут Minecraft Services.
|
||||||
|
|
||||||
|
#### void finishMicrosoftLogin(const QString &code)
|
||||||
|
|
||||||
|
Завершает вход по коду авторизации, перехваченному окном браузера. Итог приходит сигналом
|
||||||
|
`microsoftLoginSucceeded` или `microsoftLoginFailed`.
|
||||||
|
|
||||||
|
#### void cancelMicrosoftLogin()
|
||||||
|
|
||||||
|
Сбрасывает начатую сессию входа. Вызывается, когда пользователь закрыл окно или адрес возврата
|
||||||
|
пришёл без кода.
|
||||||
|
|
||||||
|
#### QVariantMap inspectMicrosoftRedirect(const QString &url) const
|
||||||
|
|
||||||
|
Разбирает адрес, на который встроенное окно возвращается после входа. Возвращает карту с полями
|
||||||
|
`matched` (является ли адрес адресом возврата), `code` и `error`.
|
||||||
|
|
||||||
|
Разбор живёт в C++, чтобы правила совпадения не разъезжались с теми, по которым сервис сам строит
|
||||||
|
`redirect_uri`.
|
||||||
|
|
||||||
|
### Сборки
|
||||||
|
|
||||||
|
#### void addCustomBuild(const QString &name, const QString &serverUrl, const QString &minecraftVersion = QString())
|
||||||
|
|
||||||
|
Создаёт пользовательскую сборку. Испускает `customBuildsChanged`.
|
||||||
|
|
||||||
|
#### void updateCustomBuild(int index, const QVariantMap &fields)
|
||||||
|
|
||||||
|
Мержит в сборку только присланные ключи: `name`, `serverUrl`, `minecraftVersion`, `loader`,
|
||||||
|
`loaderVersion`, `resolvedVersionId`. Остальные поля остаются как были — это и позволяет карточке
|
||||||
|
сборки сохранять правки по одному полю за раз.
|
||||||
|
|
||||||
|
#### QVariantMap customBuildAt(int index) const
|
||||||
|
|
||||||
|
Данные сборки для карточки редактирования.
|
||||||
|
|
||||||
|
#### QVariantMap customBuildRemovalInfo(int index) const
|
||||||
|
|
||||||
|
Что именно потеряется при удалении сборки — для текста предупреждения. Возвращает имя сборки и три
|
||||||
|
признака: есть ли у неё архив, активна ли она сейчас и последняя ли она.
|
||||||
|
|
||||||
|
#### void removeCustomBuild(int index)
|
||||||
|
|
||||||
|
Удаляет сборку вместе с её архивом. Испускает `customBuildsChanged`.
|
||||||
|
|
||||||
|
#### void installCustomBuild(int index)
|
||||||
|
|
||||||
|
Докачивает то, чего не хватает выбранной сборке: версию игры и, если он выбран, модлоадер.
|
||||||
|
Вынесено отдельной кнопкой, потому что карточка сборки сохраняет правки по ходу редактирования и
|
||||||
|
установка не должна начинаться сама при каждой правке.
|
||||||
|
|
||||||
|
#### QStringList checkInstallation(int buildIndex) const
|
||||||
|
|
||||||
|
Проверка комплектности без запуска — для подсказки в интерфейсе. Пустой список означает, что
|
||||||
|
сборку можно запускать; иначе возвращаются описания недостающих файлов.
|
||||||
|
|
||||||
|
### Запуск игры
|
||||||
|
|
||||||
|
#### void launchGame(int profileIndex, int buildIndex)
|
||||||
|
|
||||||
|
Главная кнопка. Проверяет выбор профиля и версии, комплектность `.minecraft`, при необходимости
|
||||||
|
авторизуется и стартует игру.
|
||||||
|
|
||||||
|
Авторизация асинхронна, поэтому метод возвращается сразу; дальнейший ход виден по сигналам
|
||||||
|
`launchProgress`, `launched`, `launchError` и `twoFactorRequired`.
|
||||||
|
|
||||||
|
#### void submitTwoFactorCode(const QString &code)
|
||||||
|
|
||||||
|
Продолжает прерванный запуск, отдавая одноразовый код двухфакторной аутентификации. Вызывается
|
||||||
|
после сигнала `twoFactorRequired`.
|
||||||
|
|
||||||
|
#### void cancelPendingLaunch()
|
||||||
|
|
||||||
|
Отменяет запуск, остановленный на ожидании кода двухфакторной аутентификации.
|
||||||
|
|
||||||
|
#### void stopGame()
|
||||||
|
|
||||||
|
Завершает процесс игры.
|
||||||
|
|
||||||
|
### Каталог версий
|
||||||
|
|
||||||
|
#### void refreshVersionCatalog(bool force = false)
|
||||||
|
|
||||||
|
Обновляет каталог версий. Вызывается при открытии окна выбора версии: свежий кэш отвечает без
|
||||||
|
сети. Параметр `force` обходит проверку свежести.
|
||||||
|
|
||||||
|
#### bool isVersionInstalled(const QString &versionId) const
|
||||||
|
|
||||||
|
Установлена ли версия в папке игры.
|
||||||
|
|
||||||
|
#### void installVersion(const QString &versionId)
|
||||||
|
|
||||||
|
Ставит версию игры в фоне. Ход виден по свойствам загрузки.
|
||||||
|
|
||||||
|
#### QVariantMap versionRemovalInfo(const QString &versionId) const
|
||||||
|
|
||||||
|
Что потеряется при удалении версии — для текста предупреждения. Возвращает признак установки,
|
||||||
|
занимаемый объём в мегабайтах, список зависящих профилей модлоадеров и список сборок, которые эту
|
||||||
|
версию используют.
|
||||||
|
|
||||||
|
#### void removeVersion(const QString &versionId)
|
||||||
|
|
||||||
|
Удаляет файлы версии из `versions/`. Библиотеки и ресурсы остаются: они общие для всех версий.
|
||||||
|
Испускает `installedVersionsChanged`.
|
||||||
|
|
||||||
|
#### void cancelDownload()
|
||||||
|
|
||||||
|
Отменяет текущую загрузку.
|
||||||
|
|
||||||
|
### Модлоадеры
|
||||||
|
|
||||||
|
#### QVariantList loaderVersions(const QString &loaderKey, const QString &gameVersion) const
|
||||||
|
|
||||||
|
Версии модлоадера для выбранной версии игры. Возвращает список карт с полями `version`, `label`,
|
||||||
|
`recommended` и `stable`.
|
||||||
|
|
||||||
|
Несовместимых строк в списке нет — отбор заложен в сам источник данных, поэтому проверять
|
||||||
|
совместимость вызывающему коду не нужно. Пустой список означает, что лоадер эту версию игры не
|
||||||
|
поддерживает.
|
||||||
|
|
||||||
|
Параметр `loaderKey` принимает значения `forge`, `fabric`, `neoforge` и `quilt`.
|
||||||
|
|
||||||
|
#### void refreshLoaderVersions(const QString &loaderKey, const QString &gameVersion, bool force = false)
|
||||||
|
|
||||||
|
Запрашивает обновление списка версий лоадера. Результат приходит сигналом `loaderVersionsChanged`.
|
||||||
|
|
||||||
|
#### bool loaderVersionsLoading(const QString &loaderKey, const QString &gameVersion) const
|
||||||
|
|
||||||
|
Идёт ли сейчас запрос по этой паре. Позволяет интерфейсу отличить «ещё грузим» от «не
|
||||||
|
поддерживается».
|
||||||
|
|
||||||
|
#### void installLoaderForBuild(int index)
|
||||||
|
|
||||||
|
Ставит модлоадер, выбранный в сборке, и записывает получившийся профиль в `resolvedVersionId`.
|
||||||
|
|
||||||
|
### Настройки
|
||||||
|
|
||||||
|
#### QVariantMap settings() const
|
||||||
|
|
||||||
|
Настройки запуска одной картой: `gameDir`, `javaPath`, `javaRuntime`, `minMemoryMb`,
|
||||||
|
`maxMemoryMb`, `jvmArgs`, `windowWidth`, `windowHeight`, `fullscreen`, `language` и вычисленный
|
||||||
|
`resolvedGameDir`.
|
||||||
|
|
||||||
|
#### void updateSettings(const QVariantMap &values)
|
||||||
|
|
||||||
|
Записывает настройки и сохраняет их на диск. Испускает `settingsChanged`.
|
||||||
|
|
||||||
|
Смену `language` после записи пробрасывает в [Localization](Localization.md) — порядок
|
||||||
|
«сохранили → переключили» гарантирует, что выбранный язык переживёт падение сразу после
|
||||||
|
переключения. Сам `Localization` в `settings.json` не пишет: файл ведёт только бэкенд.
|
||||||
|
|
||||||
|
#### QStringList detectedJava() const
|
||||||
|
|
||||||
|
Пути ко всем java, найденным в системе. Показывается справочной строкой в диалоге настроек.
|
||||||
|
|
||||||
|
### Сборки Java
|
||||||
|
|
||||||
|
#### void refreshJavaCatalog(bool force = false)
|
||||||
|
|
||||||
|
Обновляет каталог сборок Java. Вызывается при открытии окна выбора: свежий кэш отвечает без сети.
|
||||||
|
|
||||||
|
#### void installJavaRuntime(const QString &runtimeId)
|
||||||
|
|
||||||
|
Скачивает и распаковывает сборку Java. По завершении испускается `javaRuntimeInstalled`.
|
||||||
|
|
||||||
|
#### void removeJavaRuntime(const QString &runtimeId)
|
||||||
|
|
||||||
|
Удаляет скачанную сборку Java из папки лаунчера.
|
||||||
|
|
||||||
|
#### QVariantMap javaRuntimeInfo(const QString &runtimeId) const
|
||||||
|
|
||||||
|
Описание установленной сборки: подпись, версия, путь к java и признак установки. Пустая карта
|
||||||
|
означает, что сборки с таким идентификатором в папке лаунчера нет.
|
||||||
|
|
||||||
|
Метод не является привязкой и сам не пересчитывается, когда сборка докачается, — диалог настроек
|
||||||
|
обновляет его по сигналу `javaRuntimeInstalled`.
|
||||||
|
|
||||||
|
#### int requiredJavaMajor(int buildIndex) const
|
||||||
|
|
||||||
|
Минимальная мажорная версия Java для версии игры выбранной сборки; `0` — версия не выбрана.
|
||||||
|
Передаётся в окно выбора Java, чтобы пометить слишком старые сборки.
|
||||||
|
|
||||||
|
### Сезонные сборки
|
||||||
|
|
||||||
|
#### void refreshSeasonalCatalog(bool force = false)
|
||||||
|
|
||||||
|
Обновляет каталог сезонных сборок.
|
||||||
|
|
||||||
|
#### void installSeasonalBuild(const QString &seasonalId)
|
||||||
|
|
||||||
|
Ставит или обновляет сборку целиком одной цепочкой: запись сборки, версия игры, модлоадер, Java и
|
||||||
|
файлы.
|
||||||
|
|
||||||
|
Порядок шагов жёсткий: сначала сборка делается активной, затем ставится Java — она нужна
|
||||||
|
установщику Forge, — затем модлоадер, затем качается пак и только в конце его файлы раскатываются
|
||||||
|
поверх `.minecraft`. Раскатывать файлы имеет смысл только когда всё остальное на месте.
|
||||||
|
|
||||||
|
Строка каталога копируется на момент старта: обновление списка посреди установки не должно менять
|
||||||
|
то, что ставится. По завершении испускается `seasonalInstallFinished`.
|
||||||
|
|
||||||
|
#### void cancelSeasonalInstall()
|
||||||
|
|
||||||
|
Отменяет установку сезонной сборки.
|
||||||
|
|
||||||
|
### Папки
|
||||||
|
|
||||||
|
#### void openMinecraftFolder()
|
||||||
|
|
||||||
|
Открывает папку модов Minecraft в файловом менеджере системы.
|
||||||
|
|
||||||
|
#### void openGameFolder()
|
||||||
|
|
||||||
|
Открывает корневую папку игры в файловом менеджере системы.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
### Сигналы уведомления свойств
|
||||||
|
|
||||||
|
Эти сигналы объявлены как `NOTIFY` соответствующих свойств; обработчик перечитывает свойство.
|
||||||
|
`profilesChanged`, `customBuildsChanged`, `activeBuildChanged`, `switchChanged`,
|
||||||
|
`installedVersionsChanged`, `settingsChanged`, `gameRunningChanged`, `busyChanged`,
|
||||||
|
`versionCatalogChanged`, `catalogLoadingChanged`, `javaCatalogChanged`,
|
||||||
|
`javaCatalogLoadingChanged`, `seasonalCatalogChanged`, `seasonalCatalogLoadingChanged`,
|
||||||
|
`seasonalInstallingChanged` и `downloadChanged`.
|
||||||
|
|
||||||
|
#### loaderVersionsChanged(const QString &loaderKey, const QString &gameVersion)
|
||||||
|
|
||||||
|
Список версий модлоадера изменился. Параметры сужают событие до конкретной пары, поэтому
|
||||||
|
обработчик обязан сверить их со своим текущим состоянием: обновление может относиться к другой
|
||||||
|
строке лоадера или к прошлой версии игры. Именно так поступает [LoaderRow](../qml/LoaderRow.md).
|
||||||
|
|
||||||
|
### События установки
|
||||||
|
|
||||||
|
#### seasonalInstallFinished(const QString &seasonalId, const QString &buildName)
|
||||||
|
|
||||||
|
Сезонная сборка установлена и активна — можно запускать игру. Обработчик показывает сообщение
|
||||||
|
пользователю.
|
||||||
|
|
||||||
|
#### javaRuntimeInstalled(const QString &runtimeId)
|
||||||
|
|
||||||
|
Сборка Java установлена. Диалог настроек по этому сигналу обновляет подпись выбранной сборки, не
|
||||||
|
переоткрываясь.
|
||||||
|
|
||||||
|
### Запуск игры
|
||||||
|
|
||||||
|
#### launchProgress(const QString &message)
|
||||||
|
|
||||||
|
Описание текущего шага запуска. Обработчик показывает сообщение без таймаута: шаг может занять
|
||||||
|
заметное время, и сообщение должно держаться до следующего.
|
||||||
|
|
||||||
|
#### launched(const QString &profileName, const QString &buildName, const QString &serverUrl)
|
||||||
|
|
||||||
|
Игра запущена. Обработчик сообщает пользователю, какой профиль и какая сборка стартовали.
|
||||||
|
|
||||||
|
#### launchError(const QString &message)
|
||||||
|
|
||||||
|
Запуск не удался либо произошла ошибка, о которой нужно сказать пользователю. Через этот же сигнал
|
||||||
|
сообщается о проблемах записи файлов лаунчера.
|
||||||
|
|
||||||
|
#### twoFactorRequired(const QString &profileName)
|
||||||
|
|
||||||
|
Ely.by отклонил пароль с пометкой two factor. Обработчик открывает диалог ввода кода и передаёт
|
||||||
|
введённое значение в `submitTwoFactorCode()`; отказ должен вызвать `cancelPendingLaunch()`, иначе
|
||||||
|
запуск останется висеть в ожидании.
|
||||||
|
|
||||||
|
#### gameOutput(const QString &line)
|
||||||
|
|
||||||
|
Строка вывода процесса игры. Обработчик пишет её в журнал.
|
||||||
|
|
||||||
|
#### gameFinished(int exitCode, bool crashed)
|
||||||
|
|
||||||
|
Игра завершилась. Обработчик сообщает итог: ненулевой код или выставленный `crashed` показываются
|
||||||
|
как ошибка.
|
||||||
|
|
||||||
|
### Вход через Microsoft
|
||||||
|
|
||||||
|
#### microsoftLoginUrlReady(const QString &url)
|
||||||
|
|
||||||
|
Окну входа Microsoft: открыться на этом адресе. Обработчик создаёт окно (в сборке с Qt WebEngine)
|
||||||
|
и открывает его.
|
||||||
|
|
||||||
|
#### microsoftLoginSucceeded(const QString &playerName)
|
||||||
|
|
||||||
|
Вход выполнен. Обработчик сообщает об этом пользователю, но не трогает выбор в списке профилей:
|
||||||
|
новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль.
|
||||||
|
|
||||||
|
#### microsoftLoginFailed(const QString &message)
|
||||||
|
|
||||||
|
Вход не удался.
|
||||||
|
|
||||||
|
#### microsoftReloginRequired(int profileIndex)
|
||||||
|
|
||||||
|
Сессия профиля протухла настолько, что нужен повторный вход руками. Обработчик обычно сразу
|
||||||
|
вызывает `startMicrosoftLogin()` для этого профиля.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent`. Экземпляр создаётся декларативно в QML, поэтому
|
||||||
|
временем его жизни управляет движок QML: объект живёт столько же, сколько главное окно.
|
||||||
|
|
||||||
|
Все двенадцать сервисов создаются в конструкторе с бэкендом в роли родителя и уничтожаются вместе
|
||||||
|
с ним. Порядок создания важен для двоих: [VersionInstaller](VersionInstaller.md) принимает в
|
||||||
|
конструктор сервис манифеста, а [ModLoaderInstaller](ModLoaderInstaller.md) — сервис версий
|
||||||
|
лоадеров и установщик версий; эти указатели не переходят во владение принимающей стороны.
|
||||||
|
|
||||||
|
Кэши каталогов помечены `mutable` и пересобираются лениво из константных геттеров: QML читает
|
||||||
|
свойства помногу раз за кадр, пока открыт список, и пересборка по каждому чтению обошлась бы
|
||||||
|
дорого.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Единственная работа в другом потоке — файловые операции над содержимым
|
||||||
|
`.minecraft`, и она полностью инкапсулирована в [BuildSwitcher](BuildSwitcher.md): сам бэкенд
|
||||||
|
общается с ним обычными сигналами и слотами.
|
||||||
|
|
||||||
|
## Доступ из QML
|
||||||
|
|
||||||
|
Класс зарегистрирован макросом `QML_ELEMENT` в модуле `Minecraft_launcher`, объявленном через
|
||||||
|
`qt_add_qml_module` в `CMakeLists.txt`. Имя типа в QML совпадает с именем класса —
|
||||||
|
`LauncherBackend`.
|
||||||
|
|
||||||
|
Из QML доступны все 26 свойств, все 42 метода `Q_INVOKABLE` и все сигналы, перечисленные выше.
|
||||||
|
Синглтоном тип не объявлен: экземпляр создаётся декларативно в [Main.qml](../qml/Main.md) и
|
||||||
|
передаётся во вложенные диалоги через их свойство `backend`. Все диалоги проекта объявляют его как
|
||||||
|
`required property var backend`.
|
||||||
|
|
||||||
|
Объект, созданный из QML, принадлежит движку QML — удалять его из C++ нельзя.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
**Вниз, к сервисам.** Бэкенд подписан на сигналы всех двенадцати сервисов и сводит их к своим
|
||||||
|
свойствам. Четыре разных источника загрузки — установщик версий, установщик модлоадеров,
|
||||||
|
установщик Java и загрузчик паков — отображаются в одну группу свойств `download*`, поэтому панель
|
||||||
|
в интерфейсе не различает, кто работает; какой из источников показывать, бэкенд решает сам.
|
||||||
|
|
||||||
|
**Вверх, к QML.** Интерфейс не обращается ни к одному сервису напрямую. Каталоги отдаются уже
|
||||||
|
сведёнными с локальным состоянием: строка версии знает про `installed`, строка сезонной сборки —
|
||||||
|
про установленную ревизию и доступное обновление.
|
||||||
|
|
||||||
|
**Состояние на диске.** Профили, сборки и настройки читаются при создании и пишутся при каждом
|
||||||
|
изменении. Отсутствие файла — норма (первый запуск), а повреждённое содержимое отводится в файл с
|
||||||
|
расширением `.bak`, чтобы рабочий файл создался заново. Проблемы хранилища, замеченные на старте,
|
||||||
|
накапливаются и показываются одним сообщением, когда интерфейс уже подключился к сигналам.
|
||||||
|
|
||||||
|
Отдельно предусмотрена миграция: файл `versions.json` от прежней схемы именования переносится в
|
||||||
|
`customBuilds.json` при первом запуске после переименования.
|
||||||
|
|
||||||
|
**Восстановление после сбоя.** При старте бэкенд спрашивает у переключателя сборок, не было ли
|
||||||
|
прервано переключение, и предлагает доиграть его.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
Собственных сетевых обращений и дочерних процессов у класса нет: всё внешнее взаимодействие
|
||||||
|
делегировано сервисам — сеть у каталогов, установщиков и служб авторизации, процессы у
|
||||||
|
[GameLauncher](GameLauncher.md), [ModLoaderInstaller](ModLoaderInstaller.md) и
|
||||||
|
[JavaInstaller](JavaInstaller.md), файловые операции над `.minecraft` у
|
||||||
|
[BuildSwitcher](BuildSwitcher.md).
|
||||||
|
|
||||||
|
Единственное прямое обращение к системе — открытие папки игры в файловом менеджере методами
|
||||||
|
`openMinecraftFolder()` и `openGameFolder()`.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
Класс предназначен для создания из QML, а не из C++:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
import QtQuick
|
||||||
|
import Minecraft_launcher
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: window
|
||||||
|
visible: true
|
||||||
|
|
||||||
|
LauncherBackend {
|
||||||
|
id: backend
|
||||||
|
|
||||||
|
onLaunchError: (message) => console.warn(message)
|
||||||
|
onTwoFactorRequired: (profileName) => twoFactorDialog.open()
|
||||||
|
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: backend.activeBuildName
|
||||||
|
enabled: !backend.busy && !backend.gameRunning
|
||||||
|
onClicked: backend.launchGame(profileBox.currentIndex, backend.activeBuildIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Localization
|
||||||
|
|
||||||
|
`localization.h` / `localization.cpp`
|
||||||
|
|
||||||
|
Единственный источник всех текстов интерфейса. Каталог — [`i18n/translations.json`](#каталог),
|
||||||
|
он лежит в ресурсах и правится руками. Штатных `.ts`/`.qm` в проекте нет намеренно: `lupdate`,
|
||||||
|
`lrelease` и Linguist не нужны, а все переводы видны в одном файле.
|
||||||
|
|
||||||
|
Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.
|
||||||
|
|
||||||
|
## Доступ из QML
|
||||||
|
|
||||||
|
Синглтон зарегистрирован декларативно (`QML_NAMED_ELEMENT(Loc)` + `QML_SINGLETON`) и доступен
|
||||||
|
в любом файле модуля без импорта.
|
||||||
|
|
||||||
|
```qml
|
||||||
|
Text { text: Loc.t.settings.title }
|
||||||
|
Text { text: Loc.t.java.progress.downloading.arg(label) }
|
||||||
|
DarkCombo { model: Loc.t.profile.authTypes }
|
||||||
|
```
|
||||||
|
|
||||||
|
Точечный путь `Loc.t.a.b.c` — это обращение к вложенным объектам дерева, которое строится из
|
||||||
|
плоских ключей каталога разбиением по точке.
|
||||||
|
|
||||||
|
**Почему свойство, а не метод.** Вызов `Q_INVOKABLE` не регистрирует зависимость привязки, и
|
||||||
|
`text: Loc.t("ключ")` никогда бы не обновился при смене языка. Чтение `Q_PROPERTY` с сигналом
|
||||||
|
`NOTIFY` зависимость регистрирует: `languageChanged` перевычисляет все привязки, которые читали
|
||||||
|
`Loc.t`. Сегменты после `t` — обычные обращения к членам JS-объекта, отслеживать их не нужно,
|
||||||
|
потому что при смене языка дерево заменяется целиком.
|
||||||
|
|
||||||
|
Тип свойства — `QJSValue`, а не `QVariantMap`: `QVariantMap` пересобирался бы в новый JS-объект
|
||||||
|
при каждом чтении, а привязок в проекте полторы сотни. `QJSValue` строится один раз на смену
|
||||||
|
языка.
|
||||||
|
|
||||||
|
Из тела JS-функции `Loc.t` читается так же — это просто чтение свойства:
|
||||||
|
|
||||||
|
```qml
|
||||||
|
onLaunched: (profileName, buildName) =>
|
||||||
|
window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Доступ из C++
|
||||||
|
|
||||||
|
Свободные функции, а не методы: их вызывают и из namespace-обёрток
|
||||||
|
([LauncherPaths](launcherpaths.md), [JavaLocator](javalocator.md),
|
||||||
|
[ZlibReference](zlibreference.md)), где никакого `QObject` нет.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
emit launchError(Loc::text("launch.error.noProfile"));
|
||||||
|
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
|
||||||
|
const QStringList kinds = Loc::list("profile.authTypes");
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и `qWarning`: строка вида
|
||||||
|
`launch.error.noProfile` в интерфейсе сразу бросается в глаза.
|
||||||
|
|
||||||
|
### Потоки
|
||||||
|
|
||||||
|
`Loc::text()` и `Loc::list()` можно звать из любого потока. Каталог заполняется ровно один раз
|
||||||
|
в `load()`, который отрабатывает в `main()` до того, как [BuildSwitcher](BuildSwitcher.md)
|
||||||
|
создаст свой поток; дальше он только читается, а копирование `QString` из хэша безопасно само по
|
||||||
|
себе. Единственное, что меняется на ходу, — индекс текущего языка, и он `QAtomicInt`. В худшем
|
||||||
|
случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.
|
||||||
|
|
||||||
|
`setLanguage()` и рассылка `languageChanged` — только поток GUI; это проверяется `Q_ASSERT`.
|
||||||
|
|
||||||
|
## Каталог
|
||||||
|
|
||||||
|
`i18n/translations.json` попадает в ресурсы через список `RESOURCES` в `qt_add_qml_module`,
|
||||||
|
поэтому читается по пути `:/qt/qml/Minecraft_launcher/i18n/translations.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"languages": ["ru", "en"],
|
||||||
|
"displayNames": { "ru": "Русский", "en": "English" }
|
||||||
|
},
|
||||||
|
"strings": {
|
||||||
|
"settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
|
||||||
|
"java.progress.downloading": { "ru": "Загрузка Java «%1»…", "en": "Downloading Java \"%1\"…" },
|
||||||
|
"profile.authTypes": {
|
||||||
|
"ru": ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"],
|
||||||
|
"en": ["Offline (no password)", "Ely.by", "Microsoft (licensed)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок
|
||||||
|
файла, а третий язык добавляется колонкой без правок загрузчика.
|
||||||
|
|
||||||
|
**Имя ключа — `<домен>.<вид>.<имя>`**, сегменты в lowerCamelCase.
|
||||||
|
|
||||||
|
- **Домен** — область, а не имя файла: `app`, `common`, `settings`, `profile`, `build`,
|
||||||
|
`seasonal`, `version`, `java`, `loader`, `auth` (с `auth.ely.*`, `auth.msa.*`), `launch`,
|
||||||
|
`game`, `switch`, `storage`, `zlib`.
|
||||||
|
- **Вид** — `title`, `label`, `button`, `placeholder`, `hint`, `header`; для сообщений `error`,
|
||||||
|
`progress`, `status`, `warning`.
|
||||||
|
- **Имя** описывает условие, а не формулировку, чтобы перевод не переименовывал ключ:
|
||||||
|
`gameRunning`, `downloadFailed`, `checksumMismatch`.
|
||||||
|
|
||||||
|
Литерал, который нужен в двух и более файлах, живёт в `common.*`.
|
||||||
|
|
||||||
|
Подстановки `%1`/`%2` сохраняются дословно: их одинаково понимают `QString::arg()` и
|
||||||
|
QML-овский `String.arg()`. **Набор `%N` в `ru` и `en` обязан совпадать** — порядок слов может
|
||||||
|
отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной
|
||||||
|
строки, которой оно требуется.
|
||||||
|
|
||||||
|
## Как добавить строку
|
||||||
|
|
||||||
|
1. Добавить запись в `strings` файла `i18n/translations.json` — сразу с `ru` и `en`.
|
||||||
|
2. Сослаться на неё: `Loc.t.домен.вид.имя` в QML или `Loc::text("домен.вид.имя")` в C++.
|
||||||
|
3. Прогнать `python3 tools/check_translations.py`.
|
||||||
|
|
||||||
|
## Как добавить язык
|
||||||
|
|
||||||
|
1. Дописать код в `_meta.languages` и название в `_meta.displayNames`.
|
||||||
|
2. Добавить колонку с этим кодом в каждую запись `strings`.
|
||||||
|
3. Добавить код в `stLanguage.codes` и пункт в модель комбобокса в [Main.qml](../qml/Main.md),
|
||||||
|
а также ключ `settings.language.<код>` с эндонимом (название языка не переводится — оно
|
||||||
|
одинаково во всех колонках).
|
||||||
|
4. При необходимости поправить `systemLanguage()` в `localization.cpp`: сейчас он выбирает
|
||||||
|
русский для русской системной локали и английский во всех остальных случаях.
|
||||||
|
|
||||||
|
## Выбор языка и его хранение
|
||||||
|
|
||||||
|
Ключ настройки — `language`, значения `"system"`, `"ru"`, `"en"`, по умолчанию `"system"`.
|
||||||
|
Хранится в `settings.json` рядом с остальными настройками; в интерфейсе — первым пунктом
|
||||||
|
диалога «Настройки запуска», применяется по кнопке «Сохранить».
|
||||||
|
|
||||||
|
Круг замкнут в одну сторону, циклической зависимости нет:
|
||||||
|
|
||||||
|
```
|
||||||
|
main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile() (только чтение, один раз)
|
||||||
|
LauncherBackend::updateSettings() ──► Localization::setLanguage() (в одну сторону)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Localization` ничего не знает про [LauncherBackend](LauncherBackend.md) — писать `settings.json`
|
||||||
|
по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше,
|
||||||
|
чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.
|
||||||
|
|
||||||
|
Неизвестное значение (файл правили руками) откатывается на `"system"` с предупреждением.
|
||||||
|
Отсутствующий или испорченный каталог — ошибка на старте: `main()` пишет причину и возвращает
|
||||||
|
`-1`. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер,
|
||||||
|
у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему
|
||||||
|
не запустился.
|
||||||
|
|
||||||
|
**Известное ограничение.** Уже сложенные в поля C++ строки не перепереводятся:
|
||||||
|
`LauncherBackend::m_storageIssues` собирается при старте, а `stage()`/`status()` у
|
||||||
|
[BuildSwitcher](BuildSwitcher.md) заменяются на следующем тике прогресса. Все они
|
||||||
|
диагностические и короткоживущие.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
`tools/check_translations.py` — только чтение, ненулевой код возврата при любой ошибке:
|
||||||
|
|
||||||
|
- каждый `Loc::text("…")` и `Loc::list("…")` из C++ есть в каталоге и совпадает по типу значения;
|
||||||
|
- каждый путь `Loc.t.a.b.c` из QML разворачивается в существующий ключ;
|
||||||
|
- наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
|
||||||
|
- наборы `%N` совпадают по языкам, длины списков равны;
|
||||||
|
- не осталось ни одного `tr(`, `qsTr(` или `QCoreApplication::translate`;
|
||||||
|
- ключи, на которые никто не ссылается, — предупреждением.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# ModLoaderInstaller
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`ModLoaderInstaller` ставит модлоадер в `.minecraft`. Под одним фасадом он прячет два совершенно
|
||||||
|
разных пути установки.
|
||||||
|
|
||||||
|
**Fabric и Quilt** отдают готовое описание версии: лаунчер кладёт его в
|
||||||
|
`versions/<id>/<id>.json` и передаёт дальше [VersionInstaller](VersionInstaller.md), который по
|
||||||
|
полю `inheritsFrom` сам поставит ванильную версию и библиотеки лоадера.
|
||||||
|
|
||||||
|
**Forge и NeoForge** так не умеют: их установка — это патч клиентского jar. Поэтому лаунчер
|
||||||
|
запускает официальный `installer.jar` найденной Java в headless-режиме и смотрит, какой профиль
|
||||||
|
появился в `versions`. Процессу установщика при этом подсовывается эталонный zlib — см.
|
||||||
|
[ZlibReference](zlibreference.md).
|
||||||
|
|
||||||
|
Оба пути начинаются одинаково: пока ванильная версия не скачана целиком, ставить лоадер некуда.
|
||||||
|
|
||||||
|
Набор геттеров прогресса намеренно повторяет `VersionInstaller`: панель загрузки в интерфейсе
|
||||||
|
читает их одинаково, независимо от того, кто сейчас работает.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает [modloader.h](modloader.md). В конструктор принимает
|
||||||
|
[ModLoaderVersionService](ModLoaderVersionService.md) — за адресом установщика — и
|
||||||
|
[VersionInstaller](VersionInstaller.md) — за базовой версией и за докачиванием того, что
|
||||||
|
`installer.jar` не положил.
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QProcess`, `QFile`) и `Qt6::Network` (`QNetworkAccessManager`,
|
||||||
|
`QNetworkReply`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, шесть сигналов и владение по родителю. Объявлен
|
||||||
|
виртуальный деструктор — класс владеет процессом установщика и открытым файлом журнала.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### ModLoaderInstaller(ModLoaderVersionService \*meta, VersionInstaller \*versionInstaller, QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт установщик поверх двух сервисов. Ни один из них не переходит во владение установщика —
|
||||||
|
оба обязаны пережить его.
|
||||||
|
|
||||||
|
#### bool isRunning() const
|
||||||
|
|
||||||
|
Идёт ли установка прямо сейчас.
|
||||||
|
|
||||||
|
#### QString label() const
|
||||||
|
|
||||||
|
Подпись текущей установки для интерфейса — название лоадера с версиями.
|
||||||
|
|
||||||
|
#### QString stage() const
|
||||||
|
|
||||||
|
Текущий этап словами.
|
||||||
|
|
||||||
|
#### QString currentFile() const
|
||||||
|
|
||||||
|
Файл, который обрабатывается сейчас.
|
||||||
|
|
||||||
|
#### qint64 bytesDone() const
|
||||||
|
|
||||||
|
Сколько байт уже получено.
|
||||||
|
|
||||||
|
#### qint64 bytesTotal() const
|
||||||
|
|
||||||
|
Ожидаемый общий объём; `0` — неизвестен.
|
||||||
|
|
||||||
|
#### double fraction() const
|
||||||
|
|
||||||
|
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен. У пути с `installer.jar` доля
|
||||||
|
почти всё время равна `-1`: сколько работы осталось внутри чужого процесса, лаунчер не знает.
|
||||||
|
|
||||||
|
#### void install(const QString &gameDir, ModLoader loader, const QString &gameVersion, const QString &loaderVersion, const QString &javaPreference)
|
||||||
|
|
||||||
|
Ставит модлоадер. Параметр `javaPreference` — путь к java, указанный пользователем в настройках;
|
||||||
|
он проверяется первым, а при пустом или неподходящем значении java ищется сама. Java нужна даже на
|
||||||
|
пути Fabric и Quilt, потому что перед установкой лоадера скачивается базовая версия игры.
|
||||||
|
|
||||||
|
Перед запуском `installer.jar` установщик записывает заглушку `launcher_profiles.json`: без этого
|
||||||
|
файла официальные установщики Forge и NeoForge отказываются работать.
|
||||||
|
|
||||||
|
#### void cancel()
|
||||||
|
|
||||||
|
Отменяет установку. Если `installer.jar` уже успел отработать, за появившиеся в `versions` папки
|
||||||
|
отвечает лаунчер, и при отмене они убираются.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### started(const QString &label)
|
||||||
|
|
||||||
|
Установка началась. Обработчик показывает панель прогресса.
|
||||||
|
|
||||||
|
#### progressChanged()
|
||||||
|
|
||||||
|
Изменились числа прогресса. Обработчик перечитывает геттеры — тот же набор, что у
|
||||||
|
[VersionInstaller](VersionInstaller.md).
|
||||||
|
|
||||||
|
#### finished(const QString &loaderKey, const QString &gameVersion, const QString &loaderVersion, const QString &producedVersionId)
|
||||||
|
|
||||||
|
Модлоадер установлен. Последний параметр — идентификатор появившегося профиля `versions/<id>`.
|
||||||
|
Он важен именно для Forge: заранее этот идентификатор неизвестен, поэтому новый профиль ищется
|
||||||
|
разницей между списком версий до и после запуска установщика, а не угадыванием строки —
|
||||||
|
идентификаторы Forge отличаются по эпохам.
|
||||||
|
|
||||||
|
Обработчик записывает `producedVersionId` в сборку: именно этот профиль будет запускаться.
|
||||||
|
|
||||||
|
#### failed(const QString &label, const QString &message)
|
||||||
|
|
||||||
|
Установка не удалась. Текст ошибки по возможности объясняет причину: отдельно распознаётся случай,
|
||||||
|
когда в системе стоит zlib-ng, а подменить его было нечем — это почти наверняка причина
|
||||||
|
расхождения sha1 у Forge, и о ней стоит сказать прямым текстом.
|
||||||
|
|
||||||
|
#### canceled(const QString &label)
|
||||||
|
|
||||||
|
Установка отменена пользователем.
|
||||||
|
|
||||||
|
#### log(const QString &line)
|
||||||
|
|
||||||
|
Строка вывода процесса `installer.jar`. Обработчик пишет её в журнал; вывод также сохраняется в
|
||||||
|
файл, чтобы разбираться с неудачной установкой после закрытия лаунчера.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
|
||||||
|
Указатели на `ModLoaderVersionService` и `VersionInstaller`, переданные в конструктор, **не
|
||||||
|
принадлежат** установщику: оба сервиса создаются раньше и живут дольше.
|
||||||
|
|
||||||
|
`QNetworkAccessManager` создаётся в конструкторе с установщиком в роли родителя; сетевой ответ,
|
||||||
|
процесс установщика и файл журнала создаются по ходу работы и закрываются в деструкторе.
|
||||||
|
|
||||||
|
Установка асинхронна и состоит из вложенных продолжений: `ensureBaseVersion()` принимает функцию,
|
||||||
|
которая будет вызвана после появления базовой версии. Уничтожение установщика посреди этой цепочки
|
||||||
|
обрывает её.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Дочерний процесс `installer.jar` работает параллельно, но общение с ним идёт
|
||||||
|
через сигналы `QProcess`, которые приходят в поток GUI.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `install()` при установке сборки с модлоадером и переправляет сигналы
|
||||||
|
прогресса в те же свойства, что и у `VersionInstaller`, — панель загрузки не различает, кто
|
||||||
|
работает. По сигналу `finished` бэкенд записывает `producedVersionId` в поле сборки
|
||||||
|
`resolvedVersionId`, которое до установки пусто и означает запуск на чистой ванили.
|
||||||
|
|
||||||
|
[ModLoaderVersionService](ModLoaderVersionService.md) даёт адрес `installer.jar`,
|
||||||
|
[VersionInstaller](VersionInstaller.md) ставит базовую версию до начала работы и докачивает
|
||||||
|
недостающие библиотеки после неё, [ZlibReference](zlibreference.md) готовит окружение процесса.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Загрузка `installer.jar` по HTTPS для Forge и NeoForge. Для Fabric и
|
||||||
|
Quilt сеть используется только через сервис версий и `VersionInstaller`.
|
||||||
|
|
||||||
|
**Дочерний процесс.** `installer.jar` запускается java в headless-режиме через `QProcess`.
|
||||||
|
Окружение процесса готовится `ZlibReference::applyTo()`: на Linux с zlib-ng туда дописывается
|
||||||
|
`LD_PRELOAD` с эталонной библиотекой, иначе установщик Forge падает с сообщением «Processor
|
||||||
|
failed, invalid outputs» — он сверяет sha1 собранных им же jar-файлов с эталоном, посчитанным на
|
||||||
|
обычном zlib.
|
||||||
|
|
||||||
|
Вывод процесса читается построчно, отдаётся сигналом `log` и параллельно пишется в файл. Результат
|
||||||
|
установки определяется не кодом выхода, а появлением новой папки в `versions`.
|
||||||
|
|
||||||
|
Все сигналы приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *loaderInstaller = new ModLoaderInstaller(loaderVersions, versionInstaller, this);
|
||||||
|
|
||||||
|
connect(loaderInstaller, &ModLoaderInstaller::finished, this,
|
||||||
|
[this](const QString &key, const QString &game,
|
||||||
|
const QString &loaderVersion, const QString &producedVersionId) {
|
||||||
|
m_build.resolvedVersionId = producedVersionId;
|
||||||
|
saveBuilds();
|
||||||
|
});
|
||||||
|
connect(loaderInstaller, &ModLoaderInstaller::failed, this,
|
||||||
|
[this](const QString &, const QString &message) { emit launchError(message); });
|
||||||
|
|
||||||
|
loaderInstaller->install(gameDir, ModLoader::Forge,
|
||||||
|
QStringLiteral("1.20.1"), QStringLiteral("47.4.0"),
|
||||||
|
settings.javaPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# ModLoaderVersionService
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
Каждый модлоадер публикует свой список версий, и у каждого он устроен по-своему. `ModLoaderVersionService`
|
||||||
|
приводит все четыре к одному виду: качает списки, кэширует в папке лаунчера и отдаёт из кэша, пока
|
||||||
|
тот не устарел. Устроен так же, как [VersionManifestService](VersionManifestService.md).
|
||||||
|
|
||||||
|
Главная особенность класса — в том, чего в нём нет: отдельной проверки совместимости с версией
|
||||||
|
игры. Совместимость заложена в структуру данных. Fabric и Quilt отдают список сразу под нужную
|
||||||
|
версию игры, а `maven-metadata` Forge и NeoForge раскладывается по версиям игры при разборе.
|
||||||
|
Версии игры, под которую сборок нет, соответствует пустой список — выбрать несовместимый лоадер
|
||||||
|
физически нечем.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает [modloader.h](modloader.md): перечисление `ModLoader` и структура `LoaderVersionEntry`
|
||||||
|
приходят оттуда.
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md) — из записи списка он берёт адрес `installer.jar`.
|
||||||
|
|
||||||
|
Путь к файлу кэша даёт `LauncherPaths::loaderCacheFile()` из [launcherpaths.h](launcherpaths.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QSet`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||||
|
методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Псевдонимы типов
|
||||||
|
|
||||||
|
`ModLoaderVersionService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
||||||
|
Как и у сервиса манифеста, `ok == true` с непустым `warning` означает, что данные отдали из
|
||||||
|
устаревшего кэша.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit ModLoaderVersionService(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт сервис и его `QNetworkAccessManager`. Кэши читаются лениво, при первом обращении к
|
||||||
|
конкретному лоадеру. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### void ensureLoaded(ModLoader loader, const QString &gameVersion, Callback callback, bool forceRefresh = false)
|
||||||
|
|
||||||
|
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
||||||
|
один сетевой запрос на всех, кто успел попросить.
|
||||||
|
|
||||||
|
Свежесть считается по-разному в зависимости от лоадера: Fabric и Quilt спрашиваются по каждой
|
||||||
|
версии игры отдельно, поэтому отметка времени у них своя на каждую версию; Forge и NeoForge
|
||||||
|
приходят одним `maven-metadata` на все версии сразу, и отметка у них одна на весь лоадер.
|
||||||
|
|
||||||
|
Параметр `forceRefresh` обходит проверку свежести.
|
||||||
|
|
||||||
|
#### QList<LoaderVersionEntry> versions(ModLoader loader, const QString &gameVersion) const
|
||||||
|
|
||||||
|
Список сборок лоадера под конкретную версию игры. Новые сборки идут первыми, поэтому первая строка
|
||||||
|
— самая свежая; именно её интерфейс подставляет по умолчанию.
|
||||||
|
|
||||||
|
Пустой список означает, что лоадер эту версию игры не поддерживает.
|
||||||
|
|
||||||
|
#### bool isRefreshing(ModLoader loader, const QString &gameVersion) const
|
||||||
|
|
||||||
|
Идёт ли сейчас запрос по этой паре. Интерфейс по этому признаку отличает «ещё грузим» от «не
|
||||||
|
поддерживается» — оба случая выглядят пустым списком.
|
||||||
|
|
||||||
|
#### std::optional<LoaderVersionEntry> find(ModLoader loader, const QString &gameVersion, const QString &loaderVersion) const
|
||||||
|
|
||||||
|
Запись по версии лоадера; `std::nullopt`, если такой нет. Из неё установщик берёт ссылку на
|
||||||
|
`installer.jar`.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### versionsChanged(const QString &loaderKey, const QString &gameVersion)
|
||||||
|
|
||||||
|
Список версий изменился. Параметры сужают событие до конкретной пары: `loaderKey` принимает
|
||||||
|
значения `forge`, `fabric`, `neoforge`, `quilt`.
|
||||||
|
|
||||||
|
Обработчик должен сверить оба параметра со своим текущим состоянием и перечитать `versions()`,
|
||||||
|
только если они совпадают, — иначе обновление относится к другой строке лоадера. Именно так
|
||||||
|
поступает [LoaderRow](../qml/LoaderRow.md).
|
||||||
|
|
||||||
|
#### refreshingChanged()
|
||||||
|
|
||||||
|
Изменился признак сетевого обновления у какой-либо пары. Обработчик перечитывает
|
||||||
|
`isRefreshing()` для интересующей его пары.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||||
|
|
||||||
|
Отложенные колбэки хранятся по ключу запроса до завершения соответствующего обращения к сети.
|
||||||
|
Уничтожение сервиса с незавершённым запросом обрывает его, и накопленные колбэки не вызываются.
|
||||||
|
|
||||||
|
Ссылку на сервис держит установщик модлоадеров; уничтожать сервис раньше установщика нельзя.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` оборачивает сервис тремя методами, доступными из QML: получить список, запросить
|
||||||
|
обновление и узнать, идёт ли загрузка. Сигнал `versionsChanged` он переправляет в QML под тем же
|
||||||
|
именем, поэтому строка лоадера в карточке сборки подписывается прямо на него.
|
||||||
|
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md) обращается к `find()` за адресом установщика перед
|
||||||
|
началом установки.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс обращается к четырём разным источникам метаданных, и форматы
|
||||||
|
ответов различаются: у Fabric и Quilt это JSON, у Forge и NeoForge — XML `maven-metadata`. Разбор
|
||||||
|
разделён на две функции соответственно.
|
||||||
|
|
||||||
|
Испорченный ответ разбирается в пустой результат, и хороший кэш им не затирается — это сознательное
|
||||||
|
решение: лучше показать вчерашний список, чем стереть его из-за сбоя на сервере.
|
||||||
|
|
||||||
|
При недоступной сети данные отдаются из устаревшего кэша с `ok == true` и заполненным `warning`.
|
||||||
|
|
||||||
|
Все сигналы и колбэки приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *loaders = new ModLoaderVersionService(this);
|
||||||
|
|
||||||
|
connect(loaders, &ModLoaderVersionService::versionsChanged,
|
||||||
|
this, [this](const QString &key, const QString &game) {
|
||||||
|
if (key == loaderKey(ModLoader::Fabric) && game == m_gameVersion)
|
||||||
|
emit fabricVersionsChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
loaders->ensureLoaded(ModLoader::Fabric, QStringLiteral("1.21.1"),
|
||||||
|
[this, loaders](bool ok, const QString &warning) {
|
||||||
|
if (!ok) {
|
||||||
|
showStatus(warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto list = loaders->versions(ModLoader::Fabric,
|
||||||
|
QStringLiteral("1.21.1"));
|
||||||
|
if (!list.isEmpty())
|
||||||
|
selectVersion(list.first().loaderVersion);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# MsaAuthService
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`MsaAuthService` — авторизация через учётную запись Microsoft, то есть вход с лицензионной копией
|
||||||
|
игры. Это та же цепочка, что и в официальном лаунчере: OAuth2 → Xbox Live → XSTS → Minecraft
|
||||||
|
Services → проверка лицензии.
|
||||||
|
|
||||||
|
Результат отдаётся тем же `AuthResult`, что и [AuthService](AuthService.md) для Ely.by и офлайна,
|
||||||
|
поэтому запуск игры дальше идёт по общему пути и ничего не знает о способе входа.
|
||||||
|
|
||||||
|
Класс не показывает окно входа сам: страницу Microsoft открывает
|
||||||
|
[MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md) на стороне QML, а сервис даёт ему адрес
|
||||||
|
страницы и разбирает адрес возврата.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает `authservice.h` — ради общей структуры `AuthResult`. Экземпляр создаётся и принадлежит
|
||||||
|
[LauncherBackend](LauncherBackend.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QJsonObject`, `QString`, `QUrl`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`).
|
||||||
|
|
||||||
|
Сам класс собирается всегда и не зависит от Qt WebEngine — от наличия WebEngine зависит только
|
||||||
|
окно, в котором показывается страница входа. Поэтому в сборке без WebEngine сервис существует, но
|
||||||
|
воспользоваться им нельзя: показать страницу нечем.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, сигнал `progress`, владение по родителю. Виртуальных
|
||||||
|
методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Псевдонимы типов
|
||||||
|
|
||||||
|
`MsaAuthService::Callback` — `std::function<void(const AuthResult &)>`. Колбэк вызывается ровно
|
||||||
|
один раз и в потоке GUI.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit MsaAuthService(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### static QString clientId()
|
||||||
|
|
||||||
|
Идентификатор приложения лаунчера в Microsoft. Игра ждёт его в `${clientid}`: официальный лаунчер
|
||||||
|
подставляет туда именно идентификатор приложения, а не случайный токен сессии.
|
||||||
|
|
||||||
|
#### static QUrl authorizationUrl()
|
||||||
|
|
||||||
|
Адрес страницы входа для встроенного окна браузера. В адресе уже собраны идентификатор клиента,
|
||||||
|
`redirect_uri` и параметр выбора аккаунта — вызывающему коду достаточно открыть эту ссылку.
|
||||||
|
|
||||||
|
#### static bool matchRedirect(const QUrl &url, QString \*code, QString \*error)
|
||||||
|
|
||||||
|
Отличает адрес, на который Microsoft возвращает управление после входа, от остальной навигации
|
||||||
|
внутри окна. Возвращает `true` только для адреса возврата.
|
||||||
|
|
||||||
|
При совпадении заполняется ровно одно из двух: `code` — код авторизации при успешном входе, либо
|
||||||
|
`error` — текст отказа. Разбор адреса живёт здесь, а не в QML, потому что правила совпадения
|
||||||
|
обязаны совпадать с теми, по которым сервис сам строит `redirect_uri`.
|
||||||
|
|
||||||
|
#### void loginWithCode(const QString &code, Callback callback)
|
||||||
|
|
||||||
|
Полный вход по коду, полученному из окна браузера. Проходит всю цепочку: обмен кода на токен
|
||||||
|
Microsoft, аутентификация в Xbox Live, авторизация XSTS, вход в Minecraft Services, проверка
|
||||||
|
лицензии и получение профиля.
|
||||||
|
|
||||||
|
По ходу испускает `progress` с описанием текущего шага — цепочка длинная, и без обратной связи
|
||||||
|
вход выглядел бы зависанием. Результат приходит в `callback` один раз.
|
||||||
|
|
||||||
|
Если вход прошёл, но копии игры на аккаунте нет, в результате выставлен `licenseMissing`: этот
|
||||||
|
случай чинится только покупкой, поэтому обрабатывается отдельно от прочих ошибок.
|
||||||
|
|
||||||
|
#### void loginWithRefreshToken(const QString &refreshToken, Callback callback)
|
||||||
|
|
||||||
|
Продление сессии без участия пользователя. Refresh-токен Microsoft живёт куда дольше суточного
|
||||||
|
токена Minecraft, так что при повторном запуске лаунчера обычно хватает его, и окно входа
|
||||||
|
показывать не приходится.
|
||||||
|
|
||||||
|
Проходит ту же цепочку, начиная с обмена refresh-токена. Неудача означает, что токен окончательно
|
||||||
|
протух и нужен полноценный вход через окно.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### progress(const QString &message)
|
||||||
|
|
||||||
|
Описание текущего шага цепочки авторизации.
|
||||||
|
|
||||||
|
Обработчик показывает сообщение пользователю. Сигнал особенно важен для этого класса: шагов пять,
|
||||||
|
каждый — отдельный сетевой запрос, и между ними проходит заметное время.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит. `QNetworkAccessManager`
|
||||||
|
создаётся в конструкторе с сервисом в роли родителя.
|
||||||
|
|
||||||
|
Каждый шаг цепочки вызывается из колбэка предыдущего, поэтому незавершённый вход держит цепочку
|
||||||
|
захваченных колбэков до своего конца. Уничтожение сервиса посреди цепочки обрывает её вместе с
|
||||||
|
менеджером сети, и колбэк не вызывается.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Все методы асинхронные, колбэки и сигналы приходят в поток, где создан сервис.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` создаёт сервис, отдаёт в QML адрес страницы входа, принимает от окна код
|
||||||
|
авторизации и вызывает `loginWithCode()`. Полученные `refreshToken` и `expiresAt` он сохраняет в
|
||||||
|
профиле, чтобы при следующем запуске обойтись `loginWithRefreshToken()`.
|
||||||
|
|
||||||
|
Заполненный `AuthResult` дальше раскладывается по полям `LaunchOptions` для
|
||||||
|
[GameLauncher](GameLauncher.md) — ровно так же, как результат от [AuthService](AuthService.md).
|
||||||
|
|
||||||
|
Со стороны QML вход выглядит так: `LauncherBackend` испускает сигнал с адресом страницы, главное
|
||||||
|
окно открывает [MicrosoftLoginDialog](../qml/MicrosoftLoginDialog.md), тот следит за навигацией и
|
||||||
|
возвращает код обратно в бэкенд.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс последовательно обращается к пяти внешним службам: конечной
|
||||||
|
точке OAuth2 Microsoft, Xbox Live, XSTS, Minecraft Services и профильной конечной точке Minecraft.
|
||||||
|
Формат — JSON поверх HTTPS, кроме первого шага, где тело запроса отправляется как форма
|
||||||
|
(`postForm()`); дальше используются `postJson()` и `getJson()` с токеном в заголовке
|
||||||
|
авторизации.
|
||||||
|
|
||||||
|
Все запросы инициирует лаунчер. Ответ каждого шага разбирается тремя исходами: успех, ошибка с
|
||||||
|
кодом состояния и транспортная ошибка — последняя приходит отдельным параметром, чтобы отличить
|
||||||
|
недоступную сеть от отказа службы.
|
||||||
|
|
||||||
|
Повторных попыток класс не делает. Все сигналы и колбэки приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *msa = new MsaAuthService(this);
|
||||||
|
connect(msa, &MsaAuthService::progress, this, &Backend::showStatus);
|
||||||
|
|
||||||
|
// 1. открыть окно браузера на этом адресе
|
||||||
|
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl());
|
||||||
|
|
||||||
|
// 2. когда окно поймало адрес возврата
|
||||||
|
QString code, error;
|
||||||
|
if (MsaAuthService::matchRedirect(url, &code, &error) && !code.isEmpty()) {
|
||||||
|
msa->loginWithCode(code, [this](const AuthResult &result) {
|
||||||
|
if (result.licenseMissing) {
|
||||||
|
emit loginFailed(tr("На аккаунте нет копии Minecraft"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
emit loginFailed(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeSession(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# SeasonalBuildService
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
Кроме сборок, которые пользователь собирает сам, лаунчер умеет ставить готовые сезонные сборки с
|
||||||
|
собственного файлового сервера: набор модов под конкретную версию игры и модлоадер, подготовленный
|
||||||
|
заранее и выдаваемый целиком.
|
||||||
|
|
||||||
|
`SeasonalBuildService` — каталог этих сборок: скачивает `index.json` с файлового сервера, кэширует
|
||||||
|
в папке лаунчера и отдаёт из кэша, пока тот не устарел. Устройство повторяет
|
||||||
|
[VersionManifestService](VersionManifestService.md) — включая то, что пустой разбор считается
|
||||||
|
испорченным ответом и хороший кэш им не затирается.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Записи каталога
|
||||||
|
используются [SeasonalPackDownloader](SeasonalPackDownloader.md) — оттуда берётся адрес архива и
|
||||||
|
его контрольная сумма.
|
||||||
|
|
||||||
|
Путь к файлу кэша даёт `LauncherPaths::seasonalCatalogFile()` из
|
||||||
|
[launcherpaths.h](launcherpaths.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QDate`, `QDateTime`, `QHash`, `QUrl`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||||
|
методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Публичные структуры
|
||||||
|
|
||||||
|
### SeasonalBuildEntry
|
||||||
|
|
||||||
|
Одна готовая сборка с сервера сезонных сборок.
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `id` | `QString` | — | Идентификатор вида `season-5`; он же ключ, по которому сборка узнаётся среди локальных записей |
|
||||||
|
| `name` | `QString` | — | Название для интерфейса, например «Сезон 5: Пустоши» |
|
||||||
|
| `revision` | `int` | `0` | Растёт при каждой публикации; сравнение с установленной ревизией даёт признак доступного обновления |
|
||||||
|
| `minecraftVersion` | `QString` | — | Версия игры, например `1.20.1` |
|
||||||
|
| `loader` | `QString` | — | Ключ модлоадера: пустая строка (чистая ваниль), `forge`, `fabric`, `neoforge` или `quilt` |
|
||||||
|
| `loaderVersion` | `QString` | — | Версия модлоадера |
|
||||||
|
| `modCount` | `int` | `0` | Число модов в сборке; показывается колонкой в таблице |
|
||||||
|
| `seasonStart` | `QDate` | — | Начало сезона |
|
||||||
|
| `seasonEnd` | `QDate` | — | Конец сезона; невалидная дата означает, что сезон ещё не закончен |
|
||||||
|
| `serverUrl` | `QString` | — | Адрес игрового сервера — не файлового, с которого качается сборка |
|
||||||
|
| `javaMajor` | `int` | `0` | Требуемая версия Java; `0` означает «определять по версии игры» |
|
||||||
|
| `description` | `QString` | — | Описание сборки; показывается в подвале окна каталога |
|
||||||
|
| `archiveUrl` | `QUrl` | — | Адрес архива сборки |
|
||||||
|
| `archiveSize` | `qint64` | `0` | Размер архива в байтах |
|
||||||
|
| `archiveSha256` | `QString` | — | Контрольная сумма архива |
|
||||||
|
|
||||||
|
Метод `isValid()` возвращает `true`, когда заполнен `id`, `revision` больше нуля и `archiveUrl`
|
||||||
|
корректен.
|
||||||
|
|
||||||
|
## Псевдонимы типов
|
||||||
|
|
||||||
|
`SeasonalBuildService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
||||||
|
Сочетание `ok == true` с непустым `warning` означает данные из устаревшего кэша.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit SeasonalBuildService(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт сервис и его `QNetworkAccessManager`. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### QList<SeasonalBuildEntry> builds() const
|
||||||
|
|
||||||
|
Текущий каталог сборок. Возвращает копию.
|
||||||
|
|
||||||
|
#### bool hasData() const
|
||||||
|
|
||||||
|
Есть ли в каталоге хоть что-то — из сети или из кэша.
|
||||||
|
|
||||||
|
#### bool isRefreshing() const
|
||||||
|
|
||||||
|
Идёт ли сейчас сетевое обновление.
|
||||||
|
|
||||||
|
#### QString lastError() const
|
||||||
|
|
||||||
|
Последняя ошибка обращения к серверу; пустая строка означает, что всё в порядке.
|
||||||
|
|
||||||
|
В отличие от остальных каталогов лаунчера, ошибка здесь хранится отдельным полем: пустой список и
|
||||||
|
ошибка выглядят одинаково пустыми, и окно каталога показывает причину прямо на месте строк.
|
||||||
|
|
||||||
|
#### std::optional<SeasonalBuildEntry> find(const QString &id) const
|
||||||
|
|
||||||
|
Запись по идентификатору сборки; `std::nullopt`, если такой нет.
|
||||||
|
|
||||||
|
#### void setBaseUrl(const QUrl &baseUrl)
|
||||||
|
|
||||||
|
Задаёт адрес сервера сборок. Адрес меняется из настроек, поэтому при смене хоста накопленные
|
||||||
|
данные и кэш сбрасываются: ссылки в них указывают на старый сервер и после смены недействительны.
|
||||||
|
|
||||||
|
#### QUrl baseUrl() const
|
||||||
|
|
||||||
|
Текущий адрес сервера сборок.
|
||||||
|
|
||||||
|
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
||||||
|
|
||||||
|
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без сети; иначе запускается
|
||||||
|
один сетевой запрос на всех, кто успел попросить. Параметр `forceRefresh` обходит проверку
|
||||||
|
свежести — так работает кнопка обновления списка.
|
||||||
|
|
||||||
|
Окно каталога вызывает этот метод при каждом открытии без принудительного обновления: свежий кэш
|
||||||
|
отвечает без сети, поэтому вызов ничего не стоит.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### buildsChanged()
|
||||||
|
|
||||||
|
Каталог изменился — пришли новые данные или прочитан кэш. Обработчик перечитывает `builds()` и
|
||||||
|
обновляет таблицу.
|
||||||
|
|
||||||
|
#### refreshingChanged()
|
||||||
|
|
||||||
|
Изменился признак обновления. Обработчик показывает или убирает индикатор загрузки; в окне
|
||||||
|
каталога по нему же выключается кнопка обновления списка.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||||
|
|
||||||
|
Отложенные колбэки хранятся до завершения текущего запроса; уничтожение сервиса с незавершённым
|
||||||
|
запросом обрывает его, и колбэки не вызываются.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Чтение и запись кэша выполняются синхронно в вызывающем потоке.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна каталога и по кнопке обновления, а
|
||||||
|
сигналы переправляет в свойства для QML. Перед отдачей в интерфейс он сводит записи каталога с
|
||||||
|
локальными: строка таблицы уже содержит готовый статус и признак доступного обновления, поэтому
|
||||||
|
[SeasonalBuildsDialog](../qml/SeasonalBuildsDialog.md) ничего не считает сам.
|
||||||
|
|
||||||
|
Установка сезонной сборки начинается с `find()`: по записи бэкенд получает адрес архива и передаёт
|
||||||
|
его [SeasonalPackDownloader](SeasonalPackDownloader.md), а скачанный пак раскатывает
|
||||||
|
[BuildSwitcher](BuildSwitcher.md).
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс скачивает `index.json` с файлового сервера сборок, адрес
|
||||||
|
которого задаётся через `setBaseUrl()`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
|
||||||
|
|
||||||
|
Испорченный или пустой ответ не затирает хороший кэш. При недоступной сети данные отдаются из
|
||||||
|
устаревшего кэша с `ok == true` и заполненным `warning`; текст ошибки при этом попадает и в
|
||||||
|
`lastError()`.
|
||||||
|
|
||||||
|
Все сигналы и колбэки приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *seasonal = new SeasonalBuildService(this);
|
||||||
|
seasonal->setBaseUrl(QUrl(settings.seasonalServer));
|
||||||
|
connect(seasonal, &SeasonalBuildService::buildsChanged, this, &Backend::rebuildSeasonalCatalog);
|
||||||
|
|
||||||
|
seasonal->ensureLoaded([this, seasonal](bool ok, const QString &warning) {
|
||||||
|
if (!ok) {
|
||||||
|
emit seasonalCatalogError(seasonal->lastError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!warning.isEmpty())
|
||||||
|
showStatus(warning);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# SeasonalPackDownloader
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
`SeasonalPackDownloader` скачивает один архив сезонной сборки в файл. Задача узкая и отдельная по
|
||||||
|
двум причинам: пак — это сотни мегабайт, поэтому он пишется потоком, а не держится в памяти; и его
|
||||||
|
sha256 обязательно сверяется, потому что распаковывать битую загрузку поверх рабочей `.minecraft`
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
Распаковкой класс не занимается — это делает [BuildSwitcher](BuildSwitcher.md) в отдельном потоке
|
||||||
|
вместе с остальными операциями над содержимым папки игры.
|
||||||
|
|
||||||
|
Набор геттеров прогресса повторяет [VersionInstaller](VersionInstaller.md) и
|
||||||
|
[JavaInstaller](JavaInstaller.md): панель загрузки в интерфейсе читает их одинаково, независимо от
|
||||||
|
того, кто сейчас работает.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Адрес архива, его размер
|
||||||
|
и контрольную сумму даёт запись каталога от [SeasonalBuildService](SeasonalBuildService.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QTimer`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`, `QNetworkReply`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
||||||
|
виртуальный деструктор — класс владеет незавершённой загрузкой и открытым файлом.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit SeasonalPackDownloader(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт загрузчик, его `QNetworkAccessManager` и таймер сглаживания прогресса. Конструктор помечен
|
||||||
|
`explicit`.
|
||||||
|
|
||||||
|
#### bool isRunning() const
|
||||||
|
|
||||||
|
Идёт ли загрузка прямо сейчас.
|
||||||
|
|
||||||
|
#### QString label() const
|
||||||
|
|
||||||
|
Подпись загрузки для интерфейса — как правило, название сезонной сборки.
|
||||||
|
|
||||||
|
#### QString stage() const
|
||||||
|
|
||||||
|
Текущий этап словами.
|
||||||
|
|
||||||
|
#### QString currentFile() const
|
||||||
|
|
||||||
|
Файл, который качается сейчас.
|
||||||
|
|
||||||
|
#### qint64 bytesDone() const
|
||||||
|
|
||||||
|
Сколько байт уже получено.
|
||||||
|
|
||||||
|
#### qint64 bytesTotal() const
|
||||||
|
|
||||||
|
Ожидаемый размер архива.
|
||||||
|
|
||||||
|
#### double fraction() const
|
||||||
|
|
||||||
|
Доля выполнения от `0` до `1` либо `-1`, пока итог неизвестен — например, когда сервер не сообщил
|
||||||
|
размер, а в записи каталога он не был указан.
|
||||||
|
|
||||||
|
#### void download(const QUrl &url, const QString &targetPath, const QString &sha256, qint64 expectedSize, const QString &label)
|
||||||
|
|
||||||
|
Скачивает архив по адресу `url` в `targetPath`, сверяя sha256 с переданным значением.
|
||||||
|
|
||||||
|
Файл `targetPath` перезаписывается: недокачанный пак с прошлой попытки не должен пережить новую.
|
||||||
|
Запись идёт через `QSaveFile`, поэтому на месте назначения файл появляется только целиком и только
|
||||||
|
после успешной проверки контрольной суммы.
|
||||||
|
|
||||||
|
Параметр `expectedSize` берётся из записи каталога и используется для расчёта доли выполнения,
|
||||||
|
пока сервер не сообщил размер сам.
|
||||||
|
|
||||||
|
#### void cancel()
|
||||||
|
|
||||||
|
Отменяет загрузку. Недокачанный файл на месте назначения не остаётся.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### started(const QString &label)
|
||||||
|
|
||||||
|
Загрузка началась. Обработчик показывает панель прогресса.
|
||||||
|
|
||||||
|
#### progressChanged()
|
||||||
|
|
||||||
|
Изменились числа прогресса; испускается не чаще, чем позволяет внутренний таймер, — иначе сигнал
|
||||||
|
на каждый принятый блок обошёлся бы дороже самой загрузки. Обработчик перечитывает геттеры.
|
||||||
|
|
||||||
|
#### finished(const QString &path)
|
||||||
|
|
||||||
|
Архив скачан и проверен; в параметре — путь к готовому файлу.
|
||||||
|
|
||||||
|
Обработчик передаёт этот путь [BuildSwitcher](BuildSwitcher.md) для раскатки поверх содержимого
|
||||||
|
`.minecraft`.
|
||||||
|
|
||||||
|
#### failed(const QString &label, const QString &message)
|
||||||
|
|
||||||
|
Загрузка не удалась: сеть недоступна, сервер ответил ошибкой или не сошлась контрольная сумма.
|
||||||
|
Последний случай особенно важен — он означает, что архив повреждён и распаковывать его нельзя.
|
||||||
|
|
||||||
|
#### canceled(const QString &label)
|
||||||
|
|
||||||
|
Загрузка отменена пользователем.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` и таймер создаются в конструкторе с загрузчиком в роли родителя.
|
||||||
|
|
||||||
|
Скачиваемый файл хранится как `std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе
|
||||||
|
с уничтожением объекта, и повреждённый архив не попадает на место назначения. Сетевой ответ
|
||||||
|
создаётся по ходу работы и закрывается в деструкторе.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI. Данные пишутся на диск блоками по мере поступления, поэтому длительных
|
||||||
|
синхронных операций в потоке нет.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `download()` при установке или обновлении сезонной сборки, передавая
|
||||||
|
адрес и контрольную сумму из записи [SeasonalBuildService](SeasonalBuildService.md). Сигналы
|
||||||
|
прогресса он переправляет в те же свойства, что и остальные загрузчики, поэтому панель в главном
|
||||||
|
окне не различает, кто работает.
|
||||||
|
|
||||||
|
По сигналу `finished` бэкенд передаёт путь к архиву в `BuildSwitcher::applyPack()` вместе со
|
||||||
|
списком уходящих файлов из описания предыдущей ревизии.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Одна загрузка по HTTPS с файлового сервера сборок. Направление
|
||||||
|
одностороннее, тело ответа — двоичный архив.
|
||||||
|
|
||||||
|
Данные пишутся потоком через `QSaveFile` с одновременным подсчётом sha256; несовпадение суммы
|
||||||
|
приводит к сигналу `failed`, и файл на месте назначения не появляется. Повторных попыток класс не
|
||||||
|
делает: решение о повторе принимает пользователь.
|
||||||
|
|
||||||
|
Все сигналы приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *packLoader = new SeasonalPackDownloader(this);
|
||||||
|
|
||||||
|
connect(packLoader, &SeasonalPackDownloader::progressChanged, this, &Backend::downloadChanged);
|
||||||
|
connect(packLoader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
|
||||||
|
m_switcher->applyPack(m_buildId, m_buildName, path,
|
||||||
|
m_previousEntries, m_note, m_gameDir);
|
||||||
|
});
|
||||||
|
connect(packLoader, &SeasonalPackDownloader::failed, this,
|
||||||
|
[this](const QString &, const QString &message) { emit launchError(message); });
|
||||||
|
|
||||||
|
packLoader->download(entry.archiveUrl, targetPath,
|
||||||
|
entry.archiveSha256, entry.archiveSize, entry.name);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# VersionInstaller
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
Установить версию Minecraft — значит положить в `.minecraft` пять групп файлов: описание версии,
|
||||||
|
клиентский jar, библиотеки, индекс ресурсов и сами ресурсы. Последних — десятки тысяч мелких
|
||||||
|
файлов.
|
||||||
|
|
||||||
|
`VersionInstaller` делает это фоном, не блокируя интерфейс: складывает всё нужное в очередь
|
||||||
|
загрузок, качает несколько файлов параллельно, пишет их потоком на диск и по ходу сообщает
|
||||||
|
прогресс. Одна версия ставится за раз, остальные ждут в очереди.
|
||||||
|
|
||||||
|
Класс также разворачивает цепочку наследования: если у версии есть `inheritsFrom`, родительская
|
||||||
|
версия ставится перед ней.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Подключает [minecraftversion.h](minecraftversion.md) — по разобранной версии он и понимает, что
|
||||||
|
качать. В конструктор принимает [VersionManifestService](VersionManifestService.md): оттуда
|
||||||
|
берётся адрес описания версии.
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). На него же опирается
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md) — профиль модлоадера ставится поверх установленной
|
||||||
|
версии игры.
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QCryptographicHash`, `QSaveFile`, `QQueue`, `QTimer`) и
|
||||||
|
`Qt6::Network` (`QNetworkAccessManager`, `QNetworkReply`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, пять сигналов и владение по родителю. Объявлен
|
||||||
|
виртуальный деструктор — класс владеет незавершёнными загрузками и обязан их закрыть.
|
||||||
|
|
||||||
|
## Публичные структуры
|
||||||
|
|
||||||
|
### DownloadTask
|
||||||
|
|
||||||
|
Один файл, который нужно положить в `.minecraft`.
|
||||||
|
|
||||||
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|
|------|-----|--------------|----------|
|
||||||
|
| `kind` | `DownloadTask::Kind` | `ClientJar` | Что это за файл |
|
||||||
|
| `url` | `QUrl` | — | Откуда качать |
|
||||||
|
| `path` | `QString` | — | Абсолютный путь назначения |
|
||||||
|
| `sha1` | `QString` | — | Контрольная сумма; пустая означает «не проверять» |
|
||||||
|
| `size` | `qint64` | `0` | Ожидаемый размер; `0` — неизвестен |
|
||||||
|
| `label` | `QString` | — | Что показать в панели прогресса |
|
||||||
|
| `attempts` | `int` | `0` | Сколько попыток уже сделано |
|
||||||
|
|
||||||
|
#### Перечисление DownloadTask::Kind
|
||||||
|
|
||||||
|
| Значение | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `ClientJar` | Клиентский jar версии |
|
||||||
|
| `Library` | Библиотека из `libraries/` |
|
||||||
|
| `AssetIndex` | Индекс ресурсов |
|
||||||
|
| `AssetObject` | Один файл ресурсов |
|
||||||
|
| `LoggingConfig` | Конфигурация журналирования log4j |
|
||||||
|
|
||||||
|
### ActiveDownload
|
||||||
|
|
||||||
|
Файл в процессе скачивания: задача, сетевой ответ, открытый `QSaveFile`, накапливаемая
|
||||||
|
контрольная сумма и число принятых байт. Файлы пишутся потоком — держать десятки мегабайт в
|
||||||
|
памяти незачем, а нескольких параллельных загрузок хватило бы на сотни.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit VersionInstaller(VersionManifestService \*manifest, QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт установщик поверх сервиса манифеста. Сервис не переходит во владение установщика и обязан
|
||||||
|
пережить его. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### bool isRunning() const
|
||||||
|
|
||||||
|
Идёт ли установка прямо сейчас.
|
||||||
|
|
||||||
|
#### QString versionId() const
|
||||||
|
|
||||||
|
Идентификатор версии, которая ставится в данный момент.
|
||||||
|
|
||||||
|
#### QString stage() const
|
||||||
|
|
||||||
|
Текущий этап установки словами — это же значение показывается в заголовке панели прогресса.
|
||||||
|
|
||||||
|
#### QString currentFile() const
|
||||||
|
|
||||||
|
Подпись файла, который качается сейчас.
|
||||||
|
|
||||||
|
#### qint64 bytesDone() const
|
||||||
|
|
||||||
|
Сколько байт уже получено, с учётом идущих загрузок.
|
||||||
|
|
||||||
|
#### qint64 bytesTotal() const
|
||||||
|
|
||||||
|
Ожидаемый общий объём. Растёт по ходу установки: полный размер ресурсов становится известен
|
||||||
|
только после разбора их индекса.
|
||||||
|
|
||||||
|
#### double fraction() const
|
||||||
|
|
||||||
|
Доля выполнения от `0` до `1` либо `-1`, пока итоговый объём неизвестен. Значение `-1` панель
|
||||||
|
прогресса показывает многоточием вместо процентов.
|
||||||
|
|
||||||
|
#### void install(const QString &gameDir, const QString &versionId)
|
||||||
|
|
||||||
|
Ставит версию в указанную папку игры. Если установка уже идёт, версия становится в очередь.
|
||||||
|
|
||||||
|
Порядок работы: разрешение записи манифеста, загрузка описания версии, подготовка списка задач,
|
||||||
|
скачивание, разворачивание индекса ресурсов и — для версий до 1.6 — раскладка ресурсов в плоскую
|
||||||
|
папку, которую те версии умеют читать.
|
||||||
|
|
||||||
|
#### bool isQueued(const QString &versionId) const
|
||||||
|
|
||||||
|
Стоит ли версия в очереди на установку. Позволяет не ставить одну и ту же версию дважды.
|
||||||
|
|
||||||
|
#### void cancel()
|
||||||
|
|
||||||
|
Отменяет текущую установку и очищает очередь. Незавершённые файлы не остаются на диске: они
|
||||||
|
пишутся через `QSaveFile` и фиксируются только целиком.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### started(const QString &versionId)
|
||||||
|
|
||||||
|
Установка версии началась. Обработчик показывает панель прогресса и выставляет признак занятости.
|
||||||
|
|
||||||
|
#### progressChanged()
|
||||||
|
|
||||||
|
Изменились числа прогресса. Испускается не чаще десяти раз в секунду: при тысячах мелких файлов
|
||||||
|
сигнал на каждый принятый блок обошёлся бы дороже самой загрузки.
|
||||||
|
|
||||||
|
Обработчик перечитывает `stage()`, `currentFile()`, `bytesDone()`, `bytesTotal()` и `fraction()`.
|
||||||
|
|
||||||
|
#### finished(const QString &versionId)
|
||||||
|
|
||||||
|
Версия установлена успешно. Обработчик убирает панель прогресса, обновляет список установленных
|
||||||
|
версий и пересчитывает комплектность сборок.
|
||||||
|
|
||||||
|
#### failed(const QString &versionId, const QString &message)
|
||||||
|
|
||||||
|
Установка не удалась; в параметре — текст ошибки для пользователя. Часть файлов при этом может
|
||||||
|
остаться на диске: повторная установка докачает недостающее.
|
||||||
|
|
||||||
|
#### canceled(const QString &versionId)
|
||||||
|
|
||||||
|
Установка отменена пользователем. В отличие от `failed`, ошибку показывать не нужно.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
|
||||||
|
Указатель на `VersionManifestService`, переданный в конструктор, **не принадлежит** установщику:
|
||||||
|
сервис создаётся раньше и живёт дольше. `QNetworkAccessManager` и оба таймера создаются в
|
||||||
|
конструкторе с установщиком в роли родителя.
|
||||||
|
|
||||||
|
Активные загрузки хранятся как `std::shared_ptr<ActiveDownload>`, а файл внутри каждой — как
|
||||||
|
`std::unique_ptr<QSaveFile>`: незавершённая запись отменяется вместе с уничтожением объекта, и
|
||||||
|
испорченный файл не попадает на место назначения.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI, как и остальной сетевой код лаунчера. Параллелизм здесь — не потоки, а
|
||||||
|
несколько одновременных сетевых запросов в одном цикле событий. Раскладка ресурсов для старых
|
||||||
|
версий выполняется порциями по таймеру, чтобы не занимать поток надолго.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `install()` при установке сборки и переправляет все пять сигналов в
|
||||||
|
свойства, которые читает QML: панель прогресса главного окна показывает `stage()`, `fraction()` и
|
||||||
|
байты, а `finished` обновляет список установленных версий.
|
||||||
|
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md) держит ссылку на установщик: профиль модлоадера
|
||||||
|
требует, чтобы базовая версия игры была уже на месте.
|
||||||
|
|
||||||
|
Разбор описания версии идёт через `VersionLoader::load()` из
|
||||||
|
[minecraftversion.h](minecraftversion.md).
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс качает файлы с серверов Mojang через `QNetworkAccessManager`.
|
||||||
|
Направление одностороннее, протокол — HTTPS; описание версии и индекс ресурсов приходят как JSON,
|
||||||
|
остальное — двоичными файлами.
|
||||||
|
|
||||||
|
Каждый файл пишется потоком через `QSaveFile` с одновременным подсчётом sha1; несовпадение
|
||||||
|
контрольной суммы считается неудачей загрузки. Неудачная задача повторяется — счётчик попыток
|
||||||
|
хранится в самой задаче, — и только исчерпав попытки, приводит к сигналу `failed`.
|
||||||
|
|
||||||
|
Существующие файлы сверяются только по размеру: перехеширование сотен мегабайт при каждом
|
||||||
|
добавлении версии дороже, чем риск битого файла.
|
||||||
|
|
||||||
|
Все сигналы приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *installer = new VersionInstaller(manifestService, this);
|
||||||
|
|
||||||
|
connect(installer, &VersionInstaller::progressChanged, this, [this, installer] {
|
||||||
|
emit downloadProgress(installer->fraction(), installer->currentFile());
|
||||||
|
});
|
||||||
|
connect(installer, &VersionInstaller::finished, this, &Backend::onVersionInstalled);
|
||||||
|
connect(installer, &VersionInstaller::failed, this, [this](const QString &id, const QString &message) {
|
||||||
|
emit launchError(tr("Не удалось установить %1: %2").arg(id, message));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!installer->isQueued(versionId))
|
||||||
|
installer->install(gameDir, versionId);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# VersionManifestService
|
||||||
|
|
||||||
|
## Обзор класса
|
||||||
|
|
||||||
|
Каталог версий Minecraft — это манифест Mojang: около тысячи записей от альф 2010 года до
|
||||||
|
свежайших снапшотов. `VersionManifestService` отвечает за него целиком: скачивает манифест,
|
||||||
|
кэширует в папке лаунчера и отдаёт из кэша, пока тот не устарел.
|
||||||
|
|
||||||
|
Класс нужен двум потребителям: окну выбора версии, которому нужен весь список, и установщику,
|
||||||
|
которому по идентификатору версии нужна ссылка на её описание.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Экземпляр создаётся и принадлежит [LauncherBackend](LauncherBackend.md). Ссылку на него получает
|
||||||
|
[VersionInstaller](VersionInstaller.md) — установщик берёт из манифеста адрес описания версии.
|
||||||
|
|
||||||
|
Путь к файлу кэша даёт `LauncherPaths::versionManifestFile()` из
|
||||||
|
[launcherpaths.h](launcherpaths.md).
|
||||||
|
|
||||||
|
Требования сборки: `Qt6::Core` (`QDateTime`, `QHash`, `QList`, `QUrl`) и `Qt6::Network`
|
||||||
|
(`QNetworkAccessManager`).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Наследует `QObject`: мета-объектная система, два сигнала и владение по родителю. Виртуальных
|
||||||
|
методов базового класса не переопределяет.
|
||||||
|
|
||||||
|
## Публичные структуры
|
||||||
|
|
||||||
|
### RemoteVersionEntry
|
||||||
|
|
||||||
|
Одна строка манифеста Mojang.
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `id` | `QString` | Идентификатор версии: `1.21.8`, `25w33a`, `b1.7.3` |
|
||||||
|
| `type` | `QString` | Категория: `release`, `snapshot`, `old_beta` или `old_alpha` |
|
||||||
|
| `url` | `QUrl` | Адрес `<id>.json` с описанием версии |
|
||||||
|
| `sha1` | `QString` | Контрольная сумма самого описания |
|
||||||
|
| `releaseTime` | `QDateTime` | Дата выпуска; по ней список сортируется новыми вперёд |
|
||||||
|
|
||||||
|
Категории `type` — те же ключи, по которым окно выбора версии делит каталог на вкладки; версии, не
|
||||||
|
попавшие ни в одну из четырёх, интерфейс относит к категории «прочие».
|
||||||
|
|
||||||
|
## Псевдонимы типов
|
||||||
|
|
||||||
|
`VersionManifestService::Callback` — `std::function<void(bool ok, const QString &warning)>`.
|
||||||
|
Сочетание `ok == true` с непустым `warning` означает особый случай: данные отдали, но из
|
||||||
|
устаревшего кэша — сеть недоступна, а показать что-то нужно.
|
||||||
|
|
||||||
|
## Публичные методы
|
||||||
|
|
||||||
|
#### explicit VersionManifestService(QObject \*parent = nullptr)
|
||||||
|
|
||||||
|
Создаёт сервис и его `QNetworkAccessManager`. Манифест при этом не читается — чтение кэша
|
||||||
|
откладывается до первого обращения. Конструктор помечен `explicit`.
|
||||||
|
|
||||||
|
#### QList<RemoteVersionEntry> versions() const
|
||||||
|
|
||||||
|
Текущий список версий. Возвращает копию; пустой список означает, что данных ещё нет.
|
||||||
|
|
||||||
|
#### bool hasData() const
|
||||||
|
|
||||||
|
Есть ли хоть какие-то данные — из сети или из кэша.
|
||||||
|
|
||||||
|
#### bool isRefreshing() const
|
||||||
|
|
||||||
|
Идёт ли сейчас сетевое обновление. Интерфейс показывает по этому признаку строку загрузки вместо
|
||||||
|
пустого списка.
|
||||||
|
|
||||||
|
#### QDateTime fetchedAt() const
|
||||||
|
|
||||||
|
Когда данные были получены. По этой отметке решается, устарел ли кэш.
|
||||||
|
|
||||||
|
#### std::optional<RemoteVersionEntry> find(const QString &id) const
|
||||||
|
|
||||||
|
Запись по идентификатору версии; `std::nullopt`, если такой версии в манифесте нет. Из неё
|
||||||
|
установщик берёт ссылку на описание версии. Поиск идёт по внутреннему указателю, а не перебором.
|
||||||
|
|
||||||
|
#### void ensureLoaded(Callback callback, bool forceRefresh = false)
|
||||||
|
|
||||||
|
Единственная точка входа. Свежий кэш означает отложенный вызов колбэка без обращения к сети; иначе
|
||||||
|
запускается один сетевой запрос на всех, кто успел попросить, — колбэки накапливаются и вызываются
|
||||||
|
все разом по его завершении.
|
||||||
|
|
||||||
|
Параметр `forceRefresh` обходит проверку свежести кэша: так работает кнопка принудительного
|
||||||
|
обновления.
|
||||||
|
|
||||||
|
Колбэк вызывается ровно один раз и всегда в потоке GUI, в том числе когда данные уже есть.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### versionsChanged()
|
||||||
|
|
||||||
|
Список версий изменился — пришли новые данные из сети или прочитан кэш.
|
||||||
|
|
||||||
|
Обработчик перечитывает `versions()` и обновляет интерфейс. В лаунчере на этот сигнал завязано
|
||||||
|
свойство каталога версий, которое читает окно выбора.
|
||||||
|
|
||||||
|
#### refreshingChanged()
|
||||||
|
|
||||||
|
Изменился признак сетевого обновления. Обработчик показывает или убирает индикатор загрузки.
|
||||||
|
|
||||||
|
## Владение и время жизни
|
||||||
|
|
||||||
|
Класс наследует `QObject` и принимает `parent` — родитель его и удалит.
|
||||||
|
`QNetworkAccessManager` создаётся в конструкторе с сервисом в роли родителя.
|
||||||
|
|
||||||
|
Отложенные колбэки хранятся в списке до завершения текущего запроса. Уничтожение сервиса с
|
||||||
|
незавершённым запросом обрывает его вместе с менеджером сети, и накопленные колбэки не
|
||||||
|
вызываются.
|
||||||
|
|
||||||
|
Ссылку на сервис держит установщик версий; уничтожать сервис раньше установщика нельзя.
|
||||||
|
|
||||||
|
## Потокобезопасность
|
||||||
|
|
||||||
|
Только поток GUI — так же, как [AuthService](AuthService.md). Чтение и запись кэша выполняются в
|
||||||
|
вызывающем потоке, поэтому первое обращение к манифесту делает короткую файловую операцию
|
||||||
|
синхронно.
|
||||||
|
|
||||||
|
## Взаимодействие с другими классами
|
||||||
|
|
||||||
|
`LauncherBackend` вызывает `ensureLoaded()` при открытии окна выбора версии и по кнопке
|
||||||
|
обновления, а сигналы `versionsChanged` и `refreshingChanged` переправляет в свойства, которые
|
||||||
|
читает QML. Список из `versions()` он сводит с установленными версиями и отдаёт в интерфейс уже
|
||||||
|
готовыми строками.
|
||||||
|
|
||||||
|
[VersionInstaller](VersionInstaller.md) обращается к `find()`, чтобы получить адрес описания
|
||||||
|
версии перед началом загрузки.
|
||||||
|
|
||||||
|
## Внешнее взаимодействие
|
||||||
|
|
||||||
|
**Сеть, исходящие запросы.** Класс скачивает манифест версий Mojang через
|
||||||
|
`QNetworkAccessManager`. Формат — JSON поверх HTTPS, запрос инициирует лаунчер.
|
||||||
|
|
||||||
|
Стратегия при недоступной сети встроена в контракт колбэка: если есть устаревший кэш, он
|
||||||
|
отдаётся с `ok == true` и заполненным `warning`, и интерфейс показывает список вместо ошибки.
|
||||||
|
Полное отсутствие данных даёт `ok == false`.
|
||||||
|
|
||||||
|
**Файловый кэш.** Манифест сохраняется в файл, путь к которому даёт
|
||||||
|
`LauncherPaths::versionManifestFile()`, вместе с отметкой времени получения.
|
||||||
|
|
||||||
|
Сигналы и колбэки приходят в поток GUI.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto *manifest = new VersionManifestService(this);
|
||||||
|
connect(manifest, &VersionManifestService::versionsChanged, this, &Backend::rebuildCatalog);
|
||||||
|
|
||||||
|
manifest->ensureLoaded([this, manifest](bool ok, const QString &warning) {
|
||||||
|
if (!ok) {
|
||||||
|
emit catalogError(warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!warning.isEmpty())
|
||||||
|
showStatus(warning); // список из устаревшего кэша
|
||||||
|
|
||||||
|
const auto entry = manifest->find(QStringLiteral("1.21.8"));
|
||||||
|
if (entry)
|
||||||
|
startDownload(entry->url);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# javalocator.h — JavaLocator
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Для запуска Minecraft нужна Java подходящей версии: старым версиям игры — восьмая, новым —
|
||||||
|
семнадцатая или двадцать первая. Лаунчер умеет скачивать сборки Java сам (см.
|
||||||
|
[JavaInstaller](JavaInstaller.md)), но сначала стоит посмотреть, что уже есть на машине.
|
||||||
|
|
||||||
|
`JavaLocator` отвечает за поиск: собирает все доступные java, определяет их версии и выбирает
|
||||||
|
подходящую под требование версии игры. Пространство имён используется при подготовке запуска и при
|
||||||
|
показе списка найденных Java в настройках.
|
||||||
|
|
||||||
|
## Пространства имён
|
||||||
|
|
||||||
|
`JavaLocator` группирует четыре функции поиска и выбора. Состояния нет, но результат определения
|
||||||
|
версии кэшируется внутри реализации.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
#### QStringList findAll(const QString &gameDir)
|
||||||
|
|
||||||
|
Все java, которые удалось найти, без дублей и в порядке приоритета. Просматриваются четыре
|
||||||
|
источника: рантайм самого Minecraft внутри папки игры, переменная `JAVA_HOME`, переменная `PATH` и
|
||||||
|
стандартные каталоги установки JDK и JRE.
|
||||||
|
|
||||||
|
Параметр `gameDir` — папка игры; из неё берётся первый источник.
|
||||||
|
|
||||||
|
Результат показывается в диалоге настроек списком «что нашлось в системе».
|
||||||
|
|
||||||
|
#### int majorVersion(const QString &javaPath)
|
||||||
|
|
||||||
|
Мажорная версия java по её пути: 8, 17, 21 и так далее. Возвращает `0`, если запустить
|
||||||
|
исполняемый файл не удалось — путь неверен, файл не исполняемый или это не java.
|
||||||
|
|
||||||
|
Результат кэшируется: определение версии требует запуска процесса, а один и тот же путь
|
||||||
|
проверяется многократно.
|
||||||
|
|
||||||
|
#### QString windowlessVariant(const QString &javaPath)
|
||||||
|
|
||||||
|
Заменяет `java.exe` на `javaw.exe`, чтобы игра не открывала окно консоли. На Unix возвращает вход
|
||||||
|
без изменений — там разницы нет.
|
||||||
|
|
||||||
|
#### QString select(const QString &gameDir, int requiredMajor, const QString &preferred, QString \*error)
|
||||||
|
|
||||||
|
Выбирает java не ниже `requiredMajor`. Если задан `preferred` — путь, указанный пользователем в
|
||||||
|
настройках, — он проверяется первым и, если подходит, побеждает. Иначе перебираются найденные
|
||||||
|
`findAll()` варианты.
|
||||||
|
|
||||||
|
При неудаче возвращает пустую строку и заполняет `error` — текст объясняет, что именно не нашлось:
|
||||||
|
подходящей версии нет вовсе или указанный пользователем путь не подошёл.
|
||||||
|
|
||||||
|
Функция запускает процессы для определения версий, поэтому может занять заметное время; вызывать
|
||||||
|
её в обработчике нажатия не стоит.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Подключает `QString` и `QStringList`. От классов проекта не зависит.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
QString error;
|
||||||
|
const QString java = JavaLocator::select(gameDir,
|
||||||
|
version.javaMajor,
|
||||||
|
settings.javaPath,
|
||||||
|
&error);
|
||||||
|
if (java.isEmpty()) {
|
||||||
|
emit launchError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.start(JavaLocator::windowlessVariant(java), arguments);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# javaruntime.h — JavaRuntime, JavaRuntimeStore и JavaRequirement
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
`Minecraft_launcher` умеет скачивать Java сам: официальные сборки Mojang — те же, которыми игру
|
||||||
|
запускает официальный лаунчер, — и сборки Eclipse Temurin в вариантах JDK и JRE. Скачанные сборки
|
||||||
|
живут в папке лаунчера, по одной подпапке на сборку.
|
||||||
|
|
||||||
|
Заголовок `javaruntime.h` — словарь этой части проекта. Он даёт перечисление видов сборок, две
|
||||||
|
структуры (строка каталога и уже установленная сборка), пространство имён для работы с папкой
|
||||||
|
`<root>/java` и таблицу требований версий игры к Java.
|
||||||
|
|
||||||
|
Заголовок подключают [JavaRuntimeService](JavaRuntimeService.md) (каталог),
|
||||||
|
[JavaInstaller](JavaInstaller.md) (скачивание и распаковка) и
|
||||||
|
[LauncherBackend](LauncherBackend.md) (отдача каталога в QML).
|
||||||
|
|
||||||
|
## Типы
|
||||||
|
|
||||||
|
| Имя | Вид | Описание |
|
||||||
|
|-----|-----|----------|
|
||||||
|
| `JavaRuntimeKind` | `enum class` | Откуда взялась сборка и что именно в ней лежит |
|
||||||
|
| `JavaRuntimeEntry` | `struct` | Строка каталога — то, что можно скачать |
|
||||||
|
| `InstalledJavaRuntime` | `struct` | Сборка, уже распакованная в папке лаунчера |
|
||||||
|
|
||||||
|
### JavaRuntimeKind
|
||||||
|
|
||||||
|
| Значение | Ключ | Описание |
|
||||||
|
|----------|------|----------|
|
||||||
|
| `Mojang` | `java` | Тот же рантайм, которым игру запускает официальный лаунчер |
|
||||||
|
| `Jdk` | `jdk` | Eclipse Temurin JDK: компилятор и инструменты в комплекте |
|
||||||
|
| `Jre` | `jre` | Eclipse Temurin JRE: только то, что нужно для запуска |
|
||||||
|
|
||||||
|
Эти же ключи служат именами категорий в окне выбора Java.
|
||||||
|
|
||||||
|
### JavaRuntimeEntry
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `id` | `QString` | Идентификатор сборки, например `temurin-jdk-21.0.12.1_1` или `mojang-java-runtime-delta` |
|
||||||
|
| `kind` | `JavaRuntimeKind` | Вид сборки; по умолчанию `Jdk` |
|
||||||
|
| `major` | `int` | Мажорная версия: 8, 17, 21… |
|
||||||
|
| `version` | `QString` | Полная версия, например `21.0.12.1+1` или `21.0.7` |
|
||||||
|
| `component` | `QString` | Имя компонента вида `java-runtime-delta`; только у сборок Mojang |
|
||||||
|
| `released` | `QDateTime` | Дата выпуска; по ней каталог сортируется новыми вперёд |
|
||||||
|
| `url` | `QUrl` | Архив Temurin либо `manifest.json` компонента Mojang |
|
||||||
|
| `checksum` | `QString` | sha256 архива Temurin или sha1 манифеста Mojang |
|
||||||
|
| `size` | `qint64` | Размер загрузки в байтах; `0` — неизвестен |
|
||||||
|
| `archive` | `QString` | Формат: `zip`, `tar.gz` или `mojang` — последний означает не архив, а манифест с пофайловой загрузкой |
|
||||||
|
| `architecture` | `QString` | `x64` или `aarch64`; на macOS бывает и не родная архитектура |
|
||||||
|
| `lts` | `bool` | Версия с длительной поддержкой; помечается в списке |
|
||||||
|
|
||||||
|
Метод `isValid()` возвращает `true`, когда заполнен `id`, `major` больше нуля и `url` корректен.
|
||||||
|
|
||||||
|
### InstalledJavaRuntime
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `id` | `QString` | Идентификатор сборки, он же имя подпапки |
|
||||||
|
| `kind` | `QString` | Вид сборки строкой: `java`, `jdk` или `jre` |
|
||||||
|
| `major` | `int` | Мажорная версия |
|
||||||
|
| `version` | `QString` | Полная версия |
|
||||||
|
| `javaPath` | `QString` | Абсолютный путь к исполняемому файлу `java` или `java.exe` |
|
||||||
|
| `size` | `qint64` | Сколько сборка заняла на диске по итогам установки |
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
### Перевод видов сборок
|
||||||
|
|
||||||
|
#### QString javaKindKey(JavaRuntimeKind kind)
|
||||||
|
|
||||||
|
Строковый ключ вида сборки: `java`, `jdk` или `jre`.
|
||||||
|
|
||||||
|
#### std::optional<JavaRuntimeKind> javaKindFromKey(const QString &key)
|
||||||
|
|
||||||
|
Обратный перевод; `std::nullopt` для неизвестного ключа.
|
||||||
|
|
||||||
|
#### QString javaKindTitle(JavaRuntimeKind kind)
|
||||||
|
|
||||||
|
Человекочитаемое название вида сборки для интерфейса.
|
||||||
|
|
||||||
|
### JavaRuntimeStore — папка `<root>/java`
|
||||||
|
|
||||||
|
Устройство папки: одна подпапка на сборку плюс её описание внутри. Отдельного индекса нет
|
||||||
|
намеренно — удалённую вручную папку не пришлось бы вычищать ещё и из общего файла.
|
||||||
|
|
||||||
|
#### QString dirFor(const QString &id)
|
||||||
|
|
||||||
|
Папка конкретной сборки внутри `<root>/java`.
|
||||||
|
|
||||||
|
#### QList<InstalledJavaRuntime> installed()
|
||||||
|
|
||||||
|
Всё, что лежит в `<root>/java` и на что нашлась java. Новые версии идут первыми.
|
||||||
|
|
||||||
|
#### std::optional<InstalledJavaRuntime> find(const QString &id)
|
||||||
|
|
||||||
|
Описание одной установленной сборки; `std::nullopt`, если такой нет.
|
||||||
|
|
||||||
|
#### QString locateBinary(const QString &rootDir)
|
||||||
|
|
||||||
|
Ищет `bin/java` в распакованном дереве. Раскладка отличается между поставщиками: у Temurin на macOS
|
||||||
|
это `Contents/Home/bin`, у Mojang — `jre.bundle/Contents/Home/bin`.
|
||||||
|
|
||||||
|
#### bool writeMeta(const InstalledJavaRuntime &runtime, QString \*error)
|
||||||
|
|
||||||
|
Записывает описание сборки в её папку. Вызывается по завершении установки. При неудаче возвращает
|
||||||
|
`false` и заполняет `error`.
|
||||||
|
|
||||||
|
#### bool remove(const QString &id, QString \*error)
|
||||||
|
|
||||||
|
Удаляет папку сборки целиком. При неудаче возвращает `false` и заполняет `error`.
|
||||||
|
|
||||||
|
#### QString sanitizeId(const QString &id)
|
||||||
|
|
||||||
|
Превращает идентификатор каталога в безопасное имя папки: всё, кроме букв, цифр, точки, дефиса и
|
||||||
|
подчёркивания, заменяется. Идентификатор приходит из сети, поэтому подставлять его в путь как есть
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
### JavaRequirement — какая Java нужна какой версии игры
|
||||||
|
|
||||||
|
Точный ответ лежит в `client.json` версии (поле `javaVersion.majorVersion`) и берётся оттуда при
|
||||||
|
запуске. Эта таблица нужна раньше — когда версия ещё не скачана, а подсказку в каталоге показать
|
||||||
|
надо.
|
||||||
|
|
||||||
|
#### int minimumFor(const QString &minecraftVersionId)
|
||||||
|
|
||||||
|
Минимальная мажорная версия Java для версии игры; `0`, если разобрать идентификатор версии не
|
||||||
|
удалось.
|
||||||
|
|
||||||
|
#### QString coverage(int javaMajor)
|
||||||
|
|
||||||
|
Подпись к строке каталога вида «Minecraft 1.20.5 и новее» — какие версии игры покрывает эта версия
|
||||||
|
Java.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Подключает `QDateTime`, `QList`, `QString`, `QUrl` и `<optional>` — только Qt Core. От классов
|
||||||
|
проекта не зависит.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const auto runtime = JavaRuntimeStore::find(settings.javaRuntime);
|
||||||
|
if (runtime && QFileInfo::exists(runtime->javaPath)) {
|
||||||
|
javaPath = runtime->javaPath;
|
||||||
|
} else {
|
||||||
|
// сборка удалена вручную — возвращаемся к поиску в системе
|
||||||
|
javaPath = JavaLocator::select(gameDir, version.javaMajor, settings.javaPath, &error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# launcherpaths.h — LauncherPaths
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
`Minecraft_launcher` хранит собственные данные отдельно от папки игры: настройки, профили, описания
|
||||||
|
сборок, архивы содержимого `.minecraft`, скачанные сборки Java и кэши каталогов. Пространство имён
|
||||||
|
`LauncherPaths` — единственное место, где эти пути вычисляются.
|
||||||
|
|
||||||
|
Собственная папка `BiLauncher` лежит рядом со стандартной `.minecraft`, а не в системном
|
||||||
|
каталоге данных приложения: так все файлы лаунчера остаются там же, где сама игра, и переносятся
|
||||||
|
вместе с ней.
|
||||||
|
|
||||||
|
К этому заголовку обращается почти каждый сервис проекта — везде, где нужно прочитать или записать
|
||||||
|
файл в папке лаунчера.
|
||||||
|
|
||||||
|
## Пространства имён
|
||||||
|
|
||||||
|
`LauncherPaths` группирует функции, возвращающие абсолютные пути, и одну функцию создания корневой
|
||||||
|
папки. Состояния у пространства имён нет: все функции вычисляют путь заново при каждом вызове.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
### Корневые каталоги
|
||||||
|
|
||||||
|
#### QString containerDir()
|
||||||
|
|
||||||
|
Родительская папка, в которой лежит `.minecraft`, а рядом с ней — `BiLauncher`. От неё
|
||||||
|
отсчитываются и папка игры по умолчанию, и корень данных лаунчера.
|
||||||
|
|
||||||
|
#### QString rootDir()
|
||||||
|
|
||||||
|
Корень данных лаунчера — `<containerDir>/BiLauncher`.
|
||||||
|
|
||||||
|
#### QString defaultMinecraftDir()
|
||||||
|
|
||||||
|
Стандартная папка игры. Используется, когда пользователь не задал свою в настройках.
|
||||||
|
|
||||||
|
#### bool ensureRootExists(QString *error = nullptr)
|
||||||
|
|
||||||
|
Создаёт папку лаунчера, если её ещё нет. Вызывается при каждом запуске и перед каждой записью.
|
||||||
|
|
||||||
|
Возвращает `false` и заполняет `error`, если папку не удалось создать или в неё не пишется. Все
|
||||||
|
остальные функции пространства имён только считают строки и в этом смысле не могут завершиться
|
||||||
|
неудачей — проверять доступность каталога нужно этой функцией.
|
||||||
|
|
||||||
|
### Файлы состояния
|
||||||
|
|
||||||
|
#### QString settingsFile()
|
||||||
|
|
||||||
|
Файл настроек запуска: папка игры, путь к Java, память, аргументы JVM, размер окна.
|
||||||
|
|
||||||
|
#### QString profilesFile()
|
||||||
|
|
||||||
|
Файл профилей игрока.
|
||||||
|
|
||||||
|
#### QString customBuildsFile()
|
||||||
|
|
||||||
|
`<root>/customBuilds.json` — пользовательские сборки.
|
||||||
|
|
||||||
|
#### QString legacyCustomBuildsFile()
|
||||||
|
|
||||||
|
`<root>/versions.json` — как сборки назывались до переименования. Читается один раз при миграции и
|
||||||
|
больше ни для чего не нужен.
|
||||||
|
|
||||||
|
### Сборки и их архивы
|
||||||
|
|
||||||
|
#### QString buildStorageDir()
|
||||||
|
|
||||||
|
`<root>/builds` — архивы содержимого `.minecraft`, по одному на сборку.
|
||||||
|
|
||||||
|
#### QString buildDir(int buildId)
|
||||||
|
|
||||||
|
`<root>/builds/<id>` — папка одной сборки: её архив и, у сезонных, скачанный пак с описанием
|
||||||
|
установленной ревизии.
|
||||||
|
|
||||||
|
#### QString seasonalStateFile(int buildId)
|
||||||
|
|
||||||
|
`<root>/builds/<id>/season.json` — какая ревизия сезонной сборки установлена и какие файлы она
|
||||||
|
принесла. Список файлов нужен, чтобы при обновлении убрать те, что из сборки ушли.
|
||||||
|
|
||||||
|
### Java
|
||||||
|
|
||||||
|
#### QString javaDir()
|
||||||
|
|
||||||
|
`<root>/java` — сборки Java, скачанные лаунчером. Каждая в своей подпапке, имя подпапки —
|
||||||
|
идентификатор сборки из каталога.
|
||||||
|
|
||||||
|
#### QString javaCatalogFile()
|
||||||
|
|
||||||
|
Слепок каталога доступных сборок Java с отметкой времени.
|
||||||
|
|
||||||
|
#### QString javaDownloadDir()
|
||||||
|
|
||||||
|
Каталог, куда качаются архивы Temurin до распаковки.
|
||||||
|
|
||||||
|
### Кэши и загрузки
|
||||||
|
|
||||||
|
#### QString cacheDir()
|
||||||
|
|
||||||
|
`<root>/cache` — данные, которые можно удалить без потерь.
|
||||||
|
|
||||||
|
#### QString versionManifestFile()
|
||||||
|
|
||||||
|
Слепок манифеста версий Mojang с отметкой времени.
|
||||||
|
|
||||||
|
#### QString seasonalCatalogFile()
|
||||||
|
|
||||||
|
Слепок каталога сезонных сборок с отметкой времени.
|
||||||
|
|
||||||
|
#### QString loaderCacheFile(const QString &loaderKey)
|
||||||
|
|
||||||
|
Слепок списка версий одного модлоадера. Параметр `loaderKey` принимает значения `forge`, `fabric`,
|
||||||
|
`neoforge` и `quilt` — те же ключи, что возвращает `loaderKey()` из [modloader.h](modloader.md).
|
||||||
|
|
||||||
|
#### QString loaderDownloadDir()
|
||||||
|
|
||||||
|
Каталог, куда качаются `installer.jar` модлоадеров.
|
||||||
|
|
||||||
|
#### QString runtimeDir()
|
||||||
|
|
||||||
|
Каталог, куда качается `authlib-injector` — библиотека, подменяющая сервер авторизации при входе
|
||||||
|
через Ely.by.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Единственный подключаемый заголовок — `QString`. Пространство имён не зависит ни от одного класса
|
||||||
|
проекта, поэтому его можно подключать откуда угодно без риска циклических зависимостей.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
QString error;
|
||||||
|
if (!LauncherPaths::ensureRootExists(&error)) {
|
||||||
|
qWarning() << "папка лаунчера недоступна:" << error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile file(LauncherPaths::customBuildsFile());
|
||||||
|
if (file.open(QIODevice::WriteOnly))
|
||||||
|
file.write(document.toJson());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# main.cpp — точка входа
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt 6 Quick. Интерфейс написан на QML,
|
||||||
|
вся работа — авторизация, скачивание версий, установка модлоадеров и Java, запуск игры — лежит в
|
||||||
|
C++-классе [LauncherBackend](LauncherBackend.md) и его сервисах.
|
||||||
|
|
||||||
|
`main.cpp` — стартовая последовательность приложения: здесь при необходимости инициализируется
|
||||||
|
Qt WebEngine, создаётся объект приложения, читается каталог переводов, выбирается стиль
|
||||||
|
Qt Quick Controls, загружается QML-модуль и запускается цикл событий. Файл намеренно короткий:
|
||||||
|
ни одного объекта предметной области он не создаёт — всё, что нужно, QML заводит сам.
|
||||||
|
|
||||||
|
## Настройка приложения Qt
|
||||||
|
|
||||||
|
Создаётся `QGuiApplication` — не `QApplication`: интерфейс целиком на Qt Quick, виджеты не
|
||||||
|
используются, и модуль Qt Widgets в проект не подключён.
|
||||||
|
|
||||||
|
До создания приложения, при сборке с Qt WebEngine, вызывается `QtWebEngineQuick::initialize()`.
|
||||||
|
Порядок здесь принципиален: инициализация выставляет общий контекст OpenGL, а после создания
|
||||||
|
объекта приложения это уже не действует. Вызов обёрнут в условную компиляцию по макросу
|
||||||
|
`LAUNCHER_HAS_WEBENGINE`.
|
||||||
|
|
||||||
|
## Каталог переводов
|
||||||
|
|
||||||
|
Сразу после создания приложения вызывается `Localization::instance().load()` — он читает
|
||||||
|
`i18n/translations.json` из ресурсов и определяет язык интерфейса. Порядок важен: язык нужно
|
||||||
|
знать до того, как QML вычислит первую привязку, а [LauncherBackend](LauncherBackend.md),
|
||||||
|
который ведёт `settings.json`, появляется только вместе с движком — поэтому ключ `language`
|
||||||
|
[Localization](Localization.md) читает из файла сам.
|
||||||
|
|
||||||
|
Неудача — не повод продолжать: каталог вкомпилирован в бинарник, значит его отсутствие или
|
||||||
|
поломка означают ошибку сборки. `main()` пишет причину и возвращает `-1`.
|
||||||
|
|
||||||
|
## Стиль Qt Quick Controls
|
||||||
|
|
||||||
|
Стиль Qt Quick Controls принудительно выставляется в `Basic`
|
||||||
|
вызовом `QQuickStyle::setStyle()`. Причина в оформлении: всё окно лаунчера стилизовано вручную, а
|
||||||
|
нативный стиль Windows игнорирует пользовательские `contentItem` и `background` и сыплет
|
||||||
|
предупреждениями.
|
||||||
|
|
||||||
|
## Обработка командной строки
|
||||||
|
|
||||||
|
Аргументы командной строки не разбираются: `argc` и `argv` передаются в конструктор
|
||||||
|
`QGuiApplication` и дальше не используются. Ни `QCommandLineParser`, ни собственного разбора в
|
||||||
|
файле нет.
|
||||||
|
|
||||||
|
## Создание объектов верхнего уровня
|
||||||
|
|
||||||
|
В `main()` создаётся ровно два объекта.
|
||||||
|
|
||||||
|
| Объект | Тип | Роль |
|
||||||
|
|--------|-----|------|
|
||||||
|
| `app` | `QGuiApplication` | объект приложения и цикл событий |
|
||||||
|
| `engine` | `QQmlApplicationEngine` | загружает и исполняет QML-модуль лаунчера |
|
||||||
|
|
||||||
|
Экземпляр `LauncherBackend` здесь не создаётся: тип зарегистрирован через `QML_ELEMENT`, и главное
|
||||||
|
окно объявляет его само декларативно. Поэтому в `main.cpp` нет ни одного `#include` классов
|
||||||
|
предметной области.
|
||||||
|
|
||||||
|
## Связывание и подключения
|
||||||
|
|
||||||
|
Единственное подключение — обработка неудачи создания корневого объекта: сигнал
|
||||||
|
`QQmlApplicationEngine::objectCreationFailed` замыкается на лямбду, которая завершает приложение
|
||||||
|
с кодом `-1`. Соединение создаётся с типом `Qt::QueuedConnection` и с объектом `app` в роли
|
||||||
|
контекста, чтобы выход из приложения происходил уже внутри цикла событий, а не в разгар загрузки
|
||||||
|
QML.
|
||||||
|
|
||||||
|
Контекстные свойства не задаются, начальные свойства корневому объекту не передаются: связь между
|
||||||
|
QML и C++ идёт исключительно через зарегистрированный тип.
|
||||||
|
|
||||||
|
## Цикл событий
|
||||||
|
|
||||||
|
QML загружается вызовом `engine.loadFromModule("Minecraft_launcher", "Main")` — по URI модуля и
|
||||||
|
имени типа, а не по пути к файлу. Модуль объявлен в `CMakeLists.txt` через `qt_add_qml_module`, а
|
||||||
|
`Main` — это [Main.qml](../qml/Main.md), корневой элемент которого `Window` с `visible: true`,
|
||||||
|
поэтому окно показывается само.
|
||||||
|
|
||||||
|
Цикл событий запускается `app.exec()`, его результат возвращается из `main()` как код завершения
|
||||||
|
процесса.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
| Заголовок | Что даёт |
|
||||||
|
|-----------|----------|
|
||||||
|
| `QGuiApplication` | объект приложения и цикл событий для приложения без виджетов |
|
||||||
|
| `QQmlApplicationEngine` | загрузка QML-модуля и создание корневого объекта |
|
||||||
|
| `QQuickStyle` | выбор стиля Qt Quick Controls до загрузки QML |
|
||||||
|
| `QtWebEngineQuick` | инициализация WebEngine; подключается только при сборке с Qt WebEngine |
|
||||||
|
|
||||||
|
Модули сборки: `Qt6::Quick`, `Qt6::QuickControls2`, `Qt6::Core`, `Qt6::CorePrivate`, `Qt6::Gui`,
|
||||||
|
`Qt6::Network` и опционально `Qt6::WebEngineQuick`. Макрос `LAUNCHER_HAS_WEBENGINE` определяется в
|
||||||
|
`CMakeLists.txt` только тогда, когда `find_package` нашёл `Qt6WebEngineQuick`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# minecraftversion.h — MinecraftVersion и VersionLoader
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Каждая версия Minecraft описывается файлом `versions/<id>/<id>.json` — в нём главный класс,
|
||||||
|
аргументы запуска, список библиотек, индекс ресурсов и требуемая версия Java. Файлы образуют
|
||||||
|
цепочку: профиль модлоадера наследуется от версии игры через поле `inheritsFrom`, а та, в свою
|
||||||
|
очередь, может наследоваться дальше.
|
||||||
|
|
||||||
|
Заголовок `minecraftversion.h` даёт две вещи: структуры, описывающие версию в разобранном виде, и
|
||||||
|
пространство имён `VersionLoader` — чтение, разворачивание цепочки наследования и работа с
|
||||||
|
установленными версиями на диске.
|
||||||
|
|
||||||
|
Заголовок подключают [GameLauncher](GameLauncher.md) (собирает из версии командную строку),
|
||||||
|
[VersionInstaller](VersionInstaller.md) (по списку библиотек понимает, что качать) и
|
||||||
|
[LauncherBackend](LauncherBackend.md) (проверяет комплектность и удаляет версии).
|
||||||
|
|
||||||
|
## Типы
|
||||||
|
|
||||||
|
| Имя | Вид | Описание |
|
||||||
|
|-----|-----|----------|
|
||||||
|
| `MinecraftLibrary` | `struct` | Одна библиотека из `client.json` с уже разрешёнными правилами |
|
||||||
|
| `MinecraftVersion` | `struct` | `client.json`, «схлопнутый» по всей цепочке `inheritsFrom` |
|
||||||
|
|
||||||
|
### MinecraftLibrary
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `name` | `QString` | Maven-координаты, например `org.lwjgl:lwjgl:3.3.1:natives-windows` |
|
||||||
|
| `path` | `QString` | Путь относительно `.minecraft/libraries` |
|
||||||
|
| `url` | `QString` | Откуда качать, если файла нет; может быть пустым — тогда файл должен уже лежать на месте |
|
||||||
|
| `sha1` | `QString` | Контрольная сумма для проверки скачанного |
|
||||||
|
| `size` | `qint64` | Размер в байтах; `0` — неизвестен |
|
||||||
|
| `native` | `bool` | Библиотека распаковывается в `natives`, а не кладётся в classpath |
|
||||||
|
| `extractExclude` | `QStringList` | Префиксы путей внутри архива, которые не распаковываются |
|
||||||
|
|
||||||
|
### MinecraftVersion
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `id` | `QString` | Имя папки в `versions`, оно же значение аргумента `--version` |
|
||||||
|
| `mainClass` | `QString` | Главный класс, который запускает java |
|
||||||
|
| `type` | `QString` | `release`, `snapshot` или `modified`; идёт в `--versionType` |
|
||||||
|
| `assetIndexId` | `QString` | Идентификатор индекса ресурсов для `--assetIndex` |
|
||||||
|
| `assetsKind` | `QString` | Поле `assets` версии: `legacy`, `pre-1.6` либо тот же идентификатор |
|
||||||
|
| `clientJarPath` | `QString` | Абсолютный путь к `<id>.jar`; может лежать у родительской версии |
|
||||||
|
| `javaMajor` | `int` | Требуемая мажорная версия Java; по умолчанию `8` |
|
||||||
|
| `jvmArgs` | `QStringList` | Аргументы JVM ещё с неподставленными подстановками вида `${...}` |
|
||||||
|
| `gameArgs` | `QStringList` | Аргументы игры, тоже с неподставленными подстановками |
|
||||||
|
| `libraries` | `QList<MinecraftLibrary>` | Библиотеки версии с уже применёнными правилами |
|
||||||
|
| `loggingArgument` | `QString` | Аргумент вида `-Dlog4j.configurationFile=${path}` |
|
||||||
|
| `loggingConfigPath` | `QString` | Абсолютный путь к xml-конфигурации журнала; пуст, если конфигурации нет |
|
||||||
|
| `supportsQuickPlay` | `bool` | Версия 1.20 и новее понимает `--quickPlayMultiplayer` — так лаунчер подключается к серверу сразу при запуске |
|
||||||
|
| `hasCustomResolutionArgs` | `bool` | Версия принимает аргументы размера окна |
|
||||||
|
|
||||||
|
Метод `isValid()` возвращает `true`, когда заполнены и `id`, и `mainClass`: именно этим проверяется
|
||||||
|
успешность чтения версии.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
Пространство имён `VersionLoader`.
|
||||||
|
|
||||||
|
#### QStringList installedVersions(const QString &gameDir)
|
||||||
|
|
||||||
|
Версии, реально установленные в `<gameDir>/versions`: есть и папка, и файл `<id>.json`. Одной
|
||||||
|
только папки недостаточно — она остаётся после неудачной установки.
|
||||||
|
|
||||||
|
#### QStringList dependentsOf(const QString &gameDir, const QString &versionId)
|
||||||
|
|
||||||
|
Установленные профили, у которых `inheritsFrom` равен `versionId`. Без базовой версии они не
|
||||||
|
запустятся, поэтому список показывается пользователю перед удалением версии.
|
||||||
|
|
||||||
|
#### qint64 installedSize(const QString &gameDir, const QString &versionId)
|
||||||
|
|
||||||
|
Размер `<gameDir>/versions/<id>` в байтах; `0`, если папки нет. Используется в предупреждении об
|
||||||
|
удалении — версия весит десятки мегабайт, и стоит показать, сколько освободится.
|
||||||
|
|
||||||
|
#### bool remove(const QString &gameDir, const QString &versionId, QString \*error)
|
||||||
|
|
||||||
|
Сносит `<gameDir>/versions/<id>`. Отсутствие папки считается успехом. При неудаче возвращает
|
||||||
|
`false` и заполняет `error`.
|
||||||
|
|
||||||
|
Библиотеки и ресурсы в `libraries/` и `assets/` не трогаются: они общие для всех версий.
|
||||||
|
|
||||||
|
#### MinecraftVersion load(const QString &gameDir, const QString &versionId, const QSet<QString> &features, QString \*error)
|
||||||
|
|
||||||
|
Читает версию и разворачивает всю цепочку `inheritsFrom` в один объект. Параметр `features` —
|
||||||
|
набор включённых возможностей, влияющих на применение правил (например, запрошен ли пользовательский
|
||||||
|
размер окна).
|
||||||
|
|
||||||
|
При ошибке возвращает объект, у которого `isValid()` даёт `false`, и заполняет `error`.
|
||||||
|
|
||||||
|
#### bool rulesAllow(const QJsonArray &rules, const QSet<QString> &features = {})
|
||||||
|
|
||||||
|
Стандартный алгоритм Mojang для блоков `rules`: правила применяются по порядку, побеждает последнее
|
||||||
|
совпавшее. Используется и для библиотек, и для аргументов запуска.
|
||||||
|
|
||||||
|
#### QString nativeClassifier()
|
||||||
|
|
||||||
|
Классификатор нативных библиотек для текущей машины: `natives-windows`, `natives-macos-arm64`,
|
||||||
|
`natives-linux` и подобные. По нему из списка библиотек отбираются те, что нужно распаковать.
|
||||||
|
|
||||||
|
#### QString osName()
|
||||||
|
|
||||||
|
Имя операционной системы в терминах Mojang — оно подставляется в правила и аргументы.
|
||||||
|
|
||||||
|
#### QString osArch()
|
||||||
|
|
||||||
|
Архитектура в терминах Mojang.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Подключает `QJsonArray`, `QList`, `QSet`, `QString` и `QStringList` — только Qt Core. От классов
|
||||||
|
проекта не зависит.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
QString error;
|
||||||
|
const MinecraftVersion version = VersionLoader::load(gameDir, versionId, features, &error);
|
||||||
|
if (!version.isValid()) {
|
||||||
|
emit launchError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList classpath;
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
if (!library.native)
|
||||||
|
classpath << gameDir + "/libraries/" + library.path;
|
||||||
|
}
|
||||||
|
classpath << version.clientJarPath;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# modloader.h — ModLoader
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
`Minecraft_launcher` умеет ставить четыре модлоадера: Minecraft Forge, Fabric Loader, NeoForge и
|
||||||
|
Quilt Loader. Заголовок `modloader.h` — общий словарь для всей этой части проекта: перечисление
|
||||||
|
самих лоадеров, описание одной их сборки и функции перевода между перечислением и строковым
|
||||||
|
ключом.
|
||||||
|
|
||||||
|
Лоадеры взаимоисключающи: игра запускается ровно с одним профилем в `<gameDir>/versions`, поэтому в
|
||||||
|
сборке лаунчера хранится один ключ лоадера, а не набор.
|
||||||
|
|
||||||
|
Заголовок подключают [ModLoaderVersionService](ModLoaderVersionService.md),
|
||||||
|
[ModLoaderInstaller](ModLoaderInstaller.md) и [LauncherBackend](LauncherBackend.md).
|
||||||
|
|
||||||
|
## Типы
|
||||||
|
|
||||||
|
| Имя | Вид | Описание |
|
||||||
|
|-----|-----|----------|
|
||||||
|
| `ModLoader` | `enum class` | Модлоадеры, которые лаунчер умеет ставить |
|
||||||
|
| `LoaderVersionEntry` | `struct` | Одна сборка модлоадера под конкретную версию игры |
|
||||||
|
|
||||||
|
### ModLoader
|
||||||
|
|
||||||
|
| Значение | Ключ | Название | Описание |
|
||||||
|
|----------|------|----------|----------|
|
||||||
|
| `Forge` | `forge` | Minecraft Forge | Старейший загрузчик; ставится собственным установщиком, который собирает часть файлов на месте |
|
||||||
|
| `Fabric` | `fabric` | Fabric Loader | Лёгкий загрузчик; профиль версии формируется из метаданных без запуска установщика |
|
||||||
|
| `NeoForge` | `neoforge` | NeoForge | Ответвление Forge; ставится так же собственным установщиком |
|
||||||
|
| `Quilt` | `quilt` | Quilt Loader | Ответвление Fabric; ставится так же, как Fabric |
|
||||||
|
|
||||||
|
Перечисление объявлено как `enum class`, поэтому неявного приведения к целому нет.
|
||||||
|
|
||||||
|
### LoaderVersionEntry
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `loaderVersion` | `QString` | Версия самого лоадера — например `47.4.0`, `0.19.3` или `21.1.248` |
|
||||||
|
| `gameVersion` | `QString` | Версия Minecraft, под которую эта сборка — например `1.20.1` |
|
||||||
|
| `versionId` | `QString` | Предсказанный идентификатор профиля `versions/<id>`. У Forge пуст: он выясняется только после работы установщика |
|
||||||
|
| `installerUrl` | `QUrl` | Адрес установщика; заполнен только у Forge и NeoForge |
|
||||||
|
| `recommended` | `bool` | Сборка помечена авторами как рекомендуемая; по умолчанию `false` |
|
||||||
|
| `stable` | `bool` | Сборка стабильна, а не тестовая; по умолчанию `true` |
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
#### QString loaderKey(ModLoader loader)
|
||||||
|
|
||||||
|
Строковый ключ лоадера: `forge`, `fabric`, `neoforge` или `quilt`. Один и тот же ключ используется
|
||||||
|
в трёх местах — в интерфейсе, в имени файла кэша на диске и в поле `loader` файла
|
||||||
|
`customBuilds.json`, — поэтому менять его нельзя без миграции сохранённых сборок.
|
||||||
|
|
||||||
|
Объявлена `inline` в заголовке.
|
||||||
|
|
||||||
|
#### QString loaderTitle(ModLoader loader)
|
||||||
|
|
||||||
|
Человекочитаемое название лоадера для интерфейса: «Minecraft Forge», «Fabric Loader», «NeoForge»,
|
||||||
|
«Quilt Loader». Объявлена `inline`.
|
||||||
|
|
||||||
|
#### std::optional<ModLoader> loaderFromKey(const QString &key)
|
||||||
|
|
||||||
|
Обратный перевод: ключ в перечисление. Возвращает `std::nullopt` для неизвестного ключа, в том
|
||||||
|
числе для пустой строки — а пустая строка в сборке означает чистую ваниль без лоадера. Объявлена
|
||||||
|
`inline`.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Подключает `QString`, `QUrl` и `<optional>`. От классов проекта не зависит и сам подключается
|
||||||
|
всюду, где речь идёт о модлоадерах.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const auto loader = loaderFromKey(build.loader);
|
||||||
|
if (!loader) {
|
||||||
|
// сборка без модлоадера — запускаем чистую ваниль
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qInfo() << "ставим" << loaderTitle(*loader)
|
||||||
|
<< "версии" << entry.loaderVersion
|
||||||
|
<< "под Minecraft" << entry.gameVersion;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# zlibreference.h — ZlibReference
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
`Minecraft_launcher` умеет ставить модлоадеры Forge и NeoForge, а их установщики — это Java-программы,
|
||||||
|
которые собирают часть jar-файлов прямо на машине пользователя и сверяют sha1 каждого собранного
|
||||||
|
файла с эталоном из `install_profile.json`.
|
||||||
|
|
||||||
|
Эталон посчитан на обычном zlib. Дистрибутивы вроде CachyOS и Fedora подставляют вместо него
|
||||||
|
zlib-ng: сжатие корректное, но побайтово другое — поэтому установка падает на любой версии игры
|
||||||
|
сообщением «Processor failed, invalid outputs». Сменить Java не выйдет: сборки OpenJDK под Linux
|
||||||
|
берут `libz.so.1` из системы.
|
||||||
|
|
||||||
|
`ZlibReference` — обход этой проблемы. Рядом с лаунчером лежит собранный обычный zlib, и процессу
|
||||||
|
установщика он подсовывается через `LD_PRELOAD`. Системный zlib-ng при этом остаётся на месте:
|
||||||
|
подмена живёт ровно один процесс.
|
||||||
|
|
||||||
|
Пространство имён используется установщиком модлоадеров при подготовке окружения дочернего
|
||||||
|
процесса Java.
|
||||||
|
|
||||||
|
## Пространства имён
|
||||||
|
|
||||||
|
`ZlibReference` группирует три функции: проверку системной библиотеки, поиск собранного эталона и
|
||||||
|
подготовку окружения процесса. Состояния нет.
|
||||||
|
|
||||||
|
## Функции
|
||||||
|
|
||||||
|
#### bool systemIsZlibNg()
|
||||||
|
|
||||||
|
Отвечает на вопрос, окажется ли `libz.so.1`, который достанется процессу java, библиотекой zlib-ng.
|
||||||
|
От ответа зависит, нужна ли подмена вообще: на системе с обычным zlib она бессмысленна.
|
||||||
|
|
||||||
|
#### QString bundledPath()
|
||||||
|
|
||||||
|
Путь к собранному рядом эталонному `libz.so.1` или пустая строка, если его нет. Библиотека
|
||||||
|
собирается целью `launcher_zlib_reference` в `CMakeLists.txt` из исходников каталога `zlib/`,
|
||||||
|
которые лежат в репозитории, чтобы сборка не зависела от сети, а версия была зафиксирована — от
|
||||||
|
неё зависит побайтовый результат сжатия. Собирается только на Linux: на Windows и macOS проблемы
|
||||||
|
подмены системного zlib нет.
|
||||||
|
|
||||||
|
#### bool applyTo(QProcessEnvironment &env, QString *note = nullptr)
|
||||||
|
|
||||||
|
Дописывает `LD_PRELOAD` в переданное окружение, если подмена нужна. Изменяет `env` на месте.
|
||||||
|
|
||||||
|
Возвращает `true`, когда окружение готово, — в том числе в случае, когда подменять нечего:
|
||||||
|
система с обычным zlib или платформа, где вопрос не стоит. `false` означает, что подмена нужна, но
|
||||||
|
эталонной библиотеки нет на месте.
|
||||||
|
|
||||||
|
Необязательный параметр `note` заполняется строкой, пригодной и для журнала, и для текста ошибки:
|
||||||
|
она объясняет, была ли подмена применена и почему.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Подключает `QString`; `QProcessEnvironment` объявлен вперёд и используется только по ссылке.
|
||||||
|
Реализация опирается на макрос `LAUNCHER_ZLIB_INSTALL_DIR`, который `CMakeLists.txt` определяет на
|
||||||
|
Linux — это путь установки библиотеки в системе, помимо каталога рядом с исполняемым файлом.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||||
|
|
||||||
|
QString note;
|
||||||
|
if (!ZlibReference::applyTo(env, ¬e)) {
|
||||||
|
emit failed(note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProcess installer;
|
||||||
|
installer.setProcessEnvironment(env);
|
||||||
|
installer.start(javaPath, arguments);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Minecraft Launcher — справочник по исходному коду
|
||||||
|
|
||||||
|
Десктопный лаунчер Minecraft на Qt 6. Интерфейс написан на QML, вся работа — авторизация,
|
||||||
|
скачивание версий, установка модлоадеров и Java, запуск игры — лежит в C++.
|
||||||
|
|
||||||
|
Документация разделена на две части: [QML-компоненты](#qml-компоненты) и
|
||||||
|
[C++-классы](#c-классы).
|
||||||
|
|
||||||
|
## Как устроено приложение
|
||||||
|
|
||||||
|
Слоёв четыре, и каждый общается только с соседними:
|
||||||
|
|
||||||
|
1. **QML.** [Main](qml/Main.md) — единственное настоящее окно и точка входа. Оно держит
|
||||||
|
единственный экземпляр `LauncherBackend` и раздаёт его вложенным диалогам через свойство
|
||||||
|
`backend`. Ни один QML-файл не обращается к сервисам напрямую.
|
||||||
|
2. **Фасад.** [LauncherBackend](cpp/LauncherBackend.md) — главный класс, видимый из QML.
|
||||||
|
Хранит профили, сборки и настройки, владеет двенадцатью сервисами и сводит их состояние к
|
||||||
|
свойствам, которые читает интерфейс. Каталоги он отдаёт уже сведёнными с локальным состоянием,
|
||||||
|
поэтому окна показывают статус, не считая ничего сами.
|
||||||
|
3. **Сервисы.** Каталоги (версий, модлоадеров, Java, сезонных сборок), установщики, службы
|
||||||
|
авторизации, переключатель сборок и запуск игры. Каждый занят одним делом и ничего не знает об
|
||||||
|
интерфейсе.
|
||||||
|
Особняком стоит [Localization](cpp/Localization.md) — второй и последний тип, видимый из QML.
|
||||||
|
Это синглтон `Loc`, через который проходят все тексты интерфейса: `Loc.t.домен.вид.имя` в QML и
|
||||||
|
`Loc::text("домен.вид.имя")` в C++. Он ни от чего не зависит и доступен всем слоям сразу.
|
||||||
|
|
||||||
|
4. **Внешний мир.** Сеть (Mojang, Ely.by, Microsoft, Adoptium, файловый сервер сборок), файловая
|
||||||
|
система (`.minecraft` и папка лаунчера) и дочерние процессы (java, установщики модлоадеров,
|
||||||
|
`tar`).
|
||||||
|
|
||||||
|
Почти весь код работает в потоке GUI: параллелизм даёт асинхронная сеть, а не потоки. Единственное
|
||||||
|
исключение — операции над содержимым `.minecraft` (гигабайты модов и миров), вынесенные в
|
||||||
|
отдельный поток к [BuildArchiveWorker](cpp/BuildArchiveWorker.md).
|
||||||
|
|
||||||
|
## QML-компоненты
|
||||||
|
|
||||||
|
| Компонент | Описание |
|
||||||
|
|-----------|----------|
|
||||||
|
| [Main](qml/Main.md) | Главное окно и точка входа: экран запуска, профили, плашка сообщений, панели прогресса и внутренние диалоги |
|
||||||
|
| [BuildsDialog](qml/BuildsDialog.md) | Пользовательские сборки: список слева, карточка редактирования справа |
|
||||||
|
| [VersionPickerDialog](qml/VersionPickerDialog.md) | Выбор версии Minecraft: категории, поиск, удаление скачанных версий |
|
||||||
|
| [JavaPickerDialog](qml/JavaPickerDialog.md) | Выбор сборки Java; подтверждение при необходимости сразу начинает загрузку |
|
||||||
|
| [SeasonalBuildsDialog](qml/SeasonalBuildsDialog.md) | Каталог готовых сезонных сборок таблицей и установка одной кнопкой |
|
||||||
|
| [MicrosoftLoginDialog](qml/MicrosoftLoginDialog.md) | Окно входа в аккаунт Microsoft; собирается только с Qt WebEngine |
|
||||||
|
| [LoaderRow](qml/LoaderRow.md) | Одна строка модлоадера в карточке сборки: чекбокс и список версий |
|
||||||
|
| [ProgressPanel](qml/ProgressPanel.md) | Плашка хода долгой операции в левом нижнем углу |
|
||||||
|
| [DarkCombo](qml/DarkCombo.md) | Выпадающий список в тёмном стиле окна |
|
||||||
|
| [LabelledField](qml/LabelledField.md) | Подпись и поле ввода одной колонкой |
|
||||||
|
|
||||||
|
## C++-классы
|
||||||
|
|
||||||
|
### Точка входа
|
||||||
|
|
||||||
|
| Файл | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| [main.cpp](cpp/main.md) | Инициализация WebEngine, объект приложения, каталог переводов, стиль `Basic`, загрузка QML-модуля |
|
||||||
|
|
||||||
|
### Фасад
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [Localization](cpp/Localization.md) | Синглтон `Loc`: все тексты интерфейса в одном файле `i18n/translations.json`, переключение языка на лету |
|
||||||
|
| [LauncherBackend](cpp/LauncherBackend.md) | Единственный тип, видимый из QML: 26 свойств, 42 вызываемых метода, состояние лаунчера и порядок работы всех сервисов |
|
||||||
|
|
||||||
|
### Авторизация и запуск
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [AuthService](cpp/AuthService.md) | Вход через Ely.by и офлайн-режим; структура `AuthResult` |
|
||||||
|
| [MsaAuthService](cpp/MsaAuthService.md) | Вход через аккаунт Microsoft: OAuth2 → Xbox Live → XSTS → Minecraft Services |
|
||||||
|
| [GameLauncher](cpp/GameLauncher.md) | Сборка командной строки, распаковка нативных библиотек и запуск JVM |
|
||||||
|
|
||||||
|
### Версии игры
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [VersionManifestService](cpp/VersionManifestService.md) | Каталог версий Mojang с кэшем в папке лаунчера |
|
||||||
|
| [VersionInstaller](cpp/VersionInstaller.md) | Фоновая установка версии: jar, библиотеки, индекс ресурсов и сами ресурсы |
|
||||||
|
|
||||||
|
### Модлоадеры
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [ModLoaderVersionService](cpp/ModLoaderVersionService.md) | Списки версий Forge, Fabric, NeoForge и Quilt; совместимость заложена в структуру данных |
|
||||||
|
| [ModLoaderInstaller](cpp/ModLoaderInstaller.md) | Два пути установки под одним фасадом: готовое описание версии либо запуск `installer.jar` |
|
||||||
|
|
||||||
|
### Java
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [JavaRuntimeService](cpp/JavaRuntimeService.md) | Каталог сборок Mojang и Eclipse Temurin под текущую платформу |
|
||||||
|
| [JavaInstaller](cpp/JavaInstaller.md) | Установка сборки Java: архив Temurin либо дерево файлов Mojang |
|
||||||
|
|
||||||
|
### Сборки и их содержимое
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [BuildSwitcher](cpp/BuildSwitcher.md) | Порядок шагов смены активной сборки и восстановление после прерванной операции |
|
||||||
|
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | Упаковка, очистка, распаковка и раскатка пака в отдельном потоке |
|
||||||
|
|
||||||
|
### Сезонные сборки
|
||||||
|
|
||||||
|
| Класс | Описание |
|
||||||
|
|-------|----------|
|
||||||
|
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | Каталог готовых сборок с файлового сервера |
|
||||||
|
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | Загрузка архива сборки потоком с проверкой sha256 |
|
||||||
|
|
||||||
|
### Общие типы и утилиты
|
||||||
|
|
||||||
|
| Файл | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| [minecraftversion.h](cpp/minecraftversion.md) | Структуры версии и библиотеки, чтение и разворачивание цепочки `inheritsFrom` |
|
||||||
|
| [javaruntime.h](cpp/javaruntime.md) | Виды сборок Java, папка `<root>/java` и таблица требований версий игры |
|
||||||
|
| [modloader.h](cpp/modloader.md) | Перечисление модлоадеров, запись версии и перевод ключей |
|
||||||
|
| [launcherpaths.h](cpp/launcherpaths.md) | Все пути к данным лаунчера в одном месте |
|
||||||
|
| [javalocator.h](cpp/javalocator.md) | Поиск установленной в системе Java и выбор подходящей версии |
|
||||||
|
| [zlibreference.h](cpp/zlibreference.md) | Подмена zlib-ng эталонным zlib для установщиков Forge и NeoForge |
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Требуется Qt 6.8 или новее. Обязательные модули: `Quick`, `QuickControls2`, `Core`, `CorePrivate`,
|
||||||
|
`Gui`, `Network`. `CorePrivate` нужен ради `QZipReader` и `QZipWriter` — ими распаковываются
|
||||||
|
нативные библиотеки LWJGL и архивы сборок; привязка к версии Qt из-за приватного модуля —
|
||||||
|
осознанный выбор, и предупреждение о ней в `CMakeLists.txt` отключено.
|
||||||
|
|
||||||
|
`Qt6::WebEngineQuick` необязателен, и это принципиально: модуль ставится отдельной галочкой в
|
||||||
|
установщике Qt и тянет за собой WebChannel с Positioning, которых в типовой установке нет. Если
|
||||||
|
сделать его обязательным, у любого, кто их не поставил, проект перестанет конфигурироваться
|
||||||
|
целиком — вместе с офлайном и Ely.by. Когда модуль найден, определяется макрос
|
||||||
|
`LAUNCHER_HAS_WEBENGINE`, а [MicrosoftLoginDialog.qml](qml/MicrosoftLoginDialog.md) добавляется в
|
||||||
|
QML-модуль; без модуля лаунчер собирается и работает как обычно, только вход через Microsoft
|
||||||
|
сообщает, что эта сборка его не умеет. В QML различие видно через свойство
|
||||||
|
`backend.microsoftAvailable`.
|
||||||
|
|
||||||
|
QML-модуль объявлен как `qt_add_qml_module` с URI `Minecraft_launcher`; точка входа —
|
||||||
|
`engine.loadFromModule("Minecraft_launcher", "Main")`. Туда же, в список `RESOURCES`, попадает
|
||||||
|
каталог переводов `i18n/translations.json`.
|
||||||
|
|
||||||
|
### Тексты интерфейса
|
||||||
|
|
||||||
|
Все подписи, сообщения и ошибки лежат в одном файле `i18n/translations.json` и достаются через
|
||||||
|
синглтон `Loc` — подробности, правила именования ключей и порядок добавления языка описаны в
|
||||||
|
[Localization](cpp/Localization.md). Штатные `.ts`/`.qm` не используются, `lupdate` и `lrelease`
|
||||||
|
в сборке не участвуют. Целостность каталога проверяет `python3 tools/check_translations.py`.
|
||||||
|
|
||||||
|
### Каталог `zlib/`
|
||||||
|
|
||||||
|
В репозитории лежат исходники обычного zlib версии 1.3.1 — это чужой upstream-код, и
|
||||||
|
документацией он не покрыт. Он нужен вот зачем.
|
||||||
|
|
||||||
|
Установщики Forge и NeoForge сверяют sha1 каждого jar, который сами же и собирают, с эталоном из
|
||||||
|
`install_profile.json`. Эталон посчитан на обычном zlib, а дистрибутивы вроде CachyOS и Fedora
|
||||||
|
подставляют вместо него zlib-ng: сжатие корректное, но побайтово другое, поэтому установка падает
|
||||||
|
на любой версии игры сообщением «Processor failed, invalid outputs». Сменить Java не выйдет —
|
||||||
|
сборки OpenJDK под Linux берут `libz.so.1` из системы.
|
||||||
|
|
||||||
|
Поэтому на Linux собирается цель `launcher_zlib_reference` из этих исходников, и готовая
|
||||||
|
библиотека подсовывается через `LD_PRELOAD` только процессу установщика — см.
|
||||||
|
[zlibreference.h](cpp/zlibreference.md). Сам лаунчер с ней не линкуется, системный zlib-ng
|
||||||
|
остаётся на месте. Исходники хранятся в репозитории, чтобы сборка не зависела от сети, а версия
|
||||||
|
была зафиксирована: от неё зависит побайтовый результат сжатия.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# BuildsDialog
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Сборка в нём — это именованный
|
||||||
|
набор «версия игры + модлоадер + адрес сервера» вместе с собственным содержимым `.minecraft`:
|
||||||
|
модами, конфигами и мирами. Активная сборка одна, её содержимое лежит в `.minecraft`, остальные
|
||||||
|
хранятся в архивах и разворачиваются при переключении.
|
||||||
|
|
||||||
|
`BuildsDialog` — окно управления этими сборками: слева список со сменой активной, справа карточка
|
||||||
|
выбранной — имя, сервер, версия Minecraft и модлоадеры. Отсюда же сборка ставится (скачивание
|
||||||
|
версии игры и модлоадера) и удаляется.
|
||||||
|
|
||||||
|
Карточка сохраняет правки по ходу редактирования, отдельной кнопки «Сохранить» нет: иначе
|
||||||
|
появляется неочевидное несохранённое состояние, пока пользователь переключается между сборками в
|
||||||
|
левом списке.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
||||||
|
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
||||||
|
|
||||||
|
Использует три компонента из того же QML-модуля:
|
||||||
|
|
||||||
|
- [LabelledField](LabelledField.md) — поля названия сборки и адреса сервера;
|
||||||
|
- [LoaderRow](LoaderRow.md) — по одной строке на каждый из четырёх модлоадеров;
|
||||||
|
- [VersionPickerDialog](VersionPickerDialog.md) — вложенное окно выбора версии Minecraft.
|
||||||
|
|
||||||
|
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||||
|
`Minecraft_launcher`) через свойство `backend`. Читает свойства `customBuildNames`,
|
||||||
|
`activeBuildIndex`, `switching` и `busy`; вызывает `customBuildAt()`, `addCustomBuild()`,
|
||||||
|
`updateCustomBuild()`, `customBuildRemovalInfo()`, `removeCustomBuild()`, `checkInstallation()`,
|
||||||
|
`installCustomBuild()` и `refreshVersionCatalog()`; слушает сигнал `installedVersionsChanged`.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`.
|
||||||
|
Инстанцируется в [Main](Main.md).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Dialog`: модальный, 880×600 px, нулевой внутренний отступ, тёмный фон со
|
||||||
|
скруглением и акцентной рамкой. Пока идёт смена активной сборки (`backend.switching`), окно
|
||||||
|
закрывается только кнопкой: архивация и распаковка `.minecraft` не должны прерываться случайным
|
||||||
|
щелчком мимо.
|
||||||
|
|
||||||
|
Содержимое — `RowLayout` из двух частей:
|
||||||
|
|
||||||
|
- **Слева** (260 px) список сборок и строка «+ Новая сборка» под ним. Строка списка показывает имя,
|
||||||
|
пометку «активна» у активной сборки и корзину, появляющуюся при наведении. Вся колонка
|
||||||
|
выключается на время смены активной сборки.
|
||||||
|
- **Справа** карточка выбранной сборки внутри `Flickable` — она может не поместиться по высоте.
|
||||||
|
Карточка выключается и приглушается, когда сборка не выбрана или идёт переключение.
|
||||||
|
|
||||||
|
Карточка сверху вниз: название, адрес сервера, поле версии Minecraft, панель модлоадеров и строка
|
||||||
|
состояния комплектности. Поле версии — не выпадающий список, а прямоугольник, который только
|
||||||
|
показывает выбор и открывает отдельное окно: версий около тысячи, и разбираться в них удобнее в
|
||||||
|
окне с категориями.
|
||||||
|
|
||||||
|
Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой,
|
||||||
|
привязанный к тому же родителю, что и само окно.
|
||||||
|
|
||||||
|
Подвал несёт три кнопки: «Установить», «Сделать активной» и «Закрыть».
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — хранилище сборок и исполнитель установки, удаления и переключения. |
|
||||||
|
| `editIndex` | `int` | `-1` | Нет | Индекс сборки, открытой в карточке справа. Значение `-1` означает, что карточка пуста и выключена. С активной сборкой не связан: активную меняет отдельная кнопка, чтобы случайный клик по списку не запускал архивацию `.minecraft`. |
|
||||||
|
| `loading` | `bool` | `false` | Нет | Признак того, что карточка сейчас заполняется данными сборки. Пока он выставлен, обработчики полей не должны писать обратно — иначе открытие сборки немедленно перезаписывало бы её. |
|
||||||
|
|
||||||
|
### Внутренняя панель модлоадеров
|
||||||
|
|
||||||
|
Панель `loaderPanel` — это `Column` с четырьмя строками [LoaderRow](LoaderRow.md) и собственным
|
||||||
|
маленьким API. Лоадеры несовместимы между собой: игра запускается ровно с одним профилем
|
||||||
|
`versions/<id>`, поэтому отметка одного снимает остальные, а «ничего не отмечено» — это чистая
|
||||||
|
ваниль.
|
||||||
|
|
||||||
|
| Свойство панели | Тип | Описание |
|
||||||
|
|-----------------|-----|----------|
|
||||||
|
| `rows` | `var` | Массив из четырёх строк лоадеров в порядке показа: Minecraft Forge (`forge`), Fabric Loader (`fabric`), NeoForge (`neoforge`), Quilt Loader (`quilt`). |
|
||||||
|
|
||||||
|
Функции панели: `applyBuild(loader, loaderVersion)` раздаёт данные сборки всем строкам;
|
||||||
|
`selectedRow()` возвращает отмеченную строку или `null`; `keepOnly(row)` очищает все строки,
|
||||||
|
кроме переданной; `commitSelection()` сохраняет выбор в сборку, записывая ключ лоадера, его версию
|
||||||
|
и пустой `resolvedVersionId` — профиль появится только после установки, а до неё сборка
|
||||||
|
запускается на чистой ванили.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
Собственных сигналов компонент не объявляет: все изменения уходят прямо в бэкенд, и о них
|
||||||
|
остальной интерфейс узнаёт по его сигналам.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### selectBuild(int index) : void
|
||||||
|
|
||||||
|
Открывает сборку с указанным индексом в карточке. Читает данные через `customBuildAt()`,
|
||||||
|
выставляет `loading` на время заполнения полей, раздаёт лоадеры панели через `applyBuild()` и в
|
||||||
|
конце обновляет строку состояния.
|
||||||
|
|
||||||
|
#### askRemove(int index) : void
|
||||||
|
|
||||||
|
Спрашивает подтверждение перед удалением: оно необратимо и уносит с собой архив сборки. Запрашивает
|
||||||
|
`customBuildRemovalInfo()` и наполняет окно подтверждения именем сборки и тремя признаками — есть
|
||||||
|
ли у неё архив, активна ли она сейчас и последняя ли она. Индекс запоминается в самом окне
|
||||||
|
подтверждения, потому что к моменту ответа строка списка под курсором может быть уже другой.
|
||||||
|
|
||||||
|
Окно подтверждения объясняет последствия по этим признакам: вместе со сборкой удалится её архив, и
|
||||||
|
моды, конфиги и миры восстановить будет нельзя; активная сборка владеет содержимым `.minecraft`,
|
||||||
|
оно будет очищено, а на его место развернётся следующая сборка; для последней сборки содержимое
|
||||||
|
`.minecraft` остаётся на месте.
|
||||||
|
|
||||||
|
#### performRemove(int index) : void
|
||||||
|
|
||||||
|
Удаляет сборку через бэкенд и восстанавливает состояние карточки: если сборок не осталось,
|
||||||
|
сбрасывает `editIndex` в `-1`, иначе открывает соседнюю — ту же позицию или последнюю оставшуюся.
|
||||||
|
|
||||||
|
#### commit(var fields) : void
|
||||||
|
|
||||||
|
Сохраняет часть полей сборки: передаёт `QVariantMap` в `updateCustomBuild()` и обновляет строку
|
||||||
|
состояния. Ничего не делает во время заполнения карточки (`loading`) и при пустом `editIndex` —
|
||||||
|
это и есть защита от записи при открытии сборки.
|
||||||
|
|
||||||
|
Вызывается по завершении правки каждого поля: имени, адреса сервера, версии Minecraft и выбора
|
||||||
|
модлоадера.
|
||||||
|
|
||||||
|
#### refreshStatus() : void
|
||||||
|
|
||||||
|
Пересчитывает строку комплектности под карточкой. Спрашивает у бэкенда `checkInstallation()` и
|
||||||
|
показывает либо сообщение о готовности к запуску, либо число недостающих файлов вместе с первым из
|
||||||
|
них. При пустом `editIndex` очищает строку.
|
||||||
|
|
||||||
|
#### newBuildName() : string
|
||||||
|
|
||||||
|
Придумывает имя для новой сборки: перебирает «Сборка 1», «Сборка 2» и так далее, пока не найдёт
|
||||||
|
свободное среди существующих имён.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
**Со стороны родителя.** [Main](Main.md) задаёт `backend` и открывает окно. Начальное состояние
|
||||||
|
окно выбирает само в обработчике `onAboutToShow`: просит обновить каталог версий и открывает
|
||||||
|
активную сборку, а при пустом списке оставляет карточку выключенной.
|
||||||
|
|
||||||
|
**Внутрь — к вложенным компонентам.** Поля [LabelledField](LabelledField.md) сообщают о правке
|
||||||
|
сигналом `editingFinished`, и карточка сразу вызывает `commit()` с обрезанным по краям значением.
|
||||||
|
Поле версии открывает [VersionPickerDialog](VersionPickerDialog.md) вызовом `openFor()` и получает
|
||||||
|
результат сигналом `versionChosen`; запись выбранной версии сама вызывает `commit()` через
|
||||||
|
обработчик изменения. Строки [LoaderRow](LoaderRow.md) получают `gameVersion` привязкой к
|
||||||
|
выбранной версии игры, поэтому смена версии Minecraft автоматически перезапрашивает списки версий
|
||||||
|
лоадеров.
|
||||||
|
|
||||||
|
**Наружу — к бэкенду.** Кнопка «Установить» вызывает `installCustomBuild()`, кнопка «Сделать
|
||||||
|
активной» пишет в свойство `activeBuildIndex`. Обе выключены, пока лаунчер занят (`busy`), а
|
||||||
|
кнопка активации — ещё и когда выбранная сборка уже активна. Ход установки и переключения
|
||||||
|
показывает плашка [ProgressPanel](ProgressPanel.md) главного окна, а не это окно.
|
||||||
|
|
||||||
|
**Обратная связь от бэкенда.** Окно подписано на `installedVersionsChanged` через `Connections`:
|
||||||
|
установка версии или модлоадера меняет комплектность сборки, и строка состояния должна это
|
||||||
|
заметить, не дожидаясь переоткрытия окна.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
BuildsDialog {
|
||||||
|
id: buildsDialog
|
||||||
|
parent: Overlay.overlay
|
||||||
|
anchors.centerIn: parent
|
||||||
|
backend: launcherBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: qsTr("Сборки")
|
||||||
|
onClicked: buildsDialog.open()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# DarkCombo
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Всё окно оформлено вручную в
|
||||||
|
тёмной теме, а стиль Qt Quick Controls принудительно выставлен в `Basic` (`main.cpp`), потому что
|
||||||
|
нативные стили игнорируют пользовательские `contentItem` и `background`. Из-за этого каждый
|
||||||
|
стандартный элемент управления, попадающий в интерфейс, приходится переопределять самому.
|
||||||
|
|
||||||
|
`DarkCombo` — как раз такое переопределение: выпадающий список в общем тёмном стиле окна.
|
||||||
|
Компонент не добавляет логики, он целиком про внешний вид — фон, рамку, стрелку, делегат строки и
|
||||||
|
всплывающую панель. К нему обращаются всюду, где нужен выпадающий список внутри диалогов: выбор
|
||||||
|
версии модлоадера, выбор Java, поля в настройках.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Импортирует `QtQuick` и `QtQuick.Controls 2.15`; собственных C++-типов не использует.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (см. `CMakeLists.txt`), поэтому доступен по
|
||||||
|
имени `DarkCombo` в любом файле того же модуля без явного импорта.
|
||||||
|
|
||||||
|
Используется в [LoaderRow](LoaderRow.md) — список версий модлоадера — и дважды в [Main](Main.md):
|
||||||
|
в выпадающих списках профиля и сборки на главном окне.
|
||||||
|
|
||||||
|
Компонент ссылается на ресурсы `images/Profile_Box/Asset_23.svg` и
|
||||||
|
`images/Profile_Box/Asset_24.svg` — они перечислены в `RESOURCES` того же QML-модуля, отдельного
|
||||||
|
подключения не требуют.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `ComboBox` из Qt Quick Controls. Всё поведение (модель, `currentIndex`,
|
||||||
|
`activated`, `displayText`, клавиатурная навигация) наследуется без изменений; `DarkCombo`
|
||||||
|
переопределяет только четыре визуальных слота базового типа:
|
||||||
|
|
||||||
|
| Слот | Что даёт `DarkCombo` |
|
||||||
|
|------|----------------------|
|
||||||
|
| `indicator` | стрелка из SVG-ресурса вместо двойного шеврона Basic-стиля; при открытом списке (`down`) картинка меняется |
|
||||||
|
| `contentItem` | текст текущего значения белым, с обрезкой справа многоточием и отступом под стрелку |
|
||||||
|
| `background` | тёмный прямоугольник со скруглением 6 px; рамка подсвечивается акцентным цветом при фокусе |
|
||||||
|
| `delegate` | строка списка: белый текст, подсветка фона у элемента под курсором |
|
||||||
|
| `popup` | всплывающая панель шириной с сам комбобокс, высотой не более 220 px, с вертикальным индикатором прокрутки |
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
Собственных свойств компонент не объявляет — доступен весь набор свойств `ComboBox`
|
||||||
|
(`model`, `currentIndex`, `currentText`, `displayText`, `editable` и прочие).
|
||||||
|
|
||||||
|
Внутри `delegate` объявлены два обязательных свойства делегата, они относятся к строке списка,
|
||||||
|
а не к самому комбобоксу:
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `modelData` | `var` | — | **Да** | Значение строки модели; выводится текстом в строке списка. Модель ожидается плоской — списком строк, а не объектов. |
|
||||||
|
| `index` | `int` | — | **Да** | Позиция строки; сравнивается с `highlightedIndex` комбобокса, чтобы подсветить строку под курсором. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
Собственных сигналов нет. Наследуются сигналы `ComboBox`, из которых на практике используется
|
||||||
|
`activated(int index)` — выбор строки пользователем (в отличие от `currentIndexChanged`, он не
|
||||||
|
срабатывает при программной смене значения).
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
Собственных функций нет.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
Компонент самодостаточен и ничего не знает ни о бэкенде, ни о родителе: модель приходит извне
|
||||||
|
через `model`, результат выбора родитель получает через унаследованный `activated`. Так,
|
||||||
|
[LoaderRow](LoaderRow.md) передаёт в `model` список подписей версий модлоадера и в обработчике
|
||||||
|
`activated` переводит индекс обратно в номер версии.
|
||||||
|
|
||||||
|
Поскольку модель ожидается списком строк, вызывающий код обычно сам приводит массив объектов к
|
||||||
|
массиву подписей перед присваиванием.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
DarkCombo {
|
||||||
|
width: 200
|
||||||
|
height: 32
|
||||||
|
model: ["1.21.1", "1.20.6", "1.20.4"]
|
||||||
|
onActivated: (index) => console.log("выбрано:", model[index])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# JavaPickerDialog
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Разным версиям игры нужны разные
|
||||||
|
версии Java, и лаунчер умеет скачивать их сам: официальные сборки Mojang и сборки Temurin в двух
|
||||||
|
вариантах — JDK и JRE. Держать в голове, какая Java нужна какой версии игры, пользователь не
|
||||||
|
обязан.
|
||||||
|
|
||||||
|
`JavaPickerDialog` — окно выбора сборки Java: слева типы сборок, справа сами версии с поиском.
|
||||||
|
Устроено так же, как [VersionPickerDialog](VersionPickerDialog.md), с одним принципиальным
|
||||||
|
отличием: выбранное здесь ещё и качается. У версий игры загрузку начинает сама сборка, а сборка
|
||||||
|
Java, которой нет на диске, запуску ничем не поможет — поэтому кнопка подтверждения при
|
||||||
|
необходимости сразу ставит выбранную сборку в очередь на скачивание.
|
||||||
|
|
||||||
|
Окно также показывает, какая версия Java нужна выбранной версии игры, помечает сборки, которые
|
||||||
|
для неё слишком старые, и позволяет удалять скачанное прямо из списка.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
||||||
|
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
||||||
|
|
||||||
|
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||||
|
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства `javaCatalog` и
|
||||||
|
`javaCatalogLoading`, вызывает `refreshJavaCatalog()`, `installJavaRuntime()` и
|
||||||
|
`removeJavaRuntime()`.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg`.
|
||||||
|
Инстанцируется в диалоге настроек внутри [Main](Main.md) — сборка Java выбирается один раз для
|
||||||
|
всего лаунчера, а не отдельно для каждой сборки Minecraft.
|
||||||
|
|
||||||
|
Каталог приходит из C++ уже отсортированным — новые сверху, скачанные первыми, — поэтому окно
|
||||||
|
только отбирает записи и порядок не трогает. Запись каталога содержит поля `id`, `label`, `kind`
|
||||||
|
(тип сборки), `major` (мажорная версия Java числом), `installed`, `downloadable`, `lts`, `sizeMb`,
|
||||||
|
`detail`, `coverage` и `search`.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
|
||||||
|
щелчку мимо, оформление тёмное со скруглением и акцентной рамкой.
|
||||||
|
|
||||||
|
Содержимое — `RowLayout` из колонки типов шириной 170 px и области версий, разделённых линией.
|
||||||
|
Каждый пункт колонки типов показывает название и пояснение мелким шрифтом, а под списком типов —
|
||||||
|
подсказка о требовании выбранной версии игры, видимая только когда это требование известно.
|
||||||
|
|
||||||
|
Область версий повторяет устройство окна выбора версии Minecraft: поле поиска сверху, флажок
|
||||||
|
«Только скачанные» снизу, `ListView` между ними. Строка списка выше обычной (44 px), потому что
|
||||||
|
содержит две строки текста: подпись сборки с меткой LTS и строку подробностей, где через точку
|
||||||
|
собраны описание, покрытие версий игры, размер в мегабайтах и — при необходимости —
|
||||||
|
предупреждение о том, что сборки не хватит.
|
||||||
|
|
||||||
|
Пустое состояние объясняется текстом по центру и различает загрузку каталога, отсутствие
|
||||||
|
соединения и пустой результат поиска.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога Java, исполнитель установки и удаления. |
|
||||||
|
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной сборки. Окно открывается с текущей сборкой, и по «Отмене» выбор возвращается к ней. |
|
||||||
|
| `category` | `string` | `"java"` | Нет | Ключ активного типа сборок. Допустимые значения: `java` (сборки Mojang), `jdk` (Temurin с инструментами), `jre` (Temurin, только запуск). |
|
||||||
|
| `filterText` | `string` | `""` | Нет | Текст поиска; сравнивается в нижнем регистре с полем `search` записи внутри активного типа. |
|
||||||
|
| `installedOnly` | `bool` | `false` | Нет | Показывать только скачанные сборки. |
|
||||||
|
| `requiredMajor` | `int` | `0` | Нет | Мажорная версия Java, ниже которой выбранной версии игры не запуститься. Значение `0` означает, что версия игры не выбрана и предупреждать не о чем: подсказка в колонке типов скрывается, пометка «слишком старая» не ставится. |
|
||||||
|
| `categories` | `var` (только чтение) | список из трёх записей | Нет | Описание колонки типов: массив объектов с полями `key`, `title` и `hint`. Задаёт порядок пунктов и набор допустимых значений `category`. |
|
||||||
|
| `visibleEntries` | `var` (только чтение) | вычисляется | Нет | Отобранные строки каталога: записи активного типа, прошедшие флажок «только скачанные» и текст поиска. Модель списка. |
|
||||||
|
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки из каталога или `null`, если ничего не выбрано. Ищется по всему каталогу, а не по видимым строкам, поэтому выбор не теряется при смене фильтра. От неё зависят подпись в подвале и надпись на кнопке подтверждения. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### runtimeChosen(string runtimeId)
|
||||||
|
|
||||||
|
Сборка Java подтверждена — кнопкой, двойным щелчком по строке или клавишей Enter в поле поиска. В
|
||||||
|
параметре приходит идентификатор сборки.
|
||||||
|
|
||||||
|
Обработчик записывает выбранную сборку в настройки лаунчера — сборка Java общая для всех сборок
|
||||||
|
Minecraft, а требование конкретной версии игры влияет только на пометки в списке. Сигнал испускается и для ещё не скачанной сборки — загрузка при
|
||||||
|
этом начинается сама, и обработчику ждать её завершения не нужно.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### openFor(string runtimeId, int required) : void
|
||||||
|
|
||||||
|
Открывает окно на переданной сборке. Запоминает её в `selectedId`, выставляет `requiredMajor` из
|
||||||
|
второго аргумента (отсутствующее или нулевое значение означает «требование неизвестно»), очищает
|
||||||
|
поиск, переключается на тип именно этой сборки, просит бэкенд обновить каталог и прокручивает
|
||||||
|
список к выбранной строке.
|
||||||
|
|
||||||
|
#### categoryOf(string runtimeId) : string
|
||||||
|
|
||||||
|
Возвращает тип сборки по каталогу; для неизвестного идентификатора — `java`.
|
||||||
|
|
||||||
|
#### indexOfSelected() : int
|
||||||
|
|
||||||
|
Позиция выбранной сборки в `visibleEntries` или `-1`, если под текущим фильтром её не видно.
|
||||||
|
|
||||||
|
#### revealSelected() : void
|
||||||
|
|
||||||
|
Выставляет текущий индекс списка на выбранную сборку и прокручивает список так, чтобы строка
|
||||||
|
оказалась по центру.
|
||||||
|
|
||||||
|
#### acceptSelection() : void
|
||||||
|
|
||||||
|
Подтверждает выбор. Ничего не делает, если `selectedEntry` пуст. Иначе испускает
|
||||||
|
`runtimeChosen()`, а затем — если сборка не установлена, но доступна для скачивания, — вызывает
|
||||||
|
`installJavaRuntime()` у бэкенда и закрывает окно. Именно поэтому кнопка подтверждения называется
|
||||||
|
«Скачать» для отсутствующей сборки и «Выбрать» для уже скачанной.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
**Со стороны родителя.** Вызывающий код задаёт `backend`, открывает окно вызовом `openFor()` с
|
||||||
|
текущей сборкой и требуемой мажорной версией Java (её отдаёт `requiredJavaMajor()` бэкенда) и
|
||||||
|
подписывается на `runtimeChosen()`.
|
||||||
|
|
||||||
|
**Со стороны бэкенда.** `javaCatalog` и `javaCatalogLoading` — привязки, пересчитывающие модель и
|
||||||
|
текст пустого состояния при каждом обновлении каталога. `refreshJavaCatalog()` вызывается при
|
||||||
|
открытии окна, `installJavaRuntime()` — при подтверждении отсутствующей сборки,
|
||||||
|
`removeJavaRuntime()` — по щелчку на корзине в строке. Ход самой загрузки окно не показывает: за
|
||||||
|
это отвечает плашка [ProgressPanel](ProgressPanel.md) в главном окне.
|
||||||
|
|
||||||
|
**Удаление.** Корзина в строке доступна только у скачанных сборок; сборки весят по двести
|
||||||
|
мегабайт, и удалять их нужно прямо здесь, иначе папка лаунчера растёт молча. Если удалена была
|
||||||
|
выбранная сборка, выбор снимается.
|
||||||
|
|
||||||
|
**Слишком старые сборки.** Сборка ниже требования игры остаётся доступной для выбора, но
|
||||||
|
помечается в строке подробностей: она может пригодиться для другой сборки Minecraft.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
JavaPickerDialog {
|
||||||
|
id: javaPicker
|
||||||
|
x: (window.width - width) / 2
|
||||||
|
y: (window.height - height) / 2
|
||||||
|
backend: launcherBackend
|
||||||
|
onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: javaField
|
||||||
|
onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId,
|
||||||
|
launcherBackend.requiredJavaMajor(launcherBackend.activeBuildIndex))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# LabelledField
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick с полностью самостоятельно
|
||||||
|
оформленным тёмным интерфейсом. В диалоге настроек и в редакторе сборок много однотипных полей
|
||||||
|
ввода: подпись сверху, поле под ней. `LabelledField` собирает эту пару в один компонент, чтобы
|
||||||
|
отступы, цвета и подсветка фокуса не переписывались в каждом месте заново.
|
||||||
|
|
||||||
|
Компонент нужен там, где пользователь вводит короткое значение: имя профиля, объём памяти, путь,
|
||||||
|
аргументы запуска.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы не используются.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`) и доступен по имени внутри
|
||||||
|
модуля без импорта. Применяется в диалоге настроек и в карточке сборки — см. [Main](Main.md) и
|
||||||
|
[BuildsDialog](BuildsDialog.md).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Column` с расстоянием 3 px между элементами. Колонка содержит два потомка:
|
||||||
|
`Text` с подписью (серый, 11 px) и `TextField` фиксированной высоты 32 px с тёмным фоном,
|
||||||
|
скруглением 6 px и рамкой, которая при фокусе поля меняет цвет на акцентный.
|
||||||
|
|
||||||
|
Ширина поля привязана к ширине самой колонки, поэтому размер задаётся снаружи одним свойством
|
||||||
|
`width` корневого элемента. Высоту `Column` вычисляет сам.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `text` | `string` (алиас на `text` внутреннего поля) | `""` | Нет | Содержимое поля ввода. Работает в обе стороны: чтение возвращает введённое значение, запись подставляет новое. |
|
||||||
|
| `validator` | `var` (алиас на `validator` внутреннего поля) | `null` | Нет | Валидатор ввода — например `IntValidator` для числовых полей. Ограничивает то, что пользователь может набрать. |
|
||||||
|
| `label` | `string` | `""` | Нет | Текст подписи над полем. |
|
||||||
|
| `placeholder` | `string` | `""` | Нет | Подсказка, показываемая в пустом поле приглушённым цветом. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### editingFinished()
|
||||||
|
|
||||||
|
Проброшен из внутреннего `TextField`: срабатывает, когда правка закончена — поле потеряло фокус
|
||||||
|
или пользователь нажал Enter. Промежуточные нажатия клавиш сигнала не вызывают.
|
||||||
|
|
||||||
|
Обработчик обычно сохраняет введённое значение: читает `text` и передаёт его в бэкенд или в
|
||||||
|
модель родительского диалога. Именно из-за этой семантики поля настроек сохраняются по завершении
|
||||||
|
правки, а не на каждый символ.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
Собственных функций нет.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
Компонент не знает ни о бэкенде, ни о содержащем его диалоге. Родитель задаёт `label`,
|
||||||
|
`placeholder`, начальный `text` и при необходимости `validator`, а затем подписывается на
|
||||||
|
`editingFinished`, чтобы записать значение. Двусторонней привязки к бэкенду внутри компонента нет
|
||||||
|
— решение о том, когда и куда сохранять, целиком за родителем.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
LabelledField {
|
||||||
|
width: parent.width
|
||||||
|
label: "Оперативная память, МБ"
|
||||||
|
placeholder: "2048"
|
||||||
|
text: String(settings.memoryMb)
|
||||||
|
validator: IntValidator { bottom: 512; top: 32768 }
|
||||||
|
onEditingFinished: settings.memoryMb = parseInt(text)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# LoaderRow
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме чистой игры он умеет
|
||||||
|
ставить модлоадеры: Forge, Fabric, NeoForge и Quilt. В карточке сборки лоадер выбирается
|
||||||
|
чекбоксом, а под ним — конкретная версия лоадера.
|
||||||
|
|
||||||
|
`LoaderRow` — одна такая строка: чекбокс с названием лоадера и, когда он отмечен, выпадающий
|
||||||
|
список версий именно под выбранную версию Minecraft. Компонент берёт на себя всю возню со
|
||||||
|
списком версий: подтягивает его из кэша, обновляет по сети, следит, чтобы выбранная версия
|
||||||
|
всегда существовала под текущую версию игры, и словами объясняет случай «лоадер эту версию игры
|
||||||
|
не поддерживает» вместо показа пустого списка.
|
||||||
|
|
||||||
|
Совместимость компонент не проверяет и проверять не должен: бэкенд отдаёт список, уже собранный
|
||||||
|
под конкретную версию игры, поэтому несовместимой строки в нём не бывает. Пустой список — это и
|
||||||
|
есть отсутствие поддержки.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Импортирует `QtQuick` и `QtQuick.Controls 2.15`.
|
||||||
|
|
||||||
|
Использует компонент [DarkCombo](DarkCombo.md) из того же QML-модуля — выпадающий список версий в
|
||||||
|
тёмном стиле окна.
|
||||||
|
|
||||||
|
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, зарегистрирован через `QML_ELEMENT`
|
||||||
|
в модуле `Minecraft_launcher`), который передаётся снаружи в свойство `backend`. Из него
|
||||||
|
компонент вызывает `loaderVersions()`, `refreshLoaderVersions()` и `loaderVersionsLoading()`, а
|
||||||
|
также слушает сигнал `loaderVersionsChanged`.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`). Инстанцируется в
|
||||||
|
[BuildsDialog](BuildsDialog.md) — по одной строке на каждый поддерживаемый лоадер.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Column` с расстоянием 4 px. Внутри три потомка, видимость которых
|
||||||
|
взаимоисключающая по нижней части:
|
||||||
|
|
||||||
|
- `CheckBox` с полностью переопределённым индикатором (квадрат со скруглением и галочкой) и
|
||||||
|
подписью. Выключен, пока не выбрана версия игры.
|
||||||
|
- [DarkCombo](DarkCombo.md) со списком версий лоадера — виден, только когда чекбокс отмечен и
|
||||||
|
список непустой.
|
||||||
|
- Текстовая строка на месте списка — видна, когда чекбокс отмечен, а список пуст. Пока идёт
|
||||||
|
запрос, она серая и говорит о загрузке; когда запрос закончен, она красноватая и сообщает, что
|
||||||
|
лоадер не поддерживает выбранную версию Minecraft.
|
||||||
|
|
||||||
|
Ширина внутренних элементов считается от ширины колонки, поэтому снаружи достаточно задать
|
||||||
|
`width`.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend`. Через него запрашиваются и обновляются списки версий лоадера. |
|
||||||
|
| `loaderKey` | `string` | — | **Да** | Ключ лоадера, которым он опознаётся в бэкенде и в сохранённой сборке: `forge`, `fabric`, `neoforge`, `quilt`. |
|
||||||
|
| `title` | `string` | — | **Да** | Человекочитаемое название лоадера рядом с чекбоксом; оно же подставляется в сообщения о загрузке и об отсутствии поддержки. |
|
||||||
|
| `gameVersion` | `string` | `""` | Нет | Версия Minecraft, выбранная в карточке. Пустая строка выключает чекбокс. Смена значения сбрасывает выбранную версию лоадера и перезапрашивает список. |
|
||||||
|
| `checked` | `bool` (алиас на чекбокс) | `false` | Нет | Отмечен ли лоадер. Чтение даёт текущее состояние, запись переключает чекбокс программно — без сигнала `userChecked()`. |
|
||||||
|
| `selectedVersion` | `string` | `""` | Нет | Выбранная версия лоадера. Устанавливается только через `applyEntries()`, поэтому всегда либо пуста, либо присутствует в текущем списке. |
|
||||||
|
| `entries` | `var` | `[]` | Нет | Текущий список версий лоадера — массив записей, у каждой есть поля `version` (значение) и `label` (подпись для списка). Заполняется из бэкенда. |
|
||||||
|
| `applying` | `bool` | `false` | Нет | Признак того, что строку сейчас заполняет карточка данными сохранённой сборки. Пока он выставлен, изменения не считаются пользовательскими и сигнал `changed()` не испускается. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### userChecked()
|
||||||
|
|
||||||
|
Пользователь сам отметил чекбокс (не программная установка `checked`). Карточка сборки в ответ
|
||||||
|
снимает отметки с остальных строк лоадеров: одновременно в `.minecraft` может жить только один
|
||||||
|
лоадер.
|
||||||
|
|
||||||
|
#### changed()
|
||||||
|
|
||||||
|
Отметка или версия лоадера изменились и это изменение пользовательское. Обработчик — карточка
|
||||||
|
сборки — сохраняет сборку с новыми значениями.
|
||||||
|
|
||||||
|
Сигнал сознательно не испускается, пока выставлен `applying`, то есть при заполнении строки из
|
||||||
|
уже сохранённой сборки: иначе загрузка карточки сразу же приводила бы к её перезаписи.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### versionIndex(string version) : int
|
||||||
|
|
||||||
|
Возвращает позицию версии в текущем массиве `entries` или `-1`, если такой версии в списке нет.
|
||||||
|
Вспомогательная функция для синхронизации выбранного значения с выпадающим списком.
|
||||||
|
|
||||||
|
#### applyEntries(var list) : void
|
||||||
|
|
||||||
|
Единственное место, где меняются `entries` и `selectedVersion`. Через него проходят все три пути
|
||||||
|
получения списка — кэш, ответ сети и заполнение из сохранённой сборки, — потому что выбранная
|
||||||
|
версия обязана существовать в списке под текущую версию игры.
|
||||||
|
|
||||||
|
Записывает новый список, а затем проверяет выбранную версию: если её в списке нет, подставляет
|
||||||
|
первую строку (список отсортирован новыми вперёд, поэтому первая — максимально доступная под эту
|
||||||
|
версию игры) или пустую строку для пустого списка. Если подстановка изменила значение и строка не
|
||||||
|
находится в режиме `applying`, испускает `changed()`. В конце синхронизирует `currentIndex`
|
||||||
|
выпадающего списка.
|
||||||
|
|
||||||
|
#### reload() : void
|
||||||
|
|
||||||
|
Перезапрашивает список версий. Если лоадер не отмечен или версия игры не выбрана, очищает список
|
||||||
|
через `applyEntries([])`. Иначе сначала берёт список из кэша бэкенда (`loaderVersions()`) — он
|
||||||
|
появляется мгновенно, — а затем просит обновление по сети (`refreshLoaderVersions()`), результат
|
||||||
|
которого придёт позже сигналом.
|
||||||
|
|
||||||
|
#### applyBuild(string loader, string loaderVersion) : void
|
||||||
|
|
||||||
|
Заполняет строку данными сохранённой сборки, не испуская `changed()`: на время работы выставляет
|
||||||
|
`applying`. Отмечает чекбокс, если ключ лоадера сборки совпадает с `loaderKey`, подставляет версию
|
||||||
|
из сборки как пожелание и вызывает `reload()`. Если под выбранную версию игры такой версии
|
||||||
|
лоадера нет, `applyEntries()` заменит её на максимально доступную.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
**Что приходит извне.** Карточка сборки в [BuildsDialog](BuildsDialog.md) задаёт `backend`,
|
||||||
|
`loaderKey`, `title` и привязывает `gameVersion` к версии Minecraft, выбранной в карточке.
|
||||||
|
Заполнение сохранённой сборкой идёт вызовом `applyBuild()` снаружи.
|
||||||
|
|
||||||
|
**Что уходит наружу.** По `userChecked()` карточка снимает отметки с остальных строк — набор
|
||||||
|
строк она держит в собственном списке. По `changed()` карточка сохраняет сборку, читая `checked` и
|
||||||
|
`selectedVersion`.
|
||||||
|
|
||||||
|
**Бэкенд.** Компонент сам подписан на сигнал `loaderVersionsChanged(key, game)` через
|
||||||
|
`Connections`: пришедшее обновление принимается, только если ключ и версия игры совпадают с
|
||||||
|
текущими и чекбокс отмечен, — иначе ответ относится к другой строке или устарел. Текст в пустом
|
||||||
|
состоянии опрашивает `loaderVersionsLoading()`, чтобы отличать «ещё грузим» от «не поддерживается».
|
||||||
|
|
||||||
|
**Реакция на смену версии игры.** Обработчик `onGameVersionChanged` сбрасывает `selectedVersion` и
|
||||||
|
вызывает `reload()`: сборка лоадера привязана к версии игры, поэтому под новой версией прежний
|
||||||
|
выбор недействителен.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
LoaderRow {
|
||||||
|
id: forgeRow
|
||||||
|
width: parent.width
|
||||||
|
|
||||||
|
backend: launcherBackend
|
||||||
|
loaderKey: "forge"
|
||||||
|
title: "Forge"
|
||||||
|
gameVersion: buildCard.gameVersion
|
||||||
|
|
||||||
|
onUserChecked: buildCard.keepOnly(forgeRow)
|
||||||
|
onChanged: buildCard.commitSelection()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# Main
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. `Main.qml` — его главное и
|
||||||
|
единственное настоящее окно: точка входа приложения, которую загружает `main.cpp` вызовом
|
||||||
|
`engine.loadFromModule("Minecraft_launcher", "Main")`.
|
||||||
|
|
||||||
|
Окно совмещает четыре роли. Оно держит единственный экземпляр `LauncherBackend` — весь остальной
|
||||||
|
интерфейс получает его от главного окна. Оно рисует сам экран запуска: фоновая картинка, большая
|
||||||
|
кнопка игры по центру, выпадающий список профилей, кнопка активной сборки, кнопки папки модов,
|
||||||
|
настроек и сезонных сборок. Оно показывает обратную связь — всплывающую плашку сообщений и две
|
||||||
|
панели хода долгих операций. И наконец, оно объявляет диалоги, которые не вынесены в отдельные
|
||||||
|
файлы: создание и редактирование профиля, ввод кода двухфакторной аутентификации и настройки
|
||||||
|
запуска.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Импортирует `QtQuick`, `QtQuick.Layouts 2.15`, `QtQuick.Controls 2.15` и сам QML-модуль проекта
|
||||||
|
`Minecraft_launcher`, из которого приходит тип `LauncherBackend`.
|
||||||
|
|
||||||
|
Инстанцирует четыре компонента модуля: [ProgressPanel](ProgressPanel.md) (дважды),
|
||||||
|
[SeasonalBuildsDialog](SeasonalBuildsDialog.md), [BuildsDialog](BuildsDialog.md),
|
||||||
|
[JavaPickerDialog](JavaPickerDialog.md), а также [DarkCombo](DarkCombo.md) и
|
||||||
|
[LabelledField](LabelledField.md) внутри своих диалогов.
|
||||||
|
[MicrosoftLoginDialog](MicrosoftLoginDialog.md) создаётся динамически — см. ниже.
|
||||||
|
|
||||||
|
Стиль Qt Quick Controls принудительно выставлен в `Basic` в `main.cpp`, потому что нативные стили
|
||||||
|
игнорируют пользовательские `contentItem` и `background`; поэтому в этом файле почти каждый
|
||||||
|
элемент управления переопределяет своё оформление вручную.
|
||||||
|
|
||||||
|
Использует ресурсы из `RESOURCES` QML-модуля: фоновую картинку, три состояния кнопки запуска, по
|
||||||
|
три состояния кнопок папки и настроек, стрелки выпадающих списков, `images/Trash.svg` и
|
||||||
|
`images/Pencil.svg`.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Window` размером 1280×720 px, видимое при старте. Это не переиспользуемый
|
||||||
|
компонент, а точка входа приложения, поэтому раздел с примером использования здесь неприменим.
|
||||||
|
|
||||||
|
Раскладка держится на якорях относительно центральной кнопки запуска: список профилей — слева
|
||||||
|
сверху от неё, кнопка активной сборки — справа сверху, кнопки папки и настроек — под списком
|
||||||
|
профилей. Кнопка сезонных сборок стоит в правом нижнем углу: это единственная свободная часть
|
||||||
|
окна, потому что панели хода работ висят слева, а всё остальное собрано вокруг кнопки запуска.
|
||||||
|
|
||||||
|
Панели загрузки и смены сборки имеют одни и те же якоря — они взаимоисключающи по построению:
|
||||||
|
признак занятости бэкенда не даёт начать переключение во время установки и наоборот. Панель смены
|
||||||
|
сборки объявлена неотменяемой: отступать после очистки `.minecraft` некуда, операцию нужно довести
|
||||||
|
до конца.
|
||||||
|
|
||||||
|
Заголовки и подвалы всех диалогов сделаны на `Item` с явным `implicitHeight`, а не на
|
||||||
|
`Rectangle`: у прямоугольника `implicitHeight` равен нулю независимо от заданной высоты, и
|
||||||
|
`Dialog` не смог бы вычислить свою полную высоту.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `microsoftLoginDialog` | `var` | `null` | Нет | Созданный по требованию экземпляр окна входа Microsoft либо `null`, пока вход ни разу не запускался. Хранится в свойстве, чтобы окно создавалось один раз за сеанс. |
|
||||||
|
|
||||||
|
### Внутренние диалоги и их состояние
|
||||||
|
|
||||||
|
`Main.qml` объявляет четыре диалога прямо в файле. Их свойства — часть состояния главного окна.
|
||||||
|
|
||||||
|
**Диалог редактирования профиля** (`editProfileDialog`):
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Описание |
|
||||||
|
|----------|-----|--------------|----------|
|
||||||
|
| `editIndex` | `int` | `-1` | Индекс редактируемого профиля; `-1` — диалог не открыт ни для кого. |
|
||||||
|
| `msProfile` | `bool` | `false` | Открытый профиль имеет тип «Microsoft». |
|
||||||
|
| `msLinked` | `bool` | `false` | У профиля есть действующая сессия Microsoft — от этого зависит строка статуса и подпись кнопки входа. |
|
||||||
|
| `msName` | `string` | `""` | Ник, полученный при официальной авторизации. Показывается отдельным полем только для чтения, а не подменой поля логина: привязка сломалась бы первым же вводом в поле логина обычного профиля. |
|
||||||
|
|
||||||
|
**Диалог двухфакторной аутентификации** (`twoFactorDialog`):
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Описание |
|
||||||
|
|----------|-----|--------------|----------|
|
||||||
|
| `profileName` | `string` | `""` | Имя профиля, для которого запрошен код; подставляется в текст просьбы. |
|
||||||
|
|
||||||
|
**Диалог настроек** (`settingsDialog`):
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Описание |
|
||||||
|
|----------|-----|--------------|----------|
|
||||||
|
| `javaRuntimeId` | `string` | `""` | Выбранная сборка Java из папки лаунчера. Живёт в свойстве, а не в поле ввода: её выбирают в отдельном окне, а записывается она только по «Сохранить». Пустая строка означает «искать Java в системе». |
|
||||||
|
| `javaRuntimeInfo` | `var` | `null` | Подробности выбранной сборки для строки поля. Не привязка: `javaRuntimeInfo()` — обычный вызов, и сам он не пересчитается, когда сборка докачается, поэтому значение обновляется по событиям. |
|
||||||
|
|
||||||
|
Типы профилей во всех выпадающих списках кодируются одинаково: позиция 0 — `offline`
|
||||||
|
(офлайн, без пароля), 1 — `elyby` (Ely.by, с логином и паролем), 2 — `microsoft` (лицензия).
|
||||||
|
Третья позиция показывается, только когда лаунчер собран с Qt WebEngine; в диалоге редактирования
|
||||||
|
она показывается ещё и тогда, когда профиль уже сохранён как лицензионный — иначе в сборке без
|
||||||
|
WebEngine он молча стал бы офлайновым.
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
Собственных сигналов главное окно не объявляет.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### showToast(string text, color color, int timeout) : void
|
||||||
|
|
||||||
|
Показывает единую всплывающую плашку сообщений: через неё проходят сообщения о ходе запуска,
|
||||||
|
ошибки и статус игры. Задаёт текст и цвет фона и перезапускает таймер скрытия.
|
||||||
|
|
||||||
|
Параметр `timeout` — время показа в миллисекундах. Пропущенное значение означает четыре секунды;
|
||||||
|
`0` означает «держать до следующего сообщения» — так показываются промежуточные шаги запуска, чтобы
|
||||||
|
сообщение не исчезало посреди долгой операции.
|
||||||
|
|
||||||
|
#### openMicrosoftLogin(url) : void
|
||||||
|
|
||||||
|
Открывает окно входа в аккаунт Microsoft на переданном адресе, создавая его при первом вызове.
|
||||||
|
|
||||||
|
Окно создаётся по требованию, а не вместе с главным: `MicrosoftLoginDialog.qml` попадает в модуль
|
||||||
|
только в сборках с Qt WebEngine, и обычная декларация сломала бы всё главное окно в остальных.
|
||||||
|
Поэтому компонент загружается через `Qt.createComponent()`, и если он не готов — сборка собрана без
|
||||||
|
WebEngine, — вход отменяется у бэкенда, а пользователю показывается сообщение о том, что окно
|
||||||
|
недоступно. При успешном создании окну сразу передаётся бэкенд, а его сигнал `failed`
|
||||||
|
подключается к плашке сообщений.
|
||||||
|
|
||||||
|
#### formatMb(bytes) : string
|
||||||
|
|
||||||
|
Переводит байты в мегабайты с одним знаком после запятой. Используется в строке подробностей
|
||||||
|
панели загрузки.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
### Бэкенд
|
||||||
|
|
||||||
|
Единственный экземпляр `LauncherBackend` объявлен прямо в окне и передаётся всем вложенным
|
||||||
|
диалогам через их свойство `backend`. Главное окно — единственное место, где обрабатываются его
|
||||||
|
сигналы:
|
||||||
|
|
||||||
|
| Сигнал бэкенда | Что делает главное окно |
|
||||||
|
|----------------|-------------------------|
|
||||||
|
| `launched(profileName, buildName, serverUrl)` | показывает зелёное сообщение о запуске |
|
||||||
|
| `launchProgress(message)` | показывает сообщение без таймаута — до следующего шага |
|
||||||
|
| `launchError(message)` | показывает ошибку на восемь секунд |
|
||||||
|
| `twoFactorRequired(profileName)` | открывает диалог ввода кода: Ely.by отклонил пароль с пометкой two factor, и код добирается здесь, чтобы продолжить прерванный запуск |
|
||||||
|
| `gameFinished(exitCode, crashed)` | сообщает о закрытии игры; аварийное завершение показывается красным вместе с кодом выхода |
|
||||||
|
| `microsoftLoginUrlReady(url)` | вызывает `openMicrosoftLogin()` |
|
||||||
|
| `microsoftLoginSucceeded(playerName)` | сообщает об успешном входе. Выбор в списке профилей при этом не трогается: новый профиль уже выбран тем, кто его создал, а повторный вход мог быть и не в последний профиль |
|
||||||
|
| `microsoftLoginFailed(message)` | показывает ошибку на восемь секунд |
|
||||||
|
| `microsoftReloginRequired(profileIndex)` | сразу начинает вход заново для этого профиля |
|
||||||
|
| `gameOutput(line)` | пишет строку в консоль |
|
||||||
|
| `seasonalInstallFinished(seasonalId, buildName)` | сообщает, что сезонная сборка установлена и её можно запускать |
|
||||||
|
| `javaRuntimeInstalled(runtimeId)` | обновляет подробности выбранной сборки Java в настройках |
|
||||||
|
|
||||||
|
Привязки к свойствам бэкенда управляют доступностью интерфейса: кнопка запуска выключена, пока
|
||||||
|
лаунчер занят или игра уже идёт; кнопка активной сборки — пока идёт игра или переключение сборок;
|
||||||
|
подпись на ней берётся из `activeBuildName`, а список профилей — из `profileNames`.
|
||||||
|
|
||||||
|
### Профили
|
||||||
|
|
||||||
|
Выпадающий список профилей переопределён целиком: кнопка «+ Добавить профиль» закреплена сверху
|
||||||
|
всплывающей панели, под ней список, где у строки при наведении появляются карандаш и корзина.
|
||||||
|
Карандаш открывает диалог редактирования (`openFor()` заполняет его через `profileAt()`), корзина
|
||||||
|
вызывает `removeProfile()`.
|
||||||
|
|
||||||
|
Создание профиля вызывает `addProfile()`, выбирает новый профиль в списке и, если тип —
|
||||||
|
«Microsoft», сразу начинает вход: такой профиль без входа бесполезен. Скрытые поля при сохранении
|
||||||
|
не читаются — в них мог остаться текст, набранный до переключения типа профиля.
|
||||||
|
|
||||||
|
В диалоге редактирования кнопка входа перед вызовом `startMicrosoftLogin()` сначала сохраняет
|
||||||
|
профиль вызовом `updateProfile()` с типом `microsoft`: тип мог быть только что переключён, и без
|
||||||
|
этого бэкенд приписал бы токены профилю другого типа.
|
||||||
|
|
||||||
|
### Запуск игры
|
||||||
|
|
||||||
|
Кнопка запуска вызывает `launchGame()` с индексом выбранного профиля и индексом активной сборки.
|
||||||
|
Дальше всё идёт через сигналы бэкенда: промежуточные шаги — в плашку сообщений, запрос кода
|
||||||
|
двухфакторной аутентификации — в отдельный диалог, где подтверждение вызывает
|
||||||
|
`submitTwoFactorCode()`, а отмена — `cancelPendingLaunch()`.
|
||||||
|
|
||||||
|
### Настройки
|
||||||
|
|
||||||
|
Диалог настроек открывается методом `load()`, который читает `settings()` бэкенда и раскладывает
|
||||||
|
значения по полям, а также подставляет разрешённый путь папки игры и список найденных в системе
|
||||||
|
сборок Java (`detectedJava()`). Сохранение собирает все поля в один `QVariantMap` и передаёт его
|
||||||
|
в `updateSettings()`.
|
||||||
|
|
||||||
|
Первым пунктом диалога идёт выбор языка интерфейса — настройка уровня приложения, поэтому она
|
||||||
|
стоит над параметрами запуска. Подписи в модели переводятся, а коды (`system`, `ru`, `en`) лежат
|
||||||
|
рядом отдельным списком `codes` и не переводятся. Применяется язык по кнопке «Сохранить», как и
|
||||||
|
всё остальное в этом диалоге, и сразу же, без перезапуска: см. [Localization](../cpp/Localization.md).
|
||||||
|
|
||||||
|
Разрешённый путь папки игры хранится свойством `resolvedGameDir` диалога, а не присваивается
|
||||||
|
тексту напрямую — иначе подпись не пережила бы смену языка.
|
||||||
|
|
||||||
|
Поле выбора сборки Java открывает [JavaPickerDialog](JavaPickerDialog.md), передавая текущий выбор
|
||||||
|
и требование активной сборки (`requiredJavaMajor()`); крестик справа сбрасывает выбор обратно на
|
||||||
|
поиск Java в системе. Само окно выбора объявлено рядом с настройками, а не внутри них: оно шире и
|
||||||
|
центрируется по окну лаунчера.
|
||||||
|
|
||||||
|
Выбранная сборка Java — общая настройка лаунчера: когда она задана, запуск идёт ею, а путь к Java
|
||||||
|
из соседнего поля остаётся запасным вариантом.
|
||||||
|
|
||||||
|
### Тексты
|
||||||
|
|
||||||
|
Все подписи, сообщения и подсказки окна берутся из синглтона `Loc`: `Loc.t.домен.вид.имя`.
|
||||||
|
Ни одного текстового литерала в разметке не осталось, `qsTr` не используется. Модель типов входа
|
||||||
|
в диалогах профиля — тоже ключ каталога (`Loc.t.profile.authTypes`), причём порядок значений
|
||||||
|
в нём значим: код сравнивает `currentIndex` с 1 и 2, а вариант без Microsoft получается из той же
|
||||||
|
модели через `.slice(0, 2)`. Подробности — в [Localization](../cpp/Localization.md).
|
||||||
|
|
||||||
|
### Прочие кнопки
|
||||||
|
|
||||||
|
Кнопка папки вызывает `openMinecraftFolder()`, кнопка настроек открывает диалог настроек, кнопка
|
||||||
|
сезонных сборок — [SeasonalBuildsDialog](SeasonalBuildsDialog.md) методом `openCatalog()`, кнопка
|
||||||
|
активной сборки — [BuildsDialog](BuildsDialog.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# MicrosoftLoginDialog
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Он поддерживает несколько
|
||||||
|
способов входа: офлайн-профиль, сервер Ely.by и учётную запись Microsoft. Последний путь требует
|
||||||
|
показать пользователю настоящую страницу входа Microsoft и дождаться, пока браузер уйдёт на
|
||||||
|
`redirect_uri` с кодом авторизации в адресе — ровно так же поступает официальный лаунчер.
|
||||||
|
|
||||||
|
`MicrosoftLoginDialog` — окно с этой страницей. Внутри него живёт `WebEngineView`; диалог следит
|
||||||
|
за сменой адреса, отдаёт перехваченный код бэкенду и закрывается. Собственной логики разбора
|
||||||
|
адреса у него нет — она в C++, чтобы правила совпадения совпадали с теми, по которым сервис сам
|
||||||
|
строит `redirect_uri`.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Импортирует `QtQuick`, `QtQuick.Controls 2.15` и `QtWebEngine`. В начале файла объявлена
|
||||||
|
`pragma ComponentBehavior: Bound`.
|
||||||
|
|
||||||
|
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT`), который передаётся
|
||||||
|
снаружи в свойство `backend`. Вызывает у него `inspectMicrosoftRedirect()`,
|
||||||
|
`finishMicrosoftLogin()` и `cancelMicrosoftLogin()`.
|
||||||
|
|
||||||
|
**Особенность сборки.** Это единственный QML-файл проекта, который попадает в модуль условно.
|
||||||
|
`CMakeLists.txt` ищет `Qt6WebEngineQuick` через `find_package(... QUIET)`; модуль объявлен
|
||||||
|
необязательным сознательно — он ставится отдельной галочкой в установщике Qt и тянет за собой
|
||||||
|
WebChannel с Positioning, которых в типовой установке нет. Если модуль найден, файл добавляется в
|
||||||
|
`QML_FILES` и определяется макрос `LAUNCHER_HAS_WEBENGINE`; если нет — лаунчер собирается и
|
||||||
|
работает как прежде, только без входа через Microsoft. В QML это различие видно через свойство
|
||||||
|
`backend.microsoftAvailable`, и интерфейс не должен предлагать этот путь, когда оно ложно.
|
||||||
|
|
||||||
|
Инстанцируется динамически из [Main](Main.md) — главное окно создаёт диалог по требованию, потому
|
||||||
|
что при сборке без WebEngine самого типа в модуле не существует.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Dialog` из Qt Quick Controls: модальный, 560×680 px, по центру родителя, с нулевым
|
||||||
|
внутренним отступом и `closePolicy: Popup.NoAutoClose` — окно нельзя закрыть щелчком мимо или
|
||||||
|
клавишей Escape, выход только через кнопку отмены или успешный вход.
|
||||||
|
|
||||||
|
Оформление задано вручную: тёмный фон со скруглением и акцентной рамкой, заголовок с
|
||||||
|
разделительной линией, подвал с кнопкой «Отмена».
|
||||||
|
|
||||||
|
Содержимое — `WebEngineView` во всю площадь с отступом 12 px и индикатор занятости по центру,
|
||||||
|
видимый на время загрузки страницы. Рядом объявлен `WebEngineProfilePrototype` без `storageName`:
|
||||||
|
профиль без имени хранилища означает профиль без диска, поэтому куки живут только пока работает
|
||||||
|
лаунчер и в общий браузер не попадают. За выбор аккаунта в пределах сессии отвечает параметр
|
||||||
|
`prompt=select_account` в адресе входа, который формирует бэкенд.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend`. Разбирает перехваченный адрес и завершает или отменяет вход. |
|
||||||
|
| `codeTaken` | `bool` | `false` | Нет | Код авторизации уже отдан бэкенду. Защита от повторной обработки: `WebEngineView` успевает сообщить об изменении адреса несколько раз, и без этого признака код ушёл бы дважды. Сбрасывается в `openAt()` и принудительно выставляется при отмене. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### failed(string message)
|
||||||
|
|
||||||
|
Вход не завершён: адрес совпал с `redirect_uri`, но кода в нём нет — например, пользователь
|
||||||
|
отказался выдать разрешение, или Microsoft вернула ошибку. В параметре приходит текст ошибки от
|
||||||
|
бэкенда, а если его нет — сообщение по умолчанию о незавершённом входе.
|
||||||
|
|
||||||
|
Окно ничего не знает про тосты главного окна, поэтому о неудаче сообщает сигналом. Обработчик в
|
||||||
|
[Main](Main.md) показывает это сообщение пользователю. К моменту испускания сигнала диалог уже
|
||||||
|
закрыт, а вход у бэкенда отменён — обработчику остаётся только уведомить.
|
||||||
|
|
||||||
|
Отмена по кнопке сигнала не испускает: пользователь и так знает, что закрыл окно.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### openAt(string url) : void
|
||||||
|
|
||||||
|
Открывает диалог на переданном адресе страницы входа. Сбрасывает `codeTaken`, загружает адрес в
|
||||||
|
`WebEngineView` и показывает окно. Адрес формирует бэкенд — в нём уже присутствуют `redirect_uri`,
|
||||||
|
идентификатор клиента и `prompt=select_account`.
|
||||||
|
|
||||||
|
#### handleUrl(url) : void
|
||||||
|
|
||||||
|
Обработчик смены адреса в `WebEngineView`; вызывать снаружи не нужно. Ничего не делает, если код
|
||||||
|
уже перехвачен. Иначе передаёт адрес в `backend.inspectMicrosoftRedirect()` и смотрит на поле
|
||||||
|
`matched` ответа: если адрес не является `redirect_uri`, обработка на этом заканчивается — это
|
||||||
|
обычная навигация по страницам входа.
|
||||||
|
|
||||||
|
При совпадении выставляет `codeTaken`, закрывает окно и дальше расходится по двум путям: непустое
|
||||||
|
поле `code` уходит в `backend.finishMicrosoftLogin()`, иначе вход отменяется через
|
||||||
|
`backend.cancelMicrosoftLogin()` и испускается сигнал `failed()` с текстом из поля `error`.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
**Со стороны главного окна.** [Main](Main.md) создаёт диалог динамически (функция
|
||||||
|
`openMicrosoftLogin()`), задаёт `backend`, вызывает `openAt()` с адресом от бэкенда и
|
||||||
|
подписывается на `failed()`, чтобы показать тост с ошибкой. Показывать ли кнопку входа через
|
||||||
|
Microsoft вообще, главное окно решает по `backend.microsoftAvailable`.
|
||||||
|
|
||||||
|
**Со стороны бэкенда.** Диалог только доставляет код: `inspectMicrosoftRedirect()` — разбор
|
||||||
|
адреса, `finishMicrosoftLogin()` — продолжение обмена кода на токены, `cancelMicrosoftLogin()` —
|
||||||
|
сброс начатой сессии входа. Результат входа диалогу не возвращается: об успехе главное окно
|
||||||
|
узнаёт от бэкенда по его собственным сигналам, а диалог к этому моменту уже закрыт.
|
||||||
|
|
||||||
|
**Кнопка отмены.** Выставляет `codeTaken`, закрывает окно и отменяет вход у бэкенда. Признак
|
||||||
|
ставится до закрытия, чтобы последний сигнал об изменении адреса при закрытии не был обработан.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
MicrosoftLoginDialog {
|
||||||
|
id: msLogin
|
||||||
|
parent: Overlay.overlay
|
||||||
|
backend: launcherBackend
|
||||||
|
onFailed: (message) => showToast(message, "#cc6666", 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// открывать только в сборке с Qt WebEngine
|
||||||
|
Component.onCompleted: if (launcherBackend.microsoftAvailable) msLogin.openAt(loginUrl)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# ProgressPanel
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Почти каждое действие в нём
|
||||||
|
долгое: скачивание версии игры, установка Java, распаковка и архивация папки `.minecraft` при
|
||||||
|
смене сборки, загрузка сезонной сборки. Лаунчер не блокирует окно на это время, поэтому ход
|
||||||
|
операции нужно показывать неотрывно от остального интерфейса.
|
||||||
|
|
||||||
|
`ProgressPanel` — та самая плашка прогресса. Она размещается в левом нижнем углу главного окна,
|
||||||
|
где не перекрывает кнопку запуска, сообщение по центру и кнопки папки и настроек. Компонент
|
||||||
|
только отображает переданное состояние; сам он ничего не считает и ни за чем не следит.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
Импортирует `QtQuick` и `QtQuick.Controls 2.15`; C++-типы напрямую не использует.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля `Minecraft_launcher` (`CMakeLists.txt`), поэтому доступен по имени
|
||||||
|
внутри модуля без импорта. Инстанцируется в [Main](Main.md) — по одной плашке на вид долгой
|
||||||
|
операции.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Rectangle` фиксированного размера 320×72 px со скруглением 8 px, тёмной заливкой,
|
||||||
|
акцентной рамкой и лёгкой полупрозрачностью, чтобы плашка читалась поверх фонового изображения
|
||||||
|
окна.
|
||||||
|
|
||||||
|
Внутри — пять элементов без внешних зависимостей: заголовок слева сверху, проценты справа сверху,
|
||||||
|
полоса прогресса (дорожка и заполнение с плавной анимацией ширины на 120 мс), строка подробностей
|
||||||
|
снизу и крестик отмены в правом нижнем углу с увеличенной областью нажатия.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `title` | `string` | `""` | Нет | Заголовок операции в левом верхнем углу, полужирным. Длинный текст обрезается справа многоточием. |
|
||||||
|
| `status` | `string` | `""` | Нет | Строка состояния внизу плашки. Показывается только когда `detail` пуст. |
|
||||||
|
| `fraction` | `double` | `-1` | Нет | Доля выполнения от `0` до `1`. Значение `-1` означает «итог ещё неизвестен»: вместо процентов выводится многоточие, а полоса остаётся пустой. |
|
||||||
|
| `detail` | `string` | `""` | Нет | Необязательная вторая строка подробностей — мегабайты у загрузки, путь у архивации. Если задана, вытесняет `status`. Длинный текст обрезается посередине, чтобы у пути были видны и начало, и конец. |
|
||||||
|
| `cancellable` | `bool` | `true` | Нет | Показывать ли крестик отмены. Ставится в `false` для операций, которые прерывать нельзя. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### cancelRequested()
|
||||||
|
|
||||||
|
Пользователь нажал крестик в правом нижнем углу. Сигнал сообщает только о намерении: плашка не
|
||||||
|
скрывает себя и не меняет своё состояние.
|
||||||
|
|
||||||
|
Обработчик должен сам остановить операцию в бэкенде и убрать плашку с экрана — как правило, вызвав
|
||||||
|
соответствующий метод отмены у `LauncherBackend`; видимость плашки при этом снимется сама, потому
|
||||||
|
что она привязана к свойству занятости бэкенда.
|
||||||
|
|
||||||
|
Сигнал не испускается при `cancellable: false` — в этом случае крестик скрыт.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
Собственных функций нет.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
Все пять свойств плашки — точки внешней привязки. В [Main](Main.md) они связаны со свойствами
|
||||||
|
`LauncherBackend`: у загрузки версии это группа `downloading` / `downloadProgress` /
|
||||||
|
`downloadVersion` / `downloadStatus` / `downloadBytesDone` / `downloadBytesTotal`, у смены сборки —
|
||||||
|
`switching` / `switchProgress` / `switchStage` / `switchStatus`. Байты в мегабайты переводит
|
||||||
|
функция `formatMb()` главного окна, а не сама плашка.
|
||||||
|
|
||||||
|
Видимостью плашки управляет родитель, обычно привязывая её к тому же признаку занятости, который
|
||||||
|
питает `fraction`. Сигнал `cancelRequested` родитель замыкает на метод отмены бэкенда.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
ProgressPanel {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: 16
|
||||||
|
visible: backend.downloading
|
||||||
|
|
||||||
|
title: qsTr("Загрузка Minecraft %1").arg(backend.downloadVersion)
|
||||||
|
status: backend.downloadStatus
|
||||||
|
fraction: backend.downloadProgress
|
||||||
|
detail: formatMb(backend.downloadBytesDone) + " / " + formatMb(backend.downloadBytesTotal)
|
||||||
|
|
||||||
|
onCancelRequested: backend.cancelDownload()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# SeasonalBuildsDialog
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Кроме сборок, которые
|
||||||
|
пользователь собирает сам, он умеет ставить готовые сезонные сборки с сервера лаунчера: набор
|
||||||
|
модов под конкретную версию игры и модлоадер, подготовленный заранее и выдаваемый целиком.
|
||||||
|
|
||||||
|
`SeasonalBuildsDialog` — окно этого каталога: таблица со всем, что нужно знать, чтобы решить,
|
||||||
|
ставить сборку или нет, и одна кнопка, которая делает всё остальное — заводит сборку, ставит
|
||||||
|
версию игры, модлоадер, Java и раскладывает файлы. Окно также показывает, что установленная
|
||||||
|
сборка устарела, и предлагает обновить её до свежей ревизии.
|
||||||
|
|
||||||
|
Таблица собрана из строк `Row` с фиксированными колонками, а не из `TableView`: в проекте нет ни
|
||||||
|
одной модели `QAbstractItemModel`, а строки приходят готовыми `QVariantMap` — заводить ради семи
|
||||||
|
колонок отдельную модель незачем.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick` и
|
||||||
|
`QtQuick.Controls 2.15` — слои `QtQuick.Layouts` здесь не нужны, вся раскладка на якорях и
|
||||||
|
фиксированных ширинах колонок.
|
||||||
|
|
||||||
|
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||||
|
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает свойства
|
||||||
|
`seasonalCatalog`, `seasonalCatalogLoading`, `seasonalCatalogError`, `seasonalInstalling` и `busy`,
|
||||||
|
вызывает `refreshSeasonalCatalog()` и `installSeasonalBuild()`.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`). Инстанцируется в [Main](Main.md).
|
||||||
|
|
||||||
|
Строки каталога приходят из C++ уже отсортированными и сведёнными с локальными записями — окно
|
||||||
|
показывает статус, не считая ничего само. Запись содержит поля `id`, `name`, `description`,
|
||||||
|
`minecraftVersion`, `loaderTitle`, `loaderVersion`, `modCount`, `seasonStart`, `seasonEnd`,
|
||||||
|
`status`, `revision`, `installedRevision`, `updateAvailable`, `sizeBytes` и `serverUrl`.
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Dialog`: модальный, 960×560 px, нулевой внутренний отступ, тёмный фон со
|
||||||
|
скруглением и акцентной рамкой.
|
||||||
|
|
||||||
|
Политика закрытия зависит от состояния: пока идёт установка, окно закрывается только кнопкой
|
||||||
|
(`Popup.NoAutoClose`), потому что случайный щелчок мимо не должен спрятать единственную видимую
|
||||||
|
отмену; в остальное время работают Escape и щелчок мимо.
|
||||||
|
|
||||||
|
Содержимое — шапка таблицы (`Row` с `Repeater` по `columns`), разделительная линия, список строк и
|
||||||
|
сообщение по центру для пустого состояния. Строка списка — `Rectangle` высотой 36 px с вложенным
|
||||||
|
`Row`, который повторяет тот же набор колонок; ширины берутся из общего описания `columns`,
|
||||||
|
поэтому шапка и строки не могут разъехаться.
|
||||||
|
|
||||||
|
Подвал высотой 76 px несёт описание и размер выбранной сборки (они длинные и в таблицу не
|
||||||
|
помещаются, а решение принимается именно по ним) и две кнопки — обновления списка и установки.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога сезонных сборок и исполнитель установки. |
|
||||||
|
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной строки. Хранится по `id`, а не по индексу: список обновляется под руками, и индекс после обновления указывал бы на другую сборку. |
|
||||||
|
| `columns` | `var` (только чтение) | список из семи колонок | Нет | Описание таблицы: массив объектов с полями `key` (поле записи), `title` (заголовок), `width` (ширина в пикселях) и `align` (выравнивание). Колонки: название, версия, загрузчик, число модов, начало и конец сезона, статус. Ширины собраны в одном месте, потому что их повторяют и шапка, и делегат строки. |
|
||||||
|
| `entries` | `var` (только чтение) | `backend.seasonalCatalog` | Нет | Строки каталога напрямую из бэкенда. Отбора и сортировки в окне нет. |
|
||||||
|
| `selectedEntry` | `var` (только чтение) | вычисляется | Нет | Полная запись выбранной сборки или `null`, если ничего не выбрано. От неё зависят подвал и доступность кнопки установки. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
Собственных сигналов компонент не объявляет. Результат работы окна виден через состояние бэкенда:
|
||||||
|
установка меняет список сборок и запускает загрузку, за ходом которой следит главное окно.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### openCatalog() : void
|
||||||
|
|
||||||
|
Открывает окно. Обновление каталога происходит само в обработчике `onAboutToShow`, который просит
|
||||||
|
у бэкенда `refreshSeasonalCatalog(false)` — без принудительного обхода кэша: свежий кэш отвечает
|
||||||
|
без сети, поэтому вызов при каждом открытии ничего не стоит.
|
||||||
|
|
||||||
|
#### formatMb(bytes) : string
|
||||||
|
|
||||||
|
Переводит размер в байтах в строку с мегабайтами и одним знаком после запятой. Для нулевого или
|
||||||
|
отсутствующего значения возвращает пустую строку, чтобы размер просто не попал в строку подвала.
|
||||||
|
|
||||||
|
#### cellText(entry, string key) : string
|
||||||
|
|
||||||
|
Возвращает текст ячейки для записи и ключа колонки. Для всех колонок это значение одноимённого
|
||||||
|
поля записи, приведённое к строке; исключение — колонка загрузчика, где название и версия
|
||||||
|
склеиваются в одну подпись, а при пустой версии остаётся только название.
|
||||||
|
|
||||||
|
#### installSelected() : void
|
||||||
|
|
||||||
|
Ставит выбранную сборку. Ничего не делает, если строка не выбрана или лаунчер занят другой
|
||||||
|
операцией: установка занимает и панель загрузки, и `.minecraft` целиком, поэтому вторую начинать
|
||||||
|
нельзя. Иначе вызывает `installSeasonalBuild()` у бэкенда.
|
||||||
|
|
||||||
|
Вызывается кнопкой установки и двойным щелчком по строке.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
**Со стороны родителя.** [Main](Main.md) задаёт `backend` и открывает окно вызовом
|
||||||
|
`openCatalog()`. Обратной связи наружу через сигналы нет.
|
||||||
|
|
||||||
|
**Со стороны бэкенда.** Всё содержимое таблицы — привязка к `seasonalCatalog`, поэтому обновление
|
||||||
|
каталога и изменение статуса установленной сборки перерисовывают окно сами. Пустое состояние
|
||||||
|
различает три случая по `seasonalCatalogLoading` и `seasonalCatalogError`: идёт загрузка, сборок
|
||||||
|
пока нет, произошла ошибка — её текст показывается прямо на месте строк, потому что пустой список
|
||||||
|
и ошибка выглядят одинаково пустыми.
|
||||||
|
|
||||||
|
**Занятость.** Кнопка установки выключается по общему признаку `busy`, кнопка обновления списка —
|
||||||
|
по `seasonalCatalogLoading` (её подпись при этом меняется на «Обновление…»). Политика закрытия
|
||||||
|
окна завязана на `seasonalInstalling`.
|
||||||
|
|
||||||
|
**Обновление ревизии.** Если у записи выставлен `updateAvailable`, статус в таблице подсвечивается
|
||||||
|
акцентным цветом, в подвале дописывается установленная ревизия, а кнопка установки называется
|
||||||
|
«Обновить». Отдельного пути обновления нет — это тот же вызов `installSeasonalBuild()`.
|
||||||
|
|
||||||
|
**Ход установки.** Окно не показывает прогресс: за это отвечает плашка
|
||||||
|
[ProgressPanel](ProgressPanel.md) в главном окне, а отмена — метод `cancelSeasonalInstall()`
|
||||||
|
бэкенда.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
SeasonalBuildsDialog {
|
||||||
|
id: seasonalDialog
|
||||||
|
parent: Overlay.overlay
|
||||||
|
backend: launcherBackend
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: qsTr("Сезонные сборки")
|
||||||
|
onClicked: seasonalDialog.openCatalog()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# VersionPickerDialog
|
||||||
|
|
||||||
|
## Обзор компонента
|
||||||
|
|
||||||
|
`Minecraft_launcher` — десктопный лаунчер Minecraft на Qt Quick. Каталог версий Mojang — это около
|
||||||
|
тысячи записей: релизы, снапшоты, старые беты и альфы. Раньше выбор версии был обычным выпадающим
|
||||||
|
списком на всю эту тысячу, и найти в нём, скажем, бету 1.7 можно было только поиском по точному
|
||||||
|
номеру.
|
||||||
|
|
||||||
|
`VersionPickerDialog` заменил тот список отдельным окном: слева категории, справа сами версии с
|
||||||
|
поиском сверху. Категории делят каталог на обозримые части, а поиск работает внутри выбранной.
|
||||||
|
Помимо выбора окно умеет удалять уже скачанные версии — с предупреждением о последствиях, потому
|
||||||
|
что версия весит десятки мегабайт, а поверх неё могут стоять профили модлоадеров.
|
||||||
|
|
||||||
|
Окно открывается из карточки сборки, когда пользователь выбирает, на какой версии Minecraft
|
||||||
|
собирается играть.
|
||||||
|
|
||||||
|
## Место в проекте и зависимости
|
||||||
|
|
||||||
|
В начале файла объявлена `pragma ComponentBehavior: Bound`. Импортирует `QtQuick`,
|
||||||
|
`QtQuick.Controls 2.15` и `QtQuick.Layouts 2.15`.
|
||||||
|
|
||||||
|
Работает с C++-типом `LauncherBackend` (`launcherbackend.h`, `QML_ELEMENT` в модуле
|
||||||
|
`Minecraft_launcher`), передаваемым снаружи в свойство `backend`. Читает у него свойства
|
||||||
|
`versionCatalog` и `catalogLoading`, вызывает `refreshVersionCatalog()`, `versionRemovalInfo()` и
|
||||||
|
`removeVersion()`.
|
||||||
|
|
||||||
|
Объявлен в `QML_FILES` модуля (`CMakeLists.txt`); использует ресурс `images/Trash.svg` из
|
||||||
|
`RESOURCES` того же модуля. Инстанцируется в [BuildsDialog](BuildsDialog.md).
|
||||||
|
|
||||||
|
Каталог приходит из C++ уже отсортированным (новые сверху), поэтому окно только отбирает записи и
|
||||||
|
порядок не трогает. Каждая запись каталога — объект с полями `id` (идентификатор версии), `label`
|
||||||
|
(подпись строки), `category` (ключ категории), `installed` (скачана ли) и `search`
|
||||||
|
(предвычисленная строка для поиска в нижнем регистре).
|
||||||
|
|
||||||
|
## Иерархия и роль
|
||||||
|
|
||||||
|
Корневой тип — `Dialog`: модальный, 720×480 px, нулевой внутренний отступ, закрывается по Escape и
|
||||||
|
щелчку мимо. Оформление задано вручную — тёмный фон со скруглением и акцентной рамкой.
|
||||||
|
|
||||||
|
Содержимое — `RowLayout` из двух частей, разделённых вертикальной линией: колонка категорий
|
||||||
|
шириной 150 px (`Repeater` по `categories`) и область версий. В области версий сверху поле поиска,
|
||||||
|
внизу флажок «Только установленные», между ними `ListView` со строками версий. Строка показывает
|
||||||
|
подпись, галочку для установленной версии и корзину удаления; галочка рядом со строкой —
|
||||||
|
единственное место, где видно, что именно скачано.
|
||||||
|
|
||||||
|
Отдельно объявлен вложенный `Dialog` подтверждения удаления шириной 420 px с красной рамкой. Он
|
||||||
|
привязан к тому же родителю, что и само окно, и центрируется в нём вручную, чтобы не оказаться
|
||||||
|
внутри области выбора.
|
||||||
|
|
||||||
|
Пустое состояние списка объясняется текстом по центру, который различает три случая: каталог ещё
|
||||||
|
грузится, каталог пуст (нет соединения) и по фильтру ничего не найдено.
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Свойство | Тип | По умолчанию | Обязательное | Описание |
|
||||||
|
|----------|-----|--------------|--------------|----------|
|
||||||
|
| `backend` | `var` | — | **Да** | Экземпляр `LauncherBackend` — источник каталога версий и исполнитель удаления. |
|
||||||
|
| `selectedId` | `string` | `""` | Нет | Идентификатор выбранной версии. Окно открывается с текущей версией, и по «Отмене» выбор возвращается к ней, потому что результат уходит наружу только через сигнал. Пустая строка — версия не выбрана, кнопка подтверждения выключена. |
|
||||||
|
| `category` | `string` | `"release"` | Нет | Ключ активной категории. Допустимые значения: `release` (релизы), `snapshot` (снапшоты), `old_beta` (беты), `old_alpha` (альфы), `other` (прочие — сюда попадают в том числе установленные профили модлоадеров). |
|
||||||
|
| `filterText` | `string` | `""` | Нет | Текст поиска. Сравнивается в нижнем регистре без учёта регистра с полем `search` записи каталога; поиск идёт внутри активной категории. |
|
||||||
|
| `installedOnly` | `bool` | `false` | Нет | Показывать только скачанные версии. |
|
||||||
|
| `categories` | `var` (только чтение) | список из пяти записей | Нет | Описание колонки категорий: массив объектов с полями `key` и переведённым `title`. Задаёт и порядок пунктов, и набор допустимых значений `category`. |
|
||||||
|
| `visibleEntries` | `var` (только чтение) | вычисляется | Нет | Отобранные строки каталога: записи активной категории, прошедшие флажок «только установленные» и текст поиска. Порядок наследуется от каталога. Служит моделью списка. |
|
||||||
|
|
||||||
|
## Сигналы
|
||||||
|
|
||||||
|
#### versionChosen(string versionId)
|
||||||
|
|
||||||
|
Версия подтверждена — кнопкой «Выбрать», двойным щелчком по строке или клавишей Enter в поле
|
||||||
|
поиска. В параметре приходит идентификатор версии.
|
||||||
|
|
||||||
|
Имя не `accepted()` сознательно: такой сигнал у `Dialog` уже есть и переопределить его нельзя.
|
||||||
|
|
||||||
|
Обработчик — карточка сборки — записывает выбранную версию в сборку. К моменту вызова обработчика
|
||||||
|
окно уже закрыто. При отмене сигнал не испускается, поэтому снаружи ничего откатывать не нужно.
|
||||||
|
|
||||||
|
## Методы
|
||||||
|
|
||||||
|
#### openFor(string versionId) : void
|
||||||
|
|
||||||
|
Открывает окно на переданной версии. Запоминает её в `selectedId`, очищает поиск, переключается на
|
||||||
|
категорию именно этой версии (а не всегда на релизы), просит бэкенд обновить каталог и
|
||||||
|
прокручивает список к выбранной строке.
|
||||||
|
|
||||||
|
#### categoryOf(string versionId) : string
|
||||||
|
|
||||||
|
Возвращает ключ категории версии по каталогу; для неизвестной версии — `release`.
|
||||||
|
|
||||||
|
#### indexOfSelected() : int
|
||||||
|
|
||||||
|
Позиция выбранной версии в `visibleEntries` или `-1`, если под текущим фильтром её не видно.
|
||||||
|
|
||||||
|
#### revealSelected() : void
|
||||||
|
|
||||||
|
Выставляет текущий индекс списка на выбранную версию и прокручивает список так, чтобы строка
|
||||||
|
оказалась по центру. Вызывается после каждой смены фильтра, категории или удаления.
|
||||||
|
|
||||||
|
#### acceptSelection() : void
|
||||||
|
|
||||||
|
Подтверждает выбор: испускает `versionChosen()` и закрывает окно. При пустом `selectedId` не
|
||||||
|
делает ничего.
|
||||||
|
|
||||||
|
#### catalogHas(string versionId) : bool
|
||||||
|
|
||||||
|
Есть ли версия в каталоге. Нужен после удаления: профиль модлоадера присутствовал в каталоге
|
||||||
|
только потому, что был установлен, и после удаления строка исчезает совсем.
|
||||||
|
|
||||||
|
#### askRemove(string versionId) : void
|
||||||
|
|
||||||
|
Спрашивает подтверждение перед удалением. Запрашивает у бэкенда `versionRemovalInfo()` и, если
|
||||||
|
версия действительно установлена, наполняет окно подтверждения: занимаемый объём, список
|
||||||
|
зависящих профилей модлоадеров и список сборок, которые эту версию используют. Идентификатор
|
||||||
|
запоминается в самом окне подтверждения, потому что к моменту ответа строка под курсором может
|
||||||
|
быть уже другой.
|
||||||
|
|
||||||
|
Окно подтверждения объясняет три вещи: файлы удалятся из `versions/` и освободится столько-то
|
||||||
|
мегабайт; профили модлоадеров поверх этой версии без неё не запустятся; библиотеки и ресурсы в
|
||||||
|
`libraries/` и `assets/` общие для всех версий и остаются на месте.
|
||||||
|
|
||||||
|
#### performRemove(string versionId) : void
|
||||||
|
|
||||||
|
Удаляет версию через бэкенд. Если удалена была именно выбранная версия и её больше нет в каталоге,
|
||||||
|
снимает выбор. В конце обновляет позицию списка.
|
||||||
|
|
||||||
|
## Взаимодействие с другими компонентами
|
||||||
|
|
||||||
|
**Со стороны родителя.** [BuildsDialog](BuildsDialog.md) задаёт `backend`, открывает окно вызовом
|
||||||
|
`openFor()` с текущей версией сборки и подписывается на `versionChosen()`, чтобы записать выбор.
|
||||||
|
Никаких других точек входа у окна нет — прямая запись `selectedId` снаружи не предполагается.
|
||||||
|
|
||||||
|
**Со стороны бэкенда.** `versionCatalog` и `catalogLoading` — привязки, от которых зависят и
|
||||||
|
модель списка, и текст пустого состояния: пришедшее обновление каталога пересчитывает
|
||||||
|
`visibleEntries` само. `refreshVersionCatalog()` вызывается при открытии окна, `removeVersion()` —
|
||||||
|
после подтверждения удаления.
|
||||||
|
|
||||||
|
**Клавиатура.** Фокус при открытии уходит в поле поиска. Стрелки вверх и вниз двигают выбор по
|
||||||
|
списку функцией `step()`, Enter подтверждает выбор, Escape закрывает окно.
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
```qml
|
||||||
|
VersionPickerDialog {
|
||||||
|
id: versionPicker
|
||||||
|
parent: Overlay.overlay
|
||||||
|
backend: launcherBackend
|
||||||
|
onVersionChosen: (versionId) => buildCard.gameVersion = versionId
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: buildCard.gameVersion || qsTr("Выбрать версию")
|
||||||
|
onClicked: versionPicker.openFor(buildCard.gameVersion)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
При создании этого документа использовался ИИ.
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
#include "gamelauncher.h"
|
||||||
|
#include "localization.h"
|
||||||
|
|
||||||
|
#include "javalocator.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QtCore/private/qzipreader_p.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
constexpr QChar kClasspathSeparator = u';';
|
||||||
|
#else
|
||||||
|
constexpr QChar kClasspathSeparator = u':';
|
||||||
|
#endif
|
||||||
|
|
||||||
|
QString libraryPath(const QString &gameDir, const MinecraftLibrary &library)
|
||||||
|
{
|
||||||
|
return gameDir + QStringLiteral("/libraries/") + library.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString nativeToken(const QString &path)
|
||||||
|
{
|
||||||
|
return QDir::toNativeSeparators(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подставляет ${...}. Аргументы, в которых после подстановки остались
|
||||||
|
// неизвестные плейсхолдеры, вызывающий выбрасывает.
|
||||||
|
QString substitute(QString argument, const QHash<QString, QString> &variables)
|
||||||
|
{
|
||||||
|
for (auto it = variables.cbegin(); it != variables.cend(); ++it)
|
||||||
|
argument.replace(QStringLiteral("${") + it.key() + u'}', it.value());
|
||||||
|
return argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isNativeLibraryFile(const QString &fileName)
|
||||||
|
{
|
||||||
|
static const QStringList suffixes = {QStringLiteral(".dll"),
|
||||||
|
QStringLiteral(".so"),
|
||||||
|
QStringLiteral(".dylib"),
|
||||||
|
QStringLiteral(".jnilib")};
|
||||||
|
for (const QString &suffix : suffixes)
|
||||||
|
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
|
||||||
|
return true;
|
||||||
|
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
GameLauncher::GameLauncher(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::isRunning() const
|
||||||
|
{
|
||||||
|
return m_process && m_process->state() != QProcess::NotRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList GameLauncher::missingFiles(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
int limit)
|
||||||
|
{
|
||||||
|
QStringList missing;
|
||||||
|
const auto add = [&missing, limit](const QString &path) {
|
||||||
|
if (missing.size() < limit)
|
||||||
|
missing << QDir::toNativeSeparators(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!QDir(options.gameDir).exists())
|
||||||
|
return {QDir::toNativeSeparators(options.gameDir)};
|
||||||
|
|
||||||
|
if (!QFile::exists(version.clientJarPath))
|
||||||
|
add(version.clientJarPath);
|
||||||
|
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
const QString path = libraryPath(options.gameDir, library);
|
||||||
|
if (!QFile::exists(path))
|
||||||
|
add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version.assetIndexId.isEmpty()) {
|
||||||
|
const QString index = options.gameDir + QStringLiteral("/assets/indexes/")
|
||||||
|
+ version.assetIndexId + QStringLiteral(".json");
|
||||||
|
if (!QFile::exists(index))
|
||||||
|
add(index);
|
||||||
|
else if (!QDir(options.gameDir + QStringLiteral("/assets/objects")).exists())
|
||||||
|
add(options.gameDir + QStringLiteral("/assets/objects"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool ignoreListCovers(const QString &ignoreListArgument, const QString &fileName)
|
||||||
|
{
|
||||||
|
const QString value = ignoreListArgument.mid(QStringLiteral("-DignoreList=").size());
|
||||||
|
const QStringList prefixes = value.split(u',', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &prefix : prefixes) {
|
||||||
|
if (!prefix.isEmpty() && fileName.startsWith(prefix))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QStringList GameLauncher::buildArguments(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir)
|
||||||
|
{
|
||||||
|
QStringList classpath;
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
if (library.native)
|
||||||
|
continue;
|
||||||
|
classpath << nativeToken(libraryPath(options.gameDir, library));
|
||||||
|
}
|
||||||
|
classpath << nativeToken(version.clientJarPath);
|
||||||
|
|
||||||
|
const QString assetsRoot = options.gameDir + QStringLiteral("/assets");
|
||||||
|
const bool legacyAssets = version.assetsKind == QLatin1String("legacy")
|
||||||
|
|| version.assetsKind == QLatin1String("pre-1.6");
|
||||||
|
|
||||||
|
QHash<QString, QString> variables;
|
||||||
|
variables[QStringLiteral("auth_player_name")] = options.playerName;
|
||||||
|
variables[QStringLiteral("auth_uuid")] = options.uuid;
|
||||||
|
variables[QStringLiteral("auth_access_token")] = options.accessToken;
|
||||||
|
variables[QStringLiteral("auth_session")] =
|
||||||
|
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid);
|
||||||
|
variables[QStringLiteral("auth_xuid")] =
|
||||||
|
options.xuid.isEmpty() ? QStringLiteral("0") : options.xuid;
|
||||||
|
variables[QStringLiteral("clientid")] =
|
||||||
|
options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken;
|
||||||
|
variables[QStringLiteral("user_type")] = options.userType;
|
||||||
|
variables[QStringLiteral("user_properties")] = QStringLiteral("{}");
|
||||||
|
variables[QStringLiteral("version_name")] = version.id;
|
||||||
|
variables[QStringLiteral("version_type")] = version.type;
|
||||||
|
variables[QStringLiteral("game_directory")] = nativeToken(options.gameDir);
|
||||||
|
variables[QStringLiteral("assets_root")] = nativeToken(assetsRoot);
|
||||||
|
variables[QStringLiteral("assets_index_name")] = version.assetIndexId;
|
||||||
|
variables[QStringLiteral("game_assets")] =
|
||||||
|
nativeToken(legacyAssets ? assetsRoot + QStringLiteral("/virtual/legacy") : assetsRoot);
|
||||||
|
variables[QStringLiteral("natives_directory")] = nativeToken(nativesDir);
|
||||||
|
variables[QStringLiteral("library_directory")] =
|
||||||
|
nativeToken(options.gameDir + QStringLiteral("/libraries"));
|
||||||
|
variables[QStringLiteral("classpath")] = classpath.join(kClasspathSeparator);
|
||||||
|
variables[QStringLiteral("classpath_separator")] = QString(kClasspathSeparator);
|
||||||
|
variables[QStringLiteral("primary_jar")] = nativeToken(version.clientJarPath);
|
||||||
|
variables[QStringLiteral("launcher_name")] = options.launcherName;
|
||||||
|
variables[QStringLiteral("launcher_version")] = options.launcherVersion;
|
||||||
|
variables[QStringLiteral("resolution_width")] = QString::number(options.windowWidth);
|
||||||
|
variables[QStringLiteral("resolution_height")] = QString::number(options.windowHeight);
|
||||||
|
|
||||||
|
QStringList arguments;
|
||||||
|
|
||||||
|
// Свои jvm-аргументы идут первыми — как в строке запуска из примера.
|
||||||
|
if (!options.authlibInjectorPath.isEmpty()) {
|
||||||
|
arguments << QStringLiteral("-javaagent:%1=%2")
|
||||||
|
.arg(nativeToken(options.authlibInjectorPath), options.authlibInjectorApi)
|
||||||
|
<< QStringLiteral("-Dauthlibinjector.side=client");
|
||||||
|
}
|
||||||
|
if (options.minMemoryMb > 0)
|
||||||
|
arguments << QStringLiteral("-Xms%1M").arg(options.minMemoryMb);
|
||||||
|
if (options.maxMemoryMb > 0)
|
||||||
|
arguments << QStringLiteral("-Xmx%1M").arg(options.maxMemoryMb);
|
||||||
|
arguments += options.extraJvmArgs;
|
||||||
|
|
||||||
|
// Forge 1.17+ собирает слой модулей из classpath, пропуская всё, чьё имя
|
||||||
|
// начинается с записи в -DignoreList. Ванильный клиентский jar записан там
|
||||||
|
// как ${version_name}.jar — с расчётом на лаунчер Mojang, который кладёт в
|
||||||
|
// classpath jar самого профиля. Своего jar у профиля Forge нет, мы кладём
|
||||||
|
// родительский, и без его имени в списке он становится вторым модулем с
|
||||||
|
// классами Minecraft: «Modules minecraft and _1._20._1 export package ...».
|
||||||
|
const QString clientJarName = QFileInfo(version.clientJarPath).fileName();
|
||||||
|
|
||||||
|
for (const QString &argument : version.jvmArgs) {
|
||||||
|
QString resolved = substitute(argument, variables);
|
||||||
|
if (resolved.contains(QStringLiteral("${")))
|
||||||
|
continue;
|
||||||
|
if (!clientJarName.isEmpty() && resolved.startsWith(QLatin1String("-DignoreList="))
|
||||||
|
&& !ignoreListCovers(resolved, clientJarName))
|
||||||
|
resolved += u',' + clientJarName;
|
||||||
|
arguments << resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!version.loggingArgument.isEmpty() && QFile::exists(version.loggingConfigPath)) {
|
||||||
|
QHash<QString, QString> loggingVariable;
|
||||||
|
loggingVariable[QStringLiteral("path")] = nativeToken(version.loggingConfigPath);
|
||||||
|
arguments << substitute(version.loggingArgument, loggingVariable);
|
||||||
|
}
|
||||||
|
|
||||||
|
arguments << version.mainClass;
|
||||||
|
|
||||||
|
for (const QString &argument : version.gameArgs) {
|
||||||
|
const QString resolved = substitute(argument, variables);
|
||||||
|
if (!resolved.contains(QStringLiteral("${")))
|
||||||
|
arguments << resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.windowWidth > 0 && options.windowHeight > 0
|
||||||
|
&& !version.hasCustomResolutionArgs && !arguments.contains(QStringLiteral("--width"))) {
|
||||||
|
arguments << QStringLiteral("--width") << QString::number(options.windowWidth)
|
||||||
|
<< QStringLiteral("--height") << QString::number(options.windowHeight);
|
||||||
|
}
|
||||||
|
if (options.fullscreen && !arguments.contains(QStringLiteral("--fullscreen")))
|
||||||
|
arguments << QStringLiteral("--fullscreen");
|
||||||
|
|
||||||
|
if (!options.serverAddress.isEmpty()) {
|
||||||
|
if (version.supportsQuickPlay) {
|
||||||
|
arguments << QStringLiteral("--quickPlayMultiplayer") << options.serverAddress;
|
||||||
|
} else {
|
||||||
|
const int colon = options.serverAddress.lastIndexOf(u':');
|
||||||
|
const QString host = colon > 0 ? options.serverAddress.left(colon) : options.serverAddress;
|
||||||
|
const QString port = colon > 0 ? options.serverAddress.mid(colon + 1)
|
||||||
|
: QStringLiteral("25565");
|
||||||
|
arguments << QStringLiteral("--server") << host << QStringLiteral("--port") << port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::extractNatives(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir,
|
||||||
|
QString *error)
|
||||||
|
{
|
||||||
|
if (!QDir().mkpath(nativesDir)) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.createFolderFailed").arg(QDir::toNativeSeparators(nativesDir));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const MinecraftLibrary &library : version.libraries) {
|
||||||
|
if (!library.native)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString jar = libraryPath(options.gameDir, library);
|
||||||
|
QZipReader reader(jar);
|
||||||
|
if (!reader.isReadable()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.nativeReadFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(jar));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
|
||||||
|
for (const QZipReader::FileInfo &entry : entries) {
|
||||||
|
if (!entry.isFile)
|
||||||
|
continue;
|
||||||
|
if (entry.filePath.startsWith(QStringLiteral("META-INF/")))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool excluded = false;
|
||||||
|
for (const QString &prefix : library.extractExclude) {
|
||||||
|
if (entry.filePath.startsWith(prefix)) {
|
||||||
|
excluded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (excluded)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString fileName = QFileInfo(entry.filePath).fileName();
|
||||||
|
if (!isNativeLibraryFile(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const QString target = nativesDir + u'/' + fileName;
|
||||||
|
const QByteArray data = reader.fileData(entry.filePath);
|
||||||
|
if (QFileInfo(target).size() == data.size() && QFile::exists(target))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
QFile file(target);
|
||||||
|
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.extractFailed")
|
||||||
|
.arg(fileName, QDir::toNativeSeparators(nativesDir));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GameLauncher::launch(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
QString *error)
|
||||||
|
{
|
||||||
|
if (isRunning()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("common.error.gameRunning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("launch.progress.checkingFiles"));
|
||||||
|
const QStringList missing = missingFiles(options, version);
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.filesMissing")
|
||||||
|
.arg(missing.size())
|
||||||
|
.arg(missing.join(u'\n'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit progress(Loc::text("launch.progress.extractingNatives"));
|
||||||
|
const QString nativesDir = options.gameDir + QStringLiteral("/versions/") + version.id
|
||||||
|
+ QStringLiteral("/natives");
|
||||||
|
if (!extractNatives(options, version, nativesDir, error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
emit progress(Loc::text("launch.progress.lookingForJava").arg(version.javaMajor));
|
||||||
|
const QString java = JavaLocator::select(options.gameDir, version.javaMajor, options.javaPath, error);
|
||||||
|
if (java.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const QStringList arguments = buildArguments(options, version, nativesDir);
|
||||||
|
const QString executable = JavaLocator::windowlessVariant(java);
|
||||||
|
|
||||||
|
m_process = new QProcess(this);
|
||||||
|
m_process->setProgram(executable);
|
||||||
|
m_process->setArguments(arguments);
|
||||||
|
m_process->setWorkingDirectory(options.gameDir);
|
||||||
|
m_process->setProcessChannelMode(QProcess::MergedChannels);
|
||||||
|
|
||||||
|
connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() {
|
||||||
|
const QString chunk = QString::fromLocal8Bit(m_process->readAllStandardOutput());
|
||||||
|
const QStringList lines = chunk.split(u'\n', Qt::SkipEmptyParts);
|
||||||
|
for (const QString &line : lines)
|
||||||
|
emit output(line.trimmed());
|
||||||
|
});
|
||||||
|
connect(m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) {
|
||||||
|
emit gameFinished(exitCode, status == QProcess::CrashExit);
|
||||||
|
m_process->deleteLater();
|
||||||
|
m_process = nullptr;
|
||||||
|
});
|
||||||
|
// Токен в лог не попадает — маскируем его в отображаемой строке запуска.
|
||||||
|
QStringList shown = arguments;
|
||||||
|
const int tokenIndex = shown.indexOf(QStringLiteral("--accessToken"));
|
||||||
|
if (tokenIndex >= 0 && tokenIndex + 1 < shown.size())
|
||||||
|
shown[tokenIndex + 1] = QStringLiteral("<скрыт>");
|
||||||
|
|
||||||
|
m_process->start();
|
||||||
|
if (!m_process->waitForStarted(15000)) {
|
||||||
|
if (error)
|
||||||
|
*error = Loc::text("launch.error.startFailed")
|
||||||
|
.arg(QDir::toNativeSeparators(executable), m_process->errorString());
|
||||||
|
m_process->deleteLater();
|
||||||
|
m_process = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit gameStarted(QDir::toNativeSeparators(executable) + QStringLiteral(" ")
|
||||||
|
+ shown.join(QStringLiteral(" ")));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameLauncher::terminate()
|
||||||
|
{
|
||||||
|
if (isRunning())
|
||||||
|
m_process->kill();
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "minecraftversion.h"
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QProcess;
|
||||||
|
|
||||||
|
// Всё, что лаунчер знает к моменту нажатия PLAY.
|
||||||
|
struct LaunchOptions
|
||||||
|
{
|
||||||
|
QString gameDir; // .minecraft
|
||||||
|
QString versionId; // Папка в versions
|
||||||
|
|
||||||
|
QString playerName;
|
||||||
|
QString uuid;
|
||||||
|
QString accessToken;
|
||||||
|
QString userType;
|
||||||
|
QString clientToken;
|
||||||
|
QString xuid; // Пусто — подставится "0", как в офлайне
|
||||||
|
|
||||||
|
QString javaPath; // Пусто — ищем сами
|
||||||
|
int minMemoryMb = 512;
|
||||||
|
int maxMemoryMb = 4096;
|
||||||
|
QStringList extraJvmArgs;
|
||||||
|
|
||||||
|
int windowWidth = 0; // 0 — не передавать --width/--height
|
||||||
|
int windowHeight = 0;
|
||||||
|
bool fullscreen = false;
|
||||||
|
|
||||||
|
QString serverAddress; // host[:port] — автозаход на сервер
|
||||||
|
QString authlibInjectorPath; // Пусто — не подключаем
|
||||||
|
QString authlibInjectorApi = QStringLiteral("ely.by");
|
||||||
|
|
||||||
|
QString launcherName = QStringLiteral("BiLauncher");
|
||||||
|
QString launcherVersion = QStringLiteral("1.0");
|
||||||
|
};
|
||||||
|
|
||||||
|
class GameLauncher : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GameLauncher(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool isRunning() const;
|
||||||
|
static QStringList missingFiles(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
int limit = 12);
|
||||||
|
|
||||||
|
static QStringList buildArguments(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir);
|
||||||
|
|
||||||
|
static bool extractNatives(const LaunchOptions &options,
|
||||||
|
const MinecraftVersion &version,
|
||||||
|
const QString &nativesDir,
|
||||||
|
QString *error);
|
||||||
|
|
||||||
|
bool launch(const LaunchOptions &options, const MinecraftVersion &version, QString *error);
|
||||||
|
|
||||||
|
void terminate();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void progress(const QString &message);
|
||||||
|
void output(const QString &line);
|
||||||
|
void gameStarted(const QString &commandLine);
|
||||||
|
void gameFinished(int exitCode, bool crashed);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QProcess *m_process = nullptr;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Version=1.0
|
||||||
|
Name=BiLauncher
|
||||||
|
GenericName=Minecraft Launcher
|
||||||
|
Comment=Launcher for Minecraft
|
||||||
|
Exec=BiLauncher
|
||||||
|
Icon=bilauncher
|
||||||
|
Terminal=false
|
||||||
|
Categories=Game;ActionGame;
|
||||||
|
StartupNotify=true
|
||||||
|
After Width: | Height: | Size: 409 KiB |
@@ -0,0 +1 @@
|
|||||||
|
IDI_ICON1 ICON "icon.ico"
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 534 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #637a10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-2" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||||
|
<path class="cls-1" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-2" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||||
|
<path class="cls-1" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 40">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path class="cls-1" d="m67.66,40H2.34c-1.29,0-2.34-1.05-2.34-2.36V2.36C0,1.05,1.05,0,2.34,0h65.32c1.29,0,2.34,1.05,2.34,2.36v35.29c0,1.3-1.05,2.36-2.34,2.36Z"/>
|
||||||
|
<path class="cls-2" d="m40.18,9.72c-2.6,0-4.9,1.28-6.32,3.25h-10.36c-.97,0-1.75.79-1.75,1.76v13.8c0,.97.78,1.76,1.75,1.76h23c.97,0,1.75-.79,1.75-1.76V11.48c0-.97-.78-1.76-1.75-1.76h-6.32Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 743 B |
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-2" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-1" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-1" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 978 B |
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #637a10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-2" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-1" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-1" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 978 B |
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 69.88 39.72">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #232323;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="69.88" height="39.72" rx="2.34" ry="2.34"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="m46.69,9.71h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-2" d="m46.69,18.01h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
<path class="cls-2" d="m46.69,26.32h-23.51c-1.03,0-1.87.83-1.87,1.85h0c0,1.02.84,1.85,1.87,1.85h23.51c1.03,0,1.87-.83,1.87-1.85h0c0-1.02-.84-1.85-1.87-1.85Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 978 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9"/>
|
||||||
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 255 B |
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 170">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #637a10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-2" width="335" height="170" rx="10.7" ry="10.7"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
|
||||||
|
<path class="cls-1" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
|
||||||
|
<path class="cls-1" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
|
||||||
|
<path class="cls-1" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 170">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #91b315;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-2" width="335" height="170" rx="10.7" ry="10.7"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
|
||||||
|
<path class="cls-1" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
|
||||||
|
<path class="cls-1" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
|
||||||
|
<path class="cls-1" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 335 170">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-1, .cls-2 {
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #91b315;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="335" height="170" rx="10.7" ry="10.7"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="m72.38,98.02v26.54h-24.73V39.35h49.34c5.87,0,9.94,4.38,9.94,10.59v37.37c0,6.21-4.06,10.71-9.94,10.71h-24.62Zm0-21.3h9.82v-15.95h-9.82v15.95Z"/>
|
||||||
|
<path class="cls-2" d="m114.83,39.35h24.62v63.91h24.84v21.3h-49.46V39.35Z"/>
|
||||||
|
<path class="cls-2" d="m219.84,39.35l11.07,74.62v10.59h-24.73l-1.47-17.16h-10.84l-1.58,17.16h-24.62v-10.59l11.07-74.62h41.1Zm-16.94,46.75l-2.26-25.32h-2.71l-2.26,25.32h7.23Z"/>
|
||||||
|
<path class="cls-2" d="m227.51,49.94v-10.59h24.73l5.98,33.11h1.81l6.1-33.11h24.62v10.59l-19.31,51.37v23.25h-24.62v-23.25l-19.31-51.37Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 443 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11.29 6.05">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<polyline class="cls-1" points="10.7 .59 5.65 5.46 .59 .59"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 463 B |
|
After Width: | Height: | Size: 455 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11.29 6.05">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<polyline class="cls-1" points=".59 5.46 5.65 .59 10.7 5.46"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 464 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.83 45">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #637a10;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="118.83" height="45" rx="5.27" ry="5.27"/>
|
||||||
|
<polyline class="cls-2" points="15.36 24.93 20.41 20.07 25.47 24.93"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.83 45">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #232323;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="118.83" height="45" rx="5.27" ry="5.27"/>
|
||||||
|
<polyline class="cls-2" points="15.36 24.93 20.41 20.07 25.47 24.93"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.83 45">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #232323;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="118.83" height="45" rx="5.27" ry="5.27"/>
|
||||||
|
<polyline class="cls-2" points="15.36 20.07 20.41 24.93 25.47 20.07"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3.41 39.95">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #e6e6e6;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" x="0" y="0" width="3.41" height="39.95" rx="1.56" ry="1.56"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 395 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3.41 161.66">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #637a10;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" x="0" y="0" width="3.41" height="161.66" rx="1.56" ry="1.56"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 45">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #232323;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="210" height="45" rx="5.24" ry="5.24"/>
|
||||||
|
<polyline class="cls-2" points="16.58 20.07 21.63 24.93 26.69 20.07"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 45">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #637a10;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="210" height="45" rx="5.24" ry="5.24"/>
|
||||||
|
<polyline class="cls-2" points="16.58 24.93 21.63 20.07 26.69 24.93"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 45">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #232323;
|
||||||
|
stroke-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #e6e6e6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 1.18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<rect class="cls-1" width="210" height="45" rx="5.24" ry="5.24"/>
|
||||||
|
<polyline class="cls-2" points="16.58 24.93 21.63 20.07 26.69 24.93"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 612 B |
|
After Width: | Height: | Size: 1.3 MiB |