license login feature
This commit is contained in:
@@ -6,8 +6,31 @@ project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX C)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# CorePrivate нужен ради QZipReader — им распаковываются нативные библиотеки LWJGL.
|
||||
# Про привязку к версии Qt из-за него предупреждать не нужно: это осознанный выбор.
|
||||
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Core CorePrivate Gui Network)
|
||||
|
||||
# ── Qt WebEngine: окно входа в аккаунт Microsoft ────────────────────────────
|
||||
#
|
||||
# Модуль необязательный, и это принципиально: он ставится отдельной галочкой в
|
||||
# установщике Qt и тянет за собой WebChannel с Positioning, которых в типовой
|
||||
# установке нет. Если сделать его обязательным, у любого, кто их не поставил,
|
||||
# проект перестаёт конфигурироваться целиком — вместе с офлайном и Ely.by.
|
||||
#
|
||||
# Без модуля лаунчер собирается и работает как раньше, только вход через
|
||||
# Microsoft сообщает, что эта сборка его не умеет.
|
||||
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)
|
||||
|
||||
qt_add_executable(appMinecraft_launcher
|
||||
@@ -35,6 +58,8 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
javainstaller.cpp
|
||||
authservice.h
|
||||
authservice.cpp
|
||||
msaauthservice.h
|
||||
msaauthservice.cpp
|
||||
gamelauncher.h
|
||||
gamelauncher.cpp
|
||||
versionmanifestservice.h
|
||||
@@ -64,6 +89,7 @@ qt_add_qml_module(appMinecraft_launcher
|
||||
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
|
||||
)
|
||||
|
||||
@@ -82,6 +108,11 @@ target_link_libraries(appMinecraft_launcher
|
||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||
)
|
||||
|
||||
if(Qt6WebEngineQuick_FOUND)
|
||||
target_link_libraries(appMinecraft_launcher PRIVATE Qt6::WebEngineQuick)
|
||||
target_compile_definitions(appMinecraft_launcher PRIVATE LAUNCHER_HAS_WEBENGINE)
|
||||
endif()
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
# ── Эталонный zlib для установщиков Forge и NeoForge ────────────────────────
|
||||
|
||||
@@ -31,6 +31,13 @@ Window {
|
||||
: 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),
|
||||
@@ -52,6 +59,27 @@ Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Окно входа 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)
|
||||
}
|
||||
@@ -557,7 +585,8 @@ Window {
|
||||
id: pfLogin
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
placeholderText: "Логин"
|
||||
enabled: pfAuth.currentIndex !== 2
|
||||
placeholderText: pfAuth.currentIndex === 2 ? "Ник придёт из аккаунта" : "Логин"
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
@@ -573,7 +602,7 @@ Window {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
enabled: pfAuth.currentIndex === 1
|
||||
placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне"
|
||||
placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен для этого типа"
|
||||
echoMode: TextInput.Password
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
@@ -589,7 +618,9 @@ Window {
|
||||
id: pfAuth
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
model: ["Офлайн (без пароля)", "Ely.by"]
|
||||
model: backend.microsoftAvailable
|
||||
? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"]
|
||||
: ["Офлайн (без пароля)", "Ely.by"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,10 +672,14 @@ Window {
|
||||
|
||||
onAccepted: {
|
||||
const name = pfName.text.trim()
|
||||
const type = pfAuth.currentIndex === 2 ? "microsoft"
|
||||
: pfAuth.currentIndex === 1 ? "elyby" : "offline"
|
||||
if (name !== "") {
|
||||
backend.addProfile(name, pfLogin.text.trim(), pfPassword.text,
|
||||
pfAuth.currentIndex === 1 ? "elyby" : "offline")
|
||||
backend.addProfile(name, pfLogin.text.trim(), 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
|
||||
}
|
||||
@@ -665,6 +700,10 @@ Window {
|
||||
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)
|
||||
@@ -672,7 +711,10 @@ Window {
|
||||
epName.text = data.name || ""
|
||||
epLogin.text = data.login || ""
|
||||
epPassword.text = data.password || ""
|
||||
epAuth.currentIndex = data.authType === "elyby" ? 1 : 0
|
||||
msProfile = data.authType === "microsoft"
|
||||
epAuth.currentIndex = msProfile ? 2 : data.authType === "elyby" ? 1 : 0
|
||||
msLinked = data.hasMicrosoftSession === true
|
||||
msName = data.resolvedName || ""
|
||||
profileBox.popup.close()
|
||||
open()
|
||||
}
|
||||
@@ -725,7 +767,8 @@ Window {
|
||||
id: epLogin
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
placeholderText: "Логин"
|
||||
enabled: epAuth.currentIndex !== 2
|
||||
placeholderText: epAuth.currentIndex === 2 ? "Ник придёт из аккаунта" : "Логин"
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
background: Rectangle {
|
||||
@@ -741,7 +784,7 @@ Window {
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
enabled: epAuth.currentIndex === 1
|
||||
placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне"
|
||||
placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен для этого типа"
|
||||
echoMode: TextInput.Password
|
||||
color: "#ffffff"
|
||||
placeholderTextColor: "#666666"
|
||||
@@ -757,7 +800,54 @@ Window {
|
||||
id: epAuth
|
||||
x: 20
|
||||
width: parent.width - 40
|
||||
model: ["Офлайн (без пароля)", "Ely.by"]
|
||||
// Тип уже сохранённого профиля показываем всегда: иначе в
|
||||
// сборке без 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("Вход выполнен: %1").arg(editProfileDialog.msName)
|
||||
: 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(), epLogin.text.trim(),
|
||||
epPassword.text, "microsoft")
|
||||
editProfileDialog.close()
|
||||
backend.startMicrosoftLogin(editProfileDialog.editIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -811,7 +901,8 @@ Window {
|
||||
const name = epName.text.trim()
|
||||
if (editIndex >= 0 && name !== "")
|
||||
backend.updateProfile(editIndex, name, epLogin.text.trim(), epPassword.text,
|
||||
epAuth.currentIndex === 1 ? "elyby" : "offline")
|
||||
epAuth.currentIndex === 2 ? "microsoft"
|
||||
: epAuth.currentIndex === 1 ? "elyby" : "offline")
|
||||
editIndex = -1
|
||||
epName.text = ""; epLogin.text = ""; epPassword.text = ""
|
||||
}
|
||||
|
||||
@@ -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 : "Вход в Microsoft не завершён")
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
radius: 10
|
||||
border.color: "#91B315"
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
header: Item {
|
||||
implicitHeight: 52
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Вход в аккаунт Microsoft"
|
||||
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: "Отмена"
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
@@ -12,6 +13,9 @@ struct AuthResult
|
||||
{
|
||||
bool ok = false;
|
||||
bool twoFactorRequired = false;
|
||||
// Вход в Microsoft прошёл, но копии игры на аккаунте нет: чинится только
|
||||
// покупкой, поэтому обрабатывается отдельно от прочих ошибок.
|
||||
bool licenseMissing = false;
|
||||
QString error;
|
||||
|
||||
QString playerName; // ${auth_player_name}
|
||||
@@ -19,6 +23,11 @@ struct AuthResult
|
||||
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
|
||||
};
|
||||
|
||||
// Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные:
|
||||
|
||||
+2
-1
@@ -133,7 +133,8 @@ QStringList GameLauncher::buildArguments(const LaunchOptions &options,
|
||||
variables[QStringLiteral("auth_access_token")] = options.accessToken;
|
||||
variables[QStringLiteral("auth_session")] =
|
||||
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid);
|
||||
variables[QStringLiteral("auth_xuid")] = QStringLiteral("0");
|
||||
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;
|
||||
|
||||
@@ -18,6 +18,7 @@ struct LaunchOptions
|
||||
QString accessToken;
|
||||
QString userType;
|
||||
QString clientToken;
|
||||
QString xuid; // Пусто — подставится "0", как в офлайне
|
||||
|
||||
QString javaPath; // Пусто — ищем сами
|
||||
int minMemoryMb = 512;
|
||||
|
||||
+207
-7
@@ -10,6 +10,7 @@
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
#include "modloaderinstaller.h"
|
||||
#include "msaauthservice.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "buildswitcher.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
@@ -35,12 +36,27 @@ namespace {
|
||||
|
||||
const QString kOffline = QStringLiteral("offline");
|
||||
const QString kElyBy = QStringLiteral("elyby");
|
||||
const QString kMicrosoft = QStringLiteral("microsoft");
|
||||
|
||||
// Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра
|
||||
// проверяет сессию не в первую секунду после старта.
|
||||
const int kTokenRenewMarginSecs = 120;
|
||||
|
||||
// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
|
||||
// в settings.json — это нужно, чтобы прогонять установку против локального
|
||||
// http-сервера, не трогая боевой.
|
||||
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com");
|
||||
|
||||
// Тип авторизации профиля: всё, что лаунчер не знает, считается офлайном.
|
||||
QString normalizedAuthType(const QString &value)
|
||||
{
|
||||
if (value == kElyBy)
|
||||
return kElyBy;
|
||||
if (value == kMicrosoft)
|
||||
return kMicrosoft;
|
||||
return kOffline;
|
||||
}
|
||||
|
||||
// Дата сезона в таблице: год нужен, время — нет.
|
||||
QString formatSeasonDate(const QDate &date)
|
||||
{
|
||||
@@ -60,6 +76,7 @@ int settingInt(const QVariantMap &settings, const QString &key, int fallback)
|
||||
LauncherBackend::LauncherBackend(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_auth(new AuthService(this))
|
||||
, m_msa(new MsaAuthService(this))
|
||||
, m_launcher(new GameLauncher(this))
|
||||
, m_manifest(new VersionManifestService(this))
|
||||
, m_installer(new VersionInstaller(m_manifest, this))
|
||||
@@ -95,6 +112,7 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
}
|
||||
|
||||
connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
|
||||
connect(m_msa, &MsaAuthService::progress, this, &LauncherBackend::launchProgress);
|
||||
connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
|
||||
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
|
||||
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
|
||||
@@ -396,7 +414,7 @@ void LauncherBackend::addProfile(const QString &name,
|
||||
profile.name = name;
|
||||
profile.login = login;
|
||||
profile.password = password;
|
||||
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
|
||||
profile.authType = normalizedAuthType(authType);
|
||||
profile.clientToken = AuthService::generateClientToken();
|
||||
m_profiles.append(profile);
|
||||
saveProfiles();
|
||||
@@ -428,16 +446,30 @@ void LauncherBackend::updateProfile(int index,
|
||||
return;
|
||||
|
||||
Profile &profile = m_profiles[index];
|
||||
// Смена логина или пароля обесценивает сохранённый токен Ely.by.
|
||||
if (profile.login != login || profile.password != password) {
|
||||
const QString type = normalizedAuthType(authType);
|
||||
|
||||
// Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта
|
||||
// Microsoft ни того ни другого нет, и переименование профиля не должно
|
||||
// выкидывать из аккаунта.
|
||||
if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) {
|
||||
profile.accessToken.clear();
|
||||
profile.uuid.clear();
|
||||
profile.resolvedName.clear();
|
||||
}
|
||||
// Смена типа авторизации: чужие учётные данные к новому типу не подходят.
|
||||
if (profile.authType != type) {
|
||||
profile.accessToken.clear();
|
||||
profile.refreshToken.clear();
|
||||
profile.uuid.clear();
|
||||
profile.resolvedName.clear();
|
||||
profile.xuid.clear();
|
||||
profile.tokenExpiry.clear();
|
||||
profile.userType.clear();
|
||||
}
|
||||
profile.name = name;
|
||||
profile.login = login;
|
||||
profile.password = password;
|
||||
profile.authType = (authType == kElyBy) ? kElyBy : kOffline;
|
||||
profile.authType = type;
|
||||
if (profile.clientToken.isEmpty())
|
||||
profile.clientToken = AuthService::generateClientToken();
|
||||
|
||||
@@ -495,7 +527,9 @@ QVariantMap LauncherBackend::profileAt(int index) const
|
||||
return {{"name", p.name},
|
||||
{"login", p.login},
|
||||
{"password", p.password},
|
||||
{"authType", p.authType}};
|
||||
{"authType", p.authType},
|
||||
{"resolvedName", p.resolvedName},
|
||||
{"hasMicrosoftSession", !p.refreshToken.isEmpty()}};
|
||||
}
|
||||
|
||||
QVariantMap LauncherBackend::customBuildAt(int index) const
|
||||
@@ -1149,10 +1183,157 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex)
|
||||
beginAuthentication();
|
||||
}
|
||||
|
||||
void LauncherBackend::startMicrosoftLogin(int profileIndex)
|
||||
{
|
||||
if (!microsoftAvailable()) {
|
||||
emit microsoftLoginFailed(tr("Эта сборка лаунчера собрана без Qt WebEngine — "
|
||||
"показать окно входа Microsoft нечем"));
|
||||
return;
|
||||
}
|
||||
// -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать
|
||||
// это состояние от закрытого окна.
|
||||
if (profileIndex >= m_profiles.size()) {
|
||||
emit microsoftLoginFailed(tr("Профиль не найден"));
|
||||
return;
|
||||
}
|
||||
m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex;
|
||||
emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl().toString());
|
||||
}
|
||||
|
||||
void LauncherBackend::cancelMicrosoftLogin()
|
||||
{
|
||||
m_msaLoginProfile = -1;
|
||||
}
|
||||
|
||||
QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const
|
||||
{
|
||||
QString code;
|
||||
QString error;
|
||||
const bool matched = MsaAuthService::matchRedirect(QUrl(url), &code, &error);
|
||||
return {{QStringLiteral("matched"), matched},
|
||||
{QStringLiteral("code"), code},
|
||||
{QStringLiteral("error"), error}};
|
||||
}
|
||||
|
||||
void LauncherBackend::finishMicrosoftLogin(const QString &code)
|
||||
{
|
||||
if (m_msaLoginProfile == -1) {
|
||||
emit microsoftLoginFailed(tr("Вход в Microsoft уже отменён"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
m_msa->loginWithCode(code, [this](const AuthResult &result) {
|
||||
setBusy(false);
|
||||
const int target = m_msaLoginProfile;
|
||||
m_msaLoginProfile = -1;
|
||||
if (target == -1)
|
||||
return; // окно закрыли, пока шла авторизация
|
||||
|
||||
if (!result.ok) {
|
||||
emit microsoftLoginFailed(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target == -2) {
|
||||
// Профиля ещё нет — заводим его на ник из аккаунта.
|
||||
Profile profile;
|
||||
profile.id = m_nextProfileId++;
|
||||
profile.name = result.playerName;
|
||||
profile.authType = kMicrosoft;
|
||||
profile.clientToken = AuthService::generateClientToken();
|
||||
applyMicrosoftResult(profile, result);
|
||||
m_profiles.append(profile);
|
||||
} else if (target < m_profiles.size()) {
|
||||
Profile &profile = m_profiles[target];
|
||||
profile.authType = kMicrosoft;
|
||||
applyMicrosoftResult(profile, result);
|
||||
} else {
|
||||
emit microsoftLoginFailed(tr("Профиль был удалён, пока шёл вход"));
|
||||
return;
|
||||
}
|
||||
|
||||
saveProfiles();
|
||||
emit profilesChanged();
|
||||
emit microsoftLoginSucceeded(result.playerName);
|
||||
});
|
||||
}
|
||||
|
||||
void LauncherBackend::applyMicrosoftResult(Profile &profile, const AuthResult &auth)
|
||||
{
|
||||
profile.accessToken = auth.accessToken;
|
||||
// Microsoft выдаёт новый refresh-токен и при продлении, но если в ответе
|
||||
// его не было — прежний остаётся рабочим.
|
||||
if (!auth.refreshToken.isEmpty())
|
||||
profile.refreshToken = auth.refreshToken;
|
||||
if (!auth.uuid.isEmpty())
|
||||
profile.uuid = auth.uuid;
|
||||
if (!auth.playerName.isEmpty())
|
||||
profile.resolvedName = auth.playerName;
|
||||
profile.xuid = auth.xuid;
|
||||
profile.userType = auth.userType;
|
||||
profile.tokenExpiry = auth.expiresAt.isValid()
|
||||
? auth.expiresAt.toUTC().toString(Qt::ISODate)
|
||||
: QString();
|
||||
}
|
||||
|
||||
void LauncherBackend::beginMicrosoftAuthentication()
|
||||
{
|
||||
const Profile &profile = m_profiles[m_pendingProfile];
|
||||
|
||||
if (profile.refreshToken.isEmpty()) {
|
||||
const int index = m_pendingProfile;
|
||||
failLaunch(tr("Профиль «%1» не подключён к аккаунту Microsoft — войдите в него")
|
||||
.arg(profile.name));
|
||||
emit microsoftReloginRequired(index);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохранённый токен ещё жив — идём в игру, не дёргая Microsoft.
|
||||
const QDateTime expiry = QDateTime::fromString(profile.tokenExpiry, Qt::ISODate);
|
||||
if (!profile.accessToken.isEmpty() && expiry.isValid()
|
||||
&& QDateTime::currentDateTimeUtc().addSecs(kTokenRenewMarginSecs) < expiry) {
|
||||
AuthResult stored;
|
||||
stored.ok = true;
|
||||
stored.playerName = profile.resolvedName;
|
||||
stored.uuid = profile.uuid;
|
||||
stored.accessToken = profile.accessToken;
|
||||
stored.refreshToken = profile.refreshToken;
|
||||
stored.clientToken = MsaAuthService::clientId();
|
||||
stored.xuid = profile.xuid;
|
||||
stored.userType = profile.userType.isEmpty() ? QStringLiteral("msa") : profile.userType;
|
||||
stored.expiresAt = expiry;
|
||||
continueLaunch(stored);
|
||||
return;
|
||||
}
|
||||
|
||||
m_msa->loginWithRefreshToken(profile.refreshToken, [this](const AuthResult &result) {
|
||||
if (m_pendingProfile < 0)
|
||||
return; // запуск отменили, пока шло продление
|
||||
if (result.licenseMissing) {
|
||||
failLaunch(result.error);
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
const int index = m_pendingProfile;
|
||||
failLaunch(tr("Сессия Microsoft истекла — войдите в аккаунт заново (%1)")
|
||||
.arg(result.error));
|
||||
emit microsoftReloginRequired(index);
|
||||
return;
|
||||
}
|
||||
continueLaunch(result);
|
||||
});
|
||||
}
|
||||
|
||||
void LauncherBackend::beginAuthentication()
|
||||
{
|
||||
const Profile &profile = m_profiles[m_pendingProfile];
|
||||
|
||||
if (profile.authType == kMicrosoft) {
|
||||
beginMicrosoftAuthentication();
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.authType != kElyBy) {
|
||||
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
|
||||
const AuthResult result = AuthService::offline(nickname);
|
||||
@@ -1252,7 +1433,10 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
|
||||
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
|
||||
// чтобы в следующий раз обойтись без пароля.
|
||||
if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
|
||||
if (profile.authType == kMicrosoft && auth.ok) {
|
||||
applyMicrosoftResult(profile, auth);
|
||||
saveProfiles();
|
||||
} else if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
|
||||
profile.accessToken = auth.accessToken;
|
||||
profile.clientToken = auth.clientToken;
|
||||
if (!auth.uuid.isEmpty())
|
||||
@@ -1273,6 +1457,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
|
||||
options.accessToken = auth.accessToken;
|
||||
options.clientToken = auth.clientToken;
|
||||
options.userType = auth.userType;
|
||||
options.xuid = auth.xuid;
|
||||
|
||||
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
|
||||
// окне выбора версии Java.
|
||||
@@ -1354,6 +1539,15 @@ bool LauncherBackend::busy() const
|
||||
|| m_packDownloader->isRunning();
|
||||
}
|
||||
|
||||
bool LauncherBackend::microsoftAvailable() const
|
||||
{
|
||||
#ifdef LAUNCHER_HAS_WEBENGINE
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void LauncherBackend::setBusy(bool busy)
|
||||
{
|
||||
if (m_busy == busy)
|
||||
@@ -2195,6 +2389,9 @@ void LauncherBackend::loadData()
|
||||
profile.uuid = o.value("uuid").toString();
|
||||
profile.resolvedName = o.value("resolvedName").toString();
|
||||
profile.userType = o.value("userType").toString();
|
||||
profile.refreshToken = o.value("refreshToken").toString();
|
||||
profile.xuid = o.value("xuid").toString();
|
||||
profile.tokenExpiry = o.value("tokenExpiry").toString();
|
||||
if (profile.clientToken.isEmpty())
|
||||
profile.clientToken = AuthService::generateClientToken();
|
||||
m_profiles.append(profile);
|
||||
@@ -2308,7 +2505,10 @@ void LauncherBackend::saveProfiles()
|
||||
{"accessToken", p.accessToken},
|
||||
{"uuid", p.uuid},
|
||||
{"resolvedName", p.resolvedName},
|
||||
{"userType", p.userType}});
|
||||
{"userType", p.userType},
|
||||
{"refreshToken", p.refreshToken},
|
||||
{"xuid", p.xuid},
|
||||
{"tokenExpiry", p.tokenExpiry}});
|
||||
|
||||
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
|
||||
}
|
||||
|
||||
+32
-1
@@ -13,6 +13,7 @@
|
||||
|
||||
struct AuthResult;
|
||||
class AuthService;
|
||||
class MsaAuthService;
|
||||
class GameLauncher;
|
||||
class VersionManifestService;
|
||||
class VersionInstaller;
|
||||
@@ -48,6 +49,10 @@ class LauncherBackend : public QObject
|
||||
Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged)
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
|
||||
// Собран ли лаунчер с Qt WebEngine. Без него окно входа Microsoft
|
||||
// показать нечем, и интерфейс не должен предлагать этот путь.
|
||||
Q_PROPERTY(bool microsoftAvailable READ microsoftAvailable CONSTANT)
|
||||
|
||||
// Объединённый список для выпадающего меню версий: установленные, разделитель,
|
||||
// затем весь каталог Mojang.
|
||||
Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged)
|
||||
@@ -93,6 +98,7 @@ public:
|
||||
QString switchStatus() const;
|
||||
// Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет.
|
||||
bool busy() const;
|
||||
bool microsoftAvailable() const;
|
||||
|
||||
QVariantList versionCatalog() const;
|
||||
bool catalogLoading() const;
|
||||
@@ -128,6 +134,15 @@ public:
|
||||
// loader, loaderVersion, resolvedVersionId.
|
||||
Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields);
|
||||
Q_INVOKABLE QVariantMap profileAt(int index) const;
|
||||
|
||||
// Вход в аккаунт Microsoft. profileIndex = -1 — вход ещё не привязан к
|
||||
// профилю: он создастся по нику, который вернут Minecraft Services.
|
||||
Q_INVOKABLE void startMicrosoftLogin(int profileIndex);
|
||||
Q_INVOKABLE void finishMicrosoftLogin(const QString &code);
|
||||
Q_INVOKABLE void cancelMicrosoftLogin();
|
||||
// Разбор адреса, на который встроенное окно возвращается после входа.
|
||||
// Живёт в C++, чтобы правила совпадения не разъезжались с сервисом.
|
||||
Q_INVOKABLE QVariantMap inspectMicrosoftRedirect(const QString &url) const;
|
||||
Q_INVOKABLE QVariantMap customBuildAt(int index) const;
|
||||
Q_INVOKABLE void removeProfile(int index);
|
||||
// Что именно потеряется при удалении — для текста предупреждения.
|
||||
@@ -224,6 +239,12 @@ signals:
|
||||
void launched(const QString &profileName, const QString &buildName, const QString &serverUrl);
|
||||
void launchError(const QString &message);
|
||||
void twoFactorRequired(const QString &profileName);
|
||||
// Окну входа Microsoft: открыться на этом адресе.
|
||||
void microsoftLoginUrlReady(const QString &url);
|
||||
void microsoftLoginSucceeded(const QString &playerName);
|
||||
void microsoftLoginFailed(const QString &message);
|
||||
// Сессия профиля протухла настолько, что нужен повторный вход руками.
|
||||
void microsoftReloginRequired(int profileIndex);
|
||||
void gameOutput(const QString &line);
|
||||
void gameFinished(int exitCode, bool crashed);
|
||||
|
||||
@@ -232,8 +253,11 @@ private:
|
||||
{
|
||||
int id = 0;
|
||||
QString name, login, password;
|
||||
QString authType = QStringLiteral("offline"); // offline | elyby
|
||||
QString authType = QStringLiteral("offline"); // offline | elyby | microsoft
|
||||
QString clientToken, accessToken, uuid, resolvedName, userType;
|
||||
// Только для microsoft: продление сессии, ${auth_xuid} и срок жизни
|
||||
// accessToken в ISO-8601 (чтобы класть в JSON без конвертеров).
|
||||
QString refreshToken, xuid, tokenExpiry;
|
||||
};
|
||||
// Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со
|
||||
// второго этапа, выбранный модлоадер.
|
||||
@@ -329,6 +353,8 @@ private:
|
||||
void setBusy(bool busy);
|
||||
void failLaunch(const QString &message);
|
||||
void beginAuthentication();
|
||||
void beginMicrosoftAuthentication();
|
||||
void applyMicrosoftResult(Profile &profile, const AuthResult &auth);
|
||||
void continueLaunch(const AuthResult &auth);
|
||||
|
||||
QList<Profile> m_profiles;
|
||||
@@ -341,6 +367,7 @@ private:
|
||||
int m_nextBuildId = 1;
|
||||
|
||||
AuthService *m_auth;
|
||||
MsaAuthService *m_msa;
|
||||
GameLauncher *m_launcher;
|
||||
VersionManifestService *m_manifest;
|
||||
VersionInstaller *m_installer;
|
||||
@@ -356,6 +383,10 @@ private:
|
||||
// когда установщик отчитается.
|
||||
int m_loaderInstallBuildId = -1;
|
||||
|
||||
// Профиль, для которого открыто окно входа Microsoft. -1 — окно закрыто,
|
||||
// -2 — вход для профиля, которого ещё нет.
|
||||
int m_msaLoginProfile = -1;
|
||||
|
||||
// Каталог версий пересобирается только по изменениям: QML читает свойство
|
||||
// помногу раз за кадр, пока открыт список.
|
||||
mutable QVariantList m_catalog;
|
||||
|
||||
@@ -2,8 +2,19 @@
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQuickStyle>
|
||||
|
||||
#ifdef LAUNCHER_HAS_WEBENGINE
|
||||
#include <QtWebEngineQuick>
|
||||
#endif
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
#ifdef LAUNCHER_HAS_WEBENGINE
|
||||
// Вход в аккаунт Microsoft показывается встроенным окном браузера.
|
||||
// Инициализация обязана идти до QGuiApplication: она выставляет общий
|
||||
// контекст OpenGL, а после создания приложения это уже не действует.
|
||||
QtWebEngineQuick::initialize();
|
||||
#endif
|
||||
|
||||
QGuiApplication app(argc, argv);
|
||||
|
||||
// Нативный стиль Windows игнорирует пользовательские contentItem/background
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
#include "msaauthservice.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QUrlQuery>
|
||||
|
||||
namespace {
|
||||
|
||||
// ── Azure-приложение лаунчера ───────────────────────────────────────────────
|
||||
// Временно используется client_id официального лаунчера Mojang: это старое
|
||||
// приложение Live Connect, поэтому и конечные точки здесь от login.live.com.
|
||||
// При переходе на собственное приложение (портал Azure -> регистрация
|
||||
// приложения -> «Allow public client flows») меняются шесть констант ниже:
|
||||
// kClientId — id своего приложения;
|
||||
// kAuthorizeUrl — https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize
|
||||
// kTokenUrl — https://login.microsoftonline.com/consumers/oauth2/v2.0/token
|
||||
// kRedirectUri — тот, что зарегистрирован в Azure; для встроенного окна
|
||||
// удобен https://login.microsoftonline.com/common/oauth2/nativeclient
|
||||
// kScope — XboxLive.signin offline_access
|
||||
// kRpsTicketPrefix — "d="; у старого приложения в RpsTicket идёт сырой токен,
|
||||
// у нового — с этой приставкой.
|
||||
const QString kClientId = QStringLiteral("00000000402b5328");
|
||||
const QString kAuthorizeUrl = QStringLiteral("https://login.live.com/oauth20_authorize.srf");
|
||||
const QString kTokenUrl = QStringLiteral("https://login.live.com/oauth20_token.srf");
|
||||
const QString kRedirectUri = QStringLiteral("https://login.live.com/oauth20_desktop.srf");
|
||||
const QString kScope = QStringLiteral("service::user.auth.xboxlive.com::MBI_SSL");
|
||||
const QString kRpsTicketPrefix = QString();
|
||||
|
||||
const QString kXboxAuthUrl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate");
|
||||
const QString kXstsUrl = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize");
|
||||
const QString kMinecraftLoginUrl =
|
||||
QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox");
|
||||
const QString kEntitlementsUrl =
|
||||
QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore");
|
||||
const QString kProfileUrl = QStringLiteral("https://api.minecraftservices.com/minecraft/profile");
|
||||
|
||||
const int kTransferTimeoutMs = 20000;
|
||||
|
||||
QString normalizeUuid(const QString &uuid)
|
||||
{
|
||||
return QString(uuid).remove(u'-');
|
||||
}
|
||||
|
||||
// Первый элемент DisplayClaims.xui — единственный, который отдают и Xbox Live,
|
||||
// и XSTS: в нём лежат uhs (хеш пользователя) и xid (он же XUID).
|
||||
QJsonObject displayClaim(const QJsonObject &response)
|
||||
{
|
||||
return response.value(QStringLiteral("DisplayClaims"))
|
||||
.toObject()
|
||||
.value(QStringLiteral("xui"))
|
||||
.toArray()
|
||||
.first()
|
||||
.toObject();
|
||||
}
|
||||
|
||||
// XSTS отказывает не текстом, а числовым кодом — без расшифровки пользователь
|
||||
// видит просто «401».
|
||||
QString describeXstsError(const QJsonObject &response)
|
||||
{
|
||||
const qint64 code = static_cast<qint64>(response.value(QStringLiteral("XErr")).toDouble());
|
||||
switch (code) {
|
||||
case 2148916233:
|
||||
return MsaAuthService::tr("К аккаунту Microsoft не привязан профиль Xbox. "
|
||||
"Создайте его на xbox.com и повторите вход.");
|
||||
case 2148916235:
|
||||
return MsaAuthService::tr("Xbox Live недоступен в стране, указанной в аккаунте");
|
||||
case 2148916236:
|
||||
case 2148916237:
|
||||
return MsaAuthService::tr("Аккаунту нужна проверка возраста на xbox.com");
|
||||
case 2148916238:
|
||||
return MsaAuthService::tr("Детский аккаунт: добавьте его в семейную группу "
|
||||
"Microsoft, иначе вход в Xbox Live невозможен");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const QString message = response.value(QStringLiteral("Message")).toString();
|
||||
return message.isEmpty() ? MsaAuthService::tr("Xbox Live отклонил вход (XErr %1)").arg(code)
|
||||
: message;
|
||||
}
|
||||
|
||||
QString describeOauthError(const QJsonObject &response, int status)
|
||||
{
|
||||
const QString description = response.value(QStringLiteral("error_description")).toString();
|
||||
if (!description.isEmpty())
|
||||
return description;
|
||||
const QString error = response.value(QStringLiteral("error")).toString();
|
||||
if (!error.isEmpty())
|
||||
return error;
|
||||
return MsaAuthService::tr("Microsoft вернул код %1").arg(status);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MsaAuthService::MsaAuthService(QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_network(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
QString MsaAuthService::clientId()
|
||||
{
|
||||
return kClientId;
|
||||
}
|
||||
|
||||
QUrl MsaAuthService::authorizationUrl()
|
||||
{
|
||||
QUrl url(kAuthorizeUrl);
|
||||
QUrlQuery query;
|
||||
query.addQueryItem(QStringLiteral("client_id"), kClientId);
|
||||
query.addQueryItem(QStringLiteral("response_type"), QStringLiteral("code"));
|
||||
query.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri);
|
||||
query.addQueryItem(QStringLiteral("scope"), kScope);
|
||||
// Без этого Microsoft молча пускает по прошлой сессии браузера, и добавить
|
||||
// второй аккаунт становится нечем.
|
||||
query.addQueryItem(QStringLiteral("prompt"), QStringLiteral("select_account"));
|
||||
url.setQuery(query);
|
||||
return url;
|
||||
}
|
||||
|
||||
bool MsaAuthService::matchRedirect(const QUrl &url, QString *code, QString *error)
|
||||
{
|
||||
const QUrl redirect(kRedirectUri);
|
||||
if (url.host().compare(redirect.host(), Qt::CaseInsensitive) != 0
|
||||
|| url.path() != redirect.path())
|
||||
return false;
|
||||
|
||||
// Код приходит в query, но часть приложений Azure настроена на response_mode
|
||||
// с фрагментом — разбираем оба места.
|
||||
QUrlQuery query(url.query());
|
||||
if (!query.hasQueryItem(QStringLiteral("code")) && !query.hasQueryItem(QStringLiteral("error")))
|
||||
query = QUrlQuery(url.fragment());
|
||||
|
||||
if (code)
|
||||
*code = query.queryItemValue(QStringLiteral("code"), QUrl::FullyDecoded);
|
||||
if (error) {
|
||||
const QString description =
|
||||
query.queryItemValue(QStringLiteral("error_description"), QUrl::FullyDecoded);
|
||||
*error = description.isEmpty()
|
||||
? query.queryItemValue(QStringLiteral("error"), QUrl::FullyDecoded)
|
||||
: description;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void MsaAuthService::postForm(const QUrl &url, const QByteArray &body, JsonHandler handler)
|
||||
{
|
||||
QNetworkRequest request{url};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QStringLiteral("application/x-www-form-urlencoded"));
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
QNetworkReply *reply = m_network->post(request, body);
|
||||
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
||||
reply->deleteLater();
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
QString transportError;
|
||||
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
||||
transportError = reply->errorString();
|
||||
handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::postJson(const QUrl &url,
|
||||
const QJsonObject &body,
|
||||
const QString &bearer,
|
||||
JsonHandler handler)
|
||||
{
|
||||
QNetworkRequest request{url};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
request.setRawHeader("Accept", "application/json");
|
||||
if (!bearer.isEmpty())
|
||||
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(bearer).toUtf8());
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
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();
|
||||
QString transportError;
|
||||
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
||||
transportError = reply->errorString();
|
||||
handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::getJson(const QUrl &url, const QString &bearer, JsonHandler handler)
|
||||
{
|
||||
QNetworkRequest request{url};
|
||||
request.setRawHeader("Accept", "application/json");
|
||||
if (!bearer.isEmpty())
|
||||
request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(bearer).toUtf8());
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setTransferTimeout(kTransferTimeoutMs);
|
||||
|
||||
QNetworkReply *reply = m_network->get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [reply, handler = std::move(handler)]() {
|
||||
reply->deleteLater();
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
QString transportError;
|
||||
if (status == 0 && reply->error() != QNetworkReply::NoError)
|
||||
transportError = reply->errorString();
|
||||
handler(status, QJsonDocument::fromJson(reply->readAll()).object(), transportError);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::loginWithCode(const QString &code, Callback callback)
|
||||
{
|
||||
if (code.isEmpty()) {
|
||||
AuthResult result;
|
||||
result.error = tr("Microsoft не вернул код авторизации");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
QUrlQuery form;
|
||||
form.addQueryItem(QStringLiteral("client_id"), kClientId);
|
||||
form.addQueryItem(QStringLiteral("code"), code);
|
||||
form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("authorization_code"));
|
||||
form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri);
|
||||
|
||||
emit progress(tr("Обмен кода Microsoft на токен…"));
|
||||
exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback));
|
||||
}
|
||||
|
||||
void MsaAuthService::loginWithRefreshToken(const QString &refreshToken, Callback callback)
|
||||
{
|
||||
if (refreshToken.isEmpty()) {
|
||||
AuthResult result;
|
||||
result.error = tr("Профиль не подключён к аккаунту Microsoft");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
QUrlQuery form;
|
||||
form.addQueryItem(QStringLiteral("client_id"), kClientId);
|
||||
form.addQueryItem(QStringLiteral("refresh_token"), refreshToken);
|
||||
form.addQueryItem(QStringLiteral("grant_type"), QStringLiteral("refresh_token"));
|
||||
form.addQueryItem(QStringLiteral("redirect_uri"), kRedirectUri);
|
||||
form.addQueryItem(QStringLiteral("scope"), kScope);
|
||||
|
||||
emit progress(tr("Обновление сессии Microsoft…"));
|
||||
exchangeToken(form.toString(QUrl::FullyEncoded).toUtf8(), std::move(callback));
|
||||
}
|
||||
|
||||
void MsaAuthService::exchangeToken(const QByteArray &form, Callback callback)
|
||||
{
|
||||
postForm(QUrl(kTokenUrl), form,
|
||||
[this, callback](int status, const QJsonObject &response, const QString &transportError) {
|
||||
if (!transportError.isEmpty()) {
|
||||
AuthResult result;
|
||||
result.error = tr("Нет связи с сервером Microsoft: %1").arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
if (status != 200) {
|
||||
AuthResult result;
|
||||
result.error = describeOauthError(response, status);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString accessToken = response.value(QStringLiteral("access_token")).toString();
|
||||
// Refresh-токен приходит и при продлении: Microsoft выдаёт новый
|
||||
// и обесценивает прежний, поэтому сохранять надо свежий.
|
||||
const QString refreshToken = response.value(QStringLiteral("refresh_token")).toString();
|
||||
if (accessToken.isEmpty()) {
|
||||
AuthResult result;
|
||||
result.error = tr("Microsoft не вернул токен доступа");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
authenticateXbox(accessToken, refreshToken, callback);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::authenticateXbox(const QString µsoftToken,
|
||||
const QString &refreshToken,
|
||||
Callback callback)
|
||||
{
|
||||
emit progress(tr("Вход в Xbox Live…"));
|
||||
|
||||
QJsonObject properties{{QStringLiteral("AuthMethod"), QStringLiteral("RPS")},
|
||||
{QStringLiteral("SiteName"), QStringLiteral("user.auth.xboxlive.com")},
|
||||
{QStringLiteral("RpsTicket"), kRpsTicketPrefix + microsoftToken}};
|
||||
const QJsonObject body{{QStringLiteral("Properties"), properties},
|
||||
{QStringLiteral("RelyingParty"), QStringLiteral("http://auth.xboxlive.com")},
|
||||
{QStringLiteral("TokenType"), QStringLiteral("JWT")}};
|
||||
|
||||
postJson(QUrl(kXboxAuthUrl), body, {},
|
||||
[this, refreshToken, callback](int status, const QJsonObject &response,
|
||||
const QString &transportError) {
|
||||
AuthResult result;
|
||||
if (!transportError.isEmpty()) {
|
||||
result.error = tr("Нет связи с Xbox Live: %1").arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
if (status != 200) {
|
||||
result.error = tr("Xbox Live отклонил токен Microsoft (код %1)").arg(status);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString token = response.value(QStringLiteral("Token")).toString();
|
||||
if (token.isEmpty()) {
|
||||
result.error = tr("Xbox Live не вернул токен");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
authorizeXsts(token, refreshToken, callback);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::authorizeXsts(const QString &xblToken,
|
||||
const QString &refreshToken,
|
||||
Callback callback)
|
||||
{
|
||||
emit progress(tr("Проверка прав XSTS…"));
|
||||
|
||||
const QJsonObject properties{{QStringLiteral("SandboxId"), QStringLiteral("RETAIL")},
|
||||
{QStringLiteral("UserTokens"), QJsonArray{xblToken}}};
|
||||
const QJsonObject body{{QStringLiteral("Properties"), properties},
|
||||
{QStringLiteral("RelyingParty"),
|
||||
QStringLiteral("rp://api.minecraftservices.com/")},
|
||||
{QStringLiteral("TokenType"), QStringLiteral("JWT")}};
|
||||
|
||||
postJson(QUrl(kXstsUrl), body, {},
|
||||
[this, refreshToken, callback](int status, const QJsonObject &response,
|
||||
const QString &transportError) {
|
||||
AuthResult result;
|
||||
if (!transportError.isEmpty()) {
|
||||
result.error = tr("Нет связи с сервером XSTS: %1").arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
if (status == 401) {
|
||||
result.error = describeXstsError(response);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
if (status != 200) {
|
||||
result.error = tr("XSTS отклонил запрос (код %1)").arg(status);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString token = response.value(QStringLiteral("Token")).toString();
|
||||
const QJsonObject claim = displayClaim(response);
|
||||
const QString userHash = claim.value(QStringLiteral("uhs")).toString();
|
||||
if (token.isEmpty() || userHash.isEmpty()) {
|
||||
result.error = tr("XSTS не вернул данные пользователя");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
loginWithXbox(userHash, token, claim.value(QStringLiteral("xid")).toString(),
|
||||
refreshToken, callback);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::loginWithXbox(const QString &userHash,
|
||||
const QString &xstsToken,
|
||||
const QString &xuid,
|
||||
const QString &refreshToken,
|
||||
Callback callback)
|
||||
{
|
||||
emit progress(tr("Авторизация в Minecraft Services…"));
|
||||
|
||||
const QJsonObject body{{QStringLiteral("identityToken"),
|
||||
QStringLiteral("XBL3.0 x=%1;%2").arg(userHash, xstsToken)}};
|
||||
|
||||
postJson(QUrl(kMinecraftLoginUrl), body, {},
|
||||
[this, xuid, refreshToken, callback](int status, const QJsonObject &response,
|
||||
const QString &transportError) {
|
||||
AuthResult result;
|
||||
if (!transportError.isEmpty()) {
|
||||
result.error = tr("Нет связи с api.minecraftservices.com: %1").arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
if (status != 200) {
|
||||
result.error = tr("Minecraft Services отклонили вход (код %1)").arg(status);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
result.accessToken = response.value(QStringLiteral("access_token")).toString();
|
||||
if (result.accessToken.isEmpty()) {
|
||||
result.error = tr("Minecraft Services не вернули токен доступа");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const int lifetime = response.value(QStringLiteral("expires_in")).toInt(86400);
|
||||
result.expiresAt = QDateTime::currentDateTimeUtc().addSecs(lifetime);
|
||||
result.refreshToken = refreshToken;
|
||||
result.xuid = xuid;
|
||||
result.clientToken = kClientId;
|
||||
result.userType = QStringLiteral("msa");
|
||||
checkLicense(result, callback);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::checkLicense(AuthResult result, Callback callback)
|
||||
{
|
||||
emit progress(tr("Проверка лицензии Minecraft…"));
|
||||
|
||||
getJson(QUrl(kEntitlementsUrl), result.accessToken,
|
||||
[this, result, callback](int status, const QJsonObject &response, const QString &) {
|
||||
// Сетевую ошибку здесь не считаем отказом: окончательный ответ
|
||||
// про лицензию даёт профиль, а список покупок нужен только
|
||||
// затем, чтобы точнее объяснить отказ.
|
||||
const bool owns = status == 200
|
||||
&& !response.value(QStringLiteral("items")).toArray().isEmpty();
|
||||
fetchProfile(result, owns, callback);
|
||||
});
|
||||
}
|
||||
|
||||
void MsaAuthService::fetchProfile(AuthResult result, bool storeEntitlement, Callback callback)
|
||||
{
|
||||
getJson(QUrl(kProfileUrl), result.accessToken,
|
||||
[result, storeEntitlement, callback](int status, const QJsonObject &response,
|
||||
const QString &transportError) mutable {
|
||||
if (!transportError.isEmpty()) {
|
||||
result.error = MsaAuthService::tr("Нет связи с api.minecraftservices.com: %1")
|
||||
.arg(transportError);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 здесь — штатный ответ для аккаунта, на котором нет копии
|
||||
// игры: профиль игрока создаётся только вместе с покупкой.
|
||||
if (status == 404 || status == 401) {
|
||||
result.licenseMissing = true;
|
||||
result.error =
|
||||
storeEntitlement
|
||||
? MsaAuthService::tr("На аккаунте Microsoft есть покупка, но игровой "
|
||||
"профиль не создан. Зайдите один раз в "
|
||||
"официальный лаунчер и выберите ник.")
|
||||
: MsaAuthService::tr("На этом аккаунте Microsoft нет копии "
|
||||
"Minecraft: Java Edition");
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
if (status != 200) {
|
||||
result.error =
|
||||
MsaAuthService::tr("Не удалось получить профиль Minecraft (код %1)").arg(status);
|
||||
callback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
result.playerName = response.value(QStringLiteral("name")).toString();
|
||||
result.uuid = normalizeUuid(response.value(QStringLiteral("id")).toString());
|
||||
result.ok = !result.playerName.isEmpty() && !result.uuid.isEmpty();
|
||||
if (!result.ok) {
|
||||
result.licenseMissing = true;
|
||||
result.error = MsaAuthService::tr("Minecraft Services не вернули игровой профиль");
|
||||
}
|
||||
callback(result);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "authservice.h"
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <functional>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
// Авторизация через аккаунт Microsoft — та же цепочка, что и в официальном
|
||||
// лаунчере: OAuth2 -> Xbox Live -> XSTS -> Minecraft Services -> проверка
|
||||
// лицензии. Результат отдаётся тем же AuthResult, что и Ely.by с офлайном,
|
||||
// поэтому запуск игры дальше идёт по общему пути.
|
||||
class MsaAuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using Callback = std::function<void(const AuthResult &)>;
|
||||
|
||||
explicit MsaAuthService(QObject *parent = nullptr);
|
||||
|
||||
// Id приложения лаунчера. Игра ждёт его в ${clientid}: официальный лаунчер
|
||||
// подставляет туда именно id приложения, а не случайный токен сессии.
|
||||
static QString clientId();
|
||||
|
||||
// Адрес страницы входа для встроенного окна.
|
||||
static QUrl authorizationUrl();
|
||||
|
||||
// Отличает адрес, на который Microsoft возвращает управление после входа,
|
||||
// от остальной навигации внутри окна. Заполняет code либо error.
|
||||
static bool matchRedirect(const QUrl &url, QString *code, QString *error);
|
||||
|
||||
// Полный вход по коду из окна браузера.
|
||||
void loginWithCode(const QString &code, Callback callback);
|
||||
|
||||
// Продление сессии без участия пользователя. Refresh-токен Microsoft живёт
|
||||
// куда дольше суточного токена Minecraft, так что обычно хватает его.
|
||||
void loginWithRefreshToken(const QString &refreshToken, Callback callback);
|
||||
|
||||
signals:
|
||||
void progress(const QString &message);
|
||||
|
||||
private:
|
||||
using JsonHandler = std::function<void(int status, const QJsonObject &response,
|
||||
const QString &transportError)>;
|
||||
|
||||
void postForm(const QUrl &url, const QByteArray &body, JsonHandler handler);
|
||||
void postJson(const QUrl &url, const QJsonObject &body, const QString &bearer,
|
||||
JsonHandler handler);
|
||||
void getJson(const QUrl &url, const QString &bearer, JsonHandler handler);
|
||||
|
||||
// Шаги цепочки. Каждый следующий вызывается из колбэка предыдущего.
|
||||
void exchangeToken(const QByteArray &form, Callback callback);
|
||||
void authenticateXbox(const QString µsoftToken, const QString &refreshToken,
|
||||
Callback callback);
|
||||
void authorizeXsts(const QString &xblToken, const QString &refreshToken, Callback callback);
|
||||
void loginWithXbox(const QString &userHash, const QString &xstsToken, const QString &xuid,
|
||||
const QString &refreshToken, Callback callback);
|
||||
void checkLicense(AuthResult result, Callback callback);
|
||||
void fetchProfile(AuthResult result, bool storeEntitlement, Callback callback);
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
};
|
||||
Reference in New Issue
Block a user