license login feature

This commit is contained in:
2026-09-01 22:25:23 +03:00
parent c25e071b29
commit d6986057d1
11 changed files with 1062 additions and 19 deletions
+31
View File
@@ -6,8 +6,31 @@ project(Minecraft_launcher VERSION 0.1 LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
# CorePrivate нужен ради QZipReader — им распаковываются нативные библиотеки LWJGL. # CorePrivate нужен ради QZipReader — им распаковываются нативные библиотеки LWJGL.
# Про привязку к версии Qt из-за него предупреждать не нужно: это осознанный выбор.
set(QT_NO_PRIVATE_MODULE_WARNING ON)
find_package(Qt6 REQUIRED COMPONENTS Quick QuickControls2 Core CorePrivate Gui Network) 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_standard_project_setup(REQUIRES 6.8)
qt_add_executable(appMinecraft_launcher qt_add_executable(appMinecraft_launcher
@@ -35,6 +58,8 @@ qt_add_qml_module(appMinecraft_launcher
javainstaller.cpp javainstaller.cpp
authservice.h authservice.h
authservice.cpp authservice.cpp
msaauthservice.h
msaauthservice.cpp
gamelauncher.h gamelauncher.h
gamelauncher.cpp gamelauncher.cpp
versionmanifestservice.h versionmanifestservice.h
@@ -64,6 +89,7 @@ qt_add_qml_module(appMinecraft_launcher
SeasonalBuildsDialog.qml SeasonalBuildsDialog.qml
LoaderRow.qml LoaderRow.qml
ProgressPanel.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 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 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) include(GNUInstallDirs)
# ── Эталонный zlib для установщиков Forge и NeoForge ──────────────────────── # ── Эталонный zlib для установщиков Forge и NeoForge ────────────────────────
+101 -10
View File
@@ -31,6 +31,13 @@ Window {
: qsTr("Игра закрыта"), : qsTr("Игра закрыта"),
crashed || exitCode !== 0 ? "#cc3333" : "#555555") 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) onGameOutput: (line) => console.log(line)
onSeasonalInstallFinished: (seasonalId, buildName) => { onSeasonalInstallFinished: (seasonalId, buildName) => {
window.showToast(qsTr("Сборка «%1» установлена — можно запускать").arg(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) { function formatMb(bytes) {
return (bytes / 1048576).toFixed(1) return (bytes / 1048576).toFixed(1)
} }
@@ -557,7 +585,8 @@ Window {
id: pfLogin id: pfLogin
x: 20 x: 20
width: parent.width - 40 width: parent.width - 40
placeholderText: "Логин" enabled: pfAuth.currentIndex !== 2
placeholderText: pfAuth.currentIndex === 2 ? "Ник придёт из аккаунта" : "Логин"
color: "#ffffff" color: "#ffffff"
placeholderTextColor: "#666666" placeholderTextColor: "#666666"
background: Rectangle { background: Rectangle {
@@ -573,7 +602,7 @@ Window {
x: 20 x: 20
width: parent.width - 40 width: parent.width - 40
enabled: pfAuth.currentIndex === 1 enabled: pfAuth.currentIndex === 1
placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" placeholderText: pfAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен для этого типа"
echoMode: TextInput.Password echoMode: TextInput.Password
color: "#ffffff" color: "#ffffff"
placeholderTextColor: "#666666" placeholderTextColor: "#666666"
@@ -589,7 +618,9 @@ Window {
id: pfAuth id: pfAuth
x: 20 x: 20
width: parent.width - 40 width: parent.width - 40
model: ["Офлайн (без пароля)", "Ely.by"] model: backend.microsoftAvailable
? ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"]
: ["Офлайн (без пароля)", "Ely.by"]
} }
} }
@@ -641,10 +672,14 @@ Window {
onAccepted: { onAccepted: {
const name = pfName.text.trim() const name = pfName.text.trim()
const type = pfAuth.currentIndex === 2 ? "microsoft"
: pfAuth.currentIndex === 1 ? "elyby" : "offline"
if (name !== "") { if (name !== "") {
backend.addProfile(name, pfLogin.text.trim(), pfPassword.text, backend.addProfile(name, pfLogin.text.trim(), pfPassword.text, type)
pfAuth.currentIndex === 1 ? "elyby" : "offline")
profileBox.currentIndex = backend.profileNames.length - 1 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 pfName.text = ""; pfLogin.text = ""; pfPassword.text = ""; pfAuth.currentIndex = 0
} }
@@ -665,6 +700,10 @@ Window {
padding: 0 padding: 0
property int editIndex: -1 property int editIndex: -1
// Состояние входа в Microsoft у открытого профиля — для строки статуса.
property bool msProfile: false
property bool msLinked: false
property string msName: ""
function openFor(index) { function openFor(index) {
const data = backend.profileAt(index) const data = backend.profileAt(index)
@@ -672,7 +711,10 @@ Window {
epName.text = data.name || "" epName.text = data.name || ""
epLogin.text = data.login || "" epLogin.text = data.login || ""
epPassword.text = data.password || "" 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() profileBox.popup.close()
open() open()
} }
@@ -725,7 +767,8 @@ Window {
id: epLogin id: epLogin
x: 20 x: 20
width: parent.width - 40 width: parent.width - 40
placeholderText: "Логин" enabled: epAuth.currentIndex !== 2
placeholderText: epAuth.currentIndex === 2 ? "Ник придёт из аккаунта" : "Логин"
color: "#ffffff" color: "#ffffff"
placeholderTextColor: "#666666" placeholderTextColor: "#666666"
background: Rectangle { background: Rectangle {
@@ -741,7 +784,7 @@ Window {
x: 20 x: 20
width: parent.width - 40 width: parent.width - 40
enabled: epAuth.currentIndex === 1 enabled: epAuth.currentIndex === 1
placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен в офлайне" placeholderText: epAuth.currentIndex === 1 ? "Пароль Ely.by" : "Не нужен для этого типа"
echoMode: TextInput.Password echoMode: TextInput.Password
color: "#ffffff" color: "#ffffff"
placeholderTextColor: "#666666" placeholderTextColor: "#666666"
@@ -757,7 +800,54 @@ Window {
id: epAuth id: epAuth
x: 20 x: 20
width: parent.width - 40 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() const name = epName.text.trim()
if (editIndex >= 0 && name !== "") if (editIndex >= 0 && name !== "")
backend.updateProfile(editIndex, name, epLogin.text.trim(), epPassword.text, 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 editIndex = -1
epName.text = ""; epLogin.text = ""; epPassword.text = "" epName.text = ""; epLogin.text = ""; epPassword.text = ""
} }
+132
View File
@@ -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()
}
}
}
}
+9
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <QDateTime>
#include <QJsonObject> #include <QJsonObject>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@@ -12,6 +13,9 @@ struct AuthResult
{ {
bool ok = false; bool ok = false;
bool twoFactorRequired = false; bool twoFactorRequired = false;
// Вход в Microsoft прошёл, но копии игры на аккаунте нет: чинится только
// покупкой, поэтому обрабатывается отдельно от прочих ошибок.
bool licenseMissing = false;
QString error; QString error;
QString playerName; // ${auth_player_name} QString playerName; // ${auth_player_name}
@@ -19,6 +23,11 @@ struct AuthResult
QString accessToken; // ${auth_access_token} QString accessToken; // ${auth_access_token}
QString clientToken; // ${clientid} QString clientToken; // ${clientid}
QString userType; // ${user_type}: legacy / msa / ELYBY QString userType; // ${user_type}: legacy / msa / ELYBY
// Только для аккаунтов Microsoft.
QString refreshToken; // продлевает сессию без ввода пароля
QString xuid; // ${auth_xuid}
QDateTime expiresAt; // UTC-время, когда протухает accessToken
}; };
// Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные: // Yggdrasil-клиент Ely.by плюс офлайн-режим. Все сетевые методы асинхронные:
+2 -1
View File
@@ -133,7 +133,8 @@ QStringList GameLauncher::buildArguments(const LaunchOptions &options,
variables[QStringLiteral("auth_access_token")] = options.accessToken; variables[QStringLiteral("auth_access_token")] = options.accessToken;
variables[QStringLiteral("auth_session")] = variables[QStringLiteral("auth_session")] =
QStringLiteral("token:%1:%2").arg(options.accessToken, options.uuid); 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")] = variables[QStringLiteral("clientid")] =
options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken; options.clientToken.isEmpty() ? QStringLiteral("0") : options.clientToken;
variables[QStringLiteral("user_type")] = options.userType; variables[QStringLiteral("user_type")] = options.userType;
+1
View File
@@ -18,6 +18,7 @@ struct LaunchOptions
QString accessToken; QString accessToken;
QString userType; QString userType;
QString clientToken; QString clientToken;
QString xuid; // Пусто — подставится "0", как в офлайне
QString javaPath; // Пусто — ищем сами QString javaPath; // Пусто — ищем сами
int minMemoryMb = 512; int minMemoryMb = 512;
+207 -7
View File
@@ -10,6 +10,7 @@
#include "versioninstaller.h" #include "versioninstaller.h"
#include "versionmanifestservice.h" #include "versionmanifestservice.h"
#include "modloaderinstaller.h" #include "modloaderinstaller.h"
#include "msaauthservice.h"
#include "modloaderversionservice.h" #include "modloaderversionservice.h"
#include "buildswitcher.h" #include "buildswitcher.h"
#include "seasonalbuildservice.h" #include "seasonalbuildservice.h"
@@ -35,12 +36,27 @@ namespace {
const QString kOffline = QStringLiteral("offline"); const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby"); const QString kElyBy = QStringLiteral("elyby");
const QString kMicrosoft = QStringLiteral("microsoft");
// Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра
// проверяет сессию не в первую секунду после старта.
const int kTokenRenewMarginSecs = 120;
// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl // Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
// в settings.json — это нужно, чтобы прогонять установку против локального // в settings.json — это нужно, чтобы прогонять установку против локального
// http-сервера, не трогая боевой. // http-сервера, не трогая боевой.
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com"); 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) QString formatSeasonDate(const QDate &date)
{ {
@@ -60,6 +76,7 @@ int settingInt(const QVariantMap &settings, const QString &key, int fallback)
LauncherBackend::LauncherBackend(QObject *parent) LauncherBackend::LauncherBackend(QObject *parent)
: QObject(parent) : QObject(parent)
, m_auth(new AuthService(this)) , m_auth(new AuthService(this))
, m_msa(new MsaAuthService(this))
, m_launcher(new GameLauncher(this)) , m_launcher(new GameLauncher(this))
, m_manifest(new VersionManifestService(this)) , m_manifest(new VersionManifestService(this))
, m_installer(new VersionInstaller(m_manifest, 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_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::progress, this, &LauncherBackend::launchProgress);
connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput); connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) { connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
@@ -396,7 +414,7 @@ void LauncherBackend::addProfile(const QString &name,
profile.name = name; profile.name = name;
profile.login = login; profile.login = login;
profile.password = password; profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline; profile.authType = normalizedAuthType(authType);
profile.clientToken = AuthService::generateClientToken(); profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile); m_profiles.append(profile);
saveProfiles(); saveProfiles();
@@ -428,16 +446,30 @@ void LauncherBackend::updateProfile(int index,
return; return;
Profile &profile = m_profiles[index]; Profile &profile = m_profiles[index];
// Смена логина или пароля обесценивает сохранённый токен Ely.by. const QString type = normalizedAuthType(authType);
if (profile.login != login || profile.password != password) {
// Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта
// Microsoft ни того ни другого нет, и переименование профиля не должно
// выкидывать из аккаунта.
if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) {
profile.accessToken.clear(); profile.accessToken.clear();
profile.uuid.clear(); profile.uuid.clear();
profile.resolvedName.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.name = name;
profile.login = login; profile.login = login;
profile.password = password; profile.password = password;
profile.authType = (authType == kElyBy) ? kElyBy : kOffline; profile.authType = type;
if (profile.clientToken.isEmpty()) if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken(); profile.clientToken = AuthService::generateClientToken();
@@ -495,7 +527,9 @@ QVariantMap LauncherBackend::profileAt(int index) const
return {{"name", p.name}, return {{"name", p.name},
{"login", p.login}, {"login", p.login},
{"password", p.password}, {"password", p.password},
{"authType", p.authType}}; {"authType", p.authType},
{"resolvedName", p.resolvedName},
{"hasMicrosoftSession", !p.refreshToken.isEmpty()}};
} }
QVariantMap LauncherBackend::customBuildAt(int index) const QVariantMap LauncherBackend::customBuildAt(int index) const
@@ -1149,10 +1183,157 @@ void LauncherBackend::launchGame(int profileIndex, int buildIndex)
beginAuthentication(); 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() void LauncherBackend::beginAuthentication()
{ {
const Profile &profile = m_profiles[m_pendingProfile]; const Profile &profile = m_profiles[m_pendingProfile];
if (profile.authType == kMicrosoft) {
beginMicrosoftAuthentication();
return;
}
if (profile.authType != kElyBy) { if (profile.authType != kElyBy) {
const QString nickname = profile.login.isEmpty() ? profile.name : profile.login; const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
const AuthResult result = AuthService::offline(nickname); const AuthResult result = AuthService::offline(nickname);
@@ -1252,7 +1433,10 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
// Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем, // 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.accessToken = auth.accessToken;
profile.clientToken = auth.clientToken; profile.clientToken = auth.clientToken;
if (!auth.uuid.isEmpty()) if (!auth.uuid.isEmpty())
@@ -1273,6 +1457,7 @@ void LauncherBackend::continueLaunch(const AuthResult &auth)
options.accessToken = auth.accessToken; options.accessToken = auth.accessToken;
options.clientToken = auth.clientToken; options.clientToken = auth.clientToken;
options.userType = auth.userType; options.userType = auth.userType;
options.xuid = auth.xuid;
// Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в // Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
// окне выбора версии Java. // окне выбора версии Java.
@@ -1354,6 +1539,15 @@ bool LauncherBackend::busy() const
|| m_packDownloader->isRunning(); || m_packDownloader->isRunning();
} }
bool LauncherBackend::microsoftAvailable() const
{
#ifdef LAUNCHER_HAS_WEBENGINE
return true;
#else
return false;
#endif
}
void LauncherBackend::setBusy(bool busy) void LauncherBackend::setBusy(bool busy)
{ {
if (m_busy == busy) if (m_busy == busy)
@@ -2195,6 +2389,9 @@ void LauncherBackend::loadData()
profile.uuid = o.value("uuid").toString(); profile.uuid = o.value("uuid").toString();
profile.resolvedName = o.value("resolvedName").toString(); profile.resolvedName = o.value("resolvedName").toString();
profile.userType = o.value("userType").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()) if (profile.clientToken.isEmpty())
profile.clientToken = AuthService::generateClientToken(); profile.clientToken = AuthService::generateClientToken();
m_profiles.append(profile); m_profiles.append(profile);
@@ -2308,7 +2505,10 @@ void LauncherBackend::saveProfiles()
{"accessToken", p.accessToken}, {"accessToken", p.accessToken},
{"uuid", p.uuid}, {"uuid", p.uuid},
{"resolvedName", p.resolvedName}, {"resolvedName", p.resolvedName},
{"userType", p.userType}}); {"userType", p.userType},
{"refreshToken", p.refreshToken},
{"xuid", p.xuid},
{"tokenExpiry", p.tokenExpiry}});
writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr)); writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
} }
+32 -1
View File
@@ -13,6 +13,7 @@
struct AuthResult; struct AuthResult;
class AuthService; class AuthService;
class MsaAuthService;
class GameLauncher; class GameLauncher;
class VersionManifestService; class VersionManifestService;
class VersionInstaller; class VersionInstaller;
@@ -48,6 +49,10 @@ class LauncherBackend : public QObject
Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged) Q_PROPERTY(QString switchStatus READ switchStatus NOTIFY switchChanged)
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
// Собран ли лаунчер с Qt WebEngine. Без него окно входа Microsoft
// показать нечем, и интерфейс не должен предлагать этот путь.
Q_PROPERTY(bool microsoftAvailable READ microsoftAvailable CONSTANT)
// Объединённый список для выпадающего меню версий: установленные, разделитель, // Объединённый список для выпадающего меню версий: установленные, разделитель,
// затем весь каталог Mojang. // затем весь каталог Mojang.
Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged) Q_PROPERTY(QVariantList versionCatalog READ versionCatalog NOTIFY versionCatalogChanged)
@@ -93,6 +98,7 @@ public:
QString switchStatus() const; QString switchStatus() const;
// Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет. // Пока идёт загрузка версии, лаунчер тоже занят: кнопка запуска гаснет.
bool busy() const; bool busy() const;
bool microsoftAvailable() const;
QVariantList versionCatalog() const; QVariantList versionCatalog() const;
bool catalogLoading() const; bool catalogLoading() const;
@@ -128,6 +134,15 @@ public:
// loader, loaderVersion, resolvedVersionId. // loader, loaderVersion, resolvedVersionId.
Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields); Q_INVOKABLE void updateCustomBuild(int index, const QVariantMap &fields);
Q_INVOKABLE QVariantMap profileAt(int index) const; 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 QVariantMap customBuildAt(int index) const;
Q_INVOKABLE void removeProfile(int index); Q_INVOKABLE void removeProfile(int index);
// Что именно потеряется при удалении — для текста предупреждения. // Что именно потеряется при удалении — для текста предупреждения.
@@ -224,6 +239,12 @@ signals:
void launched(const QString &profileName, const QString &buildName, const QString &serverUrl); void launched(const QString &profileName, const QString &buildName, const QString &serverUrl);
void launchError(const QString &message); void launchError(const QString &message);
void twoFactorRequired(const QString &profileName); 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 gameOutput(const QString &line);
void gameFinished(int exitCode, bool crashed); void gameFinished(int exitCode, bool crashed);
@@ -232,8 +253,11 @@ private:
{ {
int id = 0; int id = 0;
QString name, login, password; QString name, login, password;
QString authType = QStringLiteral("offline"); // offline | elyby QString authType = QStringLiteral("offline"); // offline | elyby | microsoft
QString clientToken, accessToken, uuid, resolvedName, userType; QString clientToken, accessToken, uuid, resolvedName, userType;
// Только для microsoft: продление сессии, ${auth_xuid} и срок жизни
// accessToken в ISO-8601 (чтобы класть в JSON без конвертеров).
QString refreshToken, xuid, tokenExpiry;
}; };
// Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со // Пользовательская сборка: имя, сервер, версия Minecraft и, начиная со
// второго этапа, выбранный модлоадер. // второго этапа, выбранный модлоадер.
@@ -329,6 +353,8 @@ private:
void setBusy(bool busy); void setBusy(bool busy);
void failLaunch(const QString &message); void failLaunch(const QString &message);
void beginAuthentication(); void beginAuthentication();
void beginMicrosoftAuthentication();
void applyMicrosoftResult(Profile &profile, const AuthResult &auth);
void continueLaunch(const AuthResult &auth); void continueLaunch(const AuthResult &auth);
QList<Profile> m_profiles; QList<Profile> m_profiles;
@@ -341,6 +367,7 @@ private:
int m_nextBuildId = 1; int m_nextBuildId = 1;
AuthService *m_auth; AuthService *m_auth;
MsaAuthService *m_msa;
GameLauncher *m_launcher; GameLauncher *m_launcher;
VersionManifestService *m_manifest; VersionManifestService *m_manifest;
VersionInstaller *m_installer; VersionInstaller *m_installer;
@@ -356,6 +383,10 @@ private:
// когда установщик отчитается. // когда установщик отчитается.
int m_loaderInstallBuildId = -1; int m_loaderInstallBuildId = -1;
// Профиль, для которого открыто окно входа Microsoft. -1 — окно закрыто,
// -2 — вход для профиля, которого ещё нет.
int m_msaLoginProfile = -1;
// Каталог версий пересобирается только по изменениям: QML читает свойство // Каталог версий пересобирается только по изменениям: QML читает свойство
// помногу раз за кадр, пока открыт список. // помногу раз за кадр, пока открыт список.
mutable QVariantList m_catalog; mutable QVariantList m_catalog;
+11
View File
@@ -2,8 +2,19 @@
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQuickStyle> #include <QQuickStyle>
#ifdef LAUNCHER_HAS_WEBENGINE
#include <QtWebEngineQuick>
#endif
int main(int argc, char *argv[]) int main(int argc, char *argv[])
{ {
#ifdef LAUNCHER_HAS_WEBENGINE
// Вход в аккаунт Microsoft показывается встроенным окном браузера.
// Инициализация обязана идти до QGuiApplication: она выставляет общий
// контекст OpenGL, а после создания приложения это уже не действует.
QtWebEngineQuick::initialize();
#endif
QGuiApplication app(argc, argv); QGuiApplication app(argc, argv);
// Нативный стиль Windows игнорирует пользовательские contentItem/background // Нативный стиль Windows игнорирует пользовательские contentItem/background
+469
View File
@@ -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 &microsoftToken,
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);
});
}
+67
View File
@@ -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 &microsoftToken, 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;
};