diff --git a/Minecraft.Client/CMakeLists.txt b/Minecraft.Client/CMakeLists.txt index 3b029610..e46257cf 100644 --- a/Minecraft.Client/CMakeLists.txt +++ b/Minecraft.Client/CMakeLists.txt @@ -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 $<$: # Debug 4J libraries diff --git a/Minecraft.Client/Common/Media/LceLive1080.swf b/Minecraft.Client/Common/Media/LceLive1080.swf new file mode 100644 index 00000000..63f5e367 Binary files /dev/null and b/Minecraft.Client/Common/Media/LceLive1080.swf differ diff --git a/Minecraft.Client/Common/Media/LceLive480.swf b/Minecraft.Client/Common/Media/LceLive480.swf new file mode 100644 index 00000000..f59582c4 Binary files /dev/null and b/Minecraft.Client/Common/Media/LceLive480.swf differ diff --git a/Minecraft.Client/Common/Media/LceLive720.swf b/Minecraft.Client/Common/Media/LceLive720.swf new file mode 100644 index 00000000..d6afdee6 Binary files /dev/null and b/Minecraft.Client/Common/Media/LceLive720.swf differ diff --git a/Minecraft.Client/Common/Media/LceLiveVita.swf b/Minecraft.Client/Common/Media/LceLiveVita.swf new file mode 100644 index 00000000..459be153 Binary files /dev/null and b/Minecraft.Client/Common/Media/LceLiveVita.swf differ diff --git a/Minecraft.Client/Common/Media/MainMenu1080.swf b/Minecraft.Client/Common/Media/MainMenu1080.swf index a3c55273..b7ac6005 100644 Binary files a/Minecraft.Client/Common/Media/MainMenu1080.swf and b/Minecraft.Client/Common/Media/MainMenu1080.swf differ diff --git a/Minecraft.Client/Common/Media/MainMenu480.swf b/Minecraft.Client/Common/Media/MainMenu480.swf index adc8e52a..d1d9146a 100644 Binary files a/Minecraft.Client/Common/Media/MainMenu480.swf and b/Minecraft.Client/Common/Media/MainMenu480.swf differ diff --git a/Minecraft.Client/Common/Media/MainMenu720.swf b/Minecraft.Client/Common/Media/MainMenu720.swf index 3823791e..da6374be 100644 Binary files a/Minecraft.Client/Common/Media/MainMenu720.swf and b/Minecraft.Client/Common/Media/MainMenu720.swf differ diff --git a/Minecraft.Client/Common/Media/MainMenuVita.swf b/Minecraft.Client/Common/Media/MainMenuVita.swf index c9477bce..e8d1a68e 100644 Binary files a/Minecraft.Client/Common/Media/MainMenuVita.swf and b/Minecraft.Client/Common/Media/MainMenuVita.swf differ diff --git a/Minecraft.Client/Common/Media/MediaWindows64.arc b/Minecraft.Client/Common/Media/MediaWindows64.arc index b810e4d8..cf09046c 100644 Binary files a/Minecraft.Client/Common/Media/MediaWindows64.arc and b/Minecraft.Client/Common/Media/MediaWindows64.arc differ diff --git a/Minecraft.Client/Common/UI/UI.h b/Minecraft.Client/Common/UI/UI.h index a7c416f8..771f7531 100644 --- a/Minecraft.Client/Common/UI/UI.h +++ b/Minecraft.Client/Common/UI/UI.h @@ -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; \ No newline at end of file +extern ConsoleUIController ui; diff --git a/Minecraft.Client/Common/UI/UIEnums.h b/Minecraft.Client/Common/UI/UIEnums.h index 45aff87d..91d0046a 100644 --- a/Minecraft.Client/Common/UI/UIEnums.h +++ b/Minecraft.Client/Common/UI/UIEnums.h @@ -121,6 +121,7 @@ enum EUIScene eUIComponent_Tooltips, eUIComponent_PressStartToPlay, eUIComponent_MenuBackground, + eUIScene_LceLive, eUIScene_Keyboard, eUIScene_QuadrantSignin, eUIScene_MessageBox, diff --git a/Minecraft.Client/Common/UI/UILayer.cpp b/Minecraft.Client/Common/UI/UILayer.cpp index e1c388f5..15552e33 100644 --- a/Minecraft.Client/Common/UI/UILayer.cpp +++ b/Minecraft.Client/Common/UI/UILayer.cpp @@ -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; -} \ No newline at end of file +} diff --git a/Minecraft.Client/Common/UI/UIScene_LceLive.cpp b/Minecraft.Client/Common/UI/UIScene_LceLive.cpp new file mode 100644 index 00000000..32f8aeeb --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLive.cpp @@ -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(eControl_PrimaryAction), 0.0); + } + break; +#ifdef __ORBIS__ + case ACTION_MENU_TOUCHPAD_PRESS: + if (pressed && !repeat && m_buttonEnabled) + { + handled = true; + handlePress(static_cast(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(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; +} diff --git a/Minecraft.Client/Common/UI/UIScene_LceLive.h b/Minecraft.Client/Common/UI/UIScene_LceLive.h new file mode 100644 index 00000000..fa7a43b4 --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLive.h @@ -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); +}; diff --git a/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp b/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp index 1eef994f..fef040a4 100644 --- a/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_MainMenu.cpp @@ -41,7 +41,7 @@ UIScene_MainMenu::UIScene_MainMenu(int iPad, void *initData, UILayer *parentLaye #endif m_buttons[static_cast(eControl_Leaderboards)].init(IDS_LEADERBOARDS,eControl_Leaderboards); - m_buttons[static_cast(eControl_Achievements)].init( (UIString)IDS_ACHIEVEMENTS,eControl_Achievements); + m_buttons[static_cast(eControl_Achievements)].init((UIString)IDS_ACHIEVEMENTS, eControl_Achievements); m_buttons[static_cast(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(eControl_UnlockOrDLC)].init(IDS_UNLOCK_FULL_GAME,eControl_UnlockOrDLC); } +#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD) + m_buttons[static_cast(eControl_LceLive)].init(L"LCELIVE", eControl_LceLive); +#else + m_buttons[static_cast(eControl_LceLive)].init(L"LCELIVE", eControl_LceLive); + removeControl(&m_buttons[(int)eControl_LceLive], false); +#endif + #ifndef _DURANGO m_buttons[static_cast(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); diff --git a/Minecraft.Client/Common/UI/UIScene_MainMenu.h b/Minecraft.Client/Common/UI/UIScene_MainMenu.h index 2b49a44b..34d66de5 100644 --- a/Minecraft.Client/Common/UI/UIScene_MainMenu.h +++ b/Minecraft.Client/Common/UI/UIScene_MainMenu.h @@ -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 diff --git a/Minecraft.Client/Windows64/Windows64_LceLive.cpp b/Minecraft.Client/Windows64/Windows64_LceLive.cpp new file mode 100644 index 00000000..4d1be18d --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLive.cpp @@ -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 +#include +#include + +#include +#include +#include + +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(text.size()), nullptr, 0); + if (length <= 0) + return convStringToWstring(text); + + std::wstring result; + result.resize(static_cast(length)); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(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(text.size()), nullptr, 0, nullptr, nullptr); + if (length <= 0) + return std::string(wstringtochararray(text)); + + std::string result; + result.resize(static_cast(length)); + WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast(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 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(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(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(httpStatus)); + return fallback.empty() ? std::string(buffer) : std::string(buffer); + } + + bool ReadFileBytes(const char *path, std::vector *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(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 *outEncrypted) + { + if (outEncrypted == nullptr) + return false; + + DATA_BLOB inputBlob = {}; + inputBlob.pbData = reinterpret_cast(const_cast(plainText.data())); + inputBlob.cbData = static_cast(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 &encrypted, std::string *outPlainText) + { + if (outPlainText == nullptr || encrypted.empty()) + return false; + + DATA_BLOB inputBlob = {}; + inputBlob.pbData = const_cast(encrypted.data()); + inputBlob.cbData = static_cast(encrypted.size()); + + DATA_BLOB outputBlob = {}; + if (!CryptUnprotectData(&inputBlob, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &outputBlob)) + return false; + + outPlainText->assign(reinterpret_cast(outputBlob.pbData), reinterpret_cast(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 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 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 *hostBuffer, std::vector *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(hostBuffer->size()); + outComponents->lpszUrlPath = pathBuffer->data(); + outComponents->dwUrlPathLength = static_cast(pathBuffer->size()); + + return WinHttpCrackUrl(baseUrl.c_str(), static_cast(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 hostBuffer; + std::vector 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(request.body.data()); + sendSize = static_cast(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 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(); + if (intervalSeconds < 1) + intervalSeconds = 5; + + g_state.pendingLink.nextPollAt = GetTickCount64() + (static_cast(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(); + 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 diff --git a/Minecraft.Client/Windows64/Windows64_LceLive.h b/Minecraft.Client/Windows64/Windows64_LceLive.h new file mode 100644 index 00000000..5b117c5e --- /dev/null +++ b/Minecraft.Client/Windows64/Windows64_LceLive.h @@ -0,0 +1,39 @@ +#pragma once + +#ifdef _WINDOWS64 + +#include + +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 diff --git a/Minecraft.Client/cmake/sources/Windows.cmake b/Minecraft.Client/cmake/sources/Windows.cmake index e07f46e3..2ee2b37a 100644 --- a/Minecraft.Client/cmake/sources/Windows.cmake +++ b/Minecraft.Client/cmake/sources/Windows.cmake @@ -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" diff --git a/Minecraft.Server/CMakeLists.txt b/Minecraft.Server/CMakeLists.txt index 52e5826e..a1d9686e 100644 --- a/Minecraft.Server/CMakeLists.txt +++ b/Minecraft.Server/CMakeLists.txt @@ -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 $<$: # Debug 4J libraries diff --git a/Minecraft.Server/cmake/sources/Common.cmake b/Minecraft.Server/cmake/sources/Common.cmake index 58ae26ce..7894da22 100644 --- a/Minecraft.Server/cmake/sources/Common.cmake +++ b/Minecraft.Server/cmake/sources/Common.cmake @@ -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" diff --git a/tools/repack_media_arc.py b/tools/repack_media_arc.py new file mode 100644 index 00000000..6940d851 --- /dev/null +++ b/tools/repack_media_arc.py @@ -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())