diff --git a/Minecraft.Client/ClientConnection.cpp b/Minecraft.Client/ClientConnection.cpp index 41a337d2..901b3b7e 100644 --- a/Minecraft.Client/ClientConnection.cpp +++ b/Minecraft.Client/ClientConnection.cpp @@ -1,5 +1,8 @@ #include "stdafx.h" #include "ClientConnection.h" +#ifdef _WINDOWS64 +#include "Windows64/Windows64_LceLive.h" +#endif #include "MultiPlayerLevel.h" #include "MultiPlayerLocalPlayer.h" #include "StatsCounter.h" @@ -2464,6 +2467,31 @@ void ClientConnection::handlePreLogin(shared_ptr packet) } BOOL allAllowed, friendsAllowed; ProfileManager.AllowedPlayerCreatedContent(m_userIndex,true,&allAllowed,&friendsAllowed); + +#ifdef _WINDOWS64 + // LceLive: if signed in, fetch a join ticket and send it to the host BEFORE the LoginPacket. + // PendingConnection::handleCustomPayload on the host validates it; handleLogin then enforces it. + // Offline players (not signed in) send no ticket and are admitted normally. + { + const std::string lceliveToken = Win64LceLive::GetAccessToken(); + if (!lceliveToken.empty()) + { + const Win64LceLive::TicketResult ticketResult = Win64LceLive::RequestJoinTicketSync(); + if (ticketResult.success && !ticketResult.ticket.empty()) + { + byteArray ticketData(static_cast(ticketResult.ticket.size())); + memcpy(ticketData.data, ticketResult.ticket.data(), ticketResult.ticket.size()); + send(std::make_shared(L"lcelive:ticket", ticketData)); + app.DebugPrintf("LCELive: join ticket sent to host\n"); + } + else + { + app.DebugPrintf("LCELive: could not obtain join ticket: %s\n", ticketResult.error.c_str()); + } + } + } +#endif + send(std::make_shared(minecraft->user->name, SharedConstants::NETWORK_PROTOCOL_VERSION, offlineXUID, onlineXUID, (allAllowed != TRUE && friendsAllowed == TRUE), packet->m_ugcPlayersVersion, app.GetPlayerSkinId(m_userIndex), app.GetPlayerCapeId(m_userIndex), ProfileManager.IsGuest(m_userIndex))); diff --git a/Minecraft.Client/Common/Media/MediaWindows64.arc b/Minecraft.Client/Common/Media/MediaWindows64.arc index cf09046c..d0583a25 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/Media/MediaWindows64.arc.bak b/Minecraft.Client/Common/Media/MediaWindows64.arc.bak new file mode 100644 index 00000000..cf09046c Binary files /dev/null and b/Minecraft.Client/Common/Media/MediaWindows64.arc.bak differ diff --git a/Minecraft.Client/Common/Media/en-EN.lang b/Minecraft.Client/Common/Media/en-EN.lang index a51600cb..6c118f28 100644 --- a/Minecraft.Client/Common/Media/en-EN.lang +++ b/Minecraft.Client/Common/Media/en-EN.lang @@ -4457,6 +4457,15 @@ Would you like to unlock the full game? Are you sure you want to delete this save game? + + + Are you sure you want to remove this friend? + + + Friend Request + + + Accept this friend request? Awaiting approval diff --git a/Minecraft.Client/Common/Media/strings.resx b/Minecraft.Client/Common/Media/strings.resx index 9e824d0f..ecff97af 100644 --- a/Minecraft.Client/Common/Media/strings.resx +++ b/Minecraft.Client/Common/Media/strings.resx @@ -5097,6 +5097,15 @@ Would you like to unlock the full game? Are you sure you want to delete this save game? + + Are you sure you want to remove this friend? + + + Friend Request + + + Accept this friend request? + Awaiting approval diff --git a/Minecraft.Client/Common/Media/xuiscene_ingameinfo.xui b/Minecraft.Client/Common/Media/xuiscene_ingameinfo.xui index 0b33f42d..a0df6cff 100644 --- a/Minecraft.Client/Common/Media/xuiscene_ingameinfo.xui +++ b/Minecraft.Client/Common/Media/xuiscene_ingameinfo.xui @@ -19,7 +19,7 @@ 336.000000 390.000000,192.000046,0.000000 CXuiCtrlPassThroughList -XuiPlayerList +XuiPlayerList_NoIcon GameOptionsButton @@ -30,7 +30,7 @@ 20.000000,50.000000,0.000000 5 false -XuiListButton_L +XuiListButton_L_NoIcon @@ -41,7 +41,7 @@ 20.000000,46.000000,0.000000 5 false -XuiPlayerListButton_L +XuiListButton_L_NoIcon @@ -52,7 +52,7 @@ 20.000000,46.000000,0.000000 5 false -XuiPlayerListButton_L +XuiListButton_L_NoIcon diff --git a/Minecraft.Client/Common/Media/xuiscene_ingameinfo_480.xui b/Minecraft.Client/Common/Media/xuiscene_ingameinfo_480.xui index 57ff7a6b..78b8ec2b 100644 --- a/Minecraft.Client/Common/Media/xuiscene_ingameinfo_480.xui +++ b/Minecraft.Client/Common/Media/xuiscene_ingameinfo_480.xui @@ -19,7 +19,7 @@ 272.000000 150.000031,130.000015,0.000000 CXuiCtrlPassThroughList -XuiPlayerListSmall +XuiPlayerListSmall_NoIcon GameOptionsButton @@ -30,7 +30,7 @@ 20.000000,50.000000,0.000000 5 false -XuiListButton_L +XuiListButton_L_NoIcon @@ -41,7 +41,7 @@ 20.000000,42.000000,0.000000 5 false -XuiPlayerListButton_LThin +XuiListButton_LThin_NoIcon @@ -52,7 +52,7 @@ 20.000000,42.000000,0.000000 5 false -XuiPlayerListButton_LThin +XuiListButton_LThin_NoIcon diff --git a/Minecraft.Client/Common/Media/xuiscene_ingameinfo_small.xui b/Minecraft.Client/Common/Media/xuiscene_ingameinfo_small.xui index ac5837e7..a490c321 100644 --- a/Minecraft.Client/Common/Media/xuiscene_ingameinfo_small.xui +++ b/Minecraft.Client/Common/Media/xuiscene_ingameinfo_small.xui @@ -19,7 +19,7 @@ 236.000000 120.000023,52.000011,0.000000 CXuiCtrlPassThroughList -XuiPlayerListSmall +XuiPlayerListSmall_NoIcon GameOptionsButton @@ -30,7 +30,7 @@ 20.000000,50.000000,0.000000 5 false -XuiListButton_L +XuiListButton_L_NoIcon @@ -41,7 +41,7 @@ 20.000000,42.000000,0.000000 5 false -XuiPlayerListButton_LThin +XuiListButton_LThin_NoIcon @@ -52,7 +52,7 @@ 20.000000,42.000000,0.000000 5 false -XuiPlayerListButton_LThin +XuiListButton_LThin_NoIcon @@ -63,7 +63,7 @@ 20.000000,42.000000,0.000000 5 false -XuiPlayerListButton_LThin +XuiListButton_LThin_NoIcon diff --git a/Minecraft.Client/Common/UI/UI.h b/Minecraft.Client/Common/UI/UI.h index 771f7531..50a2d580 100644 --- a/Minecraft.Client/Common/UI/UI.h +++ b/Minecraft.Client/Common/UI/UI.h @@ -66,6 +66,9 @@ #include "UIScene_TrialExitUpsell.h" #include "UIScene_Intro.h" #include "UIScene_LceLive.h" +#include "UIScene_LceLiveLinking.h" +#include "UIScene_LceLiveFriends.h" +#include "UIScene_LceLiveRequests.h" #include "UIScene_SaveMessage.h" #include "UIScene_MainMenu.h" #include "UIScene_LoadMenu.h" diff --git a/Minecraft.Client/Common/UI/UIEnums.h b/Minecraft.Client/Common/UI/UIEnums.h index 91d0046a..0bc4c625 100644 --- a/Minecraft.Client/Common/UI/UIEnums.h +++ b/Minecraft.Client/Common/UI/UIEnums.h @@ -122,6 +122,9 @@ enum EUIScene eUIComponent_PressStartToPlay, eUIComponent_MenuBackground, eUIScene_LceLive, + eUIScene_LceLiveLinking, + eUIScene_LceLiveFriends, + eUIScene_LceLiveRequests, eUIScene_Keyboard, eUIScene_QuadrantSignin, eUIScene_MessageBox, diff --git a/Minecraft.Client/Common/UI/UILayer.cpp b/Minecraft.Client/Common/UI/UILayer.cpp index 15552e33..decaed6d 100644 --- a/Minecraft.Client/Common/UI/UILayer.cpp +++ b/Minecraft.Client/Common/UI/UILayer.cpp @@ -367,6 +367,15 @@ bool UILayer::NavigateToScene(int iPad, EUIScene scene, void *initData) case eUIScene_LceLive: newScene = new UIScene_LceLive(iPad, initData, this); break; + case eUIScene_LceLiveLinking: + newScene = new UIScene_LceLiveLinking(iPad, initData, this); + break; + case eUIScene_LceLiveFriends: + newScene = new UIScene_LceLiveFriends(iPad, initData, this); + break; + case eUIScene_LceLiveRequests: + newScene = new UIScene_LceLiveRequests(iPad, initData, this); + break; case eUIScene_MainMenu: newScene = new UIScene_MainMenu(iPad, initData, this); break; diff --git a/Minecraft.Client/Common/UI/UIScene_LceLive.cpp b/Minecraft.Client/Common/UI/UIScene_LceLive.cpp index 32f8aeeb..aa39df56 100644 --- a/Minecraft.Client/Common/UI/UIScene_LceLive.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LceLive.cpp @@ -1,11 +1,7 @@ #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 +#include "../../Minecraft.h" UIScene_LceLive::UIScene_LceLive(int iPad, void *initData, UILayer *parentLayer) : UIScene(iPad, parentLayer) { @@ -14,20 +10,18 @@ UIScene_LceLive::UIScene_LceLive(int iPad, void *initData, UILayer *parentLayer) 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""); + m_buttons[BUTTON_LCELIVE_LINKING].init(L"LINKING", BUTTON_LCELIVE_LINKING); + m_buttons[BUTTON_LCELIVE_FRIENDS].init(L"FRIENDS", BUTTON_LCELIVE_FRIENDS); + m_buttons[BUTTON_LCELIVE_REQUESTS].init(L"REQUESTS", BUTTON_LCELIVE_REQUESTS); - 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); + // Remove the four unused button slots that exist in the HelpAndOptionsMenu SWF + // so they don't appear as blank entries in the hub list + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_3], false); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_4], false); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_5], false); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_6], false); + + doHorizontalResizeCheck(); } UIScene_LceLive::~UIScene_LceLive() @@ -38,41 +32,38 @@ UIScene_LceLive::~UIScene_LceLive() wstring UIScene_LceLive::getMoviePath() { - return L"LceLive"; + if (app.GetLocalPlayerCount() > 1) + return L"HelpAndOptionsMenuSplit"; + return L"HelpAndOptionsMenu"; } 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); + ui.SetTooltips(m_iPad, IDS_TOOLTIPS_SELECT, 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); + bool bNotInGame = (Minecraft::GetInstance()->level == nullptr); + if (bNotInGame) + { + m_parentLayer->showComponent(m_iPad, eUIComponent_Panorama, true); + m_parentLayer->showComponent(m_iPad, eUIComponent_Logo, true); + } + else + { + m_parentLayer->showComponent(m_iPad, eUIComponent_Panorama, false); + m_parentLayer->showComponent(m_iPad, eUIComponent_Logo, true); + } } 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); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_3], false); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_4], false); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_5], false); + removeControl(&m_buttons[BUTTON_LCELIVE_UNUSED_6], false); + doHorizontalResizeCheck(); } void UIScene_LceLive::handleInput(int iPad, int key, bool repeat, bool pressed, bool released, bool &handled) @@ -86,27 +77,13 @@ void UIScene_LceLive::handleInput(int iPad, int key, bool repeat, bool pressed, 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: + if (pressed) + ui.PlayUISFX(eSFX_Press); case ACTION_MENU_UP: - case ACTION_MENU_PAGEUP: - case ACTION_MENU_PAGEDOWN: - case ACTION_MENU_OTHER_STICK_DOWN: - case ACTION_MENU_OTHER_STICK_UP: + case ACTION_MENU_DOWN: sendInputToMovie(key, repeat, pressed, released); break; } @@ -114,85 +91,16 @@ void UIScene_LceLive::handleInput(int iPad, int key, bool repeat, bool pressed, 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; + switch (static_cast(controlId)) + { + case BUTTON_LCELIVE_LINKING: + ui.NavigateToScene(m_iPad, eUIScene_LceLiveLinking); + break; + case BUTTON_LCELIVE_FRIENDS: + ui.NavigateToScene(m_iPad, eUIScene_LceLiveFriends); + break; + case BUTTON_LCELIVE_REQUESTS: + ui.NavigateToScene(m_iPad, eUIScene_LceLiveRequests); + break; + } } diff --git a/Minecraft.Client/Common/UI/UIScene_LceLive.h b/Minecraft.Client/Common/UI/UIScene_LceLive.h index fa7a43b4..edc50c9b 100644 --- a/Minecraft.Client/Common/UI/UIScene_LceLive.h +++ b/Minecraft.Client/Common/UI/UIScene_LceLive.h @@ -2,52 +2,51 @@ #include "UIScene.h" +// LceLive hub menu — three sub-sections navigated via the scene nav-stack. +// Modelled on UIScene_HelpAndOptionsMenu. +// Reuses the HelpAndOptionsMenu Iggy movie (7-button layout); unused slots are +// removed so only Linking / Friends / Requests appear. + +#define BUTTON_LCELIVE_LINKING 0 +#define BUTTON_LCELIVE_FRIENDS 1 +#define BUTTON_LCELIVE_REQUESTS 2 +// Slots 3-6 exist in the SWF and must be removed at construction time +#define BUTTON_LCELIVE_UNUSED_3 3 +#define BUTTON_LCELIVE_UNUSED_4 4 +#define BUTTON_LCELIVE_UNUSED_5 5 +#define BUTTON_LCELIVE_UNUSED_6 6 +#define BUTTONS_LCELIVE_TOTAL BUTTON_LCELIVE_UNUSED_6 + 1 + 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; + UIControl_Button m_buttons[BUTTONS_LCELIVE_TOTAL]; 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_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_LINKING], "Button1") + UI_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_FRIENDS], "Button2") + UI_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_REQUESTS], "Button3") + UI_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_UNUSED_3], "Button4") + UI_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_UNUSED_4], "Button5") + UI_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_UNUSED_5], "Button6") + UI_MAP_ELEMENT(m_buttons[BUTTON_LCELIVE_UNUSED_6], "Button7") UI_END_MAP_ELEMENTS_AND_NAMES() public: UIScene_LceLive(int iPad, void *initData, UILayer *parentLayer); - ~UIScene_LceLive(); + virtual ~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 handleReload(); 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_LceLiveFriends.cpp b/Minecraft.Client/Common/UI/UIScene_LceLiveFriends.cpp new file mode 100644 index 00000000..4343dd1c --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveFriends.cpp @@ -0,0 +1,443 @@ +#include "stdafx.h" +#include "UI.h" +#include "UIScene_LceLiveFriends.h" +#include "../../Minecraft.h" + +// Fallback until string ID headers are regenerated. +#ifndef IDS_TEXT_REMOVE_FRIEND_CONFIRMATION +#define IDS_TEXT_REMOVE_FRIEND_CONFIRMATION IDS_TEXT_DELETE_SAVE +#endif + +#ifdef _WINDOWS64 +namespace +{ + std::wstring Utf8ToWideLocal(const std::string &text) + { + if (text.empty()) + return L""; + + const int required = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0); + if (required <= 0) + return L""; + + std::wstring result(static_cast(required), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, &result[0], required); + if (!result.empty() && result.back() == L'\0') + result.pop_back(); + return result; + } + + std::string WideToUtf8Local(const std::wstring &text) + { + if (text.empty()) + return std::string(); + + const int required = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, nullptr, 0, nullptr, nullptr); + if (required <= 0) + return std::string(); + + std::string result(static_cast(required), '\0'); + WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, &result[0], required, nullptr, nullptr); + if (!result.empty() && result.back() == '\0') + result.pop_back(); + return result; + } + + std::wstring TrimWhitespaceLocal(const std::wstring &text) + { + const size_t begin = text.find_first_not_of(L" \t\r\n"); + if (begin == std::wstring::npos) + return L""; + + const size_t end = text.find_last_not_of(L" \t\r\n"); + return text.substr(begin, end - begin + 1); + } + + std::wstring BuildFriendLabel(const Win64LceLive::SocialEntry &entry) + { + std::wstring label; + if (!entry.displayName.empty()) + label = Utf8ToWideLocal(entry.displayName); + else if (!entry.username.empty()) + label = Utf8ToWideLocal(entry.username); + + if (!entry.username.empty()) + { + if (!label.empty()) + label += L" (@"; + else + label = L"@"; + + label += Utf8ToWideLocal(entry.username); + if (!entry.displayName.empty()) + label += L")"; + } + + if (label.empty()) + label = L""; + + return label; + } +} +#endif + +UIScene_LceLiveFriends::UIScene_LceLiveFriends(int iPad, void *initData, UILayer *parentLayer) : UIScene(iPad, parentLayer) +{ + initialiseMovie(); + + parentLayer->addComponent(iPad, eUIComponent_Panorama); + parentLayer->addComponent(iPad, eUIComponent_Logo); + + m_friendsList.init(eControl_FriendsList); + m_actionsList.init(eControl_ActionsList); + m_labelFriendsTitle.init(L"FRIENDS"); + m_labelActionsTitle.init(L"ACTIONS"); + m_labelStatus.init(L""); + m_controlFriendsTimer.setVisible(false); + m_controlActionsTimer.setVisible(false); + + m_actionsList.addItem(L"ADD FRIEND"); + m_actionsList.addItem(L"REFRESH"); + m_actionsList.setCurrentSelection(eAction_AddFriend); + + m_bDataReady = false; + m_statusMessage.clear(); +#ifdef _WINDOWS64 + m_pendingRemovalAccountId.clear(); + m_pendingRemovalLabel.clear(); +#endif + + doHorizontalResizeCheck(); + FetchAndDisplay(); +} + +UIScene_LceLiveFriends::~UIScene_LceLiveFriends() +{ + m_parentLayer->removeComponent(eUIComponent_Panorama); + m_parentLayer->removeComponent(eUIComponent_Logo); +} + +wstring UIScene_LceLiveFriends::getMoviePath() +{ + return L"LoadOrJoinMenu"; +} + +void UIScene_LceLiveFriends::updateTooltips() +{ + ui.SetTooltips(m_iPad, IDS_TOOLTIPS_SELECT, IDS_TOOLTIPS_BACK, IDS_TOOLTIPS_DELETE, IDS_TOOLTIPS_REFRESH); +} + +void UIScene_LceLiveFriends::updateComponents() +{ + const bool notInGame = (Minecraft::GetInstance()->level == nullptr); + m_parentLayer->showComponent(m_iPad, eUIComponent_Panorama, notInGame); + m_parentLayer->showComponent(m_iPad, eUIComponent_Logo, true); +} + +void UIScene_LceLiveFriends::handleReload() +{ + doHorizontalResizeCheck(); + m_controlFriendsTimer.setVisible(false); + m_controlActionsTimer.setVisible(false); + FetchAndDisplay(); +} + +void UIScene_LceLiveFriends::handleFocusChange(F64 controlId, F64 childId) +{ + if (static_cast(controlId) == eControl_FriendsList) + m_friendsList.updateChildFocus(static_cast(childId)); + else if (static_cast(controlId) == eControl_ActionsList) + m_actionsList.updateChildFocus(static_cast(childId)); + + updateTooltips(); +} + +void UIScene_LceLiveFriends::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) + { + ui.PlayUISFX(eSFX_Back); + navigateBack(); + } + break; + case ACTION_MENU_OK: +#ifdef __ORBIS__ + case ACTION_MENU_TOUCHPAD_PRESS: +#endif + if (pressed) + ui.PlayUISFX(eSFX_Press); + if (pressed && !repeat) + { + handled = true; + if (controlHasFocus(eControl_ActionsList)) + PerformSelectedAction(); + else if (controlHasFocus(eControl_FriendsList)) + PromptRemoveSelectedFriend(); + } + break; + case ACTION_MENU_X: + if (pressed && !repeat) + { +#ifdef _WINDOWS64 + PromptRemoveSelectedFriend(); +#endif + handled = true; + } + break; + case ACTION_MENU_Y: + if (pressed && !repeat) + { + FetchAndDisplay(); + handled = true; + } + break; + case ACTION_MENU_UP: + case ACTION_MENU_DOWN: + case ACTION_MENU_LEFT: + case ACTION_MENU_RIGHT: + case ACTION_MENU_PAGEUP: + case ACTION_MENU_PAGEDOWN: + sendInputToMovie(key, repeat, pressed, released); + break; + } +} + +void UIScene_LceLiveFriends::handlePress(F64 controlId, F64 childId) +{ + if (static_cast(controlId) == eControl_FriendsList) + { + m_friendsList.updateChildFocus(static_cast(childId)); + PromptRemoveSelectedFriend(); + } + else if (static_cast(controlId) == eControl_ActionsList) + { + m_actionsList.updateChildFocus(static_cast(childId)); + PerformSelectedAction(); + } +} + +void UIScene_LceLiveFriends::FetchAndDisplay() +{ +#ifdef _WINDOWS64 + const std::string accessToken = Win64LceLive::GetAccessToken(); + if (accessToken.empty()) + { + m_friends.clear(); + m_bDataReady = true; + m_statusMessage = L"Sign in to view and manage friends."; + RebuildLists(); + return; + } + + const Win64LceLive::FriendsListResult result = Win64LceLive::GetFriendsSync(); + if (!result.success) + { + m_friends.clear(); + m_bDataReady = true; + m_statusMessage = Utf8ToWideLocal(result.error); + RebuildLists(); + return; + } + + const int previousSelection = m_friendsList.getCurrentSelection(); + m_friends = result.friends; + m_bDataReady = true; + + if (m_friends.empty() && m_statusMessage.empty()) + m_statusMessage = L"No friends yet. Use ACTIONS to add one."; + else if (!m_friends.empty() && m_statusMessage == L"No friends yet. Use ACTIONS to add one.") + m_statusMessage.clear(); + + RebuildLists(); + + if (!m_friends.empty()) + { + int newSelection = previousSelection; + if (newSelection < 0) + newSelection = 0; + if (newSelection >= static_cast(m_friends.size())) + newSelection = static_cast(m_friends.size()) - 1; + m_friendsList.setCurrentSelection(newSelection); + } +#else + m_friends.clear(); + m_bDataReady = true; + m_statusMessage = L"Friends are only available on Windows64 builds."; + RebuildLists(); +#endif +} + +void UIScene_LceLiveFriends::RebuildLists() +{ + m_friendsList.clearList(); + +#ifdef _WINDOWS64 + for (const Win64LceLive::SocialEntry &entry : m_friends) + m_friendsList.addItem(BuildFriendLabel(entry)); +#endif + + UpdateStatusLabel(); +} + +void UIScene_LceLiveFriends::UpdateStatusLabel() +{ + if (m_statusMessage.empty()) + { + m_labelStatus.setVisible(false); + } + else + { + m_labelStatus.setLabel(m_statusMessage, true, true); + m_labelStatus.setVisible(true); + } + m_labelFriendsTitle.setLabel(L"FRIENDS", true, true); + m_labelActionsTitle.setLabel(L"ACTIONS", true, true); +} + +int UIScene_LceLiveFriends::FocusedFriendIndex() +{ + return m_friendsList.getCurrentSelection(); +} + +int UIScene_LceLiveFriends::SelectedActionIndex() +{ + return m_actionsList.getCurrentSelection(); +} + +void UIScene_LceLiveFriends::PerformSelectedAction() +{ + const int action = SelectedActionIndex(); + switch (action) + { + case eAction_AddFriend: + OpenAddFriendKeyboard(); + break; + case eAction_Refresh: + FetchAndDisplay(); + break; + default: + break; + } +} + +#ifdef _WINDOWS64 +void UIScene_LceLiveFriends::PromptRemoveSelectedFriend() +{ + PromptRemoveFriendAtIndex(FocusedFriendIndex()); +} + +void UIScene_LceLiveFriends::PromptRemoveFriendAtIndex(int friendIndex) +{ + if (friendIndex < 0 || friendIndex >= static_cast(m_friends.size())) + { + m_statusMessage = L"Select a friend first."; + UpdateStatusLabel(); + return; + } + + m_pendingRemovalAccountId = m_friends[friendIndex].accountId; + m_pendingRemovalLabel = BuildFriendLabel(m_friends[friendIndex]); + + UINT optionIds[2]; + optionIds[0] = IDS_NO; + optionIds[1] = IDS_YES; + + ui.RequestAlertMessage( + IDS_TOOLTIPS_DELETE, + IDS_TEXT_REMOVE_FRIEND_CONFIRMATION, + optionIds, + 2, + m_iPad, + &UIScene_LceLiveFriends::RemoveFriendConfirmCallback, + this); +} + +void UIScene_LceLiveFriends::RemovePendingFriend() +{ + if (m_pendingRemovalAccountId.empty()) + return; + + const Win64LceLive::SocialActionResult result = Win64LceLive::RemoveFriendSync(m_pendingRemovalAccountId); + if (result.success) + { + m_statusMessage = L"Friend removed: "; + m_statusMessage += m_pendingRemovalLabel; + } + else + { + m_statusMessage = Utf8ToWideLocal(result.error); + } + + m_pendingRemovalAccountId.clear(); + m_pendingRemovalLabel.clear(); + FetchAndDisplay(); +} + +int UIScene_LceLiveFriends::RemoveFriendConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result) +{ + UIScene_LceLiveFriends *scene = static_cast(pParam); + if (scene == nullptr) + return 0; + + (void)iPad; + + if (result == C4JStorage::EMessage_ResultDecline) + scene->RemovePendingFriend(); + else + { + scene->m_pendingRemovalAccountId.clear(); + scene->m_pendingRemovalLabel.clear(); + } + + return 0; +} + +void UIScene_LceLiveFriends::OpenAddFriendKeyboard() +{ + UIKeyboardInitData kbData; + kbData.title = L"Add Friend"; + kbData.defaultText = L""; + kbData.maxChars = 32; + kbData.callback = &UIScene_LceLiveFriends::AddFriendKeyboardCallback; + kbData.lpParam = this; + kbData.pcMode = g_KBMInput.IsKBMActive(); + ui.NavigateToScene(m_iPad, eUIScene_Keyboard, &kbData); +} + +int UIScene_LceLiveFriends::AddFriendKeyboardCallback(LPVOID lpParam, const bool bResult) +{ + UIScene_LceLiveFriends *scene = static_cast(lpParam); + if (scene == nullptr || !bResult) + return 0; + + uint16_t ui16Text[256] = {}; + Win64_GetKeyboardText(ui16Text, 256); + + wchar_t wBuf[256] = {}; + for (int k = 0; k < 255 && ui16Text[k]; ++k) + wBuf[k] = static_cast(ui16Text[k]); + + const std::wstring usernameW = TrimWhitespaceLocal(wBuf); + if (usernameW.empty()) + { + scene->m_statusMessage = L"Enter a username to send a friend request."; + scene->UpdateStatusLabel(); + return 0; + } + + const std::string username = WideToUtf8Local(usernameW); + const Win64LceLive::SocialActionResult result = Win64LceLive::SendFriendRequestSync(username); + if (result.success) + scene->m_statusMessage = L"Friend request sent."; + else + scene->m_statusMessage = Utf8ToWideLocal(result.error); + + scene->FetchAndDisplay(); + return 0; +} +#endif diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveFriends.h b/Minecraft.Client/Common/UI/UIScene_LceLiveFriends.h new file mode 100644 index 00000000..4cd04db5 --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveFriends.h @@ -0,0 +1,93 @@ +#pragma once + +#include "UIScene.h" + +#include + +#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD) +#include "../../Windows64/Windows64_LceLive.h" +#endif + +// LceLive sub-scene: friends list. +// Uses the same panel/list layout style as Start Game (LoadOrJoinMenu) so it +// looks native: left list = friends, right list = actions, right text = status. + +class UIScene_LceLiveFriends : public UIScene +{ +private: + enum EControls + { + eControl_FriendsList, + eControl_ActionsList, + }; + + enum EActions + { + eAction_AddFriend = 0, + eAction_Refresh, + }; + + UIControl_ButtonList m_friendsList; + UIControl_ButtonList m_actionsList; + UIControl_Label m_labelFriendsTitle; + UIControl_Label m_labelActionsTitle; + UIControl_Label m_labelStatus; + UIControl m_controlFriendsTimer; + UIControl m_controlActionsTimer; + UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene) + UI_MAP_ELEMENT(m_friendsList, "SavesList") + UI_MAP_ELEMENT(m_actionsList, "JoinList") + UI_MAP_ELEMENT(m_labelFriendsTitle, "SavesListTitle") + UI_MAP_ELEMENT(m_labelActionsTitle, "JoinListTitle") + UI_MAP_ELEMENT(m_labelStatus, "NoGames") + UI_MAP_ELEMENT(m_controlFriendsTimer, "SavesTimer") + UI_MAP_ELEMENT(m_controlActionsTimer, "JoinTimer") + UI_END_MAP_ELEMENTS_AND_NAMES() + +#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD) + std::vector m_friends; +#endif + std::wstring m_statusMessage; + bool m_bDataReady; +#ifdef _WINDOWS64 + std::string m_pendingRemovalAccountId; + std::wstring m_pendingRemovalLabel; +#endif + +public: + UIScene_LceLiveFriends(int iPad, void *initData, UILayer *parentLayer); + ~UIScene_LceLiveFriends(); + + virtual EUIScene getSceneType() { return eUIScene_LceLiveFriends; } + virtual void updateTooltips(); + virtual void updateComponents(); + +protected: + virtual wstring getMoviePath(); + virtual void handleReload(); + virtual void handleFocusChange(F64 controlId, F64 childId); + +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 FetchAndDisplay(); + void RebuildLists(); + void UpdateStatusLabel(); + int FocusedFriendIndex(); + int SelectedActionIndex(); + void PerformSelectedAction(); +#ifdef _WINDOWS64 + void PromptRemoveSelectedFriend(); + void PromptRemoveFriendAtIndex(int friendIndex); + void RemovePendingFriend(); + static int RemoveFriendConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result); +#endif +#ifdef _WINDOWS64 + void OpenAddFriendKeyboard(); + static int AddFriendKeyboardCallback(LPVOID lpParam, const bool bResult); +#endif +}; diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveLinking.cpp b/Minecraft.Client/Common/UI/UIScene_LceLiveLinking.cpp new file mode 100644 index 00000000..312d13cc --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveLinking.cpp @@ -0,0 +1,198 @@ +#include "stdafx.h" +#include "UI.h" +#include "UIScene_LceLiveLinking.h" +#include "../../../Minecraft.World/StringHelpers.h" + +#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD) +#include "../../Windows64/Windows64_LceLive.h" +#endif + +UIScene_LceLiveLinking::UIScene_LceLiveLinking(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"LINKING"); + 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_LceLiveLinking::~UIScene_LceLiveLinking() +{ + m_parentLayer->removeComponent(eUIComponent_Panorama); + m_parentLayer->removeComponent(eUIComponent_Logo); +} + +wstring UIScene_LceLiveLinking::getMoviePath() +{ + return L"LceLive"; +} + +void UIScene_LceLiveLinking::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_LceLiveLinking::updateComponents() +{ + m_parentLayer->showComponent(m_iPad, eUIComponent_Panorama, true); + m_parentLayer->showComponent(m_iPad, eUIComponent_Logo, true); +} + +void UIScene_LceLiveLinking::tick() +{ + UIScene::tick(); + RefreshUi(false); +} + +void UIScene_LceLiveLinking::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_LceLiveLinking::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_LceLiveLinking::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_LceLiveLinking::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_LceLiveLinking::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_LceLiveLinking.h b/Minecraft.Client/Common/UI/UIScene_LceLiveLinking.h new file mode 100644 index 00000000..d1fa679c --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveLinking.h @@ -0,0 +1,56 @@ +#pragma once + +#include "UIScene.h" + +// LceLive sub-scene: device-link / sign-in / sign-out flow. +// This is the original UIScene_LceLive content, now a child of the LceLive hub. + +class UIScene_LceLiveLinking : 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_LceLiveLinking(int iPad, void *initData, UILayer *parentLayer); + ~UIScene_LceLiveLinking(); + + virtual EUIScene getSceneType() { return eUIScene_LceLiveLinking; } + 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_LceLiveRequests.cpp b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.cpp new file mode 100644 index 00000000..ec7e52a6 --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.cpp @@ -0,0 +1,445 @@ +#include "stdafx.h" +#include "UI.h" +#include "UIScene_LceLiveRequests.h" +#include "../../Minecraft.h" + +// Fallbacks until string ID headers are regenerated. +#ifndef IDS_TITLE_FRIEND_REQUEST +#define IDS_TITLE_FRIEND_REQUEST IDS_TOOLTIPS_SELECT +#endif +#ifndef IDS_TEXT_ACCEPT_REQUEST_CONFIRMATION +#define IDS_TEXT_ACCEPT_REQUEST_CONFIRMATION IDS_CONFIRM_EXIT_GAME +#endif + +#ifdef _WINDOWS64 +namespace +{ + std::wstring Utf8ToWideLocal(const std::string &text) + { + if (text.empty()) + return L""; + + const int required = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0); + if (required <= 0) + return L""; + + std::wstring result(static_cast(required), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, &result[0], required); + if (!result.empty() && result.back() == L'\0') + result.pop_back(); + return result; + } + + std::wstring BuildRequestLabel(bool incoming, const std::string &username, const std::string &displayName) + { + std::wstring line = incoming ? L"[IN] " : L"[OUT] "; + + if (!displayName.empty()) + line += Utf8ToWideLocal(displayName); + else if (!username.empty()) + line += Utf8ToWideLocal(username); + else + line += L""; + + if (!username.empty()) + { + line += L" (@"; + line += Utf8ToWideLocal(username); + line += L")"; + } + + return line; + } +} +#endif + +UIScene_LceLiveRequests::UIScene_LceLiveRequests(int iPad, void *initData, UILayer *parentLayer) : UIScene(iPad, parentLayer) +{ + initialiseMovie(); + + parentLayer->addComponent(iPad, eUIComponent_Panorama); + parentLayer->addComponent(iPad, eUIComponent_Logo); + + m_requestsList.init(eControl_RequestsList); + m_actionsList.init(eControl_ActionsList); + m_labelRequestsTitle.init(L"REQUESTS"); + m_labelActionsTitle.init(L"ACTIONS"); + m_labelStatus.init(L""); + m_controlRequestsTimer.setVisible(false); + m_controlActionsTimer.setVisible(false); + + m_actionsList.addItem(L"REFRESH"); + m_actionsList.setCurrentSelection(eAction_Refresh); + + m_entries.clear(); + m_statusMessage.clear(); + m_bDataReady = false; +#ifdef _WINDOWS64 + m_pendingRequestAccountId.clear(); + m_pendingRequestIncoming = false; +#endif + + doHorizontalResizeCheck(); + FetchAndDisplay(); +} + +UIScene_LceLiveRequests::~UIScene_LceLiveRequests() +{ + m_parentLayer->removeComponent(eUIComponent_Panorama); + m_parentLayer->removeComponent(eUIComponent_Logo); +} + +wstring UIScene_LceLiveRequests::getMoviePath() +{ + return L"LoadOrJoinMenu"; +} + +void UIScene_LceLiveRequests::updateTooltips() +{ + ui.SetTooltips(m_iPad, IDS_TOOLTIPS_SELECT, IDS_TOOLTIPS_BACK, IDS_TOOLTIPS_DELETE, IDS_TOOLTIPS_REFRESH); +} + +void UIScene_LceLiveRequests::updateComponents() +{ + const bool notInGame = (Minecraft::GetInstance()->level == nullptr); + m_parentLayer->showComponent(m_iPad, eUIComponent_Panorama, notInGame); + m_parentLayer->showComponent(m_iPad, eUIComponent_Logo, true); +} + +void UIScene_LceLiveRequests::handleReload() +{ + doHorizontalResizeCheck(); + m_controlRequestsTimer.setVisible(false); + m_controlActionsTimer.setVisible(false); + FetchAndDisplay(); +} + +void UIScene_LceLiveRequests::handleFocusChange(F64 controlId, F64 childId) +{ + if (static_cast(controlId) == eControl_RequestsList) + m_requestsList.updateChildFocus(static_cast(childId)); + else if (static_cast(controlId) == eControl_ActionsList) + m_actionsList.updateChildFocus(static_cast(childId)); + + updateTooltips(); +} + +void UIScene_LceLiveRequests::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) + { + ui.PlayUISFX(eSFX_Back); + navigateBack(); + } + break; + case ACTION_MENU_OK: +#ifdef __ORBIS__ + case ACTION_MENU_TOUCHPAD_PRESS: +#endif + if (pressed) + ui.PlayUISFX(eSFX_Press); + if (pressed && !repeat) + { + handled = true; + if (controlHasFocus(eControl_ActionsList)) + PerformSelectedAction(); + else if (controlHasFocus(eControl_RequestsList)) + PromptResolveSelectedRequest(); + } + break; + case ACTION_MENU_X: + if (pressed && !repeat) + { + PerformDecline(); + handled = true; + } + break; + case ACTION_MENU_Y: + if (pressed && !repeat) + { + FetchAndDisplay(); + handled = true; + } + break; + case ACTION_MENU_UP: + case ACTION_MENU_DOWN: + case ACTION_MENU_LEFT: + case ACTION_MENU_RIGHT: + case ACTION_MENU_PAGEUP: + case ACTION_MENU_PAGEDOWN: + sendInputToMovie(key, repeat, pressed, released); + break; + } +} + +void UIScene_LceLiveRequests::handlePress(F64 controlId, F64 childId) +{ + if (static_cast(controlId) == eControl_RequestsList) + { + m_requestsList.updateChildFocus(static_cast(childId)); + PromptResolveSelectedRequest(); + } + else if (static_cast(controlId) == eControl_ActionsList) + { + m_actionsList.updateChildFocus(static_cast(childId)); + PerformSelectedAction(); + } +} + +void UIScene_LceLiveRequests::FetchAndDisplay() +{ +#ifdef _WINDOWS64 + const std::string accessToken = Win64LceLive::GetAccessToken(); + if (accessToken.empty()) + { + m_entries.clear(); + m_bDataReady = true; + m_statusMessage = L"Sign in to view and manage friend requests."; + RebuildLists(); + return; + } + + const int previousSelection = m_requestsList.getCurrentSelection(); + const Win64LceLive::PendingRequestsResult result = Win64LceLive::GetPendingRequestsSync(); + if (!result.success) + { + m_entries.clear(); + m_bDataReady = true; + m_statusMessage = Utf8ToWideLocal(result.error); + RebuildLists(); + return; + } + + m_entries.clear(); + for (const Win64LceLive::SocialEntry &entry : result.incoming) + { + RequestEntry row = {}; + row.incoming = true; + row.accountId = entry.accountId; + row.username = entry.username; + row.displayName = entry.displayName; + m_entries.push_back(row); + } + for (const Win64LceLive::SocialEntry &entry : result.outgoing) + { + RequestEntry row = {}; + row.incoming = false; + row.accountId = entry.accountId; + row.username = entry.username; + row.displayName = entry.displayName; + m_entries.push_back(row); + } + + m_bDataReady = true; + if (m_entries.empty() && m_statusMessage.empty()) + m_statusMessage = L"No pending requests."; + else if (!m_entries.empty() && m_statusMessage == L"No pending requests.") + m_statusMessage.clear(); + + RebuildLists(); + + if (!m_entries.empty()) + { + int newSelection = previousSelection; + if (newSelection < 0) + newSelection = 0; + if (newSelection >= static_cast(m_entries.size())) + newSelection = static_cast(m_entries.size()) - 1; + m_requestsList.setCurrentSelection(newSelection); + } +#else + m_entries.clear(); + m_bDataReady = true; + m_statusMessage = L"Requests are only available on Windows64 builds."; + RebuildLists(); +#endif +} + +void UIScene_LceLiveRequests::RebuildLists() +{ + m_requestsList.clearList(); + +#ifdef _WINDOWS64 + for (const RequestEntry &entry : m_entries) + m_requestsList.addItem(BuildRequestLabel(entry.incoming, entry.username, entry.displayName)); +#endif + + UpdateStatusLabel(); +} + +void UIScene_LceLiveRequests::UpdateStatusLabel() +{ + if (m_statusMessage.empty()) + { + m_labelStatus.setVisible(false); + } + else + { + m_labelStatus.setLabel(m_statusMessage, true, true); + m_labelStatus.setVisible(true); + } + + m_labelRequestsTitle.setLabel(L"REQUESTS", true, true); + m_labelActionsTitle.setLabel(L"ACTIONS", true, true); +} + +int UIScene_LceLiveRequests::FocusedRequestIndex() +{ + return m_requestsList.getCurrentSelection(); +} + +int UIScene_LceLiveRequests::SelectedActionIndex() +{ + return m_actionsList.getCurrentSelection(); +} + +void UIScene_LceLiveRequests::PerformSelectedAction() +{ + switch (SelectedActionIndex()) + { + case eAction_Refresh: + FetchAndDisplay(); + break; + default: + break; + } +} + +void UIScene_LceLiveRequests::PromptResolveSelectedRequest() +{ + const int selectedIndex = FocusedRequestIndex(); + if (selectedIndex < 0 || selectedIndex >= static_cast(m_entries.size())) + { + m_statusMessage = L"Select a request first."; + UpdateStatusLabel(); + return; + } + + if (!m_entries[selectedIndex].incoming) + { + m_statusMessage = L"Outgoing requests are waiting on the other player."; + UpdateStatusLabel(); + return; + } + +#ifdef _WINDOWS64 + m_pendingRequestAccountId = m_entries[selectedIndex].accountId; + m_pendingRequestIncoming = true; + + UINT optionIds[2]; + optionIds[0] = IDS_NO; + optionIds[1] = IDS_YES; + + ui.RequestAlertMessage( + IDS_TITLE_FRIEND_REQUEST, + IDS_TEXT_ACCEPT_REQUEST_CONFIRMATION, + optionIds, + 2, + m_iPad, + &UIScene_LceLiveRequests::ResolveRequestConfirmCallback, + this); +#endif +} + +void UIScene_LceLiveRequests::PerformAccept() +{ +#ifdef _WINDOWS64 + const int selectedIndex = FocusedRequestIndex(); + if (selectedIndex < 0 || selectedIndex >= static_cast(m_entries.size())) + { + m_statusMessage = L"Select a request first."; + UpdateStatusLabel(); + return; + } + + if (!m_entries[selectedIndex].incoming) + { + m_statusMessage = L"Only incoming requests can be accepted."; + UpdateStatusLabel(); + return; + } + + const std::string accountId = m_entries[selectedIndex].accountId; + const Win64LceLive::SocialActionResult result = Win64LceLive::AcceptFriendRequestSync(accountId); + if (result.success) + m_statusMessage = L"Friend request accepted."; + else + m_statusMessage = Utf8ToWideLocal(result.error); + + FetchAndDisplay(); +#endif +} + +void UIScene_LceLiveRequests::PerformDecline() +{ +#ifdef _WINDOWS64 + const int selectedIndex = FocusedRequestIndex(); + if (selectedIndex < 0 || selectedIndex >= static_cast(m_entries.size())) + { + m_statusMessage = L"Select a request first."; + UpdateStatusLabel(); + return; + } + + if (!m_entries[selectedIndex].incoming) + { + m_statusMessage = L"Only incoming requests can be declined."; + UpdateStatusLabel(); + return; + } + + const std::string accountId = m_entries[selectedIndex].accountId; + const Win64LceLive::SocialActionResult result = Win64LceLive::DeclineFriendRequestSync(accountId); + if (result.success) + m_statusMessage = L"Friend request declined."; + else + m_statusMessage = Utf8ToWideLocal(result.error); + + FetchAndDisplay(); +#endif +} + +#ifdef _WINDOWS64 +void UIScene_LceLiveRequests::ResolvePendingRequest(bool accept) +{ + if (!m_pendingRequestIncoming || m_pendingRequestAccountId.empty()) + return; + + const std::string accountId = m_pendingRequestAccountId; + m_pendingRequestAccountId.clear(); + m_pendingRequestIncoming = false; + + const Win64LceLive::SocialActionResult result = accept + ? Win64LceLive::AcceptFriendRequestSync(accountId) + : Win64LceLive::DeclineFriendRequestSync(accountId); + + if (result.success) + m_statusMessage = accept ? L"Friend request accepted." : L"Friend request declined."; + else + m_statusMessage = Utf8ToWideLocal(result.error); + + FetchAndDisplay(); +} + +int UIScene_LceLiveRequests::ResolveRequestConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result) +{ + UIScene_LceLiveRequests *scene = static_cast(pParam); + if (scene == nullptr) + return 0; + + (void)iPad; + + // UI returns "Decline" for the 2nd option. With [NO, YES], that means YES => accept. + if (result == C4JStorage::EMessage_ResultDecline) + scene->ResolvePendingRequest(true); + else + scene->ResolvePendingRequest(false); + + return 0; +} +#endif diff --git a/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h new file mode 100644 index 00000000..07155a7d --- /dev/null +++ b/Minecraft.Client/Common/UI/UIScene_LceLiveRequests.h @@ -0,0 +1,95 @@ +#pragma once + +#include "UIScene.h" + +#include +#include + +#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD) +#include "../../Windows64/Windows64_LceLive.h" +#endif + +// LceLive sub-scene: friend requests list. +// Uses the native Start Game style (LoadOrJoinMenu): left list of requests, +// right list of actions. + +class UIScene_LceLiveRequests : public UIScene +{ +private: + enum EControls + { + eControl_RequestsList, + eControl_ActionsList, + }; + + enum EActions + { + eAction_Refresh = 0, + }; + + struct RequestEntry + { + bool incoming; + std::string accountId; + std::string username; + std::string displayName; + }; + + UIControl_ButtonList m_requestsList; + UIControl_ButtonList m_actionsList; + UIControl_Label m_labelRequestsTitle; + UIControl_Label m_labelActionsTitle; + UIControl_Label m_labelStatus; + UIControl m_controlRequestsTimer; + UIControl m_controlActionsTimer; + std::vector m_entries; + std::wstring m_statusMessage; + bool m_bDataReady; +#ifdef _WINDOWS64 + std::string m_pendingRequestAccountId; + bool m_pendingRequestIncoming; +#endif + UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene) + UI_MAP_ELEMENT(m_requestsList, "SavesList") + UI_MAP_ELEMENT(m_actionsList, "JoinList") + UI_MAP_ELEMENT(m_labelRequestsTitle, "SavesListTitle") + UI_MAP_ELEMENT(m_labelActionsTitle, "JoinListTitle") + UI_MAP_ELEMENT(m_labelStatus, "NoGames") + UI_MAP_ELEMENT(m_controlRequestsTimer, "SavesTimer") + UI_MAP_ELEMENT(m_controlActionsTimer, "JoinTimer") + UI_END_MAP_ELEMENTS_AND_NAMES() + +public: + UIScene_LceLiveRequests(int iPad, void *initData, UILayer *parentLayer); + ~UIScene_LceLiveRequests(); + + virtual EUIScene getSceneType() { return eUIScene_LceLiveRequests; } + virtual void updateTooltips(); + virtual void updateComponents(); + +protected: + virtual wstring getMoviePath(); + virtual void handleReload(); + virtual void handleFocusChange(F64 controlId, F64 childId); + +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 FetchAndDisplay(); + void RebuildLists(); + void UpdateStatusLabel(); + int FocusedRequestIndex(); + int SelectedActionIndex(); + void PerformSelectedAction(); + void PromptResolveSelectedRequest(); + void PerformAccept(); + void PerformDecline(); +#ifdef _WINDOWS64 + void ResolvePendingRequest(bool accept); + static int ResolveRequestConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result); +#endif +}; diff --git a/Minecraft.Client/PendingConnection.cpp b/Minecraft.Client/PendingConnection.cpp index 88608cb1..9314b945 100644 --- a/Minecraft.Client/PendingConnection.cpp +++ b/Minecraft.Client/PendingConnection.cpp @@ -19,6 +19,9 @@ #include "../Minecraft.Server/Access/Access.h" #include "../Minecraft.World/Socket.h" #endif +#ifdef _WINDOWS64 +#include "Windows64/Windows64_LceLive.h" +#endif // #ifdef __PS3__ // #include "PS3/Network/NetworkPlayerSony.h" // #endif @@ -186,6 +189,17 @@ void PendingConnection::handleLogin(shared_ptr packet) //if (true)// 4J removed !server->onlineMode) bool sentDisconnect = false; +#ifdef _WINDOWS64 + // LceLive: if the joining client presented a join ticket but it failed validation, reject now. + // If no ticket was presented the player is treated as offline — vanilla play is unaffected. + if (m_lceLiveTicketPresented && !m_lceLiveTicketValid) + { + app.DebugPrintf("LCELive: rejecting %ls — invalid join ticket\n", name.c_str()); + disconnect(DisconnectPacket::eDisconnect_Banned); + return; + } +#endif + // Use the same Xuid choice as handleAcceptedLogin (offline first, online fallback). // PlayerUID loginXuid = packet->m_offlineXuid; @@ -368,6 +382,31 @@ void PendingConnection::handleKeepAlive(shared_ptr packet) // Ignore } +void PendingConnection::handleCustomPayload(shared_ptr packet) +{ +#ifdef _WINDOWS64 + if (packet->identifier == L"lcelive:ticket") + { + m_lceLiveTicketPresented = true; + if (packet->data.data != nullptr && packet->data.length > 0) + { + const std::string ticket(reinterpret_cast(packet->data.data), + static_cast(packet->data.length)); + m_lceLiveTicketValid = Win64LceLive::ValidateJoinTicketSync( + ticket, &m_lceLiveAccountId, &m_lceLiveUsername, &m_lceLiveDisplayName); + + if (m_lceLiveTicketValid) + app.DebugPrintf("LCELive: join ticket valid for %s (@%s)\n", + m_lceLiveDisplayName.c_str(), m_lceLiveUsername.c_str()); + else + app.DebugPrintf("LCELive: join ticket INVALID — will reject at login\n"); + } + return; // do not fall through to onUnhandledPacket + } +#endif + onUnhandledPacket(packet); +} + void PendingConnection::onUnhandledPacket(shared_ptr packet) { disconnect(DisconnectPacket::eDisconnect_UnexpectedPacket); diff --git a/Minecraft.Client/PendingConnection.h b/Minecraft.Client/PendingConnection.h index 58b8f7bc..fec6e650 100644 --- a/Minecraft.Client/PendingConnection.h +++ b/Minecraft.Client/PendingConnection.h @@ -1,5 +1,6 @@ #pragma once #include "../Minecraft.World/PacketListener.h" +#include class MinecraftServer; class Socket; class LoginPacket; @@ -38,7 +39,17 @@ public: virtual void onDisconnect(DisconnectPacket::eDisconnectReason reason, void *reasonObjects); virtual void handleGetInfo(shared_ptr packet); virtual void handleKeepAlive(shared_ptr packet); + virtual void handleCustomPayload(shared_ptr packet); virtual void onUnhandledPacket(shared_ptr packet); + +#ifdef _WINDOWS64 + // LceLive join ticket state — set by handleCustomPayload, checked in handleLogin. + bool m_lceLiveTicketPresented = false; + bool m_lceLiveTicketValid = false; + std::string m_lceLiveAccountId; + std::string m_lceLiveUsername; + std::string m_lceLiveDisplayName; +#endif void send(shared_ptr packet); wstring getName(); virtual bool isServerPacketListener(); diff --git a/Minecraft.Client/Windows64/Windows64_LceLive.cpp b/Minecraft.Client/Windows64/Windows64_LceLive.cpp index 4d1be18d..3a406fa5 100644 --- a/Minecraft.Client/Windows64/Windows64_LceLive.cpp +++ b/Minecraft.Client/Windows64/Windows64_LceLive.cpp @@ -54,6 +54,8 @@ namespace ERequestType type; std::string path; std::string body; + std::string authorization; // optional "Bearer "; empty for unauthenticated requests + std::string method; // if non-empty, overrides verb selection (e.g. "DELETE") }; struct CompletedRequest @@ -482,8 +484,12 @@ namespace 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); + std::wstring verbStr; + if (!request.method.empty()) + verbStr = Utf8ToWide(request.method); + else + verbStr = request.body.empty() ? L"GET" : L"POST"; + HINTERNET requestHandle = WinHttpOpenRequest(connection, verbStr.c_str(), fullPath.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, secure ? WINHTTP_FLAG_SECURE : 0); if (requestHandle == nullptr) { WinHttpCloseHandle(connection); @@ -491,9 +497,16 @@ namespace 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"; + std::wstring headersStr; + if (!request.body.empty()) + headersStr += L"Content-Type: application/json\r\n"; + headersStr += L"Accept: application/json\r\n"; + if (!request.authorization.empty()) + { + headersStr += L"Authorization: "; + headersStr += Utf8ToWide(request.authorization); + headersStr += L"\r\n"; + } LPVOID sendBuffer = WINHTTP_NO_REQUEST_DATA; DWORD sendSize = 0; if (!request.body.empty()) @@ -502,7 +515,7 @@ namespace sendSize = static_cast(request.body.size()); } - const BOOL sendOk = WinHttpSendRequest(requestHandle, headers, -1L, sendBuffer, sendSize, sendSize, 0); + const BOOL sendOk = WinHttpSendRequest(requestHandle, headersStr.c_str(), static_cast(headersStr.size()), sendBuffer, sendSize, sendSize, 0); if (!sendOk || !WinHttpReceiveResponse(requestHandle, nullptr)) { WinHttpCloseHandle(requestHandle); @@ -557,7 +570,103 @@ namespace return fallback; const std::string message = JsonStringOrEmpty(responseJson, "message"); - return message.empty() ? fallback : message; + if (!message.empty()) + return message; + + const std::string detail = JsonStringOrEmpty(responseJson, "detail"); + if (!detail.empty()) + return detail; + + const std::string title = JsonStringOrEmpty(responseJson, "title"); + if (!title.empty()) + return title; + + const std::string error = JsonStringOrEmpty(responseJson, "error"); + if (!error.empty()) + return error; + + const std::string code = JsonStringOrEmpty(responseJson, "code"); + return code.empty() ? fallback : code; + } + + bool RefreshSessionSync(std::string *outError) + { + if (outError != nullptr) + outError->clear(); + + std::string refreshToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + refreshToken = g_state.session.refreshToken; + LeaveCriticalSection(&g_state.lock); + + if (refreshToken.empty()) + { + if (outError != nullptr) + *outError = "LCELive sign-in expired. Press A to link this device again."; + return false; + } + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/auth/refresh"; + Json bodyJson; + bodyJson["refreshToken"] = refreshToken; + req.body = bodyJson.dump(); + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody)) + { + if (outError != nullptr) + *outError = "Unable to contact LCELive to refresh sign-in."; + return false; + } + + if (status < 200 || status >= 300) + { + EnterCriticalSection(&g_state.lock); + ClearSessionLocked(); + LeaveCriticalSection(&g_state.lock); + + if (outError != nullptr) + *outError = ParseErrorMessage(responseBody, "LCELive sign-in expired. Press A to link this device again."); + return false; + } + + const Json responseJson = Json::parse(responseBody, nullptr, false); + if (!responseJson.is_object()) + { + if (outError != nullptr) + *outError = "LCELive returned an invalid refresh response."; + return false; + } + + const Json::const_iterator accountIt = responseJson.find("account"); + const std::string refreshedAccessToken = JsonStringOrEmpty(responseJson, "accessToken"); + const std::string refreshedRefreshToken = JsonStringOrEmpty(responseJson, "refreshToken"); + if (accountIt == responseJson.end() || !accountIt->is_object() || refreshedAccessToken.empty() || refreshedRefreshToken.empty()) + { + EnterCriticalSection(&g_state.lock); + ClearSessionLocked(); + LeaveCriticalSection(&g_state.lock); + + if (outError != nullptr) + *outError = "LCELive refresh returned incomplete credentials. Press A to link this device again."; + return false; + } + + EnterCriticalSection(&g_state.lock); + 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; + SaveAuthSessionLocked(); + LeaveCriticalSection(&g_state.lock); + + return true; } DWORD WINAPI RequestThreadProc(LPVOID) @@ -968,6 +1077,330 @@ namespace Win64LceLive LeaveCriticalSection(&g_state.lock); return true; } + + std::string GetAccessToken() + { + EnsureInitialized(); + EnterCriticalSection(&g_state.lock); + std::string token = g_state.session.valid ? g_state.session.accessToken : std::string(); + LeaveCriticalSection(&g_state.lock); + return token; + } + + TicketResult RequestJoinTicketSync() + { + EnsureInitialized(); + + std::string accessToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, std::string(), "Not signed in to LCELive." }; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/sessions/ticket"; + req.body = "{}"; + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status < 200 || status >= 300) + return { false, std::string(), ParseErrorMessage(responseBody, "Failed to obtain join ticket.") }; + + const Json responseJson = Json::parse(responseBody, nullptr, false); + if (!responseJson.is_object()) + return { false, std::string(), "Invalid ticket response from LCELive." }; + + const std::string ticket = JsonStringOrEmpty(responseJson, "ticket"); + if (ticket.empty()) + return { false, std::string(), "Join ticket missing from LCELive response." }; + + return { true, ticket, std::string() }; + } + + bool ValidateJoinTicketSync( + const std::string& ticket, + std::string* outAccountId, + std::string* outUsername, + std::string* outDisplayName) + { + if (ticket.empty()) + return false; + + Json bodyJson; + bodyJson["ticket"] = ticket; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/sessions/validate"; + req.body = bodyJson.dump(); + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status != 200) + return false; + + const Json responseJson = Json::parse(responseBody, nullptr, false); + if (!responseJson.is_object()) + return false; + + if (outAccountId) *outAccountId = JsonStringOrEmpty(responseJson, "accountId"); + if (outUsername) *outUsername = JsonStringOrEmpty(responseJson, "username"); + if (outDisplayName) *outDisplayName = JsonStringOrEmpty(responseJson, "displayName"); + return true; + } + + FriendsListResult GetFriendsSync() + { + EnsureInitialized(); + std::string accessToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, {}, "Not signed in to LCELive." }; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/social/friends"; + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status < 200 || status >= 300) + return { false, {}, ParseErrorMessage(responseBody, "Failed to get friends list.") }; + + const Json responseJson = Json::parse(responseBody, nullptr, false); + if (!responseJson.is_object()) + return { false, {}, "Invalid friends list response." }; + + std::vector friends; + const Json::const_iterator friendsIt = responseJson.find("friends"); + if (friendsIt != responseJson.end() && friendsIt->is_array()) + { + for (const Json &entry : *friendsIt) + { + if (!entry.is_object()) continue; + SocialEntry se; + se.accountId = JsonStringOrEmpty(entry, "accountId"); + se.username = JsonStringOrEmpty(entry, "username"); + se.displayName = JsonStringOrEmpty(entry, "displayName"); + friends.push_back(se); + } + } + + return { true, friends, std::string() }; + } + + PendingRequestsResult GetPendingRequestsSync() + { + EnsureInitialized(); + std::string accessToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, {}, {}, "Not signed in to LCELive." }; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/social/requests"; + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status < 200 || status >= 300) + return { false, {}, {}, ParseErrorMessage(responseBody, "Failed to get pending requests.") }; + + const Json responseJson = Json::parse(responseBody, nullptr, false); + if (!responseJson.is_object()) + return { false, {}, {}, "Invalid pending requests response." }; + + auto parseList = [&](const char *key, const char *idKey, const char *userKey, const char *nameKey) + { + std::vector result; + const Json::const_iterator it = responseJson.find(key); + if (it != responseJson.end() && it->is_array()) + { + for (const Json &entry : *it) + { + if (!entry.is_object()) continue; + SocialEntry se; + se.accountId = JsonStringOrEmpty(entry, idKey); + se.username = JsonStringOrEmpty(entry, userKey); + se.displayName = JsonStringOrEmpty(entry, nameKey); + result.push_back(se); + } + } + return result; + }; + + std::vector incoming = parseList("incoming", "requesterAccountId", "requesterUsername", "requesterDisplayName"); + std::vector outgoing = parseList("outgoing", "targetAccountId", "targetUsername", "targetDisplayName"); + + return { true, incoming, outgoing, std::string() }; + } + + SocialActionResult SendFriendRequestSync(const std::string &username) + { + EnsureInitialized(); + std::string accessToken; + std::string refreshToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + { + accessToken = g_state.session.accessToken; + refreshToken = g_state.session.refreshToken; + } + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, "Not signed in to LCELive." }; + + app.DebugPrintf("LCELive: sending friend request for username='%s'\n", username.c_str()); + + Json bodyJson; + bodyJson["username"] = username; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/social/request"; + req.body = bodyJson.dump(); + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody)) + { + app.DebugPrintf("LCELive: friend request transport failure\n"); + return { false, "Failed to contact LCELive while sending friend request." }; + } + + if (status == 401 && !refreshToken.empty()) + { + std::string refreshError; + if (RefreshSessionSync(&refreshError)) + { + EnterCriticalSection(&g_state.lock); + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + req.authorization = "Bearer " + accessToken; + responseBody.clear(); + if (!PerformJsonRequest(req, &status, &responseBody)) + { + app.DebugPrintf("LCELive: friend request transport failure after refresh\n"); + return { false, "Failed to contact LCELive while sending friend request." }; + } + } + else + { + app.DebugPrintf("LCELive: friend request refresh failed: %s\n", refreshError.c_str()); + return { false, refreshError }; + } + } + + if (status < 200 || status >= 300) + { + app.DebugPrintf("LCELive: friend request HTTP %lu body='%s'\n", + static_cast(status), responseBody.c_str()); + const std::string parsed = ParseErrorMessage(responseBody, std::string()); + if (!parsed.empty()) + return { false, parsed }; + + char buffer[128] = {}; + sprintf_s(buffer, "LCELive rejected the friend request (HTTP %lu).", static_cast(status)); + return { false, buffer }; + } + + return { true, std::string() }; + } + + SocialActionResult AcceptFriendRequestSync(const std::string &fromAccountId) + { + EnsureInitialized(); + std::string accessToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, "Not signed in to LCELive." }; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/social/requests/" + fromAccountId + "/accept"; + req.body = "{}"; + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status < 200 || status >= 300) + return { false, ParseErrorMessage(responseBody, "Failed to accept friend request.") }; + + return { true, std::string() }; + } + + SocialActionResult DeclineFriendRequestSync(const std::string &fromAccountId) + { + EnsureInitialized(); + std::string accessToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, "Not signed in to LCELive." }; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/social/requests/" + fromAccountId + "/decline"; + req.body = "{}"; + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status < 200 || status >= 300) + return { false, ParseErrorMessage(responseBody, "Failed to decline friend request.") }; + + return { true, std::string() }; + } + + SocialActionResult RemoveFriendSync(const std::string &accountId) + { + EnsureInitialized(); + std::string accessToken; + EnterCriticalSection(&g_state.lock); + if (g_state.session.valid) + accessToken = g_state.session.accessToken; + LeaveCriticalSection(&g_state.lock); + + if (accessToken.empty()) + return { false, "Not signed in to LCELive." }; + + RequestContext req = {}; + req.type = ERequestType::None; + req.path = "/api/social/friends/" + accountId; + req.method = "DELETE"; + req.authorization = "Bearer " + accessToken; + + DWORD status = 0; + std::string responseBody; + if (!PerformJsonRequest(req, &status, &responseBody) || status < 200 || status >= 300) + return { false, ParseErrorMessage(responseBody, "Failed to remove friend.") }; + + return { true, std::string() }; + } } #endif diff --git a/Minecraft.Client/Windows64/Windows64_LceLive.h b/Minecraft.Client/Windows64/Windows64_LceLive.h index 5b117c5e..1d60a25b 100644 --- a/Minecraft.Client/Windows64/Windows64_LceLive.h +++ b/Minecraft.Client/Windows64/Windows64_LceLive.h @@ -3,6 +3,7 @@ #ifdef _WINDOWS64 #include +#include namespace Win64LceLive { @@ -30,10 +31,70 @@ namespace Win64LceLive std::wstring errorMessage; }; + struct TicketResult + { + bool success; + std::string ticket; // ASCII/URL-safe; empty on failure + std::string error; // human-readable; empty on success + }; + + // Social feature types + struct SocialEntry + { + std::string accountId; + std::string username; + std::string displayName; + }; + + struct FriendsListResult + { + bool success; + std::vector friends; + std::string error; + }; + + struct PendingRequestsResult + { + bool success; + std::vector incoming; + std::vector outgoing; + std::string error; + }; + + struct SocialActionResult + { + bool success; + std::string error; + }; + void Tick(); Snapshot GetSnapshot(); bool StartDeviceLink(); bool SignOut(); + + // Returns the current access token if signed in, or an empty string. + // Synchronous — reads from cached state, never blocks. + std::string GetAccessToken(); + + // Requests a short-lived join ticket from the LceLive API. + // Synchronous blocking call (~localhost RTT). Call from a non-UI thread. + TicketResult RequestJoinTicketSync(); + + // Validates a join ticket presented by a remote client by calling the LceLive API. + // Synchronous blocking call. Returns true if the ticket is valid and not yet used. + // Populates out-params with the verified account identity on success. + bool ValidateJoinTicketSync(const std::string& ticket, + std::string* outAccountId, + std::string* outUsername, + std::string* outDisplayName); + + // Social sync functions — all blocking, call from UI thread only when brief (~localhost RTT). + FriendsListResult GetFriendsSync(); + PendingRequestsResult GetPendingRequestsSync(); + SocialActionResult SendFriendRequestSync(const std::string& username); + SocialActionResult AcceptFriendRequestSync(const std::string& fromAccountId); + SocialActionResult DeclineFriendRequestSync(const std::string& fromAccountId); + SocialActionResult RemoveFriendSync(const std::string& accountId); } #endif diff --git a/Minecraft.Client/cmake/sources/Windows.cmake b/Minecraft.Client/cmake/sources/Windows.cmake index 2ee2b37a..fe10545b 100644 --- a/Minecraft.Client/cmake/sources/Windows.cmake +++ b/Minecraft.Client/cmake/sources/Windows.cmake @@ -152,6 +152,12 @@ set(_MINECRAFT_CLIENT_WINDOWS_COMMON_UI_SCENES_FRONTEND_MENU_SCREENS "${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_LceLiveLinking.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLiveLinking.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLiveFriends.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLiveFriends.h" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLiveRequests.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/Common/UI/UIScene_LceLiveRequests.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" diff --git a/Minecraft.Server/cmake/sources/Common.cmake b/Minecraft.Server/cmake/sources/Common.cmake index 7894da22..3ab839c5 100644 --- a/Minecraft.Server/cmake/sources/Common.cmake +++ b/Minecraft.Server/cmake/sources/Common.cmake @@ -209,6 +209,9 @@ set(_MINECRAFT_SERVER_COMMON_ROOT "${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_LceLiveLinking.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_LceLiveFriends.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../Minecraft.Client/Common/UI/UIScene_LceLiveRequests.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"