Files
minecraft-launcher/Main.qml
T

1425 lines
56 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick
import QtQuick.Layouts 2.15
import QtQuick.Controls 2.15
import Minecraft_launcher
Window {
id: window
width: 1280
height: 720
visible: true
flags: Qt.Window
title: Loc.t.app.title
// ── Backend ────────────────────────────────────────────────────────────
LauncherBackend {
id: backend
onLaunched: (profileName, buildName, serverUrl) => {
window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")
}
onLaunchProgress: (message) => window.showToast(message, "#3a5a8c", 0)
onLaunchError: (message) => window.showToast(message, "#cc3333", 8000)
onTwoFactorRequired: (profileName) => {
twoFactorDialog.profileName = profileName
twoFactorCode.text = ""
twoFactorDialog.open()
}
onGameFinished: (exitCode, crashed) => {
window.showToast(crashed || exitCode !== 0
? Loc.t.game.status.crashed.arg(exitCode)
: Loc.t.game.status.closed,
crashed || exitCode !== 0 ? "#cc3333" : "#555555")
}
onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url)
// Выбор в списке здесь не трогаем: новый профиль уже выбран тем, кто
// его создал, а повторный вход мог быть и не в последний профиль.
onMicrosoftLoginSucceeded: (playerName) =>
window.showToast(Loc.t.auth.status.signedInAs.arg(playerName), "#4b7a1f")
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
onGameOutput: (line) => console.log(line)
onSeasonalInstallFinished: (seasonalId, buildName) => {
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
"#4b7a1f", 8000)
}
}
// Единая всплывающая плашка: сообщения о ходе запуска, ошибки, статус игры.
// timeout === 0 — держим до следующего сообщения.
function showToast(text, color, timeout) {
toastLabel.text = text
toastBackground.color = color
toastTimer.stop()
if (timeout === undefined)
timeout = 4000
if (timeout > 0) {
toastTimer.interval = timeout
toastTimer.restart()
}
}
// Окно входа Microsoft создаётся по требованию, а не вместе с главным
// окном: MicrosoftLoginDialog.qml попадает в модуль только в сборках с Qt
// WebEngine, и обычная декларация сломала бы всё окно в остальных.
property var microsoftLoginDialog: null
function openMicrosoftLogin(url) {
if (microsoftLoginDialog === null) {
const component = Qt.createComponent("MicrosoftLoginDialog.qml")
if (component.status !== Component.Ready) {
backend.cancelMicrosoftLogin()
window.showToast(Loc.t.auth.msa.error.windowUnavailable,
"#cc3333", 8000)
return
}
microsoftLoginDialog = component.createObject(window, { backend: backend })
microsoftLoginDialog.failed.connect(
(message) => window.showToast(message, "#cc3333", 6000))
}
microsoftLoginDialog.openAt(url)
}
function formatMb(bytes) {
return (bytes / 1048576).toFixed(1)
}
// ── Background ─────────────────────────────────────────────────────────
Image {
id: image
opacity: 0.296
anchors.fill: parent
source: "images/GovuztTW8AAHqBf.jpeg"
fillMode: Image.Stretch
}
// ── Status toast ───────────────────────────────────────────────────────
Rectangle {
id: toastBackground
visible: toastLabel.text !== ""
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: (downloadPanel.visible || switchPanel.visible) ? 110 : 40
width: Math.min(window.width - 80, toastLabel.implicitWidth + 32)
height: toastLabel.implicitHeight + 20
radius: 8
color: "#cc3333"
Text {
id: toastLabel
anchors.centerIn: parent
width: parent.width - 32
color: "#ffffff"
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
Timer {
id: toastTimer
interval: 4000
onTriggered: toastLabel.text = ""
}
}
// ── Панели хода работ ──────────────────────────────────────────────────
// Загрузка и смена сборки взаимоисключающи по построению: busy() не даёт
// начать переключение во время установки и наоборот. Поэтому у панелей
// одни и те же якоря.
ProgressPanel {
id: downloadPanel
visible: backend.downloading
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.leftMargin: 24
anchors.bottomMargin: 24
title: Loc.t.common.progress.downloadingNamed.arg(backend.downloadVersion)
fraction: backend.downloadProgress
status: backend.downloadStatus
detail: backend.downloadBytesTotal > 0
? Loc.t.common.progress.bytes.arg(backend.downloadStatus)
.arg(window.formatMb(backend.downloadBytesDone))
.arg(window.formatMb(backend.downloadBytesTotal))
: ""
onCancelRequested: backend.cancelDownload()
}
ProgressPanel {
id: switchPanel
visible: backend.switching
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.leftMargin: 24
anchors.bottomMargin: 24
title: backend.switchStage
fraction: backend.switchProgress
status: backend.switchStatus
// Отменять можно только пока идёт архивация: после очистки .minecraft
// отступать некуда, операцию нужно довести до конца.
cancellable: false
}
// ── Play button ────────────────────────────────────────────────────────
Button {
id: button
width: 335
height: 170
anchors.centerIn: parent
hoverEnabled: false
enabled: !backend.busy && !backend.gameRunning
background: Image {
id: buttonImage
source: "images/Play_Button/Play_Active.svg"
opacity: button.enabled ? 1.0 : 0.45
}
onPressed: buttonImage.source = "images/Play_Button/Play_pressed.svg"
onReleased: buttonImage.source = "images/Play_Button/Play_Active.svg"
onClicked: backend.launchGame(profileBox.currentIndex, backend.activeBuildIndex)
}
// ── Profile ComboBox ───────────────────────────────────────────────────
ComboBox {
id: profileBox
y: 451
width: 146
height: 42
anchors.left: button.right
anchors.bottom: button.top
anchors.leftMargin: -335
anchors.bottomMargin: -218
rotation: 0
editable: false
hoverEnabled: true
model: backend.profileNames
// Пока список закрыт: открытый popup накрывает кнопку, и подсказка
// висела бы поверх собственного меню.
ToolTip.visible: profileBox.hovered && !profileBox.down
ToolTip.text: backend.profileNames.length === 0
? Loc.t.profile.hint.addFirst
: Loc.t.profile.label.current.arg(profileBox.displayText)
contentItem: Text {
id: profileText
text: profileBox.displayText
font: profileBox.font
color: "#ffffff"
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
anchors.fill: parent
anchors.leftMargin: 30
anchors.rightMargin: 5
anchors.topMargin: 5
anchors.bottomMargin: 5
}
indicator: Image {
id: profileArrow
width: 10; height: 10
visible: true
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
source: profileBox.down ? "images/Profile_Box/Asset_23.svg"
: "images/Profile_Box/Asset_24.svg"
rotation: 180
sourceSize.width: 10; sourceSize.height: 10
fillMode: Image.PreserveAspectFit
autoTransform: true
}
background: Rectangle {
implicitWidth: 120; implicitHeight: 40
color: profileBox.down ? "#91B315" : "#232323"
radius: 6
border.color: profileBox.pressed ? "#ffffff" : "#232323"
border.width: profileBox.visualFocus ? 2 : 1
}
popup: Popup {
y: profileBox.height - 1
width: profileBox.width
padding: 0
// Высота = кнопка "+" (32) + элементы списка, не более 200
height: 32 + Math.min(profileItemList.contentHeight, 200)
contentItem: Item {
anchors.fill: parent
// ── "+" button — всегда сверху ──────────────────────────
Rectangle {
id: profileAddBtnBg
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 32
color: profileAddArea.containsPress ? "#2d2d2d" : "transparent"
Text {
anchors.centerIn: parent
text: Loc.t.profile.button.add
color: "#91B315"
font.pixelSize: 12
}
MouseArea {
id: profileAddArea
anchors.fill: parent
onClicked: {
profileBox.popup.close()
addProfileDialog.open()
}
}
}
// ── Добавленные профили — всегда ниже кнопки ───────────
ListView {
id: profileItemList
anchors.top: profileAddBtnBg.bottom
anchors.left: parent.left
anchors.right: parent.right
height: Math.min(contentHeight, 200)
clip: true
model: backend.profileNames
delegate: ItemDelegate {
id: profileItem
required property string modelData
required property int index
width: profileItemList.width
height: 40
contentItem: Text {
text: profileItem.modelData
color: "#ffffff"
font: profileBox.font
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignVCenter
leftPadding: 8
rightPadding: 62
}
background: Rectangle {
color: (profileBox.currentIndex === profileItem.index || profileItem.hovered)
? "#91B315" : "#232323"
radius: 5
}
onClicked: {
profileBox.currentIndex = profileItem.index
profileBox.popup.close()
}
// ── Карандаш — видна при наведении на строку ────
Image {
id: profilePencil
width: 16; height: 16
anchors.right: profileTrash.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
visible: profileItem.hovered
opacity: profilePencilArea.containsMouse ? 1.0 : 0.7
source: "images/Pencil.svg"
sourceSize.width: 16; sourceSize.height: 16
fillMode: Image.PreserveAspectFit
MouseArea {
id: profilePencilArea
anchors.fill: parent
anchors.margins: -6
hoverEnabled: true
onClicked: editProfileDialog.openFor(profileItem.index)
}
}
// ── Корзина — видна при наведении на строку ─────
Image {
id: profileTrash
width: 16; height: 16
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
visible: profileItem.hovered
opacity: profileTrashArea.containsMouse ? 1.0 : 0.7
source: "images/Trash.svg"
sourceSize.width: 16; sourceSize.height: 16
fillMode: Image.PreserveAspectFit
MouseArea {
id: profileTrashArea
anchors.fill: parent
anchors.margins: -6
hoverEnabled: true
onClicked: backend.removeProfile(profileItem.index)
}
}
}
ScrollIndicator.vertical: ScrollIndicator {
parent: profileItemList.parent
anchors.top: profileItemList.top
anchors.left: profileItemList.right
anchors.bottom: profileItemList.bottom
}
}
}
background: Rectangle {
color: "#232323"
radius: 6
border.color: "#232323"
}
}
}
// ── Активная сборка ────────────────────────────────────────────────────
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
// и её содержимое правятся теперь в модальном окне.
Button {
id: buildButton
x: 624
y: 451
width: 183
height: 42
anchors.right: button.right
anchors.bottom: button.top
anchors.rightMargin: 0
anchors.bottomMargin: -218
hoverEnabled: true
enabled: !backend.gameRunning && !backend.switching
contentItem: Text {
text: backend.activeBuildName !== "" ? backend.activeBuildName
: Loc.t.common.status.noBuildSelected
color: "#ffffff"
font: buildButton.font
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
leftPadding: 30
rightPadding: 5
}
background: Rectangle {
color: buildButton.pressed || buildsDialog.opened ? "#91B315" : "#232323"
radius: 6
border.color: buildButton.hovered ? "#91B315" : "#232323"
border.width: 1
Image {
width: 10; height: 10
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
source: buildsDialog.opened ? "images/Profile_Box/Asset_23.svg"
: "images/Profile_Box/Asset_24.svg"
rotation: 180
sourceSize.width: 10; sourceSize.height: 10
fillMode: Image.PreserveAspectFit
}
}
ToolTip.visible: hovered && !buildsDialog.opened
ToolTip.text: Loc.t.common.title.customBuilds
onClicked: buildsDialog.open()
}
// ── Open mods folder button ────────────────────────────────────────────
Button {
id: folderButton
width: 70
height: 40
anchors.left: buildButton.left
anchors.bottom: profileBox.bottom
anchors.leftMargin: -59
anchors.bottomMargin: -45
hoverEnabled: true
background: Image {
id: folderButtonImage
anchors.fill: parent
source: folderButton.pressed ? "images/Folder/Folder_Pressed.svg"
: folderButton.hovered ? "images/Folder/Folder_Active.svg"
: "images/Folder/Folder_Idle.svg"
fillMode: Image.PreserveAspectFit
}
ToolTip.visible: hovered
ToolTip.text: Loc.t.app.tooltip.openMods
onClicked: backend.openMinecraftFolder()
}
// ── Settings button ────────────────────────────────────────────────────
Button {
id: settingsButton
width: 70
height: 40
anchors.left: folderButton.right
anchors.verticalCenter: folderButton.verticalCenter
anchors.leftMargin: 8
hoverEnabled: true
background: Image {
anchors.fill: parent
// Ассеты Options названы наоборот относительно Folder: зелёный
// #637a10 лежит в Options_Pressed.svg, а Options_active.svg —
// тёмный, как Idle. Берём их по цвету, а не по имени, чтобы
// наведение выглядело так же, как у кнопки папки модов.
source: settingsButton.pressed ? "images/Options/Options_active.svg"
: settingsButton.hovered ? "images/Options/Options_Pressed.svg"
: "images/Options/Options_Idle.svg"
fillMode: Image.PreserveAspectFit
}
ToolTip.visible: hovered
ToolTip.text: Loc.t.app.tooltip.settings
onClicked: settingsDialog.load()
}
// ── Seasonal builds button ─────────────────────────────────────────────
// Правый нижний угол — единственная свободная часть окна: панели хода работ
// висят слева, а всё остальное собрано вокруг кнопки запуска.
Button {
id: seasonsButton
width: 183
height: 42
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 24
anchors.bottomMargin: 24
hoverEnabled: true
contentItem: Text {
text: Loc.t.common.title.seasonalBuilds
color: "#ffffff"
font.pixelSize: 14
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: seasonsButton.pressed ? "#2d2d2d" : "#232323"
radius: 6
border.color: "#91B315"
border.width: seasonsButton.hovered ? 2 : 1
}
ToolTip.visible: hovered
ToolTip.text: Loc.t.app.tooltip.seasonal
onClicked: seasonalDialog.openCatalog()
}
SeasonalBuildsDialog {
id: seasonalDialog
backend: backend
x: (window.width - width) / 2
y: (window.height - height) / 2
}
// ── Add Profile Dialog ─────────────────────────────────────────────────
// Bug fix: header/footer must be Item (not Rectangle) with explicit
// implicitHeight so Dialog correctly computes its own total height.
// Rectangle.implicitHeight defaults to 0 regardless of height:.
Dialog {
id: addProfileDialog
modal: true
width: 320
x: (window.width - width) / 2
y: (window.height - height) / 2
padding: 0
background: Rectangle {
color: "#1e1e1e"
radius: 10
border.color: "#91B315"
border.width: 1
}
header: Item {
implicitHeight: 52 // tells Dialog how tall the header is
Text {
anchors.centerIn: parent
text: Loc.t.profile.title.new
color: "#ffffff"
font.pixelSize: 17
font.bold: true
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: "#333333"
}
}
contentItem: Column {
spacing: 12
topPadding: 20
bottomPadding: 20
TextField {
id: pfName
x: 20
width: parent.width - 40
placeholderText: Loc.t.profile.placeholder.name
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: pfName.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
}
TextField {
id: pfLogin
x: 20
width: parent.width - 40
// Ник лицензионного профиля приходит из аккаунта: поля здесь нет.
visible: pfAuth.currentIndex !== 2
placeholderText: Loc.t.profile.placeholder.login
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: pfLogin.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
}
TextField {
id: pfPassword
x: 20
width: parent.width - 40
visible: pfAuth.currentIndex === 1
placeholderText: Loc.t.profile.placeholder.elyPassword
echoMode: TextInput.Password
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: pfPassword.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
}
DarkCombo {
id: pfAuth
x: 20
width: parent.width - 40
model: backend.microsoftAvailable
? Loc.t.profile.authTypes
: Loc.t.profile.authTypes.slice(0, 2)
}
}
footer: Item {
implicitHeight: 60 // tells Dialog how tall the footer is
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: 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: addProfileDialog.reject()
}
Button {
text: Loc.t.common.button.add
width: 110; 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: addProfileDialog.accept()
}
}
}
onAccepted: {
const name = pfName.text.trim()
const type = pfAuth.currentIndex === 2 ? "microsoft"
: pfAuth.currentIndex === 1 ? "elyby" : "offline"
if (name !== "") {
// Скрытые поля не сохраняем: в них мог остаться текст, набранный
// до переключения типа профиля.
backend.addProfile(name,
type === "microsoft" ? "" : pfLogin.text.trim(),
type === "elyby" ? pfPassword.text : "",
type)
profileBox.currentIndex = backend.profileNames.length - 1
// Профиль Microsoft без входа бесполезен: сразу показываем окно.
if (type === "microsoft")
backend.startMicrosoftLogin(backend.profileNames.length - 1)
}
pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0
}
onRejected: {
pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0
}
}
// ── Edit Profile Dialog ────────────────────────────────────────────────
// Идентичен диалогу создания, но с предзаполненными данными выбранного
// профиля. По нажатию ОК данные перезаписываются под тем же индексом.
Dialog {
id: editProfileDialog
modal: true
width: 320
x: (window.width - width) / 2
y: (window.height - height) / 2
padding: 0
property int editIndex: -1
// Состояние входа в Microsoft у открытого профиля — для строки статуса.
property bool msProfile: false
property bool msLinked: false
property string msName: ""
function openFor(index) {
const data = backend.profileAt(index)
editIndex = index
epName.text = data.name || ""
epLogin.text = data.login || ""
epPassword.text = data.password || ""
msProfile = data.authType === "microsoft"
epAuth.currentIndex = msProfile ? 2 : data.authType === "elyby" ? 1 : 0
msLinked = data.hasMicrosoftSession === true
msName = data.resolvedName || ""
profileBox.popup.close()
open()
}
background: Rectangle {
color: "#1e1e1e"
radius: 10
border.color: "#91B315"
border.width: 1
}
header: Item {
implicitHeight: 52
Text {
anchors.centerIn: parent
text: Loc.t.profile.title.edit
color: "#ffffff"
font.pixelSize: 17
font.bold: true
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: "#333333"
}
}
contentItem: Column {
spacing: 12
topPadding: 20
bottomPadding: 20
TextField {
id: epName
x: 20
width: parent.width - 40
placeholderText: Loc.t.profile.placeholder.name
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: epName.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
}
TextField {
id: epLogin
x: 20
width: parent.width - 40
// Для лицензии логин не вводят: ниже показан ник из аккаунта.
visible: epAuth.currentIndex !== 2
placeholderText: Loc.t.profile.placeholder.login
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: epLogin.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
}
// Логин лицензионного профиля — ник, полученный при официальной
// авторизации. Отдельное поле, а не подмена epLogin.text: биндинг
// сломался бы первым же вводом в поле логина обычного профиля.
TextField {
id: epMsLogin
x: 20
width: parent.width - 40
visible: epAuth.currentIndex === 2 && editProfileDialog.msName !== ""
readOnly: true
text: editProfileDialog.msName
color: "#ffffff"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: "#444444"
border.width: 1
}
}
TextField {
id: epPassword
x: 20
width: parent.width - 40
visible: epAuth.currentIndex === 1
placeholderText: Loc.t.profile.placeholder.elyPassword
echoMode: TextInput.Password
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: epPassword.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
}
DarkCombo {
id: epAuth
x: 20
width: parent.width - 40
// Тип уже сохранённого профиля показываем всегда: иначе в
// сборке без WebEngine он молча стал бы офлайновым.
model: (backend.microsoftAvailable || editProfileDialog.msProfile)
? Loc.t.profile.authTypes
: Loc.t.profile.authTypes.slice(0, 2)
}
// Состояние аккаунта Microsoft и кнопка повторного входа: токен
// может протухнуть, а сменить аккаунт иначе нечем.
Column {
x: 20
width: parent.width - 40
spacing: 8
visible: epAuth.currentIndex === 2
Text {
text: editProfileDialog.msLinked ? Loc.t.auth.status.signedIn
: Loc.t.auth.status.signedOut
color: editProfileDialog.msLinked ? "#91B315" : "#aaaaaa"
font.pixelSize: 13
}
Button {
width: parent.width; height: 34
enabled: backend.microsoftAvailable
text: editProfileDialog.msLinked ? "Войти заново" : "Войти в Microsoft"
contentItem: Text {
text: parent.text
color: "#ffffff"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.pressed ? "#6a8510" : "#91B315"
radius: 6
}
onClicked: {
// Тип профиля мог быть только что переключён на
// Microsoft — сохраняем его до входа, иначе backend
// припишет токены профилю другого типа.
backend.updateProfile(editProfileDialog.editIndex,
epName.text.trim(), "", "", "microsoft")
editProfileDialog.close()
backend.startMicrosoftLogin(editProfileDialog.editIndex)
}
}
}
}
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: 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: editProfileDialog.reject()
}
Button {
text: Loc.t.common.button.ok
width: 110; 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: editProfileDialog.accept()
}
}
}
onAccepted: {
const name = epName.text.trim()
const type = epAuth.currentIndex === 2 ? "microsoft"
: epAuth.currentIndex === 1 ? "elyby" : "offline"
if (editIndex >= 0 && name !== "")
backend.updateProfile(editIndex, name,
type === "microsoft" ? "" : epLogin.text.trim(),
type === "elyby" ? epPassword.text : "",
type)
editIndex = -1
epName.text = ""; epLogin.text = ""; epPassword.text = ""
}
onRejected: {
editIndex = -1
epName.text = ""; epLogin.text = ""; epPassword.text = ""
}
}
// ── Builds Dialog ──────────────────────────────────────────────────────
BuildsDialog {
id: buildsDialog
backend: backend
x: (window.width - width) / 2
y: (window.height - height) / 2
}
// ── Two-factor Dialog ──────────────────────────────────────────────────
// Ely.by отклоняет пароль с пометкой two factor — код добираем здесь и
// продолжаем прерванный запуск.
Dialog {
id: twoFactorDialog
modal: true
width: 320
x: (window.width - width) / 2
y: (window.height - height) / 2
padding: 0
closePolicy: Popup.NoAutoClose
property string profileName: ""
background: Rectangle {
color: "#1e1e1e"
radius: 10
border.color: "#91B315"
border.width: 1
}
header: Item {
implicitHeight: 52
Text {
anchors.centerIn: parent
text: Loc.t.auth.ely.title.twoFactor
color: "#ffffff"
font.pixelSize: 15
font.bold: true
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: "#333333"
}
}
contentItem: Column {
spacing: 12
topPadding: 20
bottomPadding: 20
Text {
x: 20
width: parent.width - 40
text: Loc.t.auth.ely.hint.enterCode
.arg(twoFactorDialog.profileName)
color: "#aaaaaa"
font.pixelSize: 12
wrapMode: Text.Wrap
}
TextField {
id: twoFactorCode
x: 20
width: parent.width - 40
placeholderText: "000000"
inputMethodHints: Qt.ImhDigitsOnly
color: "#ffffff"
placeholderTextColor: "#666666"
background: Rectangle {
color: "#2a2a2a"
radius: 6
border.color: twoFactorCode.activeFocus ? "#91B315" : "#444444"
border.width: 1
}
onAccepted: twoFactorDialog.accept()
}
}
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: 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: twoFactorDialog.reject()
}
Button {
text: Loc.t.common.button.signIn
width: 110; 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: twoFactorDialog.accept()
}
}
}
onAccepted: backend.submitTwoFactorCode(twoFactorCode.text)
onRejected: backend.cancelPendingLaunch()
}
// ── Settings Dialog ────────────────────────────────────────────────────
Dialog {
id: settingsDialog
modal: true
width: 460
x: (window.width - width) / 2
y: (window.height - height) / 2
padding: 0
// Выбранная сборка Java живёт в свойстве, а не в поле ввода: её выбирают
// в отдельном окне, а записывается она только по «Сохранить».
property string javaRuntimeId: ""
// Раскрытый путь к папке игры: хранится свойством, а не присваивается
// тексту напрямую, — иначе подпись не пережила бы смену языка.
property string resolvedGameDir: ""
// Не привязка: javaRuntimeInfo() — обычный вызов, и сам он не
// пересчитается, когда сборка докачается. Обновляем по событиям.
property var javaRuntimeInfo: null
onJavaRuntimeIdChanged: settingsDialog.refreshJavaInfo()
function refreshJavaInfo() {
javaRuntimeInfo = javaRuntimeId === "" ? null
: backend.javaRuntimeInfo(javaRuntimeId)
}
Connections {
target: backend
function onJavaRuntimeInstalled(runtimeId) { settingsDialog.refreshJavaInfo() }
}
function load() {
const s = backend.settings()
stGameDir.text = s.gameDir || ""
stJavaPath.text = s.javaPath || ""
settingsDialog.javaRuntimeId = s.javaRuntime || ""
settingsDialog.refreshJavaInfo()
stMinMemory.text = String(s.minMemoryMb)
stMaxMemory.text = String(s.maxMemoryMb)
stJvmArgs.text = s.jvmArgs || ""
stWidth.text = String(s.windowWidth)
stHeight.text = String(s.windowHeight)
stFullscreen.checked = s.fullscreen === true
stLanguage.currentIndex = Math.max(0, stLanguage.codes.indexOf(s.language || "system"))
settingsDialog.resolvedGameDir = s.resolvedGameDir
open()
}
background: Rectangle {
color: "#1e1e1e"
radius: 10
border.color: "#91B315"
border.width: 1
}
header: Item {
implicitHeight: 52
Text {
anchors.centerIn: parent
text: Loc.t.settings.title
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
// Язык — настройка уровня приложения, поэтому стоит над параметрами
// запуска. Подписи в модели переводятся, коды рядом — нет.
Column {
x: 20
width: parent.width - 40
spacing: 3
Text {
text: Loc.t.settings.language.label
color: "#aaaaaa"
font.pixelSize: 11
}
DarkCombo {
id: stLanguage
width: parent.width
height: 32
readonly property var codes: ["system", "ru", "en"]
model: [Loc.t.settings.language.system,
Loc.t.settings.language.ru,
Loc.t.settings.language.en]
}
}
LabelledField {
id: stGameDir
x: 20
width: parent.width - 40
label: Loc.t.settings.label.gameDir
placeholder: Loc.t.settings.placeholder.gameDir
}
LabelledField {
id: stJavaPath
x: 20
width: parent.width - 40
label: Loc.t.settings.label.javaPath
placeholder: Loc.t.settings.placeholder.javaPath
}
// Сборка Java из папки лаунчера. Выбрана — запуск идёт ей, а не тем,
// что нашлось в системе; путь выше остаётся запасным вариантом.
Column {
x: 20
width: parent.width - 40
spacing: 3
Text {
text: Loc.t.settings.label.javaRuntime
color: "#aaaaaa"
font.pixelSize: 11
}
Rectangle {
width: parent.width
height: 32
radius: 6
color: "#2a2a2a"
border.color: javaFieldArea.containsMouse ? "#91B315" : "#444444"
border.width: 1
Text {
anchors.fill: parent
leftPadding: 10
rightPadding: 150
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
color: settingsDialog.javaRuntimeId === "" ? "#666666" : "#ffffff"
text: {
if (settingsDialog.javaRuntimeId === "")
return Loc.t.settings.status.javaRuntimeNone
const info = settingsDialog.javaRuntimeInfo
if (!info)
return settingsDialog.javaRuntimeId
return info.installed ? info.label
: Loc.t.settings.status.javaRuntimeDownloading.arg(info.label)
}
}
Text {
anchors.right: clearJava.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Loc.t.common.button.choose
color: "#91B315"
font.pixelSize: 12
}
Text {
id: clearJava
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
visible: settingsDialog.javaRuntimeId !== ""
text: "✕"
color: clearJavaArea.containsMouse ? "#cc6666" : "#666666"
font.pixelSize: 12
MouseArea {
id: clearJavaArea
anchors.fill: parent
anchors.margins: -6
hoverEnabled: true
onClicked: settingsDialog.javaRuntimeId = ""
}
}
MouseArea {
id: javaFieldArea
anchors.fill: parent
anchors.rightMargin: 26
hoverEnabled: true
onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId,
backend.requiredJavaMajor(backend.activeBuildIndex))
}
}
}
Row {
x: 20
spacing: 10
LabelledField {
id: stMinMemory
width: 100
label: Loc.t.settings.label.minMemory
validator: IntValidator { bottom: 0; top: 1048576 }
}
LabelledField {
id: stMaxMemory
width: 100
label: Loc.t.settings.label.maxMemory
validator: IntValidator { bottom: 0; top: 1048576 }
}
LabelledField {
id: stWidth
width: 100
label: Loc.t.settings.label.windowWidth
validator: IntValidator { bottom: 0; top: 16384 }
}
LabelledField {
id: stHeight
width: 100
label: Loc.t.settings.label.windowHeight
validator: IntValidator { bottom: 0; top: 16384 }
}
}
LabelledField {
id: stJvmArgs
x: 20
width: parent.width - 40
label: Loc.t.settings.label.jvmArgs
placeholder: "-XX:+UseG1GC …"
}
CheckBox {
id: stFullscreen
x: 20
text: Loc.t.settings.label.fullscreen
// Индикатор Basic-стиля — крупный светлый квадрат, на тёмном
// фоне диалога он не читается. Рисуем свой в палитре окна.
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: stFullscreen.leftPadding
y: (stFullscreen.height - height) / 2
radius: 4
color: "#2a2a2a"
border.color: stFullscreen.checked ? "#91B315" : "#444444"
border.width: 1
Rectangle {
anchors.centerIn: parent
width: 10; height: 10
radius: 2
color: "#91B315"
visible: stFullscreen.checked
}
}
contentItem: Text {
text: stFullscreen.text
color: "#ffffff"
leftPadding: (stFullscreen.indicator ? stFullscreen.indicator.width : 0) + 6
verticalAlignment: Text.AlignVCenter
}
}
Text {
id: stResolved
text: Loc.t.settings.status.resolvedGameDir.arg(settingsDialog.resolvedGameDir)
x: 20
width: parent.width - 40
color: "#888888"
font.pixelSize: 11
wrapMode: Text.Wrap
}
Text {
id: stJavaList
text: backend.detectedJava().join("\n") || Loc.t.settings.status.javaNotFound
x: 20
width: parent.width - 40
color: "#888888"
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.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: settingsDialog.reject()
}
Button {
text: Loc.t.common.button.save
width: 110; 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: settingsDialog.accept()
}
}
}
onAccepted: backend.updateSettings({
"gameDir": stGameDir.text.trim(),
"javaPath": stJavaPath.text.trim(),
"javaRuntime": settingsDialog.javaRuntimeId,
"minMemoryMb": parseInt(stMinMemory.text) || 0,
"maxMemoryMb": parseInt(stMaxMemory.text) || 0,
"jvmArgs": stJvmArgs.text.trim(),
"windowWidth": parseInt(stWidth.text) || 0,
"windowHeight": parseInt(stHeight.text) || 0,
"fullscreen": stFullscreen.checked,
"language": stLanguage.codes[stLanguage.currentIndex]
})
}
// Окно выбора Java. Живёт рядом с настройками, а не внутри: оно шире их и
// центрируется по самому окну лаунчера.
JavaPickerDialog {
id: javaPicker
backend: backend
x: (window.width - width) / 2
y: (window.height - height) / 2
onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId
}
}