mirror of
https://forge.banditvault.co.uk/racoon/MinecraftConsoles.git
synced 2026-06-25 01:15:34 +00:00
Add LceLive UI and Windows64 integration
- Implement UIScene_LceLive class for managing the LceLive UI components. - Create Windows64_LceLive.cpp and Windows64_LceLive.h for handling LceLive authentication and device linking. - Introduce a Python script for repacking MediaWindows64.arc archives with overlay support. - Enhance error handling and session management for LceLive interactions. - Add methods for reading and writing UTF-8 strings in the archive format. - Implement functionality to apply overlays to existing archive entries.
This commit is contained in:
@@ -51,9 +51,11 @@ set_target_properties(Minecraft.Client PROPERTIES
|
||||
|
||||
target_link_libraries(Minecraft.Client PRIVATE
|
||||
Minecraft.World
|
||||
crypt32
|
||||
d3d11
|
||||
d3dcompiler
|
||||
XInput9_1_0
|
||||
winhttp
|
||||
wsock32
|
||||
legacy_stdio_definitions
|
||||
$<$<CONFIG:Debug>: # Debug 4J libraries
|
||||
|
||||
BIN
Minecraft.Client/Common/Media/LceLive1080.swf
Normal file
BIN
Minecraft.Client/Common/Media/LceLive1080.swf
Normal file
Binary file not shown.
BIN
Minecraft.Client/Common/Media/LceLive480.swf
Normal file
BIN
Minecraft.Client/Common/Media/LceLive480.swf
Normal file
Binary file not shown.
BIN
Minecraft.Client/Common/Media/LceLive720.swf
Normal file
BIN
Minecraft.Client/Common/Media/LceLive720.swf
Normal file
Binary file not shown.
BIN
Minecraft.Client/Common/Media/LceLiveVita.swf
Normal file
BIN
Minecraft.Client/Common/Media/LceLiveVita.swf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -65,6 +65,7 @@
|
||||
|
||||
#include "UIScene_TrialExitUpsell.h"
|
||||
#include "UIScene_Intro.h"
|
||||
#include "UIScene_LceLive.h"
|
||||
#include "UIScene_SaveMessage.h"
|
||||
#include "UIScene_MainMenu.h"
|
||||
#include "UIScene_LoadMenu.h"
|
||||
@@ -125,4 +126,4 @@
|
||||
#include "UIScene_EULA.h"
|
||||
#include "UIScene_NewUpdateMessage.h"
|
||||
|
||||
extern ConsoleUIController ui;
|
||||
extern ConsoleUIController ui;
|
||||
|
||||
@@ -121,6 +121,7 @@ enum EUIScene
|
||||
eUIComponent_Tooltips,
|
||||
eUIComponent_PressStartToPlay,
|
||||
eUIComponent_MenuBackground,
|
||||
eUIScene_LceLive,
|
||||
eUIScene_Keyboard,
|
||||
eUIScene_QuadrantSignin,
|
||||
eUIScene_MessageBox,
|
||||
|
||||
@@ -364,6 +364,9 @@ bool UILayer::NavigateToScene(int iPad, EUIScene scene, void *initData)
|
||||
case eUIScene_SaveMessage:
|
||||
newScene = new UIScene_SaveMessage(iPad, initData, this);
|
||||
break;
|
||||
case eUIScene_LceLive:
|
||||
newScene = new UIScene_LceLive(iPad, initData, this);
|
||||
break;
|
||||
case eUIScene_MainMenu:
|
||||
newScene = new UIScene_MainMenu(iPad, initData, this);
|
||||
break;
|
||||
@@ -906,4 +909,4 @@ UIScene *UILayer::FindScene(EUIScene sceneType)
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
198
Minecraft.Client/Common/UI/UIScene_LceLive.cpp
Normal file
198
Minecraft.Client/Common/UI/UIScene_LceLive.cpp
Normal file
@@ -0,0 +1,198 @@
|
||||
#include "stdafx.h"
|
||||
#include "UI.h"
|
||||
#include "UIScene_LceLive.h"
|
||||
#include "../../../Minecraft.World/StringHelpers.h"
|
||||
|
||||
#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD)
|
||||
#include "../../Windows64/Windows64_LceLive.h"
|
||||
#endif
|
||||
|
||||
UIScene_LceLive::UIScene_LceLive(int iPad, void *initData, UILayer *parentLayer) : UIScene(iPad, parentLayer)
|
||||
{
|
||||
initialiseMovie();
|
||||
|
||||
parentLayer->addComponent(iPad, eUIComponent_Panorama);
|
||||
parentLayer->addComponent(iPad, eUIComponent_Logo);
|
||||
|
||||
m_buttonEnabled = true;
|
||||
m_descriptionApplied = false;
|
||||
m_buttonPrimaryAction.init(L"START LINK", eControl_PrimaryAction);
|
||||
m_labelTitle.init(L"LCELIVE");
|
||||
m_labelDescription.init(L"");
|
||||
|
||||
IggyDataValue result;
|
||||
IggyDataValue value[2];
|
||||
value[0].type = IGGY_DATATYPE_number;
|
||||
value[0].number = 1;
|
||||
value[1].type = IGGY_DATATYPE_number;
|
||||
value[1].number = 0;
|
||||
IggyPlayerCallMethodRS(getMovie(), &result, IggyPlayerRootPath(getMovie()), m_funcInit, 2, value);
|
||||
IggyPlayerCallMethodRS(getMovie(), &result, IggyPlayerRootPath(getMovie()), m_funcAutoResize, 0, nullptr);
|
||||
}
|
||||
|
||||
UIScene_LceLive::~UIScene_LceLive()
|
||||
{
|
||||
m_parentLayer->removeComponent(eUIComponent_Panorama);
|
||||
m_parentLayer->removeComponent(eUIComponent_Logo);
|
||||
}
|
||||
|
||||
wstring UIScene_LceLive::getMoviePath()
|
||||
{
|
||||
return L"LceLive";
|
||||
}
|
||||
|
||||
void UIScene_LceLive::updateTooltips()
|
||||
{
|
||||
if (m_buttonEnabled)
|
||||
ui.SetTooltips(DEFAULT_XUI_MENU_USER, IDS_TOOLTIPS_SELECT, IDS_TOOLTIPS_BACK);
|
||||
else
|
||||
ui.SetTooltips(DEFAULT_XUI_MENU_USER, -1, IDS_TOOLTIPS_BACK);
|
||||
}
|
||||
|
||||
void UIScene_LceLive::updateComponents()
|
||||
{
|
||||
m_parentLayer->showComponent(m_iPad, eUIComponent_Panorama, true);
|
||||
m_parentLayer->showComponent(m_iPad, eUIComponent_Logo, true);
|
||||
}
|
||||
|
||||
void UIScene_LceLive::tick()
|
||||
{
|
||||
UIScene::tick();
|
||||
RefreshUi(false);
|
||||
}
|
||||
|
||||
void UIScene_LceLive::handleReload()
|
||||
{
|
||||
m_descriptionApplied = false;
|
||||
IggyDataValue result;
|
||||
IggyDataValue value[2];
|
||||
value[0].type = IGGY_DATATYPE_number;
|
||||
value[0].number = 1;
|
||||
value[1].type = IGGY_DATATYPE_number;
|
||||
value[1].number = 0;
|
||||
IggyPlayerCallMethodRS(getMovie(), &result, IggyPlayerRootPath(getMovie()), m_funcInit, 2, value);
|
||||
IggyPlayerCallMethodRS(getMovie(), &result, IggyPlayerRootPath(getMovie()), m_funcAutoResize, 0, nullptr);
|
||||
RefreshUi(true);
|
||||
}
|
||||
|
||||
void UIScene_LceLive::handleInput(int iPad, int key, bool repeat, bool pressed, bool released, bool &handled)
|
||||
{
|
||||
ui.AnimateKeyPress(m_iPad, key, repeat, pressed, released);
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case ACTION_MENU_CANCEL:
|
||||
if (pressed && !repeat)
|
||||
navigateBack();
|
||||
break;
|
||||
case ACTION_MENU_OK:
|
||||
if (pressed && !repeat && m_buttonEnabled)
|
||||
{
|
||||
handled = true;
|
||||
handlePress(static_cast<F64>(eControl_PrimaryAction), 0.0);
|
||||
}
|
||||
break;
|
||||
#ifdef __ORBIS__
|
||||
case ACTION_MENU_TOUCHPAD_PRESS:
|
||||
if (pressed && !repeat && m_buttonEnabled)
|
||||
{
|
||||
handled = true;
|
||||
handlePress(static_cast<F64>(eControl_PrimaryAction), 0.0);
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
case ACTION_MENU_DOWN:
|
||||
case ACTION_MENU_UP:
|
||||
case ACTION_MENU_PAGEUP:
|
||||
case ACTION_MENU_PAGEDOWN:
|
||||
case ACTION_MENU_OTHER_STICK_DOWN:
|
||||
case ACTION_MENU_OTHER_STICK_UP:
|
||||
sendInputToMovie(key, repeat, pressed, released);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void UIScene_LceLive::handlePress(F64 controlId, F64 childId)
|
||||
{
|
||||
if (static_cast<int>(controlId) != eControl_PrimaryAction || !m_buttonEnabled)
|
||||
return;
|
||||
|
||||
#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD)
|
||||
const Win64LceLive::Snapshot snapshot = Win64LceLive::GetSnapshot();
|
||||
ui.PlayUISFX(eSFX_Press);
|
||||
|
||||
if (snapshot.state == Win64LceLive::EClientState::SignedIn)
|
||||
Win64LceLive::SignOut();
|
||||
else
|
||||
Win64LceLive::StartDeviceLink();
|
||||
#endif
|
||||
}
|
||||
|
||||
void UIScene_LceLive::RefreshUi(bool force)
|
||||
{
|
||||
std::wstring buttonLabel = L"LCELIVE UNAVAILABLE";
|
||||
std::wstring description = L"LCELIVE\r\n\r\nThis build does not provide the Windows64 LCELive client runtime.";
|
||||
bool buttonEnabled = false;
|
||||
|
||||
#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD)
|
||||
const Win64LceLive::Snapshot snapshot = Win64LceLive::GetSnapshot();
|
||||
|
||||
switch (snapshot.state)
|
||||
{
|
||||
case Win64LceLive::EClientState::SignedIn:
|
||||
buttonLabel = L"SIGN OUT";
|
||||
buttonEnabled = !snapshot.requestInFlight;
|
||||
break;
|
||||
case Win64LceLive::EClientState::StartingLink:
|
||||
case Win64LceLive::EClientState::Polling:
|
||||
buttonLabel = L"PLEASE WAIT";
|
||||
buttonEnabled = false;
|
||||
break;
|
||||
case Win64LceLive::EClientState::LinkPending:
|
||||
buttonLabel = L"RESTART LINK";
|
||||
buttonEnabled = true;
|
||||
break;
|
||||
case Win64LceLive::EClientState::SignedOut:
|
||||
default:
|
||||
buttonLabel = L"START LINK";
|
||||
buttonEnabled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
description = snapshot.statusMessage;
|
||||
if (snapshot.hasError && !snapshot.errorMessage.empty())
|
||||
{
|
||||
description += L"\r\nError:\r\n";
|
||||
description += snapshot.errorMessage;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (force || m_lastButtonLabel != buttonLabel)
|
||||
{
|
||||
m_lastButtonLabel = buttonLabel;
|
||||
m_buttonPrimaryAction.setLabel(buttonLabel, true, true);
|
||||
}
|
||||
|
||||
if (force || m_buttonEnabled != buttonEnabled)
|
||||
{
|
||||
m_buttonEnabled = buttonEnabled;
|
||||
m_buttonPrimaryAction.setEnable(buttonEnabled);
|
||||
}
|
||||
|
||||
if (!m_descriptionApplied || m_lastDescription != description)
|
||||
{
|
||||
m_lastDescription = description;
|
||||
ApplyDescription(description);
|
||||
}
|
||||
|
||||
updateTooltips();
|
||||
}
|
||||
|
||||
void UIScene_LceLive::ApplyDescription(const std::wstring &description)
|
||||
{
|
||||
m_labelDescription.setLabel(description, true, true);
|
||||
|
||||
IggyDataValue result;
|
||||
IggyPlayerCallMethodRS(getMovie(), &result, IggyPlayerRootPath(getMovie()), m_funcAutoResize, 0, nullptr);
|
||||
m_descriptionApplied = true;
|
||||
}
|
||||
53
Minecraft.Client/Common/UI/UIScene_LceLive.h
Normal file
53
Minecraft.Client/Common/UI/UIScene_LceLive.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include "UIScene.h"
|
||||
|
||||
class UIScene_LceLive : public UIScene
|
||||
{
|
||||
private:
|
||||
enum EControls
|
||||
{
|
||||
eControl_PrimaryAction,
|
||||
};
|
||||
|
||||
bool m_buttonEnabled;
|
||||
bool m_descriptionApplied;
|
||||
std::wstring m_lastButtonLabel;
|
||||
std::wstring m_lastDescription;
|
||||
|
||||
UIControl_Button m_buttonPrimaryAction;
|
||||
UIControl_Label m_labelTitle;
|
||||
UIControl_Label m_labelDescription;
|
||||
IggyName m_funcInit;
|
||||
IggyName m_funcAutoResize;
|
||||
UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene)
|
||||
UI_MAP_ELEMENT(m_buttonPrimaryAction, "Button3")
|
||||
UI_MAP_ELEMENT(m_labelTitle, "Title")
|
||||
UI_MAP_ELEMENT(m_labelDescription, "Content")
|
||||
UI_MAP_NAME(m_funcInit, L"Init")
|
||||
UI_MAP_NAME(m_funcAutoResize, L"AutoResize")
|
||||
UI_END_MAP_ELEMENTS_AND_NAMES()
|
||||
|
||||
public:
|
||||
UIScene_LceLive(int iPad, void *initData, UILayer *parentLayer);
|
||||
~UIScene_LceLive();
|
||||
|
||||
virtual EUIScene getSceneType() { return eUIScene_LceLive; }
|
||||
virtual void updateTooltips();
|
||||
virtual void updateComponents();
|
||||
virtual void tick();
|
||||
|
||||
protected:
|
||||
virtual wstring getMoviePath();
|
||||
virtual void handleReload();
|
||||
|
||||
public:
|
||||
virtual void handleInput(int iPad, int key, bool repeat, bool pressed, bool released, bool &handled);
|
||||
|
||||
protected:
|
||||
void handlePress(F64 controlId, F64 childId);
|
||||
|
||||
private:
|
||||
void RefreshUi(bool force);
|
||||
void ApplyDescription(const std::wstring &description);
|
||||
};
|
||||
@@ -41,7 +41,7 @@ UIScene_MainMenu::UIScene_MainMenu(int iPad, void *initData, UILayer *parentLaye
|
||||
#endif
|
||||
|
||||
m_buttons[static_cast<int>(eControl_Leaderboards)].init(IDS_LEADERBOARDS,eControl_Leaderboards);
|
||||
m_buttons[static_cast<int>(eControl_Achievements)].init( (UIString)IDS_ACHIEVEMENTS,eControl_Achievements);
|
||||
m_buttons[static_cast<int>(eControl_Achievements)].init((UIString)IDS_ACHIEVEMENTS, eControl_Achievements);
|
||||
m_buttons[static_cast<int>(eControl_HelpAndOptions)].init(IDS_HELP_AND_OPTIONS,eControl_HelpAndOptions);
|
||||
if(ProfileManager.IsFullVersion())
|
||||
{
|
||||
@@ -54,6 +54,13 @@ UIScene_MainMenu::UIScene_MainMenu(int iPad, void *initData, UILayer *parentLaye
|
||||
m_buttons[static_cast<int>(eControl_UnlockOrDLC)].init(IDS_UNLOCK_FULL_GAME,eControl_UnlockOrDLC);
|
||||
}
|
||||
|
||||
#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD)
|
||||
m_buttons[static_cast<int>(eControl_LceLive)].init(L"LCELIVE", eControl_LceLive);
|
||||
#else
|
||||
m_buttons[static_cast<int>(eControl_LceLive)].init(L"LCELIVE", eControl_LceLive);
|
||||
removeControl(&m_buttons[(int)eControl_LceLive], false);
|
||||
#endif
|
||||
|
||||
#ifndef _DURANGO
|
||||
m_buttons[static_cast<int>(eControl_Exit)].init(app.GetString(IDS_EXIT_GAME),eControl_Exit);
|
||||
#else
|
||||
@@ -65,11 +72,13 @@ UIScene_MainMenu::UIScene_MainMenu(int iPad, void *initData, UILayer *parentLaye
|
||||
removeControl( &m_buttons[(int)eControl_Exit], false );
|
||||
// We don't have a way to display trophies/achievements, so remove the button
|
||||
removeControl( &m_buttons[(int)eControl_Achievements], false );
|
||||
removeControl( &m_buttons[(int)eControl_LceLive], false );
|
||||
m_bLaunchFullVersionPurchase=false;
|
||||
#endif
|
||||
#ifdef _DURANGO
|
||||
// Allowed to not have achievements in the menu
|
||||
removeControl( &m_buttons[(int)eControl_Achievements], false );
|
||||
removeControl( &m_buttons[(int)eControl_LceLive], false );
|
||||
// Not allowed to exit from a Xbox One game from the game - have to use the Home button
|
||||
//removeControl( &m_buttons[(int)eControl_Exit], false );
|
||||
m_bWaitingForDLCInfo=false;
|
||||
@@ -237,10 +246,14 @@ void UIScene_MainMenu::handleReload()
|
||||
removeControl( &m_buttons[(int)eControl_Exit], false );
|
||||
// We don't have a way to display trophies/achievements, so remove the button
|
||||
removeControl( &m_buttons[(int)eControl_Achievements], false );
|
||||
removeControl( &m_buttons[(int)eControl_LceLive], false );
|
||||
#endif
|
||||
#ifdef _DURANGO
|
||||
// Allowed to not have achievements in the menu
|
||||
removeControl( &m_buttons[(int)eControl_Achievements], false );
|
||||
removeControl( &m_buttons[(int)eControl_LceLive], false );
|
||||
#elif !defined(_WINDOWS64) || defined(MINECRAFT_SERVER_BUILD)
|
||||
removeControl( &m_buttons[(int)eControl_LceLive], false );
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -341,10 +354,16 @@ void UIScene_MainMenu::handlePress(F64 controlId, F64 childId)
|
||||
case eControl_Achievements:
|
||||
//CD - Added for audio
|
||||
ui.PlayUISFX(eSFX_Press);
|
||||
|
||||
m_eAction=eAction_RunAchievements;
|
||||
signInReturnedFunc = &UIScene_MainMenu::Achievements_SignInReturned;
|
||||
break;
|
||||
case eControl_LceLive:
|
||||
//CD - Added for audio
|
||||
ui.PlayUISFX(eSFX_Press);
|
||||
#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD)
|
||||
ui.NavigateToScene(m_iPad, eUIScene_LceLive);
|
||||
#endif
|
||||
return;
|
||||
case eControl_HelpAndOptions:
|
||||
//CD - Added for audio
|
||||
ui.PlayUISFX(eSFX_Press);
|
||||
|
||||
@@ -12,6 +12,7 @@ private:
|
||||
eControl_Achievements,
|
||||
eControl_HelpAndOptions,
|
||||
eControl_UnlockOrDLC,
|
||||
eControl_LceLive,
|
||||
#ifndef _DURANGO
|
||||
eControl_Exit,
|
||||
#else
|
||||
@@ -37,6 +38,7 @@ private:
|
||||
UI_MAP_ELEMENT( m_buttons[(int)eControl_Achievements], "Button3")
|
||||
UI_MAP_ELEMENT( m_buttons[(int)eControl_HelpAndOptions], "Button4")
|
||||
UI_MAP_ELEMENT( m_buttons[(int)eControl_UnlockOrDLC], "Button5")
|
||||
UI_MAP_ELEMENT( m_buttons[(int)eControl_LceLive], "Button7")
|
||||
#ifndef _DURANGO
|
||||
UI_MAP_ELEMENT( m_buttons[(int)eControl_Exit], "Button6")
|
||||
#else
|
||||
|
||||
973
Minecraft.Client/Windows64/Windows64_LceLive.cpp
Normal file
973
Minecraft.Client/Windows64/Windows64_LceLive.cpp
Normal file
@@ -0,0 +1,973 @@
|
||||
#include "stdafx.h"
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include "Windows64_LceLive.h"
|
||||
|
||||
#include "Windows64_Xuid.h"
|
||||
#include "../../Minecraft.World/StringHelpers.h"
|
||||
#include "../../Minecraft.Server/vendor/nlohmann/json.hpp"
|
||||
|
||||
#include <Windows.h>
|
||||
#include <wincrypt.h>
|
||||
#include <winhttp.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
using Json = nlohmann::json;
|
||||
|
||||
enum class ERequestType
|
||||
{
|
||||
None,
|
||||
StartLink,
|
||||
Poll,
|
||||
Refresh,
|
||||
Logout,
|
||||
};
|
||||
|
||||
struct AuthSession
|
||||
{
|
||||
bool valid;
|
||||
std::string accountId;
|
||||
std::string username;
|
||||
std::string displayName;
|
||||
std::string accessToken;
|
||||
std::string refreshToken;
|
||||
};
|
||||
|
||||
struct PendingLink
|
||||
{
|
||||
bool active;
|
||||
std::string deviceCode;
|
||||
std::string userCode;
|
||||
std::string verificationUri;
|
||||
std::string verificationUriComplete;
|
||||
ULONGLONG nextPollAt;
|
||||
};
|
||||
|
||||
struct RequestContext
|
||||
{
|
||||
ERequestType type;
|
||||
std::string path;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
struct CompletedRequest
|
||||
{
|
||||
ERequestType type;
|
||||
bool transportOk;
|
||||
DWORD httpStatus;
|
||||
std::string responseBody;
|
||||
};
|
||||
|
||||
struct RuntimeState
|
||||
{
|
||||
bool initialized;
|
||||
CRITICAL_SECTION lock;
|
||||
bool requestInFlight;
|
||||
bool completedReady;
|
||||
bool sessionRefreshInFlight;
|
||||
HANDLE workerThread;
|
||||
RequestContext request;
|
||||
CompletedRequest completed;
|
||||
AuthSession session;
|
||||
PendingLink pendingLink;
|
||||
std::string lastError;
|
||||
};
|
||||
|
||||
RuntimeState g_state = {};
|
||||
INIT_ONCE g_initializeOnce = INIT_ONCE_STATIC_INIT;
|
||||
INIT_ONCE g_authBlobPathOnce = INIT_ONCE_STATIC_INIT;
|
||||
char g_authBlobPath[MAX_PATH] = {};
|
||||
|
||||
bool BuildExeRelativePath(const char *fileName, char *outPath, size_t outPathSize)
|
||||
{
|
||||
if (fileName == nullptr || outPath == nullptr || outPathSize == 0)
|
||||
return false;
|
||||
|
||||
outPath[0] = 0;
|
||||
|
||||
char exePath[MAX_PATH] = {};
|
||||
DWORD len = GetModuleFileNameA(nullptr, exePath, MAX_PATH);
|
||||
if (len == 0 || len >= MAX_PATH)
|
||||
return false;
|
||||
|
||||
char *lastSlash = strrchr(exePath, '\\');
|
||||
if (lastSlash != nullptr)
|
||||
*(lastSlash + 1) = 0;
|
||||
|
||||
if (strcpy_s(outPath, outPathSize, exePath) != 0)
|
||||
return false;
|
||||
if (strcat_s(outPath, outPathSize, fileName) != 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::wstring Utf8ToWide(const std::string &text)
|
||||
{
|
||||
if (text.empty())
|
||||
return L"";
|
||||
|
||||
const int length = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), nullptr, 0);
|
||||
if (length <= 0)
|
||||
return convStringToWstring(text);
|
||||
|
||||
std::wstring result;
|
||||
result.resize(static_cast<size_t>(length));
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), &result[0], length);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string WideToUtf8(const std::wstring &text)
|
||||
{
|
||||
if (text.empty())
|
||||
return std::string();
|
||||
|
||||
const int length = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr);
|
||||
if (length <= 0)
|
||||
return std::string(wstringtochararray(text));
|
||||
|
||||
std::string result;
|
||||
result.resize(static_cast<size_t>(length));
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), &result[0], length, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string JsonStringOrEmpty(const Json &object, const char *key)
|
||||
{
|
||||
const Json::const_iterator it = object.find(key);
|
||||
if (it == object.end() || !it->is_string())
|
||||
return std::string();
|
||||
return it->get<std::string>();
|
||||
}
|
||||
|
||||
std::string ParseErrorMessage(const std::string &responseBody, const std::string &fallback);
|
||||
|
||||
void TrimTrailingSlashes(std::string *value)
|
||||
{
|
||||
if (value == nullptr)
|
||||
return;
|
||||
|
||||
while (!value->empty() && (value->back() == '/' || value->back() == '\\' || value->back() == '\r' || value->back() == '\n' || value->back() == ' ' || value->back() == '\t'))
|
||||
value->pop_back();
|
||||
}
|
||||
|
||||
std::string BuildDeviceId()
|
||||
{
|
||||
const unsigned long long xuid = static_cast<unsigned long long>(Win64Xuid::ResolvePersistentXuid());
|
||||
char buffer[64] = {};
|
||||
sprintf_s(buffer, "win64-%016llX", xuid);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
std::string BuildDeviceName()
|
||||
{
|
||||
wchar_t computerName[MAX_COMPUTERNAME_LENGTH + 1] = {};
|
||||
DWORD size = MAX_COMPUTERNAME_LENGTH + 1;
|
||||
if (GetComputerNameW(computerName, &size))
|
||||
{
|
||||
const std::wstring displayName = L"MCLCE Windows64 (" + std::wstring(computerName) + L")";
|
||||
return WideToUtf8(displayName);
|
||||
}
|
||||
|
||||
return "MCLCE Windows64";
|
||||
}
|
||||
|
||||
std::string GetApiBaseUrl()
|
||||
{
|
||||
char envValue[512] = {};
|
||||
const DWORD envLength = GetEnvironmentVariableA("LCELIVE_API_BASE_URL", envValue, static_cast<DWORD>(sizeof(envValue)));
|
||||
if (envLength > 0 && envLength < sizeof(envValue))
|
||||
{
|
||||
std::string value(envValue);
|
||||
TrimTrailingSlashes(&value);
|
||||
return value;
|
||||
}
|
||||
|
||||
char configPath[MAX_PATH] = {};
|
||||
if (BuildExeRelativePath("lcelive.properties", configPath, sizeof(configPath)))
|
||||
{
|
||||
FILE *file = nullptr;
|
||||
if (fopen_s(&file, configPath, "rb") == 0 && file != nullptr)
|
||||
{
|
||||
char line[512] = {};
|
||||
while (fgets(line, sizeof(line), file) != nullptr)
|
||||
{
|
||||
std::string currentLine(line);
|
||||
while (!currentLine.empty() &&
|
||||
(currentLine[currentLine.size() - 1] == '\n' || currentLine[currentLine.size() - 1] == '\r'))
|
||||
{
|
||||
currentLine.erase(currentLine.size() - 1);
|
||||
}
|
||||
|
||||
const size_t equalsIndex = currentLine.find('=');
|
||||
if (equalsIndex == std::string::npos)
|
||||
continue;
|
||||
|
||||
if (currentLine.substr(0, equalsIndex) == "api_base_url")
|
||||
{
|
||||
fclose(file);
|
||||
std::string value = currentLine.substr(equalsIndex + 1);
|
||||
TrimTrailingSlashes(&value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
}
|
||||
}
|
||||
|
||||
std::string fallback = "http://localhost:5187";
|
||||
TrimTrailingSlashes(&fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
std::string BuildHttpFailureMessage(DWORD httpStatus, const std::string &responseBody, const std::string &fallback)
|
||||
{
|
||||
const std::string parsed = ParseErrorMessage(responseBody, std::string());
|
||||
if (!parsed.empty())
|
||||
return parsed;
|
||||
|
||||
switch (httpStatus)
|
||||
{
|
||||
case 404:
|
||||
return "LCELive start request returned HTTP 404. Check the API base URL and port.";
|
||||
case 500:
|
||||
return "LCELive API returned HTTP 500 while creating a device link.";
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return "LCELive API is unavailable right now.";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
char buffer[96] = {};
|
||||
sprintf_s(buffer, "LCELive start request failed with HTTP %lu.", static_cast<unsigned long>(httpStatus));
|
||||
return fallback.empty() ? std::string(buffer) : std::string(buffer);
|
||||
}
|
||||
|
||||
bool ReadFileBytes(const char *path, std::vector<unsigned char> *outBytes)
|
||||
{
|
||||
if (path == nullptr || outBytes == nullptr)
|
||||
return false;
|
||||
|
||||
FILE *file = nullptr;
|
||||
if (fopen_s(&file, path, "rb") != 0 || file == nullptr)
|
||||
return false;
|
||||
|
||||
if (fseek(file, 0, SEEK_END) != 0)
|
||||
{
|
||||
fclose(file);
|
||||
return false;
|
||||
}
|
||||
|
||||
const long fileSize = ftell(file);
|
||||
if (fileSize <= 0)
|
||||
{
|
||||
fclose(file);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fseek(file, 0, SEEK_SET) != 0)
|
||||
{
|
||||
fclose(file);
|
||||
return false;
|
||||
}
|
||||
|
||||
outBytes->resize(static_cast<size_t>(fileSize));
|
||||
const size_t readCount = fread(outBytes->data(), 1, outBytes->size(), file);
|
||||
fclose(file);
|
||||
return readCount == outBytes->size();
|
||||
}
|
||||
|
||||
bool WriteFileBytes(const char *path, const unsigned char *data, size_t size)
|
||||
{
|
||||
if (path == nullptr || data == nullptr || size == 0)
|
||||
return false;
|
||||
|
||||
FILE *file = nullptr;
|
||||
if (fopen_s(&file, path, "wb") != 0 || file == nullptr)
|
||||
return false;
|
||||
|
||||
const size_t writeCount = fwrite(data, 1, size, file);
|
||||
fclose(file);
|
||||
return writeCount == size;
|
||||
}
|
||||
|
||||
bool ProtectString(const std::string &plainText, std::vector<unsigned char> *outEncrypted)
|
||||
{
|
||||
if (outEncrypted == nullptr)
|
||||
return false;
|
||||
|
||||
DATA_BLOB inputBlob = {};
|
||||
inputBlob.pbData = reinterpret_cast<BYTE *>(const_cast<char *>(plainText.data()));
|
||||
inputBlob.cbData = static_cast<DWORD>(plainText.size());
|
||||
|
||||
DATA_BLOB outputBlob = {};
|
||||
if (!CryptProtectData(&inputBlob, L"LCELive", nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &outputBlob))
|
||||
return false;
|
||||
|
||||
outEncrypted->assign(outputBlob.pbData, outputBlob.pbData + outputBlob.cbData);
|
||||
LocalFree(outputBlob.pbData);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UnprotectBytes(const std::vector<unsigned char> &encrypted, std::string *outPlainText)
|
||||
{
|
||||
if (outPlainText == nullptr || encrypted.empty())
|
||||
return false;
|
||||
|
||||
DATA_BLOB inputBlob = {};
|
||||
inputBlob.pbData = const_cast<BYTE *>(encrypted.data());
|
||||
inputBlob.cbData = static_cast<DWORD>(encrypted.size());
|
||||
|
||||
DATA_BLOB outputBlob = {};
|
||||
if (!CryptUnprotectData(&inputBlob, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &outputBlob))
|
||||
return false;
|
||||
|
||||
outPlainText->assign(reinterpret_cast<const char *>(outputBlob.pbData), reinterpret_cast<const char *>(outputBlob.pbData) + outputBlob.cbData);
|
||||
LocalFree(outputBlob.pbData);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char *GetAuthBlobPath()
|
||||
{
|
||||
InitOnceExecuteOnce(&g_authBlobPathOnce,
|
||||
[](PINIT_ONCE, PVOID, PVOID *) -> BOOL
|
||||
{
|
||||
BuildExeRelativePath("lcelive_auth.dat", g_authBlobPath, sizeof(g_authBlobPath));
|
||||
return TRUE;
|
||||
},
|
||||
nullptr,
|
||||
nullptr);
|
||||
return g_authBlobPath;
|
||||
}
|
||||
|
||||
void SaveAuthSessionLocked()
|
||||
{
|
||||
if (!g_state.session.valid)
|
||||
{
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
return;
|
||||
}
|
||||
|
||||
Json sessionJson;
|
||||
sessionJson["version"] = 1;
|
||||
sessionJson["accountId"] = g_state.session.accountId;
|
||||
sessionJson["username"] = g_state.session.username;
|
||||
sessionJson["displayName"] = g_state.session.displayName;
|
||||
sessionJson["accessToken"] = g_state.session.accessToken;
|
||||
sessionJson["refreshToken"] = g_state.session.refreshToken;
|
||||
|
||||
std::vector<unsigned char> encrypted;
|
||||
if (!ProtectString(sessionJson.dump(), &encrypted))
|
||||
{
|
||||
app.DebugPrintf("LCELive: failed to protect auth blob for local storage\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WriteFileBytes(GetAuthBlobPath(), encrypted.data(), encrypted.size()))
|
||||
app.DebugPrintf("LCELive: failed to write auth blob to disk\n");
|
||||
}
|
||||
|
||||
void ClearSessionLocked()
|
||||
{
|
||||
g_state.session = {};
|
||||
g_state.pendingLink = {};
|
||||
g_state.lastError.clear();
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
}
|
||||
|
||||
void LoadPersistedSessionLocked()
|
||||
{
|
||||
std::vector<unsigned char> encrypted;
|
||||
if (!ReadFileBytes(GetAuthBlobPath(), &encrypted))
|
||||
return;
|
||||
|
||||
std::string decrypted;
|
||||
if (!UnprotectBytes(encrypted, &decrypted))
|
||||
{
|
||||
app.DebugPrintf("LCELive: unable to decrypt stored auth state, clearing local blob\n");
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
return;
|
||||
}
|
||||
|
||||
const Json sessionJson = Json::parse(decrypted, nullptr, false);
|
||||
if (!sessionJson.is_object())
|
||||
{
|
||||
app.DebugPrintf("LCELive: stored auth state is invalid JSON, clearing local blob\n");
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
return;
|
||||
}
|
||||
|
||||
g_state.session.refreshToken = JsonStringOrEmpty(sessionJson, "refreshToken");
|
||||
if (g_state.session.refreshToken.empty())
|
||||
{
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
g_state.session = {};
|
||||
return;
|
||||
}
|
||||
|
||||
g_state.session.valid = true;
|
||||
g_state.session.accountId = JsonStringOrEmpty(sessionJson, "accountId");
|
||||
g_state.session.username = JsonStringOrEmpty(sessionJson, "username");
|
||||
g_state.session.displayName = JsonStringOrEmpty(sessionJson, "displayName");
|
||||
g_state.session.accessToken = JsonStringOrEmpty(sessionJson, "accessToken");
|
||||
}
|
||||
|
||||
bool CrackUrl(const std::wstring &baseUrl, URL_COMPONENTSW *outComponents, std::vector<wchar_t> *hostBuffer, std::vector<wchar_t> *pathBuffer)
|
||||
{
|
||||
if (outComponents == nullptr || hostBuffer == nullptr || pathBuffer == nullptr)
|
||||
return false;
|
||||
|
||||
hostBuffer->assign(256, 0);
|
||||
pathBuffer->assign(2048, 0);
|
||||
|
||||
ZeroMemory(outComponents, sizeof(*outComponents));
|
||||
outComponents->dwStructSize = sizeof(*outComponents);
|
||||
outComponents->lpszHostName = hostBuffer->data();
|
||||
outComponents->dwHostNameLength = static_cast<DWORD>(hostBuffer->size());
|
||||
outComponents->lpszUrlPath = pathBuffer->data();
|
||||
outComponents->dwUrlPathLength = static_cast<DWORD>(pathBuffer->size());
|
||||
|
||||
return WinHttpCrackUrl(baseUrl.c_str(), static_cast<DWORD>(baseUrl.length()), 0, outComponents) == TRUE;
|
||||
}
|
||||
|
||||
bool PerformJsonRequest(const RequestContext &request, DWORD *outStatus, std::string *outResponseBody)
|
||||
{
|
||||
if (outStatus == nullptr || outResponseBody == nullptr)
|
||||
return false;
|
||||
|
||||
*outStatus = 0;
|
||||
outResponseBody->clear();
|
||||
|
||||
const std::string baseUrlUtf8 = GetApiBaseUrl();
|
||||
const std::wstring baseUrl = Utf8ToWide(baseUrlUtf8);
|
||||
URL_COMPONENTSW components = {};
|
||||
std::vector<wchar_t> hostBuffer;
|
||||
std::vector<wchar_t> pathBuffer;
|
||||
if (!CrackUrl(baseUrl, &components, &hostBuffer, &pathBuffer))
|
||||
{
|
||||
app.DebugPrintf("LCELive: WinHttpCrackUrl failed for '%s'\n", baseUrlUtf8.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring fullPath = components.lpszUrlPath != nullptr
|
||||
? std::wstring(components.lpszUrlPath, components.dwUrlPathLength)
|
||||
: std::wstring();
|
||||
if (!request.path.empty())
|
||||
fullPath += Utf8ToWide(request.path);
|
||||
if (fullPath.empty())
|
||||
fullPath = L"/";
|
||||
|
||||
const bool secure = components.nScheme == INTERNET_SCHEME_HTTPS;
|
||||
|
||||
HINTERNET session = WinHttpOpen(L"MCLCE-LceLive/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
|
||||
if (session == nullptr)
|
||||
return false;
|
||||
|
||||
WinHttpSetTimeouts(session, 5000, 5000, 10000, 10000);
|
||||
|
||||
const std::wstring hostWide(components.lpszHostName, components.dwHostNameLength);
|
||||
HINTERNET connection = WinHttpConnect(session, hostWide.c_str(), components.nPort, 0);
|
||||
if (connection == nullptr)
|
||||
{
|
||||
WinHttpCloseHandle(session);
|
||||
return false;
|
||||
}
|
||||
|
||||
const wchar_t *verb = request.body.empty() ? L"GET" : L"POST";
|
||||
HINTERNET requestHandle = WinHttpOpenRequest(connection, verb, fullPath.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0);
|
||||
if (requestHandle == nullptr)
|
||||
{
|
||||
WinHttpCloseHandle(connection);
|
||||
WinHttpCloseHandle(session);
|
||||
return false;
|
||||
}
|
||||
|
||||
const wchar_t *headers = request.body.empty()
|
||||
? L"Accept: application/json\r\n"
|
||||
: L"Content-Type: application/json\r\nAccept: application/json\r\n";
|
||||
LPVOID sendBuffer = WINHTTP_NO_REQUEST_DATA;
|
||||
DWORD sendSize = 0;
|
||||
if (!request.body.empty())
|
||||
{
|
||||
sendBuffer = const_cast<char *>(request.body.data());
|
||||
sendSize = static_cast<DWORD>(request.body.size());
|
||||
}
|
||||
|
||||
const BOOL sendOk = WinHttpSendRequest(requestHandle, headers, -1L, sendBuffer, sendSize, sendSize, 0);
|
||||
if (!sendOk || !WinHttpReceiveResponse(requestHandle, nullptr))
|
||||
{
|
||||
WinHttpCloseHandle(requestHandle);
|
||||
WinHttpCloseHandle(connection);
|
||||
WinHttpCloseHandle(session);
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD statusCode = 0;
|
||||
DWORD statusCodeSize = sizeof(statusCode);
|
||||
if (!WinHttpQueryHeaders(requestHandle, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &statusCode, &statusCodeSize, WINHTTP_NO_HEADER_INDEX))
|
||||
{
|
||||
WinHttpCloseHandle(requestHandle);
|
||||
WinHttpCloseHandle(connection);
|
||||
WinHttpCloseHandle(session);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string responseBody;
|
||||
for (;;)
|
||||
{
|
||||
DWORD bytesAvailable = 0;
|
||||
if (!WinHttpQueryDataAvailable(requestHandle, &bytesAvailable))
|
||||
break;
|
||||
if (bytesAvailable == 0)
|
||||
break;
|
||||
|
||||
std::vector<char> buffer(bytesAvailable);
|
||||
DWORD bytesRead = 0;
|
||||
if (!WinHttpReadData(requestHandle, buffer.data(), bytesAvailable, &bytesRead))
|
||||
break;
|
||||
|
||||
responseBody.append(buffer.data(), buffer.data() + bytesRead);
|
||||
}
|
||||
|
||||
*outStatus = statusCode;
|
||||
*outResponseBody = responseBody;
|
||||
|
||||
WinHttpCloseHandle(requestHandle);
|
||||
WinHttpCloseHandle(connection);
|
||||
WinHttpCloseHandle(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ParseErrorMessage(const std::string &responseBody, const std::string &fallback)
|
||||
{
|
||||
if (responseBody.empty())
|
||||
return fallback;
|
||||
|
||||
const Json responseJson = Json::parse(responseBody, nullptr, false);
|
||||
if (!responseJson.is_object())
|
||||
return fallback;
|
||||
|
||||
const std::string message = JsonStringOrEmpty(responseJson, "message");
|
||||
return message.empty() ? fallback : message;
|
||||
}
|
||||
|
||||
DWORD WINAPI RequestThreadProc(LPVOID)
|
||||
{
|
||||
RequestContext request = {};
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
request = g_state.request;
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
|
||||
CompletedRequest completed = {};
|
||||
completed.type = request.type;
|
||||
completed.transportOk = PerformJsonRequest(request, &completed.httpStatus, &completed.responseBody);
|
||||
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
g_state.completed = completed;
|
||||
g_state.completedReady = true;
|
||||
g_state.requestInFlight = false;
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool QueueRequestLocked(ERequestType type, const std::string &path, const std::string &body)
|
||||
{
|
||||
if (g_state.requestInFlight)
|
||||
return false;
|
||||
|
||||
if (g_state.workerThread != nullptr)
|
||||
{
|
||||
WaitForSingleObject(g_state.workerThread, INFINITE);
|
||||
CloseHandle(g_state.workerThread);
|
||||
g_state.workerThread = nullptr;
|
||||
}
|
||||
|
||||
g_state.request = {};
|
||||
g_state.request.type = type;
|
||||
g_state.request.path = path;
|
||||
g_state.request.body = body;
|
||||
g_state.requestInFlight = true;
|
||||
g_state.completedReady = false;
|
||||
g_state.workerThread = CreateThread(nullptr, 0, &RequestThreadProc, nullptr, 0, nullptr);
|
||||
if (g_state.workerThread == nullptr)
|
||||
{
|
||||
g_state.requestInFlight = false;
|
||||
g_state.lastError = "Unable to create LCELive worker thread.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void QueueSessionRefreshLocked()
|
||||
{
|
||||
if (!g_state.session.valid || g_state.session.refreshToken.empty() || g_state.requestInFlight)
|
||||
return;
|
||||
|
||||
Json requestJson;
|
||||
requestJson["refreshToken"] = g_state.session.refreshToken;
|
||||
if (QueueRequestLocked(ERequestType::Refresh, "/api/auth/refresh", requestJson.dump()))
|
||||
g_state.sessionRefreshInFlight = true;
|
||||
}
|
||||
|
||||
BOOL CALLBACK InitializeRuntimeState(PINIT_ONCE, PVOID, PVOID *)
|
||||
{
|
||||
InitializeCriticalSection(&g_state.lock);
|
||||
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
g_state.initialized = true;
|
||||
LoadPersistedSessionLocked();
|
||||
QueueSessionRefreshLocked();
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void EnsureInitialized()
|
||||
{
|
||||
InitOnceExecuteOnce(&g_initializeOnce, &InitializeRuntimeState, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void ApplyStartLinkResponseLocked(const CompletedRequest &completed)
|
||||
{
|
||||
if (!completed.transportOk)
|
||||
{
|
||||
g_state.lastError = "Unable to contact LCELive.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (completed.httpStatus < 200 || completed.httpStatus >= 300)
|
||||
{
|
||||
g_state.lastError = BuildHttpFailureMessage(completed.httpStatus, completed.responseBody, "LCELive rejected the device-link request.");
|
||||
return;
|
||||
}
|
||||
|
||||
const Json responseJson = Json::parse(completed.responseBody, nullptr, false);
|
||||
if (!responseJson.is_object())
|
||||
{
|
||||
g_state.lastError = "LCELive returned an invalid device-link response.";
|
||||
return;
|
||||
}
|
||||
|
||||
g_state.pendingLink = {};
|
||||
g_state.pendingLink.active = true;
|
||||
g_state.pendingLink.deviceCode = JsonStringOrEmpty(responseJson, "deviceCode");
|
||||
g_state.pendingLink.userCode = JsonStringOrEmpty(responseJson, "userCode");
|
||||
g_state.pendingLink.verificationUri = JsonStringOrEmpty(responseJson, "verificationUri");
|
||||
g_state.pendingLink.verificationUriComplete = JsonStringOrEmpty(responseJson, "verificationUriComplete");
|
||||
|
||||
int intervalSeconds = 5;
|
||||
const Json::const_iterator intervalIt = responseJson.find("intervalSeconds");
|
||||
if (intervalIt != responseJson.end() && intervalIt->is_number_integer())
|
||||
intervalSeconds = intervalIt->get<int>();
|
||||
if (intervalSeconds < 1)
|
||||
intervalSeconds = 5;
|
||||
|
||||
g_state.pendingLink.nextPollAt = GetTickCount64() + (static_cast<ULONGLONG>(intervalSeconds) * 1000ULL);
|
||||
g_state.lastError.clear();
|
||||
}
|
||||
|
||||
void ApplyPollResponseLocked(const CompletedRequest &completed)
|
||||
{
|
||||
if (!g_state.pendingLink.active)
|
||||
return;
|
||||
|
||||
if (!completed.transportOk)
|
||||
{
|
||||
g_state.pendingLink.active = false;
|
||||
g_state.lastError = "LCELive polling failed. Press A to request a new code.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (completed.httpStatus < 200 || completed.httpStatus >= 300)
|
||||
{
|
||||
g_state.pendingLink.active = false;
|
||||
g_state.lastError = ParseErrorMessage(completed.responseBody, "LCELive rejected the device poll.");
|
||||
return;
|
||||
}
|
||||
|
||||
const Json responseJson = Json::parse(completed.responseBody, nullptr, false);
|
||||
if (!responseJson.is_object())
|
||||
{
|
||||
g_state.pendingLink.active = false;
|
||||
g_state.lastError = "LCELive returned an invalid poll response.";
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string status = JsonStringOrEmpty(responseJson, "status");
|
||||
const bool isLinked = responseJson.contains("isLinked") && responseJson["isLinked"].is_boolean() && responseJson["isLinked"].get<bool>();
|
||||
if (isLinked)
|
||||
{
|
||||
const Json::const_iterator accountIt = responseJson.find("account");
|
||||
if (accountIt == responseJson.end() || !accountIt->is_object())
|
||||
{
|
||||
g_state.pendingLink.active = false;
|
||||
g_state.lastError = "LCELive linked the device but omitted account details.";
|
||||
return;
|
||||
}
|
||||
|
||||
g_state.session.valid = true;
|
||||
g_state.session.accountId = JsonStringOrEmpty(*accountIt, "accountId");
|
||||
g_state.session.username = JsonStringOrEmpty(*accountIt, "username");
|
||||
g_state.session.displayName = JsonStringOrEmpty(*accountIt, "displayName");
|
||||
g_state.session.accessToken = JsonStringOrEmpty(responseJson, "accessToken");
|
||||
g_state.session.refreshToken = JsonStringOrEmpty(responseJson, "refreshToken");
|
||||
g_state.pendingLink = {};
|
||||
g_state.lastError.clear();
|
||||
SaveAuthSessionLocked();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == "pending")
|
||||
{
|
||||
g_state.pendingLink.nextPollAt = GetTickCount64() + 5000ULL;
|
||||
g_state.lastError.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
g_state.pendingLink.active = false;
|
||||
if (status == "expired")
|
||||
g_state.lastError = "That device code expired. Press A to request a new one.";
|
||||
else
|
||||
g_state.lastError = "LCELive returned an unexpected poll status.";
|
||||
}
|
||||
|
||||
void ApplyRefreshResponseLocked(const CompletedRequest &completed)
|
||||
{
|
||||
g_state.sessionRefreshInFlight = false;
|
||||
if (!g_state.session.valid)
|
||||
return;
|
||||
|
||||
if (!completed.transportOk)
|
||||
{
|
||||
g_state.lastError = "Unable to reach LCELive. Using the cached local sign-in for now.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (completed.httpStatus < 200 || completed.httpStatus >= 300)
|
||||
{
|
||||
ClearSessionLocked();
|
||||
g_state.lastError = "Saved LCELive sign-in expired. Press A to link this device again.";
|
||||
return;
|
||||
}
|
||||
|
||||
const Json responseJson = Json::parse(completed.responseBody, nullptr, false);
|
||||
if (!responseJson.is_object())
|
||||
{
|
||||
g_state.lastError = "LCELive returned an invalid session refresh response.";
|
||||
return;
|
||||
}
|
||||
|
||||
const Json::const_iterator accountIt = responseJson.find("account");
|
||||
if (accountIt == responseJson.end() || !accountIt->is_object())
|
||||
{
|
||||
ClearSessionLocked();
|
||||
g_state.lastError = "LCELive refresh omitted account details. Link this device again.";
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string refreshedAccessToken = JsonStringOrEmpty(responseJson, "accessToken");
|
||||
const std::string refreshedRefreshToken = JsonStringOrEmpty(responseJson, "refreshToken");
|
||||
if (refreshedAccessToken.empty() || refreshedRefreshToken.empty())
|
||||
{
|
||||
ClearSessionLocked();
|
||||
g_state.lastError = "LCELive refresh returned incomplete credentials. Link this device again.";
|
||||
return;
|
||||
}
|
||||
|
||||
g_state.session.valid = true;
|
||||
g_state.session.accountId = JsonStringOrEmpty(*accountIt, "accountId");
|
||||
g_state.session.username = JsonStringOrEmpty(*accountIt, "username");
|
||||
g_state.session.displayName = JsonStringOrEmpty(*accountIt, "displayName");
|
||||
g_state.session.accessToken = refreshedAccessToken;
|
||||
g_state.session.refreshToken = refreshedRefreshToken;
|
||||
g_state.lastError.clear();
|
||||
SaveAuthSessionLocked();
|
||||
}
|
||||
|
||||
void IntegrateCompletedRequestLocked()
|
||||
{
|
||||
if (!g_state.completedReady)
|
||||
return;
|
||||
|
||||
const CompletedRequest completed = g_state.completed;
|
||||
g_state.completedReady = false;
|
||||
|
||||
if (g_state.workerThread != nullptr)
|
||||
{
|
||||
WaitForSingleObject(g_state.workerThread, INFINITE);
|
||||
CloseHandle(g_state.workerThread);
|
||||
g_state.workerThread = nullptr;
|
||||
}
|
||||
|
||||
switch (completed.type)
|
||||
{
|
||||
case ERequestType::StartLink:
|
||||
ApplyStartLinkResponseLocked(completed);
|
||||
break;
|
||||
case ERequestType::Poll:
|
||||
ApplyPollResponseLocked(completed);
|
||||
break;
|
||||
case ERequestType::Refresh:
|
||||
ApplyRefreshResponseLocked(completed);
|
||||
break;
|
||||
case ERequestType::Logout:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring BuildStatusMessageLocked()
|
||||
{
|
||||
if (g_state.session.valid)
|
||||
{
|
||||
std::wstring message;
|
||||
message += L"Signed in as:\r\n";
|
||||
message += Utf8ToWide(g_state.session.displayName);
|
||||
if (!g_state.session.username.empty())
|
||||
{
|
||||
message += L" (@";
|
||||
message += Utf8ToWide(g_state.session.username);
|
||||
message += L")";
|
||||
}
|
||||
|
||||
message += L"\r\nThis device is linked locally for LCELive features.";
|
||||
if (g_state.sessionRefreshInFlight)
|
||||
message += L"\r\nRefreshing the saved session with LCELive...";
|
||||
|
||||
message += L"\r\nPress A to sign out on this device.";
|
||||
return message;
|
||||
}
|
||||
|
||||
if (g_state.pendingLink.active)
|
||||
{
|
||||
std::wstring message;
|
||||
message += L"Go to:\r\n";
|
||||
message += Utf8ToWide(g_state.pendingLink.verificationUri);
|
||||
message += L"\r\nEnter code:\r\n";
|
||||
message += Utf8ToWide(g_state.pendingLink.userCode);
|
||||
message += L"\r\nThis screen will keep checking until the link completes.";
|
||||
return message;
|
||||
}
|
||||
|
||||
return L"Sign-in is optional. Offline play is unchanged.\r\nPress A to request a device code.\r\nThen visit the LCELive link page and enter the code shown here.";
|
||||
}
|
||||
}
|
||||
|
||||
namespace Win64LceLive
|
||||
{
|
||||
void Tick()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
IntegrateCompletedRequestLocked();
|
||||
if (g_state.pendingLink.active && !g_state.requestInFlight && GetTickCount64() >= g_state.pendingLink.nextPollAt)
|
||||
QueueRequestLocked(ERequestType::Poll, "/api/auth/device/poll/" + g_state.pendingLink.deviceCode, std::string());
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
}
|
||||
|
||||
Snapshot GetSnapshot()
|
||||
{
|
||||
EnsureInitialized();
|
||||
Tick();
|
||||
|
||||
Snapshot snapshot = {};
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
snapshot.requestInFlight = g_state.requestInFlight;
|
||||
snapshot.hasError = !g_state.lastError.empty();
|
||||
snapshot.errorMessage = Utf8ToWide(g_state.lastError);
|
||||
snapshot.statusMessage = BuildStatusMessageLocked();
|
||||
|
||||
if (g_state.session.valid)
|
||||
{
|
||||
snapshot.state = EClientState::SignedIn;
|
||||
snapshot.accountDisplayName = Utf8ToWide(g_state.session.displayName);
|
||||
snapshot.accountUsername = Utf8ToWide(g_state.session.username);
|
||||
snapshot.accountId = Utf8ToWide(g_state.session.accountId);
|
||||
}
|
||||
else if (g_state.pendingLink.active)
|
||||
{
|
||||
snapshot.state = g_state.requestInFlight ? EClientState::Polling : EClientState::LinkPending;
|
||||
snapshot.verificationUri = Utf8ToWide(g_state.pendingLink.verificationUri);
|
||||
snapshot.verificationUriComplete = Utf8ToWide(g_state.pendingLink.verificationUriComplete);
|
||||
snapshot.userCode = Utf8ToWide(g_state.pendingLink.userCode);
|
||||
}
|
||||
else if (g_state.requestInFlight)
|
||||
{
|
||||
snapshot.state = EClientState::StartingLink;
|
||||
}
|
||||
else
|
||||
{
|
||||
snapshot.state = EClientState::SignedOut;
|
||||
}
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
bool StartDeviceLink()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
IntegrateCompletedRequestLocked();
|
||||
if (g_state.requestInFlight || g_state.session.valid)
|
||||
{
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
g_state.pendingLink = {};
|
||||
g_state.lastError.clear();
|
||||
|
||||
Json requestJson;
|
||||
requestJson["deviceId"] = BuildDeviceId();
|
||||
requestJson["deviceName"] = BuildDeviceName();
|
||||
|
||||
const bool queued = QueueRequestLocked(ERequestType::StartLink, "/api/auth/device/start", requestJson.dump());
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
return queued;
|
||||
}
|
||||
|
||||
bool SignOut()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_state.lock);
|
||||
IntegrateCompletedRequestLocked();
|
||||
if (g_state.requestInFlight)
|
||||
{
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!g_state.session.valid && !g_state.pendingLink.active)
|
||||
{
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string refreshToken = g_state.session.refreshToken;
|
||||
ClearSessionLocked();
|
||||
if (!refreshToken.empty() && !g_state.requestInFlight)
|
||||
{
|
||||
Json requestJson;
|
||||
requestJson["refreshToken"] = refreshToken;
|
||||
QueueRequestLocked(ERequestType::Logout, "/api/auth/logout", requestJson.dump());
|
||||
}
|
||||
LeaveCriticalSection(&g_state.lock);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
39
Minecraft.Client/Windows64/Windows64_LceLive.h
Normal file
39
Minecraft.Client/Windows64/Windows64_LceLive.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Win64LceLive
|
||||
{
|
||||
enum class EClientState
|
||||
{
|
||||
SignedOut,
|
||||
StartingLink,
|
||||
LinkPending,
|
||||
Polling,
|
||||
SignedIn,
|
||||
};
|
||||
|
||||
struct Snapshot
|
||||
{
|
||||
EClientState state;
|
||||
bool requestInFlight;
|
||||
bool hasError;
|
||||
std::wstring accountDisplayName;
|
||||
std::wstring accountUsername;
|
||||
std::wstring accountId;
|
||||
std::wstring verificationUri;
|
||||
std::wstring verificationUriComplete;
|
||||
std::wstring userCode;
|
||||
std::wstring statusMessage;
|
||||
std::wstring errorMessage;
|
||||
};
|
||||
|
||||
void Tick();
|
||||
Snapshot GetSnapshot();
|
||||
bool StartDeviceLink();
|
||||
bool SignOut();
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -150,6 +150,8 @@ set(_MINECRAFT_CLIENT_WINDOWS_COMMON_UI_SCENES_FRONTEND_MENU_SCREENS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_Intro.h"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_JoinMenu.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_JoinMenu.h"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLive.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLive.h"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LaunchMoreOptionsMenu.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LaunchMoreOptionsMenu.h"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LeaderboardsMenu.cpp"
|
||||
@@ -340,6 +342,8 @@ set(_MINECRAFT_CLIENT_WINDOWS_WINDOWS64
|
||||
"${BASE_DIR}/Resource.h"
|
||||
"${BASE_DIR}/Windows64_App.cpp"
|
||||
"${BASE_DIR}/Windows64_App.h"
|
||||
"${BASE_DIR}/Windows64_LceLive.cpp"
|
||||
"${BASE_DIR}/Windows64_LceLive.h"
|
||||
"${BASE_DIR}/Windows64_UIController.cpp"
|
||||
"${BASE_DIR}/Windows64_UIController.h"
|
||||
"${BASE_DIR}/KeyboardMouseInput.cpp"
|
||||
|
||||
@@ -37,9 +37,11 @@ set_target_properties(Minecraft.Server PROPERTIES
|
||||
|
||||
target_link_libraries(Minecraft.Server PRIVATE
|
||||
Minecraft.World
|
||||
crypt32
|
||||
d3d11
|
||||
d3dcompiler
|
||||
XInput9_1_0
|
||||
winhttp
|
||||
wsock32
|
||||
legacy_stdio_definitions
|
||||
$<$<CONFIG:Debug>: # Debug 4J libraries
|
||||
|
||||
@@ -208,6 +208,7 @@ set(_MINECRAFT_SERVER_COMMON_ROOT
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_InventoryMenu.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_JoinMenu.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_Keyboard.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_LceLive.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_LanguageSelector.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_LaunchMoreOptionsMenu.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_LeaderboardsMenu.cpp"
|
||||
|
||||
150
tools/repack_media_arc.py
Normal file
150
tools/repack_media_arc.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
raw_name: str
|
||||
ptr: int
|
||||
size: int
|
||||
data: bytes
|
||||
|
||||
@property
|
||||
def logical_name(self) -> str:
|
||||
return self.raw_name[1:] if self.raw_name.startswith("*") else self.raw_name
|
||||
|
||||
|
||||
def read_utf(data: bytes, offset: int) -> tuple[str, int]:
|
||||
(utf_len,) = struct.unpack_from(">H", data, offset)
|
||||
offset += 2
|
||||
raw = data[offset : offset + utf_len]
|
||||
offset += utf_len
|
||||
return raw.decode("utf-8"), offset
|
||||
|
||||
|
||||
def write_utf(value: str) -> bytes:
|
||||
raw = value.encode("utf-8")
|
||||
if len(raw) > 0xFFFF:
|
||||
raise ValueError(f"archive entry name is too long: {value}")
|
||||
return struct.pack(">H", len(raw)) + raw
|
||||
|
||||
|
||||
def load_archive(path: Path) -> list[Entry]:
|
||||
blob = path.read_bytes()
|
||||
offset = 0
|
||||
(count,) = struct.unpack_from(">i", blob, offset)
|
||||
offset += 4
|
||||
|
||||
headers: list[tuple[str, int, int]] = []
|
||||
for _ in range(count):
|
||||
raw_name, offset = read_utf(blob, offset)
|
||||
ptr, size = struct.unpack_from(">ii", blob, offset)
|
||||
offset += 8
|
||||
headers.append((raw_name, ptr, size))
|
||||
|
||||
entries: list[Entry] = []
|
||||
for raw_name, ptr, size in headers:
|
||||
entries.append(Entry(raw_name=raw_name, ptr=ptr, size=size, data=blob[ptr : ptr + size]))
|
||||
return entries
|
||||
|
||||
|
||||
def build_archive(entries: list[Entry]) -> bytes:
|
||||
header_parts: list[bytes] = [struct.pack(">i", len(entries))]
|
||||
data_offset = 4
|
||||
|
||||
encoded_names = [write_utf(entry.raw_name) for entry in entries]
|
||||
for encoded_name, entry in zip(encoded_names, entries, strict=True):
|
||||
data_offset += len(encoded_name) + 8
|
||||
|
||||
payload_parts: list[bytes] = []
|
||||
current_ptr = data_offset
|
||||
for encoded_name, entry in zip(encoded_names, entries, strict=True):
|
||||
header_parts.append(encoded_name)
|
||||
header_parts.append(struct.pack(">ii", current_ptr, len(entry.data)))
|
||||
payload_parts.append(entry.data)
|
||||
current_ptr += len(entry.data)
|
||||
|
||||
return b"".join(header_parts + payload_parts)
|
||||
|
||||
|
||||
def apply_overlays(entries: list[Entry], overlays: dict[str, Path]) -> list[str]:
|
||||
replaced: list[str] = []
|
||||
by_name = {entry.logical_name: entry for entry in entries}
|
||||
|
||||
for logical_name, overlay_path in overlays.items():
|
||||
if not overlay_path.exists():
|
||||
raise FileNotFoundError(f"overlay file does not exist: {overlay_path}")
|
||||
|
||||
overlay_data = overlay_path.read_bytes()
|
||||
existing = by_name.get(logical_name)
|
||||
if existing is None:
|
||||
entries.append(Entry(raw_name=logical_name, ptr=0, size=len(overlay_data), data=overlay_data))
|
||||
else:
|
||||
if existing.raw_name.startswith("*"):
|
||||
raise ValueError(
|
||||
f"refusing to replace compressed archive entry '{existing.raw_name}' with raw data"
|
||||
)
|
||||
existing.data = overlay_data
|
||||
existing.size = len(overlay_data)
|
||||
replaced.append(logical_name)
|
||||
|
||||
return replaced
|
||||
|
||||
|
||||
def parse_overlay_args(values: list[str]) -> dict[str, Path]:
|
||||
overlays: dict[str, Path] = {}
|
||||
for value in values:
|
||||
name, separator, path = value.partition("=")
|
||||
if not separator:
|
||||
raise ValueError(f"overlay must be NAME=PATH, got: {value}")
|
||||
overlays[name] = Path(path)
|
||||
return overlays
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Overlay selected files into a MediaWindows64.arc archive.")
|
||||
parser.add_argument("input", type=Path, help="Existing archive to read")
|
||||
parser.add_argument("output", type=Path, help="Archive path to write")
|
||||
parser.add_argument(
|
||||
"--overlay",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=PATH",
|
||||
help="Replace or add archive entry NAME with the bytes from PATH",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backup",
|
||||
action="store_true",
|
||||
help="Write a .bak copy of the output file before replacing it when the output already exists",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
overlays = parse_overlay_args(args.overlay)
|
||||
entries = load_archive(args.input)
|
||||
touched = apply_overlays(entries, overlays)
|
||||
rebuilt = build_archive(entries)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if args.backup and args.output.exists():
|
||||
backup_path = args.output.with_suffix(args.output.suffix + ".bak")
|
||||
shutil.copy2(args.output, backup_path)
|
||||
|
||||
args.output.write_bytes(rebuilt)
|
||||
|
||||
print(f"wrote {args.output}")
|
||||
print(f"entries: {len(entries)}")
|
||||
if touched:
|
||||
print("overlays:")
|
||||
for logical_name in touched:
|
||||
print(f" {logical_name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user