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: qsTr("Minecraft Launcher") // ── Backend ──────────────────────────────────────────────────────────── LauncherBackend { id: backend onLaunched: (profileName, buildName, serverUrl) => { window.showToast(qsTr("Запущено: %1 — %2").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 ? qsTr("Игра завершилась с ошибкой (код %1)").arg(exitCode) : qsTr("Игра закрыта"), crashed || exitCode !== 0 ? "#cc3333" : "#555555") } onMicrosoftLoginUrlReady: (url) => window.openMicrosoftLogin(url) // Выбор в списке здесь не трогаем: новый профиль уже выбран тем, кто // его создал, а повторный вход мог быть и не в последний профиль. onMicrosoftLoginSucceeded: (playerName) => window.showToast(qsTr("Вход выполнен: %1").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(qsTr("Сборка «%1» установлена — можно запускать").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(qsTr("Окно входа Microsoft недоступно: сборка без Qt WebEngine"), "#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: qsTr("Загрузка %1").arg(backend.downloadVersion) fraction: backend.downloadProgress status: backend.downloadStatus detail: backend.downloadBytesTotal > 0 ? qsTr("%1 — %2 / %3 МБ").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 ? qsTr("Профили игрока — добавьте первый профиль") : qsTr("Профиль игрока: %1").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: "+ Добавить профиль" 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 : qsTr("Сборка не выбрана") 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: qsTr("Пользовательские сборки") 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: qsTr("Открыть папку с модами Minecraft") 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: qsTr("Настройки запуска: Java, память, папка игры") 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: qsTr("Сезонные сборки") 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: qsTr("Готовые сборки с сервера: моды, конфиги и Java одной кнопкой") 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: "Новый профиль" 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: "Имя профиля" 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: "Логин" 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: "Пароль Ely.by" 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 ? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"] : ["Офлайн (без пароля)", "Ely.by"] } } 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: "Отмена" 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: "Добавить" 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: "Редактирование профиля" 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: "Имя профиля" 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: "Логин" 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: "Пароль Ely.by" 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) ? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"] : ["Офлайн (без пароля)", "Ely.by"] } // Состояние аккаунта Microsoft и кнопка повторного входа: токен // может протухнуть, а сменить аккаунт иначе нечем. Column { x: 20 width: parent.width - 40 spacing: 8 visible: epAuth.currentIndex === 2 Text { text: editProfileDialog.msLinked ? qsTr("Вход выполнен") : qsTr("Вход не выполнен") 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: "Отмена" 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: "ОК" 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: "Двухфакторная аутентификация" 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: qsTr("Введите код из приложения-аутентификатора для «%1»") .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: "Отмена" 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: "Войти" 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: "" // Не привязка: 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 stResolved.text = qsTr("Папка игры: %1").arg(s.resolvedGameDir) stJavaList.text = backend.detectedJava().join("\n") || qsTr("Java не найдена") open() } background: Rectangle { color: "#1e1e1e" radius: 10 border.color: "#91B315" border.width: 1 } header: Item { implicitHeight: 52 Text { anchors.centerIn: parent text: "Настройки запуска" 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 LabelledField { id: stGameDir x: 20 width: parent.width - 40 label: "Папка .minecraft" placeholder: "по умолчанию" } LabelledField { id: stJavaPath x: 20 width: parent.width - 40 label: "Путь к Java" placeholder: "автоопределение" } // Сборка Java из папки лаунчера. Выбрана — запуск идёт ей, а не тем, // что нашлось в системе; путь выше остаётся запасным вариантом. Column { x: 20 width: parent.width - 40 spacing: 3 Text { text: "Версия Java из папки лаунчера" color: "#aaaaaa" font.pixelSize: 11 } Rectangle { width: parent.width height: 32 radius: 6 color: "#2a2a2a" border.color: javaFieldArea.containsMouse ? "#91B315" : "#444444" border.width: 1 Text { anchors.fill: parent leftPadding: 10 rightPadding: 150 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight color: settingsDialog.javaRuntimeId === "" ? "#666666" : "#ffffff" text: { if (settingsDialog.javaRuntimeId === "") return qsTr("не выбрана — искать в системе") const info = settingsDialog.javaRuntimeInfo if (!info) return settingsDialog.javaRuntimeId return info.installed ? info.label : qsTr("%1 — скачивается").arg(info.label) } } Text { anchors.right: clearJava.left anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: qsTr("Выбрать…") color: "#91B315" font.pixelSize: 12 } Text { id: clearJava anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter visible: settingsDialog.javaRuntimeId !== "" text: "✕" color: clearJavaArea.containsMouse ? "#cc6666" : "#666666" font.pixelSize: 12 MouseArea { id: clearJavaArea anchors.fill: parent anchors.margins: -6 hoverEnabled: true onClicked: settingsDialog.javaRuntimeId = "" } } MouseArea { id: javaFieldArea anchors.fill: parent anchors.rightMargin: 26 hoverEnabled: true onClicked: javaPicker.openFor(settingsDialog.javaRuntimeId, backend.requiredJavaMajor(backend.activeBuildIndex)) } } } Row { x: 20 spacing: 10 LabelledField { id: stMinMemory width: 100 label: "-Xms, МБ" validator: IntValidator { bottom: 0; top: 1048576 } } LabelledField { id: stMaxMemory width: 100 label: "-Xmx, МБ" validator: IntValidator { bottom: 0; top: 1048576 } } LabelledField { id: stWidth width: 100 label: "Ширина окна" validator: IntValidator { bottom: 0; top: 16384 } } LabelledField { id: stHeight width: 100 label: "Высота окна" validator: IntValidator { bottom: 0; top: 16384 } } } LabelledField { id: stJvmArgs x: 20 width: parent.width - 40 label: "Дополнительные аргументы JVM" placeholder: "-XX:+UseG1GC …" } CheckBox { id: stFullscreen x: 20 text: "Запускать в полноэкранном режиме" // Индикатор 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 x: 20 width: parent.width - 40 color: "#888888" font.pixelSize: 11 wrapMode: Text.Wrap } Text { id: stJavaList 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: "Отмена" 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: "Сохранить" 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 }) } // Окно выбора Java. Живёт рядом с настройками, а не внутри: оно шире их и // центрируется по самому окну лаунчера. JavaPickerDialog { id: javaPicker backend: backend x: (window.width - width) / 2 y: (window.height - height) / 2 onRuntimeChosen: (runtimeId) => settingsDialog.javaRuntimeId = runtimeId } }