mirror of
https://forge.banditvault.co.uk/racoon/MinecraftConsoles.git
synced 2026-05-21 21:45:31 +00:00
Add TCP-over-WebSocket relay client for Minecraft
- Implemented Win64LceLiveRelay.h and Win64LceLiveSignaling.cpp to facilitate TCP-over-WebSocket communication for Minecraft, allowing game traffic to route through the LCELive relay server when direct TCP is blocked. - Introduced signaling mechanisms for host and joiner connections, including session management and candidate exchange. - Added logging functionality in Windows_Log.cpp and Windows_Log.h for better debugging and session tracking. - Created build-release.bat script for streamlined build and deployment process, including exclusion of unnecessary files.
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
#include "../../Windows64/Network/WinsockNetLayer.h"
|
||||
#include "../../Windows64/Windows64_Xuid.h"
|
||||
#include "../../Windows64/Windows64_LceLive.h"
|
||||
#include "../../Windows64/Windows64_LceLiveP2P.h"
|
||||
#include "../../Windows64/Windows64_LceLiveSignaling.h"
|
||||
#include "../../Minecraft.h"
|
||||
#include "../../User.h"
|
||||
#include "../../MinecraftServer.h"
|
||||
@@ -445,6 +447,8 @@ bool CPlatformNetworkManagerStub::LeaveGame(bool bMigrateHost)
|
||||
SystemFlagReset();
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
Win64LceLiveSignaling::Close(); // close signaling WebSocket before P2P teardown
|
||||
Win64LceLiveP2P::HostClose(); // tear down P2P socket + remove UPnP mapping
|
||||
WinsockNetLayer::Shutdown();
|
||||
WinsockNetLayer::Initialize();
|
||||
#endif
|
||||
@@ -499,6 +503,9 @@ void CPlatformNetworkManagerStub::HostGame(int localUsersMask, bool bOnlineGame,
|
||||
|
||||
if (WinsockNetLayer::IsActive())
|
||||
{
|
||||
// Start P2P discovery (UPnP IGD → STUN fallback) so we have an
|
||||
// external endpoint ready before the first joiner arrives.
|
||||
Win64LceLiveP2P::HostOpen();
|
||||
// For Dedicated Server, refer to `lan-advertise` in `server.properties`
|
||||
bool enableLanAdvertising = true;
|
||||
if (g_Win64DedicatedServer)
|
||||
|
||||
@@ -48,7 +48,7 @@ UIScene_DebugOverlay::UIScene_DebugOverlay(int iPad, void *initData, UILayer *pa
|
||||
{
|
||||
if (Item::items[i] != nullptr)
|
||||
{
|
||||
sortedItems.emplace_back(std::wstring(app.GetString(Item::items[i]->getDescriptionId())), i);
|
||||
sortedItems.emplace_back(std::wstring(app.GetString(Item::items[i]->getDescriptionId())), static_cast<unsigned int>(i));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
#include "UIScene_LceLiveInvites.h"
|
||||
#include "../../Minecraft.h"
|
||||
#include "../../Windows64/Network/WinsockNetLayer.h"
|
||||
#ifdef _WINDOWS64
|
||||
#include "../../Windows64/Windows64_LceLiveP2P.h"
|
||||
#include "../../Windows64/Windows64_LceLiveSignaling.h"
|
||||
#include "../../Windows64/Windows64_LceLiveRelay.h"
|
||||
#endif
|
||||
|
||||
// Fallbacks until string ID headers are regenerated.
|
||||
#ifndef IDS_TITLE_SEND_INVITE
|
||||
@@ -88,6 +93,12 @@ UIScene_LceLiveInvites::UIScene_LceLiveInvites(int iPad, void *initData, UILayer
|
||||
m_invitedAccountIds.clear();
|
||||
m_pendingInviteAccountId.clear();
|
||||
m_pendingInviteLabel.clear();
|
||||
m_gameInvites.clear();
|
||||
m_pendingAcceptInviteId.clear();
|
||||
m_pendingAcceptHostIp.clear();
|
||||
m_pendingAcceptHostPort = 0;
|
||||
m_pendingAcceptHostName.clear();
|
||||
m_pendingAcceptSignalingSessionId.clear();
|
||||
#endif
|
||||
|
||||
doHorizontalResizeCheck();
|
||||
@@ -162,14 +173,24 @@ void UIScene_LceLiveInvites::handleInput(int iPad, int key, bool repeat, bool pr
|
||||
if (controlHasFocus(eControl_ActionsList))
|
||||
PerformSelectedAction();
|
||||
else if (controlHasFocus(eControl_FriendsList))
|
||||
PromptInviteSelectedFriend();
|
||||
{
|
||||
#ifdef _WINDOWS64
|
||||
if (IsReceiveMode())
|
||||
PromptAcceptSelectedInvite();
|
||||
else
|
||||
PromptInviteSelectedFriend();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ACTION_MENU_X:
|
||||
if (pressed && !repeat)
|
||||
{
|
||||
#ifdef _WINDOWS64
|
||||
PromptInviteSelectedFriend();
|
||||
if (IsReceiveMode())
|
||||
PromptAcceptSelectedInvite();
|
||||
else
|
||||
PromptInviteSelectedFriend();
|
||||
#endif
|
||||
handled = true;
|
||||
}
|
||||
@@ -197,7 +218,12 @@ void UIScene_LceLiveInvites::handlePress(F64 controlId, F64 childId)
|
||||
if (static_cast<int>(controlId) == eControl_FriendsList)
|
||||
{
|
||||
m_friendsList.updateChildFocus(static_cast<int>(childId));
|
||||
PromptInviteSelectedFriend();
|
||||
#ifdef _WINDOWS64
|
||||
if (IsReceiveMode())
|
||||
PromptAcceptSelectedInvite();
|
||||
else
|
||||
PromptInviteSelectedFriend();
|
||||
#endif
|
||||
}
|
||||
else if (static_cast<int>(controlId) == eControl_ActionsList)
|
||||
{
|
||||
@@ -214,12 +240,51 @@ void UIScene_LceLiveInvites::FetchAndDisplay()
|
||||
{
|
||||
m_friends.clear();
|
||||
m_invitedAccountIds.clear();
|
||||
m_gameInvites.clear();
|
||||
m_bDataReady = true;
|
||||
m_statusMessage = L"Sign in to LCELIVE to invite friends.";
|
||||
m_statusMessage = IsReceiveMode()
|
||||
? L"Sign in to LCELive to view game invites."
|
||||
: L"Sign in to LCELive to invite friends.";
|
||||
RebuildLists();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Receive mode: show incoming game invites ──────────────────────────
|
||||
if (IsReceiveMode())
|
||||
{
|
||||
const int previousSelection = m_friendsList.getCurrentSelection();
|
||||
const Win64LceLive::GameInvitesResult result = Win64LceLive::GetGameInvitesSync();
|
||||
if (!result.success)
|
||||
{
|
||||
m_gameInvites.clear();
|
||||
m_bDataReady = true;
|
||||
m_statusMessage = Utf8ToWideLocal(result.error);
|
||||
RebuildLists();
|
||||
return;
|
||||
}
|
||||
|
||||
m_gameInvites = result.incoming;
|
||||
m_bDataReady = true;
|
||||
|
||||
if (m_gameInvites.empty())
|
||||
m_statusMessage = L"No pending game invites.";
|
||||
else if (m_statusMessage == L"No pending game invites.")
|
||||
m_statusMessage.clear();
|
||||
|
||||
RebuildLists();
|
||||
|
||||
if (!m_gameInvites.empty())
|
||||
{
|
||||
int sel = previousSelection;
|
||||
if (sel < 0) sel = 0;
|
||||
if (sel >= static_cast<int>(m_gameInvites.size()))
|
||||
sel = static_cast<int>(m_gameInvites.size()) - 1;
|
||||
m_friendsList.setCurrentSelection(sel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Send mode: show friends list for inviting ─────────────────────────
|
||||
const int previousSelection = m_friendsList.getCurrentSelection();
|
||||
const Win64LceLive::FriendsListResult result = Win64LceLive::GetFriendsSync();
|
||||
if (!result.success)
|
||||
@@ -270,12 +335,43 @@ void UIScene_LceLiveInvites::RebuildLists()
|
||||
m_friendsList.clearList();
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
for (const Win64LceLive::SocialEntry &entry : m_friends)
|
||||
if (IsReceiveMode())
|
||||
{
|
||||
std::wstring label = BuildFriendLabel(entry);
|
||||
if (AlreadyInvited(entry.accountId))
|
||||
label += L" [SENT]";
|
||||
m_friendsList.addItem(label);
|
||||
for (const Win64LceLive::GameInviteEntry &entry : m_gameInvites)
|
||||
{
|
||||
std::wstring label;
|
||||
if (!entry.senderDisplayName.empty())
|
||||
label = Utf8ToWideLocal(entry.senderDisplayName);
|
||||
else if (!entry.senderUsername.empty())
|
||||
label = Utf8ToWideLocal(entry.senderUsername);
|
||||
else
|
||||
label = L"<unknown>";
|
||||
|
||||
if (!entry.senderUsername.empty())
|
||||
{
|
||||
label += L" (@";
|
||||
label += Utf8ToWideLocal(entry.senderUsername);
|
||||
label += L")";
|
||||
}
|
||||
if (!entry.hostName.empty())
|
||||
{
|
||||
label += L" - ";
|
||||
label += Utf8ToWideLocal(entry.hostName);
|
||||
}
|
||||
if (!entry.sessionActive)
|
||||
label += L" [inactive]";
|
||||
m_friendsList.addItem(label);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const Win64LceLive::SocialEntry &entry : m_friends)
|
||||
{
|
||||
std::wstring label = BuildFriendLabel(entry);
|
||||
if (AlreadyInvited(entry.accountId))
|
||||
label += L" [SENT]";
|
||||
m_friendsList.addItem(label);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -294,7 +390,7 @@ void UIScene_LceLiveInvites::UpdateStatusLabel()
|
||||
m_labelStatus.setVisible(true);
|
||||
}
|
||||
|
||||
m_labelFriendsTitle.setLabel(L"INVITE FRIENDS", true, true);
|
||||
m_labelFriendsTitle.setLabel(IsReceiveMode() ? L"GAME INVITES" : L"INVITE FRIENDS", true, true);
|
||||
m_labelActionsTitle.setLabel(L"ACTIONS", true, true);
|
||||
}
|
||||
|
||||
@@ -368,7 +464,7 @@ void UIScene_LceLiveInvites::InvitePendingFriend()
|
||||
if (m_pendingInviteAccountId.empty())
|
||||
return;
|
||||
|
||||
if (!g_NetworkManager.IsHost() || !WinsockNetLayer::IsHosting() || g_Win64MultiplayerIP[0] == 0 || g_Win64MultiplayerPort <= 0)
|
||||
if (!g_NetworkManager.IsHost() || !WinsockNetLayer::IsHosting() || WinsockNetLayer::GetHostPort() <= 0)
|
||||
{
|
||||
m_statusMessage = L"The game session is no longer active.";
|
||||
m_pendingInviteAccountId.clear();
|
||||
@@ -377,11 +473,26 @@ void UIScene_LceLiveInvites::InvitePendingFriend()
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the right host IP for joiners:
|
||||
// tcpPortMapped=true → UPnP mapped the TCP game port on the router,
|
||||
// so internet joiners can reach externalIp:port directly.
|
||||
// tcpPortMapped=false → no TCP port mapping; use the LAN IP so same-network
|
||||
// testing works. Internet play without UPnP needs KCP.
|
||||
const Win64LceLiveP2P::P2PSnapshot snap = Win64LceLiveP2P::GetP2PSnapshot();
|
||||
const std::string hostIp = (snap.tcpPortMapped && !snap.externalIp.empty())
|
||||
? snap.externalIp
|
||||
: WinsockNetLayer::GetLocalIPv4();
|
||||
const int hostPort = WinsockNetLayer::GetHostPort();
|
||||
|
||||
// Include the P2P signaling session ID so the joiner can do hole punching.
|
||||
const std::string signalingSessionId = Win64LceLiveSignaling::GetSnapshot().sessionId;
|
||||
|
||||
const Win64LceLive::SocialActionResult result = Win64LceLive::SendGameInviteSync(
|
||||
m_pendingInviteAccountId,
|
||||
g_Win64MultiplayerIP,
|
||||
g_Win64MultiplayerPort,
|
||||
"");
|
||||
hostIp,
|
||||
hostPort,
|
||||
"",
|
||||
signalingSessionId);
|
||||
if (!result.success)
|
||||
{
|
||||
m_statusMessage = Utf8ToWideLocal(result.error);
|
||||
@@ -421,4 +532,183 @@ int UIScene_LceLiveInvites::InviteFriendConfirmCallback(void *pParam, int iPad,
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Receive mode ──────────────────────────────────────────────────────────
|
||||
|
||||
bool UIScene_LceLiveInvites::IsReceiveMode() const
|
||||
{
|
||||
return Minecraft::GetInstance()->level == nullptr;
|
||||
}
|
||||
|
||||
void UIScene_LceLiveInvites::PromptAcceptSelectedInvite()
|
||||
{
|
||||
const int idx = FocusedFriendIndex();
|
||||
if (idx < 0 || idx >= static_cast<int>(m_gameInvites.size()))
|
||||
{
|
||||
m_statusMessage = L"Select an invite first.";
|
||||
UpdateStatusLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
m_pendingAcceptInviteId = m_gameInvites[idx].inviteId;
|
||||
|
||||
UINT optionIds[2];
|
||||
optionIds[0] = IDS_NO;
|
||||
optionIds[1] = IDS_YES;
|
||||
|
||||
ui.RequestAlertMessage(
|
||||
IDS_TITLE_SEND_INVITE,
|
||||
IDS_TEXT_SEND_INVITE_CONFIRMATION,
|
||||
optionIds,
|
||||
2,
|
||||
m_iPad,
|
||||
&UIScene_LceLiveInvites::AcceptInviteConfirmCallback,
|
||||
this);
|
||||
}
|
||||
|
||||
void UIScene_LceLiveInvites::ResolvePendingInvite(bool accept)
|
||||
{
|
||||
if (m_pendingAcceptInviteId.empty())
|
||||
return;
|
||||
|
||||
const std::string inviteId = m_pendingAcceptInviteId;
|
||||
|
||||
if (accept)
|
||||
{
|
||||
const Win64LceLive::GameInviteActionResult result = Win64LceLive::AcceptGameInviteSync(inviteId);
|
||||
if (result.success)
|
||||
{
|
||||
m_pendingAcceptHostIp = result.hostIp;
|
||||
m_pendingAcceptHostPort = result.hostPort;
|
||||
m_pendingAcceptHostName = result.hostName;
|
||||
m_pendingAcceptSignalingSessionId = result.signalingSessionId;
|
||||
|
||||
if (!result.signalingSessionId.empty())
|
||||
{
|
||||
// Always open the joiner relay — the host always has one ready.
|
||||
// Relay goes over WSS (port 443) so it works on any network including
|
||||
// campus WiFi, hotel WiFi, or any firewall that blocks port 25565.
|
||||
const int relayProxyPort =
|
||||
Win64LceLiveRelay::JoinerOpen(result.signalingSessionId);
|
||||
|
||||
// Decide which address to give the game:
|
||||
// Public host IP → try direct TCP first (UPnP may have mapped the port).
|
||||
// If the local network blocks port 25565, this will
|
||||
// fail and the relay is already waiting as a fallback.
|
||||
// Private host IP → direct TCP can't work from the internet; use relay.
|
||||
const std::string& hIp = result.hostIp;
|
||||
const bool hostIsPublicIp =
|
||||
!hIp.empty() &&
|
||||
hIp.substr(0, 8) != "192.168." &&
|
||||
hIp.substr(0, 3) != "10." &&
|
||||
hIp.substr(0, 7) != "172.16." &&
|
||||
hIp != "127.0.0.1";
|
||||
|
||||
if (!hostIsPublicIp && relayProxyPort > 0)
|
||||
{
|
||||
// No public IP — relay is the only path.
|
||||
m_pendingAcceptHostIp = "127.0.0.1";
|
||||
m_pendingAcceptHostPort = relayProxyPort;
|
||||
}
|
||||
else if (relayProxyPort > 0)
|
||||
{
|
||||
// Public IP → try direct TCP first. Stash the relay proxy port so
|
||||
// the main Tick auto-retries through the relay if the direct attempt
|
||||
// fails (e.g. campus / hotel WiFi blocks port 25565 outbound).
|
||||
// This is Xbox Live's TURN fallback pattern: relay is pre-allocated
|
||||
// on both sides before any direct connection is even attempted.
|
||||
g_LceLiveRelayFallbackPort = relayProxyPort;
|
||||
}
|
||||
|
||||
// Set up the P2P/signaling path for hole-punching (best-effort).
|
||||
Win64LceLiveP2P::HostOpen();
|
||||
Win64LceLiveSignaling::PrepareJoin(result.signalingSessionId);
|
||||
}
|
||||
|
||||
JoinAcceptedInvite();
|
||||
m_pendingAcceptInviteId.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
m_statusMessage = Utf8ToWideLocal(result.error);
|
||||
UpdateStatusLabel();
|
||||
FetchAndDisplay();
|
||||
m_pendingAcceptInviteId.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const Win64LceLive::SocialActionResult declineResult = Win64LceLive::DeclineGameInviteSync(inviteId);
|
||||
m_statusMessage = declineResult.success ? L"Game invite declined." : Utf8ToWideLocal(declineResult.error);
|
||||
m_pendingAcceptInviteId.clear();
|
||||
|
||||
FetchAndDisplay();
|
||||
}
|
||||
|
||||
void UIScene_LceLiveInvites::JoinAcceptedInvite()
|
||||
{
|
||||
if (m_pendingAcceptHostIp.empty() || m_pendingAcceptHostPort <= 0)
|
||||
{
|
||||
m_statusMessage = L"The game session is no longer active.";
|
||||
UpdateStatusLabel();
|
||||
FetchAndDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
ProfileManager.SetLockedProfile(m_iPad);
|
||||
ProfileManager.SetPrimaryPad(m_iPad);
|
||||
g_NetworkManager.SetLocalGame(false);
|
||||
ProfileManager.QuerySigninStatus();
|
||||
Minecraft::GetInstance()->clearConnectionFailed();
|
||||
|
||||
int localUsersMask = 0;
|
||||
for (unsigned int index = 0; index < XUSER_MAX_COUNT; ++index)
|
||||
{
|
||||
if (ProfileManager.IsSignedIn(index))
|
||||
localUsersMask |= g_NetworkManager.GetLocalPlayerMask(index);
|
||||
}
|
||||
|
||||
INVITE_INFO inviteInfo = {};
|
||||
inviteInfo.netVersion = MINECRAFT_NET_VERSION;
|
||||
strcpy_s(inviteInfo.hostIP, m_pendingAcceptHostIp.c_str());
|
||||
inviteInfo.hostPort = m_pendingAcceptHostPort;
|
||||
inviteInfo.sessionActive = true;
|
||||
const std::wstring hostNameWide = m_pendingAcceptHostName.empty()
|
||||
? L"LCELive" : Utf8ToWideLocal(m_pendingAcceptHostName);
|
||||
wcsncpy_s(inviteInfo.hostName, hostNameWide.c_str(), _TRUNCATE);
|
||||
strcpy_s(inviteInfo.inviteId, m_pendingAcceptInviteId.c_str());
|
||||
|
||||
const bool success = g_NetworkManager.JoinGameFromInviteInfo(m_iPad, localUsersMask, &inviteInfo);
|
||||
if (!success)
|
||||
m_statusMessage = L"Could not join this game.";
|
||||
else
|
||||
m_statusMessage = L"Joining game...";
|
||||
|
||||
m_pendingAcceptInviteId.clear();
|
||||
m_pendingAcceptHostIp.clear();
|
||||
m_pendingAcceptHostPort = 0;
|
||||
m_pendingAcceptHostName.clear();
|
||||
m_pendingAcceptSignalingSessionId.clear();
|
||||
UpdateStatusLabel();
|
||||
FetchAndDisplay();
|
||||
}
|
||||
|
||||
int UIScene_LceLiveInvites::AcceptInviteConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result)
|
||||
{
|
||||
UIScene_LceLiveInvites *scene = static_cast<UIScene_LceLiveInvites *>(pParam);
|
||||
if (scene == nullptr)
|
||||
return 0;
|
||||
|
||||
(void)iPad;
|
||||
|
||||
// [NO, YES] — "Decline" is the 2nd option (YES).
|
||||
if (result == C4JStorage::EMessage_ResultDecline)
|
||||
scene->ResolvePendingInvite(true);
|
||||
else
|
||||
{
|
||||
scene->m_pendingAcceptInviteId.clear();
|
||||
scene->FetchAndDisplay();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -46,10 +46,19 @@ private:
|
||||
UI_END_MAP_ELEMENTS_AND_NAMES()
|
||||
|
||||
#if defined(_WINDOWS64) && !defined(MINECRAFT_SERVER_BUILD)
|
||||
// Send-invite mode (in-game, hosting)
|
||||
std::vector<Win64LceLive::SocialEntry> m_friends;
|
||||
std::vector<std::string> m_invitedAccountIds;
|
||||
std::string m_pendingInviteAccountId;
|
||||
std::wstring m_pendingInviteLabel;
|
||||
|
||||
// Receive-invite mode (main menu, not in game)
|
||||
std::vector<Win64LceLive::GameInviteEntry> m_gameInvites;
|
||||
std::string m_pendingAcceptInviteId;
|
||||
std::string m_pendingAcceptHostIp;
|
||||
int m_pendingAcceptHostPort;
|
||||
std::string m_pendingAcceptHostName;
|
||||
std::string m_pendingAcceptSignalingSessionId;
|
||||
#endif
|
||||
|
||||
std::wstring m_statusMessage;
|
||||
@@ -75,6 +84,7 @@ protected:
|
||||
void handlePress(F64 controlId, F64 childId);
|
||||
|
||||
private:
|
||||
bool IsReceiveMode() const; // true when opened from main menu (no active game)
|
||||
void FetchAndDisplay();
|
||||
void RebuildLists();
|
||||
void UpdateStatusLabel();
|
||||
@@ -83,10 +93,17 @@ private:
|
||||
void PerformSelectedAction();
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
// Send mode
|
||||
void PromptInviteSelectedFriend();
|
||||
void PromptInviteFriendAtIndex(int friendIndex);
|
||||
void InvitePendingFriend();
|
||||
bool AlreadyInvited(const std::string &accountId) const;
|
||||
static int InviteFriendConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result);
|
||||
|
||||
// Receive mode
|
||||
void PromptAcceptSelectedInvite();
|
||||
void ResolvePendingInvite(bool accept);
|
||||
void JoinAcceptedInvite();
|
||||
static int AcceptInviteConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result);
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
#include "UI.h"
|
||||
#include "UIScene_LceLiveRequests.h"
|
||||
#include "../../Minecraft.h"
|
||||
#ifdef _WINDOWS64
|
||||
#include "../../Windows64/Windows64_LceLiveP2P.h"
|
||||
#include "../../Windows64/Windows64_LceLiveSignaling.h"
|
||||
#endif
|
||||
|
||||
// Fallbacks until string ID headers are regenerated.
|
||||
#ifndef IDS_TITLE_FRIEND_REQUEST
|
||||
@@ -36,37 +40,22 @@ namespace
|
||||
return result;
|
||||
}
|
||||
|
||||
std::wstring BuildInviteLabel(
|
||||
const std::string &senderDisplayName,
|
||||
const std::string &senderUsername,
|
||||
const std::string &hostName,
|
||||
bool sessionActive)
|
||||
std::wstring BuildRequestLabel(const std::string &displayName, const std::string &username)
|
||||
{
|
||||
std::wstring line = L"[INVITE] ";
|
||||
|
||||
if (!senderDisplayName.empty())
|
||||
line += Utf8ToWideLocal(senderDisplayName);
|
||||
else if (!senderUsername.empty())
|
||||
line += Utf8ToWideLocal(senderUsername);
|
||||
std::wstring line;
|
||||
if (!displayName.empty())
|
||||
line = Utf8ToWideLocal(displayName);
|
||||
else if (!username.empty())
|
||||
line = Utf8ToWideLocal(username);
|
||||
else
|
||||
line += L"<unknown>";
|
||||
line = L"<unknown>";
|
||||
|
||||
if (!senderUsername.empty())
|
||||
if (!username.empty())
|
||||
{
|
||||
line += L" (@";
|
||||
line += Utf8ToWideLocal(senderUsername);
|
||||
line += Utf8ToWideLocal(username);
|
||||
line += L")";
|
||||
}
|
||||
|
||||
if (!hostName.empty())
|
||||
{
|
||||
line += L" - ";
|
||||
line += Utf8ToWideLocal(hostName);
|
||||
}
|
||||
|
||||
if (!sessionActive)
|
||||
line += L" [inactive]";
|
||||
|
||||
return line;
|
||||
}
|
||||
}
|
||||
@@ -81,7 +70,7 @@ UIScene_LceLiveRequests::UIScene_LceLiveRequests(int iPad, void *initData, UILay
|
||||
|
||||
m_requestsList.init(eControl_RequestsList);
|
||||
m_actionsList.init(eControl_ActionsList);
|
||||
m_labelRequestsTitle.init(L"GAME INVITES");
|
||||
m_labelRequestsTitle.init(L"FRIEND REQUESTS");
|
||||
m_labelActionsTitle.init(L"ACTIONS");
|
||||
m_labelStatus.init(L"");
|
||||
m_controlRequestsTimer.setVisible(false);
|
||||
@@ -94,10 +83,7 @@ UIScene_LceLiveRequests::UIScene_LceLiveRequests(int iPad, void *initData, UILay
|
||||
m_statusMessage.clear();
|
||||
m_bDataReady = false;
|
||||
#ifdef _WINDOWS64
|
||||
m_pendingInviteId.clear();
|
||||
m_pendingInviteHostIp.clear();
|
||||
m_pendingInviteHostPort = 0;
|
||||
m_pendingInviteHostName.clear();
|
||||
m_pendingFromAccountId.clear();
|
||||
#endif
|
||||
|
||||
doHorizontalResizeCheck();
|
||||
@@ -220,13 +206,13 @@ void UIScene_LceLiveRequests::FetchAndDisplay()
|
||||
{
|
||||
m_entries.clear();
|
||||
m_bDataReady = true;
|
||||
m_statusMessage = L"Sign in to view and manage game invites.";
|
||||
m_statusMessage = L"Sign in to view friend requests.";
|
||||
RebuildLists();
|
||||
return;
|
||||
}
|
||||
|
||||
const int previousSelection = m_requestsList.getCurrentSelection();
|
||||
const Win64LceLive::GameInvitesResult result = Win64LceLive::GetGameInvitesSync();
|
||||
const Win64LceLive::PendingRequestsResult result = Win64LceLive::GetPendingRequestsSync();
|
||||
if (!result.success)
|
||||
{
|
||||
m_entries.clear();
|
||||
@@ -237,28 +223,19 @@ void UIScene_LceLiveRequests::FetchAndDisplay()
|
||||
}
|
||||
|
||||
m_entries.clear();
|
||||
for (const Win64LceLive::GameInviteEntry &entry : result.incoming)
|
||||
for (const Win64LceLive::SocialEntry &entry : result.incoming)
|
||||
{
|
||||
RequestEntry row = {};
|
||||
row.inviteId = entry.inviteId;
|
||||
row.senderAccountId = entry.senderAccountId;
|
||||
row.senderUsername = entry.senderUsername;
|
||||
row.senderDisplayName = entry.senderDisplayName;
|
||||
row.recipientAccountId = entry.recipientAccountId;
|
||||
row.recipientUsername = entry.recipientUsername;
|
||||
row.recipientDisplayName = entry.recipientDisplayName;
|
||||
row.hostIp = entry.hostIp;
|
||||
row.hostPort = entry.hostPort;
|
||||
row.hostName = entry.hostName;
|
||||
row.status = entry.status;
|
||||
row.sessionActive = entry.sessionActive;
|
||||
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 game invites.";
|
||||
else if (!m_entries.empty() && m_statusMessage == L"No pending game invites.")
|
||||
m_statusMessage = L"No pending friend requests.";
|
||||
else if (!m_entries.empty() && m_statusMessage == L"No pending friend requests.")
|
||||
m_statusMessage.clear();
|
||||
|
||||
RebuildLists();
|
||||
@@ -275,7 +252,7 @@ void UIScene_LceLiveRequests::FetchAndDisplay()
|
||||
#else
|
||||
m_entries.clear();
|
||||
m_bDataReady = true;
|
||||
m_statusMessage = L"Game invites are only available on Windows64 builds.";
|
||||
m_statusMessage = L"Friend requests are only available on Windows64 builds.";
|
||||
RebuildLists();
|
||||
#endif
|
||||
}
|
||||
@@ -286,7 +263,7 @@ void UIScene_LceLiveRequests::RebuildLists()
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
for (const RequestEntry &entry : m_entries)
|
||||
m_requestsList.addItem(BuildInviteLabel(entry.senderDisplayName, entry.senderUsername, entry.hostName, entry.sessionActive));
|
||||
m_requestsList.addItem(BuildRequestLabel(entry.displayName, entry.username));
|
||||
#endif
|
||||
|
||||
UpdateStatusLabel();
|
||||
@@ -304,7 +281,7 @@ void UIScene_LceLiveRequests::UpdateStatusLabel()
|
||||
m_labelStatus.setVisible(true);
|
||||
}
|
||||
|
||||
m_labelRequestsTitle.setLabel(L"GAME INVITES", true, true);
|
||||
m_labelRequestsTitle.setLabel(L"FRIEND REQUESTS", true, true);
|
||||
m_labelActionsTitle.setLabel(L"ACTIONS", true, true);
|
||||
}
|
||||
|
||||
@@ -335,24 +312,21 @@ void UIScene_LceLiveRequests::PromptResolveSelectedRequest()
|
||||
const int selectedIndex = FocusedRequestIndex();
|
||||
if (selectedIndex < 0 || selectedIndex >= static_cast<int>(m_entries.size()))
|
||||
{
|
||||
m_statusMessage = L"Select an invite first.";
|
||||
m_statusMessage = L"Select a request first.";
|
||||
UpdateStatusLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
m_pendingInviteId = m_entries[selectedIndex].inviteId;
|
||||
m_pendingInviteHostIp = m_entries[selectedIndex].hostIp;
|
||||
m_pendingInviteHostPort = m_entries[selectedIndex].hostPort;
|
||||
m_pendingInviteHostName = m_entries[selectedIndex].hostName;
|
||||
m_pendingFromAccountId = m_entries[selectedIndex].accountId;
|
||||
|
||||
UINT optionIds[2];
|
||||
optionIds[0] = IDS_NO;
|
||||
optionIds[1] = IDS_YES;
|
||||
|
||||
ui.RequestAlertMessage(
|
||||
IDS_TITLE_SEND_INVITE,
|
||||
IDS_TEXT_SEND_INVITE_CONFIRMATION,
|
||||
IDS_TITLE_FRIEND_REQUEST,
|
||||
IDS_TEXT_ACCEPT_REQUEST_CONFIRMATION,
|
||||
optionIds,
|
||||
2,
|
||||
m_iPad,
|
||||
@@ -367,7 +341,7 @@ void UIScene_LceLiveRequests::PerformAccept()
|
||||
const int selectedIndex = FocusedRequestIndex();
|
||||
if (selectedIndex < 0 || selectedIndex >= static_cast<int>(m_entries.size()))
|
||||
{
|
||||
m_statusMessage = L"Select an invite first.";
|
||||
m_statusMessage = L"Select a request first.";
|
||||
UpdateStatusLabel();
|
||||
return;
|
||||
}
|
||||
@@ -381,7 +355,7 @@ void UIScene_LceLiveRequests::PerformDecline()
|
||||
const int selectedIndex = FocusedRequestIndex();
|
||||
if (selectedIndex < 0 || selectedIndex >= static_cast<int>(m_entries.size()))
|
||||
{
|
||||
m_statusMessage = L"Select an invite first.";
|
||||
m_statusMessage = L"Select a request first.";
|
||||
UpdateStatusLabel();
|
||||
return;
|
||||
}
|
||||
@@ -392,84 +366,29 @@ void UIScene_LceLiveRequests::PerformDecline()
|
||||
#ifdef _WINDOWS64
|
||||
void UIScene_LceLiveRequests::ResolvePendingRequest(bool accept)
|
||||
{
|
||||
if (m_pendingInviteId.empty())
|
||||
if (m_pendingFromAccountId.empty())
|
||||
return;
|
||||
|
||||
const std::string inviteId = m_pendingInviteId;
|
||||
const std::string fromAccountId = m_pendingFromAccountId;
|
||||
m_pendingFromAccountId.clear();
|
||||
|
||||
if (accept)
|
||||
{
|
||||
const Win64LceLive::GameInviteActionResult result = Win64LceLive::AcceptGameInviteSync(inviteId);
|
||||
const Win64LceLive::SocialActionResult result = Win64LceLive::AcceptFriendRequestSync(fromAccountId);
|
||||
if (result.success)
|
||||
{
|
||||
m_pendingInviteHostIp = result.hostIp;
|
||||
m_pendingInviteHostPort = result.hostPort;
|
||||
m_pendingInviteHostName = result.hostName;
|
||||
JoinAcceptedInvite();
|
||||
m_pendingInviteId.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
m_statusMessage = Utf8ToWideLocal(result.error);
|
||||
UpdateStatusLabel();
|
||||
FetchAndDisplay();
|
||||
m_pendingInviteId.clear();
|
||||
return;
|
||||
m_statusMessage = L"Friend request accepted.";
|
||||
else
|
||||
m_statusMessage = Utf8ToWideLocal(result.error);
|
||||
}
|
||||
|
||||
const Win64LceLive::SocialActionResult declineResult = Win64LceLive::DeclineGameInviteSync(inviteId);
|
||||
if (declineResult.success)
|
||||
m_statusMessage = L"Game invite declined.";
|
||||
else
|
||||
m_statusMessage = Utf8ToWideLocal(declineResult.error);
|
||||
m_pendingInviteId.clear();
|
||||
|
||||
FetchAndDisplay();
|
||||
}
|
||||
|
||||
void UIScene_LceLiveRequests::JoinAcceptedInvite()
|
||||
{
|
||||
if (m_pendingInviteHostIp.empty() || m_pendingInviteHostPort <= 0)
|
||||
{
|
||||
m_statusMessage = L"The game session is no longer active.";
|
||||
UpdateStatusLabel();
|
||||
FetchAndDisplay();
|
||||
return;
|
||||
const Win64LceLive::SocialActionResult result = Win64LceLive::DeclineFriendRequestSync(fromAccountId);
|
||||
if (result.success)
|
||||
m_statusMessage = L"Friend request declined.";
|
||||
else
|
||||
m_statusMessage = Utf8ToWideLocal(result.error);
|
||||
}
|
||||
|
||||
ProfileManager.SetLockedProfile(m_iPad);
|
||||
ProfileManager.SetPrimaryPad(m_iPad);
|
||||
g_NetworkManager.SetLocalGame(false);
|
||||
ProfileManager.QuerySigninStatus();
|
||||
Minecraft::GetInstance()->clearConnectionFailed();
|
||||
|
||||
int localUsersMask = 0;
|
||||
for (unsigned int index = 0; index < XUSER_MAX_COUNT; ++index)
|
||||
{
|
||||
if (ProfileManager.IsSignedIn(index))
|
||||
localUsersMask |= g_NetworkManager.GetLocalPlayerMask(index);
|
||||
}
|
||||
|
||||
INVITE_INFO inviteInfo = {};
|
||||
inviteInfo.netVersion = MINECRAFT_NET_VERSION;
|
||||
strcpy_s(inviteInfo.hostIP, m_pendingInviteHostIp.c_str());
|
||||
inviteInfo.hostPort = m_pendingInviteHostPort;
|
||||
inviteInfo.sessionActive = true;
|
||||
const std::wstring hostNameWide = m_pendingInviteHostName.empty() ? L"LCELive" : Utf8ToWideLocal(m_pendingInviteHostName);
|
||||
wcsncpy_s(inviteInfo.hostName, hostNameWide.c_str(), _TRUNCATE);
|
||||
strcpy_s(inviteInfo.inviteId, m_pendingInviteId.c_str());
|
||||
|
||||
const bool success = g_NetworkManager.JoinGameFromInviteInfo(m_iPad, localUsersMask, &inviteInfo);
|
||||
if (!success)
|
||||
m_statusMessage = L"Could not join this game.";
|
||||
else
|
||||
m_statusMessage = L"Joining game...";
|
||||
|
||||
m_pendingInviteId.clear();
|
||||
m_pendingInviteHostIp.clear();
|
||||
m_pendingInviteHostPort = 0;
|
||||
m_pendingInviteHostName.clear();
|
||||
UpdateStatusLabel();
|
||||
FetchAndDisplay();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#include "../../Windows64/Windows64_LceLive.h"
|
||||
#endif
|
||||
|
||||
// LceLive sub-scene: game invite inbox.
|
||||
// LceLive sub-scene: incoming friend-request inbox.
|
||||
// Uses the native Start Game style (LoadOrJoinMenu): left list of requests,
|
||||
// right list of actions.
|
||||
|
||||
@@ -29,18 +29,9 @@ private:
|
||||
|
||||
struct RequestEntry
|
||||
{
|
||||
std::string inviteId;
|
||||
std::string senderAccountId;
|
||||
std::string senderUsername;
|
||||
std::string senderDisplayName;
|
||||
std::string recipientAccountId;
|
||||
std::string recipientUsername;
|
||||
std::string recipientDisplayName;
|
||||
std::string hostIp;
|
||||
int hostPort;
|
||||
std::string hostName;
|
||||
std::string status;
|
||||
bool sessionActive;
|
||||
std::string accountId; // sender's account ID
|
||||
std::string username;
|
||||
std::string displayName;
|
||||
};
|
||||
|
||||
UIControl_ButtonList m_requestsList;
|
||||
@@ -54,10 +45,7 @@ private:
|
||||
std::wstring m_statusMessage;
|
||||
bool m_bDataReady;
|
||||
#ifdef _WINDOWS64
|
||||
std::string m_pendingInviteId;
|
||||
std::string m_pendingInviteHostIp;
|
||||
int m_pendingInviteHostPort;
|
||||
std::string m_pendingInviteHostName;
|
||||
std::string m_pendingFromAccountId;
|
||||
#endif
|
||||
UI_BEGIN_MAP_ELEMENTS_AND_NAMES(UIScene)
|
||||
UI_MAP_ELEMENT(m_requestsList, "SavesList")
|
||||
@@ -100,7 +88,6 @@ private:
|
||||
void PerformDecline();
|
||||
#ifdef _WINDOWS64
|
||||
void ResolvePendingRequest(bool accept);
|
||||
void JoinAcceptedInvite();
|
||||
static int ResolveRequestConfirmCallback(void *pParam, int iPad, C4JStorage::EMessageResult result);
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -1273,7 +1273,7 @@ void UIScene_LoadOrJoinMenu::handleInput(int iPad, int key, bool repeat, bool pr
|
||||
#elif defined(_WINDOWS64)
|
||||
if(pressed && !repeat && iPad == ProfileManager.GetPrimaryPad())
|
||||
{
|
||||
ui.NavigateToScene(m_iPad, eUIScene_LceLiveRequests);
|
||||
ui.NavigateToScene(m_iPad, eUIScene_LceLiveInvites);
|
||||
handled = true;
|
||||
}
|
||||
#elif defined(_DURANGO)
|
||||
|
||||
@@ -1583,4 +1583,27 @@ DisconnectPacket::eDisconnectReason WinsockNetLayer::GetJoinRejectReason()
|
||||
return s_joinRejectReason;
|
||||
}
|
||||
|
||||
std::string WinsockNetLayer::GetLocalIPv4()
|
||||
{
|
||||
char hostname[256] = {};
|
||||
if (gethostname(hostname, sizeof(hostname)) != 0)
|
||||
return "127.0.0.1";
|
||||
|
||||
struct addrinfo hints = {};
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
struct addrinfo* result = nullptr;
|
||||
if (getaddrinfo(hostname, nullptr, &hints, &result) != 0 || result == nullptr)
|
||||
return "127.0.0.1";
|
||||
|
||||
char ipBuf[INET_ADDRSTRLEN] = {};
|
||||
const struct sockaddr_in* sin = reinterpret_cast<const struct sockaddr_in*>(result->ai_addr);
|
||||
inet_ntop(AF_INET, &sin->sin_addr, ipBuf, sizeof(ipBuf));
|
||||
freeaddrinfo(result);
|
||||
|
||||
// If the resolved address is still loopback, return it anyway — caller will handle.
|
||||
return std::string(ipBuf);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -123,6 +123,7 @@ public:
|
||||
static std::vector<Win64LANSession> GetDiscoveredSessions();
|
||||
|
||||
static int GetHostPort() { return s_hostGamePort; }
|
||||
static std::string GetLocalIPv4();
|
||||
|
||||
private:
|
||||
static DWORD WINAPI AcceptThreadProc(LPVOID param);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include "Windows64_LceLive.h"
|
||||
#include "Windows64_Log.h"
|
||||
|
||||
#include "Windows64_Xuid.h"
|
||||
#include "../../Minecraft.World/StringHelpers.h"
|
||||
@@ -386,12 +387,12 @@ namespace
|
||||
std::vector<unsigned char> encrypted;
|
||||
if (!ProtectString(sessionJson.dump(), &encrypted))
|
||||
{
|
||||
app.DebugPrintf("LCELive: failed to protect auth blob for local storage\n");
|
||||
LCELOG("LCE", "failed to protect auth blob for local storage");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WriteFileBytes(GetAuthBlobPath(), encrypted.data(), encrypted.size()))
|
||||
app.DebugPrintf("LCELive: failed to write auth blob to disk\n");
|
||||
LCELOG("LCE", "failed to write auth blob to disk");
|
||||
}
|
||||
|
||||
void ClearSessionLocked()
|
||||
@@ -411,7 +412,7 @@ namespace
|
||||
std::string decrypted;
|
||||
if (!UnprotectBytes(encrypted, &decrypted))
|
||||
{
|
||||
app.DebugPrintf("LCELive: unable to decrypt stored auth state, clearing local blob\n");
|
||||
LCELOG("LCE", "unable to decrypt stored auth state, clearing local blob");
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
return;
|
||||
}
|
||||
@@ -419,7 +420,7 @@ namespace
|
||||
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");
|
||||
LCELOG("LCE", "stored auth state is invalid JSON, clearing local blob");
|
||||
DeleteFileA(GetAuthBlobPath());
|
||||
return;
|
||||
}
|
||||
@@ -472,7 +473,7 @@ namespace
|
||||
std::vector<wchar_t> pathBuffer;
|
||||
if (!CrackUrl(baseUrl, &components, &hostBuffer, &pathBuffer))
|
||||
{
|
||||
app.DebugPrintf("LCELive: WinHttpCrackUrl failed for '%s'\n", baseUrlUtf8.c_str());
|
||||
LCELOG("LCE", "WinHttpCrackUrl failed for '%s'", baseUrlUtf8.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1281,7 +1282,7 @@ namespace Win64LceLive
|
||||
if (accessToken.empty())
|
||||
return { false, "Not signed in to LCELive." };
|
||||
|
||||
app.DebugPrintf("LCELive: sending friend request for username='%s'\n", username.c_str());
|
||||
LCELOG("LCE", "sending friend request for username='%s'", username.c_str());
|
||||
|
||||
Json bodyJson;
|
||||
bodyJson["username"] = username;
|
||||
@@ -1296,7 +1297,7 @@ namespace Win64LceLive
|
||||
std::string responseBody;
|
||||
if (!PerformJsonRequest(req, &status, &responseBody))
|
||||
{
|
||||
app.DebugPrintf("LCELive: friend request transport failure\n");
|
||||
LCELOG("LCE", "friend request transport failure");
|
||||
return { false, "Failed to contact LCELive while sending friend request." };
|
||||
}
|
||||
|
||||
@@ -1313,20 +1314,20 @@ namespace Win64LceLive
|
||||
responseBody.clear();
|
||||
if (!PerformJsonRequest(req, &status, &responseBody))
|
||||
{
|
||||
app.DebugPrintf("LCELive: friend request transport failure after refresh\n");
|
||||
LCELOG("LCE", "friend request transport failure after refresh");
|
||||
return { false, "Failed to contact LCELive while sending friend request." };
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
app.DebugPrintf("LCELive: friend request refresh failed: %s\n", refreshError.c_str());
|
||||
LCELOG("LCE", "friend request refresh failed: %s", refreshError.c_str());
|
||||
return { false, refreshError };
|
||||
}
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300)
|
||||
{
|
||||
app.DebugPrintf("LCELive: friend request HTTP %lu body='%s'\n",
|
||||
LCELOG("LCE", "friend request HTTP %lu body='%s'",
|
||||
static_cast<unsigned long>(status), responseBody.c_str());
|
||||
const std::string parsed = ParseErrorMessage(responseBody, std::string());
|
||||
if (!parsed.empty())
|
||||
@@ -1479,7 +1480,7 @@ namespace Win64LceLive
|
||||
return { true, parseList("incoming"), parseList("outgoing"), std::string() };
|
||||
}
|
||||
|
||||
SocialActionResult SendGameInviteSync(const std::string &recipientAccountId, const std::string &hostIp, int hostPort, const std::string &hostName)
|
||||
SocialActionResult SendGameInviteSync(const std::string &recipientAccountId, const std::string &hostIp, int hostPort, const std::string &hostName, const std::string &signalingSessionId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
std::string accessToken;
|
||||
@@ -1500,6 +1501,10 @@ namespace Win64LceLive
|
||||
bodyJson["hostIp"] = hostIp;
|
||||
bodyJson["hostPort"] = hostPort;
|
||||
bodyJson["hostName"] = hostName;
|
||||
if (!signalingSessionId.empty())
|
||||
bodyJson["signalingSessionId"] = signalingSessionId;
|
||||
else
|
||||
bodyJson["signalingSessionId"] = nullptr;
|
||||
|
||||
RequestContext req = {};
|
||||
req.type = ERequestType::None;
|
||||
@@ -1571,6 +1576,7 @@ namespace Win64LceLive
|
||||
result.hostIp = JsonStringOrEmpty(responseJson, "hostIp");
|
||||
result.hostPort = JsonIntOrDefault(responseJson, "hostPort", 0);
|
||||
result.hostName = JsonStringOrEmpty(responseJson, "hostName");
|
||||
result.signalingSessionId = JsonStringOrEmpty(responseJson, "signalingSessionId");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ namespace Win64LceLive
|
||||
bool sessionActive;
|
||||
std::string createdUtc;
|
||||
std::string expiresUtc;
|
||||
std::string signalingSessionId; // empty if host didn't provide one (Phase 4b+)
|
||||
};
|
||||
|
||||
struct GameInvitesResult
|
||||
@@ -100,6 +101,7 @@ namespace Win64LceLive
|
||||
std::string hostIp;
|
||||
int hostPort;
|
||||
std::string hostName;
|
||||
std::string signalingSessionId; // empty if not a Phase 4b session
|
||||
std::string error;
|
||||
};
|
||||
|
||||
@@ -133,7 +135,7 @@ namespace Win64LceLive
|
||||
SocialActionResult RemoveFriendSync(const std::string& accountId);
|
||||
|
||||
GameInvitesResult GetGameInvitesSync();
|
||||
SocialActionResult SendGameInviteSync(const std::string& recipientAccountId, const std::string& hostIp, int hostPort, const std::string& hostName);
|
||||
SocialActionResult SendGameInviteSync(const std::string& recipientAccountId, const std::string& hostIp, int hostPort, const std::string& hostName, const std::string& signalingSessionId);
|
||||
GameInviteActionResult AcceptGameInviteSync(const std::string& inviteId);
|
||||
SocialActionResult DeclineGameInviteSync(const std::string& inviteId);
|
||||
SocialActionResult DeactivateGameInvitesSync();
|
||||
|
||||
909
Minecraft.Client/Windows64/Windows64_LceLiveP2P.cpp
Normal file
909
Minecraft.Client/Windows64/Windows64_LceLiveP2P.cpp
Normal file
@@ -0,0 +1,909 @@
|
||||
#include "stdafx.h"
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
// Winsock2 must come after stdafx.h (which defines WIN32_LEAN_AND_MEAN before
|
||||
// windows.h, so the old winsock.h v1 is never pulled in — no conflict).
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
|
||||
// UPnP IGD support via Windows native COM interfaces (no third-party lib needed).
|
||||
// natupnp.h is in the Windows 8.1+ SDK; comdef.h provides _bstr_t/_variant_t helpers.
|
||||
#include <natupnp.h>
|
||||
#include <comdef.h>
|
||||
|
||||
#include "Windows64_LceLiveP2P.h"
|
||||
#include "Windows64_Log.h"
|
||||
#include "Network/WinsockNetLayer.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#pragma comment(lib, "Ws2_32.lib")
|
||||
#pragma comment(lib, "Ole32.lib")
|
||||
#pragma comment(lib, "OleAut32.lib")
|
||||
|
||||
// ============================================================================
|
||||
// Internal implementation
|
||||
// ============================================================================
|
||||
|
||||
namespace
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// STUN constants (RFC 5389)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
static const unsigned long STUN_MAGIC_COOKIE = 0x2112A442UL;
|
||||
static const unsigned short STUN_BINDING_REQUEST = 0x0001;
|
||||
static const unsigned short STUN_BINDING_RESPONSE = 0x0101;
|
||||
static const unsigned short STUN_ATTR_XOR_MAPPED_ADDR = 0x0020;
|
||||
static const unsigned short STUN_ATTR_MAPPED_ADDR = 0x0001;
|
||||
|
||||
static const char* STUN_HOST_PRIMARY = "stun.l.google.com";
|
||||
static const char* STUN_HOST_FALLBACK = "stun1.l.google.com";
|
||||
static const int STUN_PORT = 19302;
|
||||
static const DWORD STUN_TIMEOUT_MS = 5000;
|
||||
|
||||
// Re-send a keepalive STUN binding request every N ms to keep the NAT
|
||||
// mapping alive.
|
||||
static const ULONGLONG KEEPALIVE_INTERVAL_MS = 20000ULL;
|
||||
|
||||
// Bring the public EConnMethod into this anonymous namespace for convenience.
|
||||
using EConnMethod = Win64LceLiveP2P::EConnMethod;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// STUN message helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct StunRequest
|
||||
{
|
||||
unsigned char bytes[20]; // Full 20-byte binding request
|
||||
unsigned char txId[12]; // Transaction ID (last 12 bytes)
|
||||
};
|
||||
|
||||
struct StunResult
|
||||
{
|
||||
bool success;
|
||||
std::string externalIp;
|
||||
unsigned short externalPort;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
// Build a 20-byte STUN Binding Request with a fresh random transaction ID.
|
||||
StunRequest BuildStunRequest()
|
||||
{
|
||||
StunRequest req = {};
|
||||
|
||||
for (int i = 0; i < 12; ++i)
|
||||
req.txId[i] = static_cast<unsigned char>(rand() & 0xFF);
|
||||
|
||||
unsigned char* p = req.bytes;
|
||||
// Message Type: Binding Request (big-endian)
|
||||
p[0] = 0x00; p[1] = 0x01;
|
||||
// Message Length: 0 (no attributes in a request)
|
||||
p[2] = 0x00; p[3] = 0x00;
|
||||
// Magic Cookie: 0x2112A442
|
||||
p[4] = 0x21; p[5] = 0x12; p[6] = 0xA4; p[7] = 0x42;
|
||||
// Transaction ID
|
||||
memcpy(p + 8, req.txId, 12);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
// Parse a STUN Binding Success Response.
|
||||
// Looks for XOR-MAPPED-ADDRESS first, falls back to MAPPED-ADDRESS.
|
||||
// Returns true and fills outIp/outPort on success.
|
||||
bool ParseStunResponse(
|
||||
const unsigned char* data, int length,
|
||||
std::string* outIp, unsigned short* outPort)
|
||||
{
|
||||
if (length < 20)
|
||||
return false;
|
||||
|
||||
// Magic cookie check
|
||||
if (data[4] != 0x21 || data[5] != 0x12 || data[6] != 0xA4 || data[7] != 0x42)
|
||||
return false;
|
||||
|
||||
// Must be a Binding Success Response
|
||||
const unsigned short msgType =
|
||||
(static_cast<unsigned short>(data[0]) << 8) | data[1];
|
||||
if (msgType != STUN_BINDING_RESPONSE)
|
||||
return false;
|
||||
|
||||
const unsigned short msgLen =
|
||||
(static_cast<unsigned short>(data[2]) << 8) | data[3];
|
||||
if (static_cast<int>(msgLen) + 20 > length)
|
||||
return false;
|
||||
|
||||
// Walk the attribute list
|
||||
int offset = 20;
|
||||
const int end = 20 + static_cast<int>(msgLen);
|
||||
|
||||
while (offset + 4 <= end)
|
||||
{
|
||||
const unsigned short attrType =
|
||||
(static_cast<unsigned short>(data[offset]) << 8) | data[offset + 1];
|
||||
const unsigned short attrLen =
|
||||
(static_cast<unsigned short>(data[offset + 2]) << 8) | data[offset + 3];
|
||||
|
||||
const int valueOffset = offset + 4;
|
||||
const int attrEnd = valueOffset + static_cast<int>(attrLen);
|
||||
|
||||
// Bounds check before reading the attribute value
|
||||
if (attrEnd > length)
|
||||
break;
|
||||
|
||||
if ((attrType == STUN_ATTR_XOR_MAPPED_ADDR || attrType == STUN_ATTR_MAPPED_ADDR)
|
||||
&& attrLen >= 8)
|
||||
{
|
||||
const unsigned char family = data[valueOffset + 1];
|
||||
if (family != 0x01) // IPv4 only
|
||||
{
|
||||
// Skip — IPv6 not supported here
|
||||
}
|
||||
else if (attrType == STUN_ATTR_XOR_MAPPED_ADDR)
|
||||
{
|
||||
// X-Port = port XOR (magic_cookie >> 16)
|
||||
// X-Address = address XOR magic_cookie
|
||||
// Both the packet value and the mask are in network byte order,
|
||||
// so we can XOR the big-endian values read directly from the bytes.
|
||||
const unsigned short xport =
|
||||
(static_cast<unsigned short>(data[valueOffset + 2]) << 8) |
|
||||
data[valueOffset + 3];
|
||||
const unsigned short port =
|
||||
xport ^ static_cast<unsigned short>(STUN_MAGIC_COOKIE >> 16);
|
||||
|
||||
const unsigned long xaddr =
|
||||
(static_cast<unsigned long>(data[valueOffset + 4]) << 24) |
|
||||
(static_cast<unsigned long>(data[valueOffset + 5]) << 16) |
|
||||
(static_cast<unsigned long>(data[valueOffset + 6]) << 8) |
|
||||
data[valueOffset + 7];
|
||||
const unsigned long addr = xaddr ^ STUN_MAGIC_COOKIE;
|
||||
|
||||
// addr is in host byte order; convert to network for inet_ntop.
|
||||
const unsigned long addrNet = htonl(addr);
|
||||
char ipStr[INET_ADDRSTRLEN] = {};
|
||||
inet_ntop(AF_INET, &addrNet, ipStr, sizeof(ipStr));
|
||||
|
||||
*outIp = ipStr;
|
||||
*outPort = port;
|
||||
return true;
|
||||
}
|
||||
else // STUN_ATTR_MAPPED_ADDR (no XOR)
|
||||
{
|
||||
const unsigned short port =
|
||||
(static_cast<unsigned short>(data[valueOffset + 2]) << 8) |
|
||||
data[valueOffset + 3];
|
||||
const unsigned long addr =
|
||||
(static_cast<unsigned long>(data[valueOffset + 4]) << 24) |
|
||||
(static_cast<unsigned long>(data[valueOffset + 5]) << 16) |
|
||||
(static_cast<unsigned long>(data[valueOffset + 6]) << 8) |
|
||||
data[valueOffset + 7];
|
||||
|
||||
const unsigned long addrNet = htonl(addr);
|
||||
char ipStr[INET_ADDRSTRLEN] = {};
|
||||
inet_ntop(AF_INET, &addrNet, ipStr, sizeof(ipStr));
|
||||
|
||||
*outIp = ipStr;
|
||||
*outPort = port;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past this attribute (padded to 4-byte boundary)
|
||||
const int padded = (static_cast<int>(attrLen) + 3) & ~3;
|
||||
offset += 4 + padded;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UPnP IGD helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct UPnPResult
|
||||
{
|
||||
bool success;
|
||||
std::string externalIp;
|
||||
int externalPort; // same as localPort we passed in
|
||||
int localPort; // what we registered
|
||||
std::string error;
|
||||
};
|
||||
|
||||
// Attempts to add a UPnP port mapping (UDP or TCP) via the Windows IUPnPNAT
|
||||
// COM interface. Handles its own CoInitializeEx/CoUninitialize.
|
||||
// Blocks for up to a few seconds while UPnP discovery runs.
|
||||
// Set tcp=false for UDP (P2P socket), tcp=true for TCP (game server port).
|
||||
UPnPResult TryUPnPMapping(int localPort, bool tcp)
|
||||
{
|
||||
UPnPResult result = {};
|
||||
result.localPort = localPort;
|
||||
|
||||
// COM must be initialised on this thread (STA for UPnP callbacks).
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
const bool coInitialized = SUCCEEDED(hr) || (hr == RPC_E_CHANGED_MODE);
|
||||
|
||||
IUPnPNAT* pNAT = nullptr;
|
||||
hr = CoCreateInstance(__uuidof(UPnPNAT), nullptr, CLSCTX_ALL,
|
||||
__uuidof(IUPnPNAT), reinterpret_cast<void**>(&pNAT));
|
||||
if (FAILED(hr))
|
||||
{
|
||||
result.error = "UPnP: CoCreateInstance(UPnPNAT) failed hr=0x"
|
||||
+ std::to_string(static_cast<unsigned long>(hr));
|
||||
if (coInitialized) CoUninitialize();
|
||||
return result;
|
||||
}
|
||||
|
||||
// get_StaticPortMappingCollection blocks until UPnP discovery completes
|
||||
// (~1-3 s on a cooperative router, full timeout if no IGD present).
|
||||
IStaticPortMappingCollection* pMappings = nullptr;
|
||||
hr = pNAT->get_StaticPortMappingCollection(&pMappings);
|
||||
pNAT->Release();
|
||||
|
||||
if (FAILED(hr) || pMappings == nullptr)
|
||||
{
|
||||
result.error = "UPnP: no IGD found (router may not support UPnP)";
|
||||
if (coInitialized) CoUninitialize();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Determine our local IP address (pick first IPv4 from hostname resolution).
|
||||
char localIp[INET_ADDRSTRLEN] = {};
|
||||
{
|
||||
char hostname[256] = {};
|
||||
gethostname(hostname, sizeof(hostname));
|
||||
addrinfo hints = {};
|
||||
hints.ai_family = AF_INET;
|
||||
addrinfo* info = nullptr;
|
||||
if (getaddrinfo(hostname, nullptr, &hints, &info) == 0 && info != nullptr)
|
||||
{
|
||||
inet_ntop(AF_INET,
|
||||
&reinterpret_cast<sockaddr_in*>(info->ai_addr)->sin_addr,
|
||||
localIp, sizeof(localIp));
|
||||
freeaddrinfo(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (localIp[0] == '\0')
|
||||
{
|
||||
pMappings->Release();
|
||||
result.error = "UPnP: could not determine local IPv4 address";
|
||||
if (coInitialized) CoUninitialize();
|
||||
return result;
|
||||
}
|
||||
|
||||
const wchar_t* protocol = tcp ? L"TCP" : L"UDP";
|
||||
const wchar_t* description = tcp ? L"LceLive Game" : L"LceLive P2P";
|
||||
|
||||
// Remove any stale LceLive mapping on this port (best-effort).
|
||||
pMappings->Remove(localPort, _bstr_t(protocol));
|
||||
|
||||
// Convert local IP to wide for the COM API.
|
||||
wchar_t localIpW[INET_ADDRSTRLEN] = {};
|
||||
MultiByteToWideChar(CP_ACP, 0, localIp, -1, localIpW, INET_ADDRSTRLEN);
|
||||
|
||||
IStaticPortMapping* pMapping = nullptr;
|
||||
hr = pMappings->Add(
|
||||
localPort, // external port
|
||||
_bstr_t(protocol), // protocol
|
||||
localPort, // internal port
|
||||
_bstr_t(localIpW), // internal client (our LAN IP)
|
||||
VARIANT_TRUE, // enabled
|
||||
_bstr_t(description), // description
|
||||
&pMapping
|
||||
);
|
||||
pMappings->Release();
|
||||
|
||||
if (FAILED(hr) || pMapping == nullptr)
|
||||
{
|
||||
result.error = "UPnP: AddPortMapping failed hr=0x"
|
||||
+ std::to_string(static_cast<unsigned long>(hr));
|
||||
if (coInitialized) CoUninitialize();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Read back the external IP the router assigned.
|
||||
BSTR extIpBstr = nullptr;
|
||||
pMapping->get_ExternalIPAddress(&extIpBstr);
|
||||
if (extIpBstr != nullptr)
|
||||
{
|
||||
char buf[64] = {};
|
||||
WideCharToMultiByte(CP_UTF8, 0, extIpBstr, -1, buf, sizeof(buf), nullptr, nullptr);
|
||||
result.externalIp = buf;
|
||||
SysFreeString(extIpBstr);
|
||||
}
|
||||
pMapping->Release();
|
||||
|
||||
if (coInitialized) CoUninitialize();
|
||||
|
||||
// Sanity-check: router must return a non-empty, non-private external IP.
|
||||
// If the router returns 0.0.0.0 or a private range we can't use it.
|
||||
if (result.externalIp.empty() ||
|
||||
result.externalIp == "0.0.0.0" ||
|
||||
result.externalIp.substr(0, 3) == "10." ||
|
||||
result.externalIp.substr(0, 8) == "192.168." ||
|
||||
result.externalIp.substr(0, 7) == "172.16." ||
|
||||
result.externalIp.substr(0, 7) == "172.17." ||
|
||||
result.externalIp.substr(0, 7) == "172.18." ||
|
||||
result.externalIp.substr(0, 7) == "172.19." ||
|
||||
result.externalIp.substr(0, 7) == "172.20." ||
|
||||
result.externalIp.substr(0, 7) == "172.21." ||
|
||||
result.externalIp.substr(0, 7) == "172.22." ||
|
||||
result.externalIp.substr(0, 7) == "172.23." ||
|
||||
result.externalIp.substr(0, 7) == "172.24." ||
|
||||
result.externalIp.substr(0, 7) == "172.25." ||
|
||||
result.externalIp.substr(0, 7) == "172.26." ||
|
||||
result.externalIp.substr(0, 7) == "172.27." ||
|
||||
result.externalIp.substr(0, 7) == "172.28." ||
|
||||
result.externalIp.substr(0, 7) == "172.29." ||
|
||||
result.externalIp.substr(0, 7) == "172.30." ||
|
||||
result.externalIp.substr(0, 7) == "172.31.")
|
||||
{
|
||||
result.error = "UPnP: router returned unusable external IP ("
|
||||
+ result.externalIp + ")";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.externalPort = localPort;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Remove a previously added UPnP port mapping.
|
||||
// Call from HostClose() on any thread (handles its own CoInit/Uninit).
|
||||
// tcp=false removes a UDP mapping, tcp=true removes a TCP mapping.
|
||||
void RemoveUPnPMapping(int port, bool tcp)
|
||||
{
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
const bool coInitialized = SUCCEEDED(hr) || (hr == RPC_E_CHANGED_MODE);
|
||||
|
||||
IUPnPNAT* pNAT = nullptr;
|
||||
hr = CoCreateInstance(__uuidof(UPnPNAT), nullptr, CLSCTX_ALL,
|
||||
__uuidof(IUPnPNAT), reinterpret_cast<void**>(&pNAT));
|
||||
if (SUCCEEDED(hr) && pNAT != nullptr)
|
||||
{
|
||||
IStaticPortMappingCollection* pMappings = nullptr;
|
||||
if (SUCCEEDED(pNAT->get_StaticPortMappingCollection(&pMappings)) && pMappings != nullptr)
|
||||
{
|
||||
pMappings->Remove(port, _bstr_t(tcp ? L"TCP" : L"UDP"));
|
||||
pMappings->Release();
|
||||
}
|
||||
pNAT->Release();
|
||||
}
|
||||
|
||||
if (coInitialized) CoUninitialize();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct WorkerResult
|
||||
{
|
||||
bool success;
|
||||
EConnMethod method;
|
||||
std::string externalIp;
|
||||
int externalPort;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct P2PState
|
||||
{
|
||||
bool initialized;
|
||||
CRITICAL_SECTION lock;
|
||||
|
||||
Win64LceLiveP2P::EP2PState state;
|
||||
EConnMethod connMethod; // set once worker succeeds
|
||||
|
||||
// The long-lived UDP socket. INVALID_SOCKET when not open.
|
||||
// Owned by the main thread once discovery completes.
|
||||
// During discovery, owned by StunWorkerProc.
|
||||
SOCKET udpSocket;
|
||||
int localPort; // Local port we bound (host order)
|
||||
|
||||
// UPnP: we keep track of mapped ports so HostClose can remove them.
|
||||
bool upnpMappingActive; // UDP P2P port
|
||||
int upnpMappedPort;
|
||||
bool tcpUpnpMappingActive; // TCP game port
|
||||
int tcpUpnpMappedPort;
|
||||
|
||||
// Resolved STUN server address — cached to avoid re-DNS on keepalives.
|
||||
sockaddr_in stunServerAddr;
|
||||
bool stunServerAddrValid;
|
||||
|
||||
// Discovery results (written by worker, read by main thread after join)
|
||||
WorkerResult workerResult;
|
||||
std::string externalIp;
|
||||
int externalPort;
|
||||
|
||||
// Worker thread
|
||||
HANDLE workerThread;
|
||||
bool workerDone;
|
||||
|
||||
std::string lastError;
|
||||
ULONGLONG nextKeepaliveAt;
|
||||
};
|
||||
|
||||
static P2PState g_p2p = {};
|
||||
static INIT_ONCE g_initOnce = INIT_ONCE_STATIC_INIT;
|
||||
|
||||
BOOL CALLBACK InitP2PState(PINIT_ONCE, PVOID, PVOID*)
|
||||
{
|
||||
// Initialize Winsock once for the lifetime of the process.
|
||||
// WinHTTP already does this internally, but we do it explicitly so raw
|
||||
// Winsock calls (WSASocket, getaddrinfo, etc.) are available.
|
||||
WSADATA wsaData = {};
|
||||
WSAStartup(MAKEWORD(2, 2), &wsaData);
|
||||
|
||||
InitializeCriticalSection(&g_p2p.lock);
|
||||
g_p2p.state = Win64LceLiveP2P::EP2PState::Idle;
|
||||
g_p2p.udpSocket = INVALID_SOCKET;
|
||||
g_p2p.initialized = true;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void EnsureInitialized()
|
||||
{
|
||||
InitOnceExecuteOnce(&g_initOnce, &InitP2PState, nullptr, nullptr);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolve the STUN server address. Returns true on success.
|
||||
// -------------------------------------------------------------------------
|
||||
bool ResolveStunServer(const char* host, sockaddr_in* outAddr)
|
||||
{
|
||||
addrinfo hints = {};
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_DGRAM;
|
||||
|
||||
char portStr[16] = {};
|
||||
sprintf_s(portStr, "%d", STUN_PORT);
|
||||
|
||||
addrinfo* info = nullptr;
|
||||
if (getaddrinfo(host, portStr, &hints, &info) != 0 || info == nullptr)
|
||||
return false;
|
||||
|
||||
*outAddr = *reinterpret_cast<const sockaddr_in*>(info->ai_addr);
|
||||
freeaddrinfo(info);
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Discovery worker thread
|
||||
//
|
||||
// Attempt order:
|
||||
// 1. UPnP IGD port mapping — no hole-punching needed, works on ~60% of home routers
|
||||
// 2. STUN external endpoint — requires hole-punching, works on ~95% of the rest
|
||||
//
|
||||
// In both cases the long-lived UDP socket stays open for the life of the
|
||||
// host session (keepalives via STUN, actual data via KCP later).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DWORD WINAPI DiscoveryWorkerProc(LPVOID)
|
||||
{
|
||||
// ---- Open and bind the long-lived UDP socket ----
|
||||
SOCKET sock = WSASocket(AF_INET, SOCK_DGRAM, IPPROTO_UDP, nullptr, 0, 0);
|
||||
if (sock == INVALID_SOCKET)
|
||||
{
|
||||
WorkerResult r = {};
|
||||
r.errorMessage = "P2P: failed to create UDP socket (WSA "
|
||||
+ std::to_string(WSAGetLastError()) + ")";
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
g_p2p.workerResult = r;
|
||||
g_p2p.workerDone = true;
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
sockaddr_in bindAddr = {};
|
||||
bindAddr.sin_family = AF_INET;
|
||||
bindAddr.sin_addr.s_addr = INADDR_ANY;
|
||||
bindAddr.sin_port = 0;
|
||||
if (::bind(sock, reinterpret_cast<const sockaddr*>(&bindAddr), sizeof(bindAddr)) != 0)
|
||||
{
|
||||
closesocket(sock);
|
||||
WorkerResult r = {};
|
||||
r.errorMessage = "P2P: failed to bind UDP socket";
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
g_p2p.workerResult = r;
|
||||
g_p2p.workerDone = true;
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
sockaddr_in local = {};
|
||||
int localLen = sizeof(local);
|
||||
getsockname(sock, reinterpret_cast<sockaddr*>(&local), &localLen);
|
||||
const int localPort = ntohs(local.sin_port);
|
||||
|
||||
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO,
|
||||
reinterpret_cast<const char*>(&STUN_TIMEOUT_MS), sizeof(STUN_TIMEOUT_MS));
|
||||
|
||||
// ---- Step 1a: UPnP IGD — UDP (P2P socket) ----
|
||||
LCELOG("P2P", "trying UPnP IGD UDP (local port %d)", localPort);
|
||||
const UPnPResult upnp = TryUPnPMapping(localPort, false /*udp*/);
|
||||
|
||||
// ---- Step 1b: UPnP IGD — TCP (game server port) ----
|
||||
// Always attempt regardless of UDP outcome: the router maps UDP and TCP
|
||||
// independently. A successful TCP mapping means joiners can reach the game
|
||||
// server directly over the internet without port-forwarding.
|
||||
const int tcpGamePort = WinsockNetLayer::GetHostPort();
|
||||
UPnPResult tcpUpnp = {};
|
||||
if (tcpGamePort > 0)
|
||||
{
|
||||
LCELOG("P2P", "trying UPnP IGD TCP (game port %d)", tcpGamePort);
|
||||
tcpUpnp = TryUPnPMapping(tcpGamePort, true /*tcp*/);
|
||||
if (tcpUpnp.success)
|
||||
LCELOG("P2P", "UPnP TCP game port %d mapped", tcpGamePort);
|
||||
else
|
||||
LCELOG("P2P", "UPnP TCP failed (%s)", tcpUpnp.error.c_str());
|
||||
}
|
||||
|
||||
if (upnp.success)
|
||||
{
|
||||
LCELOG("P2P", "UPnP UDP mapped — external %s:%d local port %d",
|
||||
upnp.externalIp.c_str(), upnp.externalPort, localPort);
|
||||
|
||||
WorkerResult r = {};
|
||||
r.success = true;
|
||||
r.method = EConnMethod::UPnP;
|
||||
r.externalIp = upnp.externalIp;
|
||||
r.externalPort = upnp.externalPort;
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
g_p2p.udpSocket = sock;
|
||||
g_p2p.localPort = localPort;
|
||||
g_p2p.stunServerAddrValid = false; // keepalives still done via STUN below
|
||||
g_p2p.upnpMappingActive = true;
|
||||
g_p2p.upnpMappedPort = localPort;
|
||||
g_p2p.tcpUpnpMappingActive = tcpUpnp.success;
|
||||
g_p2p.tcpUpnpMappedPort = tcpUpnp.success ? tcpGamePort : 0;
|
||||
g_p2p.workerResult = r;
|
||||
g_p2p.workerDone = true;
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
LCELOG("P2P", "UPnP UDP failed (%s) — falling back to STUN", upnp.error.c_str());
|
||||
|
||||
// ---- Step 2: STUN ----
|
||||
const char* hosts[2] = { STUN_HOST_PRIMARY, STUN_HOST_FALLBACK };
|
||||
WorkerResult result = {};
|
||||
sockaddr_in serverAddr = {};
|
||||
bool serverAddrValid = false;
|
||||
|
||||
for (int h = 0; h < 2 && !result.success; ++h)
|
||||
{
|
||||
const char* stunHost = hosts[h];
|
||||
|
||||
if (!ResolveStunServer(stunHost, &serverAddr))
|
||||
{
|
||||
result.errorMessage = std::string("P2P: DNS failed for ") + stunHost;
|
||||
LCELOG("P2P", "STUN DNS lookup failed for %s", stunHost);
|
||||
continue;
|
||||
}
|
||||
|
||||
serverAddrValid = true;
|
||||
|
||||
// Try up to 2 transmissions per server
|
||||
for (int attempt = 0; attempt < 2 && !result.success; ++attempt)
|
||||
{
|
||||
const StunRequest req = BuildStunRequest();
|
||||
|
||||
const int sent = sendto(
|
||||
sock,
|
||||
reinterpret_cast<const char*>(req.bytes), sizeof(req.bytes),
|
||||
0,
|
||||
reinterpret_cast<const sockaddr*>(&serverAddr), sizeof(serverAddr));
|
||||
|
||||
if (sent != static_cast<int>(sizeof(req.bytes)))
|
||||
{
|
||||
result.errorMessage = "P2P: STUN sendto failed";
|
||||
continue;
|
||||
}
|
||||
|
||||
unsigned char buf[512] = {};
|
||||
sockaddr_in fromAddr = {};
|
||||
int fromLen = sizeof(fromAddr);
|
||||
|
||||
const int received = recvfrom(
|
||||
sock,
|
||||
reinterpret_cast<char*>(buf), sizeof(buf),
|
||||
0,
|
||||
reinterpret_cast<sockaddr*>(&fromAddr), &fromLen);
|
||||
|
||||
if (received < 20)
|
||||
{
|
||||
result.errorMessage = "P2P: STUN timeout or empty response";
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string ip = {};
|
||||
unsigned short port = 0;
|
||||
if (ParseStunResponse(buf, received, &ip, &port))
|
||||
{
|
||||
result.success = true;
|
||||
result.method = EConnMethod::STUN;
|
||||
result.externalIp = ip;
|
||||
result.externalPort = port;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.errorMessage = "P2P: STUN response parse failed";
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success)
|
||||
LCELOG("P2P", "STUN failed for %s — %s", stunHost, result.errorMessage.c_str());
|
||||
}
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
if (result.success)
|
||||
{
|
||||
g_p2p.udpSocket = sock;
|
||||
g_p2p.localPort = localPort;
|
||||
g_p2p.stunServerAddr = serverAddr;
|
||||
g_p2p.stunServerAddrValid = serverAddrValid;
|
||||
// TCP UPnP was already attempted at the top (Step 1b) — record result.
|
||||
g_p2p.tcpUpnpMappingActive = tcpUpnp.success;
|
||||
g_p2p.tcpUpnpMappedPort = tcpUpnp.success ? tcpGamePort : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
closesocket(sock);
|
||||
}
|
||||
g_p2p.workerResult = result;
|
||||
g_p2p.workerDone = true;
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Keepalive — sends a STUN binding request to keep the NAT mapping warm.
|
||||
// Called from P2PTick() outside the critical section.
|
||||
// Uses the cached server address; no DNS lookup.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void SendKeepalive(SOCKET sock, const sockaddr_in& serverAddr)
|
||||
{
|
||||
const StunRequest req = BuildStunRequest();
|
||||
sendto(
|
||||
sock,
|
||||
reinterpret_cast<const char*>(req.bytes), sizeof(req.bytes),
|
||||
0,
|
||||
reinterpret_cast<const sockaddr*>(&serverAddr), sizeof(serverAddr));
|
||||
|
||||
LCELOG("P2P", "keepalive sent");
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
namespace Win64LceLiveP2P
|
||||
{
|
||||
bool HostOpen()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
|
||||
if (g_p2p.state != EP2PState::Idle)
|
||||
{
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
return false; // Already running — caller should HostClose() first.
|
||||
}
|
||||
|
||||
g_p2p.state = EP2PState::Discovering;
|
||||
g_p2p.workerDone = false;
|
||||
g_p2p.connMethod = EConnMethod::None;
|
||||
g_p2p.lastError.clear();
|
||||
g_p2p.externalIp.clear();
|
||||
g_p2p.externalPort = 0;
|
||||
g_p2p.localPort = 0;
|
||||
g_p2p.upnpMappingActive = false;
|
||||
g_p2p.upnpMappedPort = 0;
|
||||
g_p2p.tcpUpnpMappingActive = false;
|
||||
g_p2p.tcpUpnpMappedPort = 0;
|
||||
|
||||
g_p2p.workerThread = CreateThread(nullptr, 0, &DiscoveryWorkerProc, nullptr, 0, nullptr);
|
||||
if (g_p2p.workerThread == nullptr)
|
||||
{
|
||||
g_p2p.state = EP2PState::Failed;
|
||||
g_p2p.lastError = "P2P: failed to create discovery thread.";
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
LCELOG("P2P", "discovery started (UPnP -> STUN fallback)");
|
||||
return true;
|
||||
}
|
||||
|
||||
void HostClose()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
// Read state we need without blocking the worker.
|
||||
HANDLE threadToWait = nullptr;
|
||||
bool removeUpnp = false;
|
||||
int upnpPortToRemove = 0;
|
||||
bool removeTcpUpnp = false;
|
||||
int tcpUpnpPortToRemove = 0;
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
threadToWait = g_p2p.workerThread;
|
||||
removeUpnp = g_p2p.upnpMappingActive;
|
||||
upnpPortToRemove = g_p2p.upnpMappedPort;
|
||||
removeTcpUpnp = g_p2p.tcpUpnpMappingActive;
|
||||
tcpUpnpPortToRemove = g_p2p.tcpUpnpMappedPort;
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
|
||||
// Wait for in-flight discovery thread BEFORE taking the lock again, to
|
||||
// avoid deadlocking (the worker also takes the lock at the end of its run).
|
||||
if (threadToWait != nullptr)
|
||||
{
|
||||
WaitForSingleObject(threadToWait, 8000);
|
||||
CloseHandle(threadToWait);
|
||||
}
|
||||
|
||||
// Remove UPnP mappings while we still have the info (before we clear state).
|
||||
if (removeUpnp && upnpPortToRemove != 0)
|
||||
{
|
||||
LCELOG("P2P", "removing UPnP UDP mapping for port %d", upnpPortToRemove);
|
||||
RemoveUPnPMapping(upnpPortToRemove, false /*udp*/);
|
||||
}
|
||||
if (removeTcpUpnp && tcpUpnpPortToRemove != 0)
|
||||
{
|
||||
LCELOG("P2P", "removing UPnP TCP mapping for port %d", tcpUpnpPortToRemove);
|
||||
RemoveUPnPMapping(tcpUpnpPortToRemove, true /*tcp*/);
|
||||
}
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
g_p2p.workerThread = nullptr;
|
||||
|
||||
if (g_p2p.udpSocket != INVALID_SOCKET)
|
||||
{
|
||||
closesocket(g_p2p.udpSocket);
|
||||
g_p2p.udpSocket = INVALID_SOCKET;
|
||||
}
|
||||
|
||||
g_p2p.state = EP2PState::Idle;
|
||||
g_p2p.connMethod = EConnMethod::None;
|
||||
g_p2p.localPort = 0;
|
||||
g_p2p.externalIp.clear();
|
||||
g_p2p.externalPort = 0;
|
||||
g_p2p.stunServerAddrValid = false;
|
||||
g_p2p.lastError.clear();
|
||||
g_p2p.workerDone = false;
|
||||
g_p2p.upnpMappingActive = false;
|
||||
g_p2p.upnpMappedPort = 0;
|
||||
g_p2p.tcpUpnpMappingActive = false;
|
||||
g_p2p.tcpUpnpMappedPort = 0;
|
||||
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
LCELOG("P2P", "host socket closed");
|
||||
}
|
||||
|
||||
void P2PTick()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
|
||||
// Integrate a completed discovery
|
||||
if (g_p2p.state == EP2PState::Discovering && g_p2p.workerDone)
|
||||
{
|
||||
HANDLE t = g_p2p.workerThread;
|
||||
g_p2p.workerThread = nullptr;
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
|
||||
if (t != nullptr)
|
||||
{
|
||||
WaitForSingleObject(t, INFINITE);
|
||||
CloseHandle(t);
|
||||
}
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
const WorkerResult& r = g_p2p.workerResult;
|
||||
if (r.success)
|
||||
{
|
||||
g_p2p.state = EP2PState::Ready;
|
||||
g_p2p.connMethod = r.method;
|
||||
g_p2p.externalIp = r.externalIp;
|
||||
g_p2p.externalPort = r.externalPort;
|
||||
g_p2p.nextKeepaliveAt = GetTickCount64() + KEEPALIVE_INTERVAL_MS;
|
||||
|
||||
const char* methodName = (r.method == EConnMethod::UPnP) ? "UPnP" : "STUN";
|
||||
LCELOG("P2P", "ready via %s — external %s:%d local port %d",
|
||||
methodName, r.externalIp.c_str(), r.externalPort, g_p2p.localPort);
|
||||
}
|
||||
else
|
||||
{
|
||||
g_p2p.state = EP2PState::Failed;
|
||||
g_p2p.lastError = r.errorMessage;
|
||||
LCELOG("P2P", "all discovery methods failed — %s", r.errorMessage.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a STUN keepalive is due.
|
||||
// UPnP sessions also send keepalives to keep the STUN mapping warm for
|
||||
// potential fallback, but only if we have a STUN server address cached.
|
||||
bool doKeepalive = false;
|
||||
SOCKET keepaliveSock = INVALID_SOCKET;
|
||||
sockaddr_in keepaliveAddr = {};
|
||||
|
||||
if (g_p2p.state == EP2PState::Ready &&
|
||||
g_p2p.stunServerAddrValid &&
|
||||
GetTickCount64() >= g_p2p.nextKeepaliveAt)
|
||||
{
|
||||
doKeepalive = true;
|
||||
keepaliveSock = g_p2p.udpSocket;
|
||||
keepaliveAddr = g_p2p.stunServerAddr;
|
||||
g_p2p.nextKeepaliveAt = GetTickCount64() + KEEPALIVE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
|
||||
if (doKeepalive && keepaliveSock != INVALID_SOCKET)
|
||||
SendKeepalive(keepaliveSock, keepaliveAddr);
|
||||
}
|
||||
|
||||
P2PSnapshot GetP2PSnapshot()
|
||||
{
|
||||
EnsureInitialized();
|
||||
P2PTick();
|
||||
|
||||
P2PSnapshot snap = {};
|
||||
|
||||
EnterCriticalSection(&g_p2p.lock);
|
||||
snap.state = g_p2p.state;
|
||||
snap.connMethod = g_p2p.connMethod;
|
||||
snap.externalIp = g_p2p.externalIp;
|
||||
snap.externalPort = g_p2p.externalPort;
|
||||
snap.localPort = g_p2p.localPort;
|
||||
snap.tcpPortMapped = g_p2p.tcpUpnpMappingActive;
|
||||
|
||||
switch (g_p2p.state)
|
||||
{
|
||||
case EP2PState::Idle:
|
||||
snap.statusMessage = L"P2P: idle.";
|
||||
break;
|
||||
|
||||
case EP2PState::Discovering:
|
||||
snap.statusMessage = L"P2P: discovering external endpoint (UPnP \u2192 STUN)...";
|
||||
break;
|
||||
|
||||
case EP2PState::Ready:
|
||||
{
|
||||
const wchar_t* method = (g_p2p.connMethod == EConnMethod::UPnP) ? L"UPnP" : L"STUN";
|
||||
wchar_t buf[256] = {};
|
||||
swprintf_s(buf,
|
||||
L"P2P ready via %s. External %hs:%d (local port %d)",
|
||||
method,
|
||||
g_p2p.externalIp.c_str(), g_p2p.externalPort, g_p2p.localPort);
|
||||
snap.statusMessage = buf;
|
||||
break;
|
||||
}
|
||||
|
||||
case EP2PState::Failed:
|
||||
snap.statusMessage = L"P2P: discovery failed (UPnP + STUN). "
|
||||
L"Manual port forwarding may be required.";
|
||||
snap.errorMessage = std::wstring(
|
||||
g_p2p.lastError.begin(), g_p2p.lastError.end());
|
||||
break;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_p2p.lock);
|
||||
return snap;
|
||||
}
|
||||
|
||||
} // namespace Win64LceLiveP2P
|
||||
|
||||
#endif // _WINDOWS64
|
||||
65
Minecraft.Client/Windows64/Windows64_LceLiveP2P.h
Normal file
65
Minecraft.Client/Windows64/Windows64_LceLiveP2P.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Win64LceLiveP2P
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
enum class EP2PState
|
||||
{
|
||||
Idle, // No socket open; call HostOpen() to start.
|
||||
Discovering, // Discovery in progress (UPnP → STUN) on a background thread.
|
||||
Ready, // External endpoint known; socket is live and kept warm.
|
||||
Failed, // All discovery methods failed; see P2PSnapshot::errorMessage.
|
||||
};
|
||||
|
||||
// How the external endpoint was obtained once state == Ready.
|
||||
enum class EConnMethod
|
||||
{
|
||||
None, // Not yet known.
|
||||
UPnP, // UPnP IGD port mapping — joiner can connect directly.
|
||||
STUN, // STUN-derived endpoint — requires UDP hole punching.
|
||||
};
|
||||
|
||||
struct P2PSnapshot
|
||||
{
|
||||
EP2PState state;
|
||||
EConnMethod connMethod; // None until Ready.
|
||||
std::string externalIp; // Empty until Ready.
|
||||
int externalPort; // 0 until Ready.
|
||||
int localPort; // UDP port we bound on this machine.
|
||||
bool tcpPortMapped; // true if UPnP also mapped the TCP game port.
|
||||
std::wstring statusMessage; // Human-readable; always set.
|
||||
std::wstring errorMessage; // Non-empty only on Failed.
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Open the long-lived host UDP socket and begin STUN discovery.
|
||||
// Returns true if discovery was successfully kicked off.
|
||||
// Non-blocking; transition to Discovering happens immediately.
|
||||
// Call P2PTick() regularly to integrate the result.
|
||||
// Returns false if already open/discovering/ready (call HostClose first).
|
||||
bool HostOpen();
|
||||
|
||||
// Close the host socket and reset to Idle.
|
||||
// Blocks briefly to join the discovery thread if it is still running.
|
||||
void HostClose();
|
||||
|
||||
// Drive the state machine: integrate completed STUN results, send keepalives.
|
||||
// Call once per frame from the game loop, same cadence as Win64LceLive::Tick().
|
||||
void P2PTick();
|
||||
|
||||
// Thread-safe snapshot of current state.
|
||||
// Calls P2PTick() internally so you can call this without a separate Tick call.
|
||||
P2PSnapshot GetP2PSnapshot();
|
||||
}
|
||||
|
||||
#endif // _WINDOWS64
|
||||
962
Minecraft.Client/Windows64/Windows64_LceLiveRelay.cpp
Normal file
962
Minecraft.Client/Windows64/Windows64_LceLiveRelay.cpp
Normal file
@@ -0,0 +1,962 @@
|
||||
#include "stdafx.h"
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
// Winsock2 must appear before any Windows.h inclusion; the PCH has already
|
||||
// pulled in windows.h, so undef any byte-order macros it may have injected
|
||||
// (they conflict with the BIGENDIAN/LITTLEENDIAN enum in Definitions.h).
|
||||
#ifdef BIGENDIAN
|
||||
#undef BIGENDIAN
|
||||
#endif
|
||||
#ifdef LITTLEENDIAN
|
||||
#undef LITTLEENDIAN
|
||||
#endif
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <winhttp.h>
|
||||
#include <objbase.h>
|
||||
|
||||
#include "Windows64_LceLiveRelay.h"
|
||||
#include "Windows64_LceLive.h"
|
||||
#include "Windows64_Log.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <atomic>
|
||||
|
||||
#pragma comment(lib, "Ws2_32.lib")
|
||||
#pragma comment(lib, "Winhttp.lib")
|
||||
|
||||
// Global: joiner sets this to the relay proxy port when it tries direct TCP first.
|
||||
// The main Tick automatically retries via relay if the direct attempt fails.
|
||||
int g_LceLiveRelayFallbackPort = 0;
|
||||
|
||||
// ============================================================================
|
||||
// Internal implementation
|
||||
// ============================================================================
|
||||
|
||||
namespace
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// URL / string helpers (mirrored from signaling)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::wstring Utf8ToWide(const std::string& s)
|
||||
{
|
||||
if (s.empty()) return L"";
|
||||
const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0);
|
||||
if (n <= 0) return L"";
|
||||
std::wstring out(static_cast<size_t>(n), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &out[0], n);
|
||||
if (!out.empty() && out.back() == L'\0') out.pop_back();
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string GetBaseUrl()
|
||||
{
|
||||
char envValue[512] = {};
|
||||
if (GetEnvironmentVariableA("LCELIVE_API_BASE_URL", envValue, sizeof(envValue)) > 0)
|
||||
return std::string(envValue);
|
||||
|
||||
char exePath[MAX_PATH] = {};
|
||||
GetModuleFileNameA(nullptr, exePath, MAX_PATH);
|
||||
std::string props(exePath);
|
||||
const size_t lastSlash = props.find_last_of("\\/");
|
||||
if (lastSlash != std::string::npos) props = props.substr(0, lastSlash + 1);
|
||||
props += "lcelive.properties";
|
||||
|
||||
FILE* f = nullptr;
|
||||
if (fopen_s(&f, props.c_str(), "rb") == 0 && f != nullptr)
|
||||
{
|
||||
char line[512] = {};
|
||||
while (fgets(line, sizeof(line), f) != nullptr)
|
||||
{
|
||||
std::string s(line);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back();
|
||||
if (s.substr(0, 12) == "api_base_url")
|
||||
{
|
||||
const size_t eq = s.find('=');
|
||||
if (eq != std::string::npos) { fclose(f); return s.substr(eq + 1); }
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
return "http://localhost:5187";
|
||||
}
|
||||
|
||||
std::string UrlEncode(const std::string& s)
|
||||
{
|
||||
std::string out;
|
||||
for (unsigned char c : s)
|
||||
{
|
||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
|
||||
out += static_cast<char>(c);
|
||||
else { char hex[4] = {}; snprintf(hex, sizeof(hex), "%%%02X", c); out += hex; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recv helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Reliable recv: keeps reading until 'len' bytes arrive or an error occurs.
|
||||
bool RecvExact(SOCKET sock, void* buf, int len)
|
||||
{
|
||||
char* p = static_cast<char*>(buf);
|
||||
int remaining = len;
|
||||
while (remaining > 0)
|
||||
{
|
||||
const int r = recv(sock, p, remaining, 0);
|
||||
if (r <= 0) return false;
|
||||
p += r;
|
||||
remaining -= r;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket connection helper — opens a WinHTTP WS to the relay endpoint.
|
||||
// Returns the HINTERNET handle on success, nullptr on failure.
|
||||
// Caller owns the handle and must close it.
|
||||
// Also returns hSession/hConnect so caller can clean them up.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
HINTERNET OpenRelayWebSocket(
|
||||
const std::string& sessionId,
|
||||
bool isHost,
|
||||
const std::string& accessToken,
|
||||
const std::string& baseUrl,
|
||||
HINTERNET* outSession,
|
||||
HINTERNET* outConnect)
|
||||
{
|
||||
*outSession = nullptr;
|
||||
*outConnect = nullptr;
|
||||
|
||||
const std::wstring baseUrlW = Utf8ToWide(baseUrl);
|
||||
|
||||
std::vector<wchar_t> hostBuf(256, 0);
|
||||
std::vector<wchar_t> pathBuf(2048, 0);
|
||||
|
||||
URL_COMPONENTSW comp = {};
|
||||
comp.dwStructSize = sizeof(comp);
|
||||
comp.lpszHostName = hostBuf.data();
|
||||
comp.dwHostNameLength = static_cast<DWORD>(hostBuf.size());
|
||||
comp.lpszUrlPath = pathBuf.data();
|
||||
comp.dwUrlPathLength = static_cast<DWORD>(pathBuf.size());
|
||||
|
||||
if (!WinHttpCrackUrl(baseUrlW.c_str(), 0, 0, &comp))
|
||||
return nullptr;
|
||||
|
||||
const bool secure = (comp.nScheme == INTERNET_SCHEME_HTTPS);
|
||||
const std::wstring hostW(comp.lpszHostName, comp.dwHostNameLength);
|
||||
const std::wstring basePath = comp.lpszUrlPath
|
||||
? std::wstring(comp.lpszUrlPath, comp.dwUrlPathLength) : L"";
|
||||
|
||||
const std::wstring wsPath = basePath + L"/api/relay/ws"
|
||||
+ L"?sessionId=" + Utf8ToWide(UrlEncode(sessionId))
|
||||
+ L"&role=" + (isHost ? L"host" : L"joiner");
|
||||
|
||||
LCELOG("RELAY", "connecting %s%ls",
|
||||
secure ? "wss://" : "ws://", (hostW + wsPath).c_str());
|
||||
|
||||
HINTERNET hSession = WinHttpOpen(L"MCLCE-LceLive/1.0",
|
||||
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
|
||||
if (!hSession) return nullptr;
|
||||
|
||||
WinHttpSetTimeouts(hSession, 10000, 10000, 30000, 30000);
|
||||
|
||||
HINTERNET hConnect = WinHttpConnect(hSession, hostW.c_str(), comp.nPort, 0);
|
||||
if (!hConnect) { WinHttpCloseHandle(hSession); return nullptr; }
|
||||
|
||||
HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", wsPath.c_str(),
|
||||
nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||
secure ? WINHTTP_FLAG_SECURE : 0);
|
||||
if (!hRequest) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return nullptr; }
|
||||
|
||||
WinHttpSetOption(hRequest, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0);
|
||||
|
||||
if (!accessToken.empty())
|
||||
{
|
||||
const std::wstring auth = L"Authorization: Bearer " + Utf8ToWide(accessToken);
|
||||
WinHttpAddRequestHeaders(hRequest, auth.c_str(), static_cast<DWORD>(auth.size()),
|
||||
WINHTTP_ADDREQ_FLAG_ADD);
|
||||
}
|
||||
|
||||
if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
|
||||
WINHTTP_NO_REQUEST_DATA, 0, 0, 0))
|
||||
{
|
||||
WinHttpCloseHandle(hRequest);
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (!WinHttpReceiveResponse(hRequest, nullptr))
|
||||
{
|
||||
WinHttpCloseHandle(hRequest);
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
HINTERNET hWs = WinHttpWebSocketCompleteUpgrade(hRequest, 0);
|
||||
WinHttpCloseHandle(hRequest);
|
||||
|
||||
if (!hWs) { WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession); return nullptr; }
|
||||
|
||||
*outSession = hSession;
|
||||
*outConnect = hConnect;
|
||||
return hWs;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-direction forwarding thread params
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct ForwardWsToTcpParams
|
||||
{
|
||||
HINTERNET wsHandle;
|
||||
SOCKET tcpSocket;
|
||||
std::atomic<bool>* stop;
|
||||
};
|
||||
|
||||
struct ForwardTcpToWsParams
|
||||
{
|
||||
SOCKET tcpSocket;
|
||||
HINTERNET wsHandle;
|
||||
CRITICAL_SECTION* wsSendLock;
|
||||
std::atomic<bool>* stop;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WS → TCP forwarding thread
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DWORD WINAPI ForwardWsToTcpProc(LPVOID param)
|
||||
{
|
||||
auto* p = static_cast<ForwardWsToTcpParams*>(param);
|
||||
std::vector<BYTE> buf(65536);
|
||||
|
||||
while (!p->stop->load())
|
||||
{
|
||||
DWORD bytesRead = 0;
|
||||
WINHTTP_WEB_SOCKET_BUFFER_TYPE bufType = WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE;
|
||||
|
||||
const DWORD err = WinHttpWebSocketReceive(
|
||||
p->wsHandle,
|
||||
buf.data(),
|
||||
static_cast<DWORD>(buf.size()),
|
||||
&bytesRead,
|
||||
&bufType);
|
||||
|
||||
if (err != ERROR_SUCCESS)
|
||||
break;
|
||||
|
||||
// Only forward binary frames; ignore text (control) frames.
|
||||
if (bufType != WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE &&
|
||||
bufType != WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE)
|
||||
continue;
|
||||
|
||||
if (bytesRead == 0)
|
||||
continue;
|
||||
|
||||
// Write all bytes to the TCP socket.
|
||||
const char* src = reinterpret_cast<const char*>(buf.data());
|
||||
DWORD remaining = bytesRead;
|
||||
while (remaining > 0 && !p->stop->load())
|
||||
{
|
||||
const int sent = send(p->tcpSocket, src, static_cast<int>(remaining), 0);
|
||||
if (sent <= 0) { p->stop->store(true); break; }
|
||||
src += sent;
|
||||
remaining -= static_cast<DWORD>(sent);
|
||||
}
|
||||
}
|
||||
|
||||
p->stop->store(true);
|
||||
delete p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TCP → WS forwarding thread
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DWORD WINAPI ForwardTcpToWsProc(LPVOID param)
|
||||
{
|
||||
auto* p = static_cast<ForwardTcpToWsParams*>(param);
|
||||
std::vector<char> buf(65536);
|
||||
|
||||
while (!p->stop->load())
|
||||
{
|
||||
const int received = recv(p->tcpSocket, buf.data(), static_cast<int>(buf.size()), 0);
|
||||
if (received <= 0)
|
||||
break;
|
||||
|
||||
EnterCriticalSection(p->wsSendLock);
|
||||
const DWORD err = WinHttpWebSocketSend(
|
||||
p->wsHandle,
|
||||
WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE,
|
||||
buf.data(),
|
||||
static_cast<DWORD>(received));
|
||||
LeaveCriticalSection(p->wsSendLock);
|
||||
|
||||
if (err != ERROR_SUCCESS)
|
||||
break;
|
||||
}
|
||||
|
||||
p->stop->store(true);
|
||||
delete p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Global relay state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct RelayState
|
||||
{
|
||||
bool initialized = false;
|
||||
CRITICAL_SECTION lock;
|
||||
|
||||
Win64LceLiveRelay::ERelayState state = Win64LceLiveRelay::ERelayState::Idle;
|
||||
std::string lastError;
|
||||
|
||||
// Active handles — closed in Close().
|
||||
HINTERNET wsHandle = nullptr;
|
||||
HINTERNET wsSession = nullptr;
|
||||
HINTERNET wsConnect = nullptr;
|
||||
SOCKET tcpSocket = INVALID_SOCKET;
|
||||
SOCKET listenSocket = INVALID_SOCKET; // Joiner only.
|
||||
|
||||
// Forwarding threads.
|
||||
HANDLE wsToTcpThread = nullptr;
|
||||
HANDLE tcpToWsThread = nullptr;
|
||||
std::atomic<bool> stopForwarding{false};
|
||||
|
||||
CRITICAL_SECTION wsSendLock;
|
||||
};
|
||||
|
||||
static RelayState g_relay;
|
||||
static INIT_ONCE g_initOnce = INIT_ONCE_STATIC_INIT;
|
||||
|
||||
BOOL CALLBACK InitRelayState(PINIT_ONCE, PVOID, PVOID*)
|
||||
{
|
||||
InitializeCriticalSection(&g_relay.lock);
|
||||
InitializeCriticalSection(&g_relay.wsSendLock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Idle;
|
||||
g_relay.initialized = true;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void EnsureInitialized()
|
||||
{
|
||||
InitOnceExecuteOnce(&g_initOnce, &InitRelayState, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void CloseHandlesLocked()
|
||||
{
|
||||
g_relay.stopForwarding.store(true);
|
||||
|
||||
if (g_relay.wsHandle != nullptr)
|
||||
{
|
||||
WinHttpWebSocketClose(g_relay.wsHandle,
|
||||
WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
|
||||
WinHttpCloseHandle(g_relay.wsHandle);
|
||||
g_relay.wsHandle = nullptr;
|
||||
}
|
||||
if (g_relay.wsConnect != nullptr) { WinHttpCloseHandle(g_relay.wsConnect); g_relay.wsConnect = nullptr; }
|
||||
if (g_relay.wsSession != nullptr) { WinHttpCloseHandle(g_relay.wsSession); g_relay.wsSession = nullptr; }
|
||||
if (g_relay.tcpSocket != INVALID_SOCKET)
|
||||
{
|
||||
closesocket(g_relay.tcpSocket);
|
||||
g_relay.tcpSocket = INVALID_SOCKET;
|
||||
}
|
||||
if (g_relay.listenSocket != INVALID_SOCKET)
|
||||
{
|
||||
closesocket(g_relay.listenSocket);
|
||||
g_relay.listenSocket = INVALID_SOCKET;
|
||||
}
|
||||
|
||||
// Wait for forwarding threads (they read from now-closed sockets, so they'll exit).
|
||||
auto waitThread = [](HANDLE& h) {
|
||||
if (h != nullptr)
|
||||
{
|
||||
WaitForSingleObject(h, 3000);
|
||||
CloseHandle(h);
|
||||
h = nullptr;
|
||||
}
|
||||
};
|
||||
LeaveCriticalSection(&g_relay.lock); // unlock while waiting (threads may need the lock)
|
||||
waitThread(g_relay.wsToTcpThread);
|
||||
waitThread(g_relay.tcpToWsThread);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Start the two forwarding threads once both WS and TCP are ready.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void StartForwarding()
|
||||
{
|
||||
g_relay.stopForwarding.store(false);
|
||||
|
||||
auto* wsToTcpP = new ForwardWsToTcpParams();
|
||||
wsToTcpP->wsHandle = g_relay.wsHandle;
|
||||
wsToTcpP->tcpSocket = g_relay.tcpSocket;
|
||||
wsToTcpP->stop = &g_relay.stopForwarding;
|
||||
|
||||
auto* tcpToWsP = new ForwardTcpToWsParams();
|
||||
tcpToWsP->tcpSocket = g_relay.tcpSocket;
|
||||
tcpToWsP->wsHandle = g_relay.wsHandle;
|
||||
tcpToWsP->wsSendLock = &g_relay.wsSendLock;
|
||||
tcpToWsP->stop = &g_relay.stopForwarding;
|
||||
|
||||
g_relay.wsToTcpThread = CreateThread(nullptr, 0, ForwardWsToTcpProc, wsToTcpP, 0, nullptr);
|
||||
g_relay.tcpToWsThread = CreateThread(nullptr, 0, ForwardTcpToWsProc, tcpToWsP, 0, nullptr);
|
||||
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Relaying;
|
||||
LCELOG("RELAY", "forwarding active — data flowing");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worker thread context
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct WorkerContext
|
||||
{
|
||||
bool isHost;
|
||||
std::string sessionId;
|
||||
int tcpPort; // Host: game server port. Joiner: listen port (filled by worker).
|
||||
std::string accessToken;
|
||||
std::string baseUrl;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Host worker: WS open → wait for joiner's first packet → lazy TCP connect
|
||||
//
|
||||
// The TCP connection to the local game server is opened LAZILY — only once
|
||||
// the first binary frame arrives from the relay server (the joiner's JOIN
|
||||
// packet). Connecting eagerly (before the joiner arrives) leaves a TCP
|
||||
// socket idle for up to ~20 s, long enough for the game server to time it
|
||||
// out and close it, causing the join to silently fail.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DWORD WINAPI HostWorkerProc(LPVOID param)
|
||||
{
|
||||
auto* ctx = static_cast<WorkerContext*>(param);
|
||||
|
||||
// 1. Open WebSocket to the relay server.
|
||||
HINTERNET hSession = nullptr, hConnect = nullptr;
|
||||
HINTERNET hWs = OpenRelayWebSocket(ctx->sessionId, true,
|
||||
ctx->accessToken, ctx->baseUrl, &hSession, &hConnect);
|
||||
|
||||
if (!hWs)
|
||||
{
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (host): failed to open WebSocket";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
LCELOG("RELAY", "host WS open failed");
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Publish the WS handle immediately so Close() can abort the blocking
|
||||
// receive below (closing the handle makes WinHttpWebSocketReceive return).
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.wsHandle = hWs;
|
||||
g_relay.wsSession = hSession;
|
||||
g_relay.wsConnect = hConnect;
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
LCELOG("RELAY", "host WS open — session %s, game port %d — waiting for joiner",
|
||||
ctx->sessionId.c_str(), ctx->tcpPort);
|
||||
|
||||
// 2. Wait for the relay server to signal that the joiner's WS has connected.
|
||||
// The server sends a TEXT frame "peer_connected" the instant the joiner
|
||||
// arrives, BEFORE any game data flows. We do NOT open the TCP socket to
|
||||
// the game server until we receive this signal: opening it eagerly leaves
|
||||
// an idle socket the game server can time out in the ~15-20 s before the
|
||||
// joiner arrives.
|
||||
// The server also sends this before its own ForwardLoopAsync, so the signal
|
||||
// is guaranteed to arrive before any binary game-data frames. However, we
|
||||
// buffer any binary data that sneaks in early and forward it after TCP opens.
|
||||
std::vector<BYTE> recvBuf(65536);
|
||||
std::vector<BYTE> earlyBinary; // binary frames received before the signal (rare)
|
||||
bool signalReceived = false;
|
||||
|
||||
while (!signalReceived)
|
||||
{
|
||||
DWORD bytesRead = 0;
|
||||
WINHTTP_WEB_SOCKET_BUFFER_TYPE bufType =
|
||||
WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE;
|
||||
|
||||
const DWORD err = WinHttpWebSocketReceive(
|
||||
hWs, recvBuf.data(), static_cast<DWORD>(recvBuf.size()),
|
||||
&bytesRead, &bufType);
|
||||
|
||||
if (err != ERROR_SUCCESS)
|
||||
{
|
||||
// WS closed — session ended or Close() was called.
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (bufType == WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE && bytesRead > 0)
|
||||
{
|
||||
const std::string text(reinterpret_cast<const char*>(recvBuf.data()), bytesRead);
|
||||
if (text == "peer_connected")
|
||||
signalReceived = true;
|
||||
// Unknown text frames are ignored.
|
||||
}
|
||||
else if (bufType == WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE ||
|
||||
bufType == WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE)
|
||||
{
|
||||
// Unexpected binary data before the signal — buffer it.
|
||||
earlyBinary.insert(earlyBinary.end(),
|
||||
recvBuf.begin(), recvBuf.begin() + bytesRead);
|
||||
// A complete binary message means the joiner is definitely here.
|
||||
if (bufType == WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE)
|
||||
signalReceived = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. NOW connect TCP to the game server — the connection is fresh and the
|
||||
// game server's first packet will arrive immediately (no idle-timeout risk).
|
||||
LCELOG("RELAY", "host peer_connected signal — connecting to game port %d", ctx->tcpPort);
|
||||
|
||||
SOCKET tcpSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (tcpSock == INVALID_SOCKET)
|
||||
{
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (host): failed to create TCP socket";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
sockaddr_in sa = {};
|
||||
sa.sin_family = AF_INET;
|
||||
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
sa.sin_port = htons(static_cast<u_short>(ctx->tcpPort));
|
||||
|
||||
// Retry briefly in case the game server isn't listening yet.
|
||||
bool connected = false;
|
||||
for (int attempt = 0; attempt < 8 && !connected; ++attempt)
|
||||
{
|
||||
if (connect(tcpSock, reinterpret_cast<sockaddr*>(&sa), sizeof(sa)) == 0)
|
||||
connected = true;
|
||||
else
|
||||
Sleep(250);
|
||||
}
|
||||
|
||||
if (!connected)
|
||||
{
|
||||
closesocket(tcpSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (host): could not connect to local game server";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
LCELOG("RELAY", "host TCP connect to 127.0.0.1:%d failed", ctx->tcpPort);
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 4. Forward any binary data buffered before the signal (normally none).
|
||||
if (!earlyBinary.empty())
|
||||
{
|
||||
const char* src = reinterpret_cast<const char*>(earlyBinary.data());
|
||||
int remain = static_cast<int>(earlyBinary.size());
|
||||
while (remain > 0)
|
||||
{
|
||||
const int sent = send(tcpSock, src, remain, 0);
|
||||
if (sent <= 0)
|
||||
{
|
||||
closesocket(tcpSock);
|
||||
LCELOG("RELAY", "host TCP send of early-buffered data failed");
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
src += sent;
|
||||
remain -= sent;
|
||||
}
|
||||
}
|
||||
|
||||
LCELOG("RELAY", "host WS+TCP ready — session %s, game port %d",
|
||||
ctx->sessionId.c_str(), ctx->tcpPort);
|
||||
|
||||
// 5. Start the forwarding threads for the rest of the session.
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.tcpSocket = tcpSock; // wsHandle already stored in step 1
|
||||
StartForwarding();
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Joiner worker: listen on local port, WS→accept connection from game
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DWORD WINAPI JoinerWorkerProc(LPVOID param)
|
||||
{
|
||||
auto* ctx = static_cast<WorkerContext*>(param);
|
||||
|
||||
// 1. Bind a local TCP listen socket on a random OS-assigned port.
|
||||
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (listenSock == INVALID_SOCKET)
|
||||
{
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): failed to create listen socket";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
sockaddr_in bindAddr = {};
|
||||
bindAddr.sin_family = AF_INET;
|
||||
bindAddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bindAddr.sin_port = 0; // OS picks the port.
|
||||
|
||||
if (::bind(listenSock, reinterpret_cast<sockaddr*>(&bindAddr), sizeof(bindAddr)) != 0 ||
|
||||
listen(listenSock, 1) != 0)
|
||||
{
|
||||
closesocket(listenSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): bind/listen failed";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Read back the assigned port and publish it.
|
||||
sockaddr_in actual = {};
|
||||
int actualLen = sizeof(actual);
|
||||
getsockname(listenSock, reinterpret_cast<sockaddr*>(&actual), &actualLen);
|
||||
const int localPort = ntohs(actual.sin_port);
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = listenSock;
|
||||
// Store port in lastError field as a temporary channel (read by JoinerOpen).
|
||||
// We piggyback ctx->tcpPort for the return value.
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
ctx->tcpPort = localPort;
|
||||
|
||||
LCELOG("RELAY", "joiner proxy listening on 127.0.0.1:%d", localPort);
|
||||
|
||||
// 2. Connect relay WebSocket (in parallel with waiting for the game to connect).
|
||||
HINTERNET hSession = nullptr, hConnect = nullptr;
|
||||
HINTERNET hWs = OpenRelayWebSocket(ctx->sessionId, false,
|
||||
ctx->accessToken, ctx->baseUrl, &hSession, &hConnect);
|
||||
|
||||
if (!hWs)
|
||||
{
|
||||
closesocket(listenSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = INVALID_SOCKET;
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): failed to open WebSocket";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
LCELOG("RELAY", "joiner WS open failed");
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 3. Accept the local TCP connection from the game.
|
||||
// Set a generous timeout on the listen socket (game may take a few seconds to call BeginJoinGame).
|
||||
DWORD timeout = 30000;
|
||||
setsockopt(listenSock, SOL_SOCKET, SO_RCVTIMEO,
|
||||
reinterpret_cast<const char*>(&timeout), sizeof(timeout));
|
||||
|
||||
const SOCKET gameSock = accept(listenSock, nullptr, nullptr);
|
||||
closesocket(listenSock);
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = INVALID_SOCKET;
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
if (gameSock == INVALID_SOCKET)
|
||||
{
|
||||
WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
|
||||
WinHttpCloseHandle(hWs); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = Win64LceLiveRelay::ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): game did not connect to proxy port";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
LCELOG("RELAY", "joiner accept() timed out — game never connected to proxy port");
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LCELOG("RELAY", "joiner WS+TCP ready — forwarding started");
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.wsHandle = hWs;
|
||||
g_relay.wsSession = hSession;
|
||||
g_relay.wsConnect = hConnect;
|
||||
g_relay.tcpSocket = gameSock;
|
||||
StartForwarding();
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
namespace Win64LceLiveRelay
|
||||
{
|
||||
bool HostOpen(const std::string& sessionId, int tcpGamePort)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
if (g_relay.state != ERelayState::Idle)
|
||||
{
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return false; // Already active.
|
||||
}
|
||||
g_relay.state = ERelayState::Connecting;
|
||||
g_relay.lastError.clear();
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
auto* ctx = new WorkerContext();
|
||||
ctx->isHost = true;
|
||||
ctx->sessionId = sessionId;
|
||||
ctx->tcpPort = tcpGamePort;
|
||||
ctx->accessToken = Win64LceLive::GetAccessToken();
|
||||
ctx->baseUrl = GetBaseUrl();
|
||||
|
||||
HANDLE t = CreateThread(nullptr, 0, HostWorkerProc, ctx, 0, nullptr);
|
||||
if (!t)
|
||||
{
|
||||
delete ctx;
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = ERelayState::Failed;
|
||||
g_relay.lastError = "Relay: failed to create host worker thread";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return false;
|
||||
}
|
||||
CloseHandle(t); // Thread is detached; lifetime managed by its own logic.
|
||||
|
||||
LCELOG("RELAY", "host open — session %s, game port %d", sessionId.c_str(), tcpGamePort);
|
||||
return true;
|
||||
}
|
||||
|
||||
int JoinerOpen(const std::string& sessionId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
if (g_relay.state != ERelayState::Idle)
|
||||
{
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return 0;
|
||||
}
|
||||
g_relay.state = ERelayState::Connecting;
|
||||
g_relay.lastError.clear();
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
// We run the joiner worker SYNCHRONOUSLY for the bind/listen phase so we
|
||||
// can return the local port to the caller before the game calls JoinGame.
|
||||
// The rest (WS connect + accept) runs on the worker thread.
|
||||
//
|
||||
// To do this we do the bind ourselves here, publish the port, then hand off.
|
||||
|
||||
SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (listenSock == INVALID_SOCKET)
|
||||
{
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): socket() failed";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
sockaddr_in bindAddr = {};
|
||||
bindAddr.sin_family = AF_INET;
|
||||
bindAddr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
bindAddr.sin_port = 0;
|
||||
|
||||
if (::bind(listenSock, reinterpret_cast<sockaddr*>(&bindAddr), sizeof(bindAddr)) != 0 ||
|
||||
listen(listenSock, 1) != 0)
|
||||
{
|
||||
closesocket(listenSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): bind/listen failed";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
sockaddr_in actual = {};
|
||||
int actualLen = sizeof(actual);
|
||||
getsockname(listenSock, reinterpret_cast<sockaddr*>(&actual), &actualLen);
|
||||
const int localPort = ntohs(actual.sin_port);
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = listenSock;
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
LCELOG("RELAY", "joiner open — proxy port %d, session %s", localPort, sessionId.c_str());
|
||||
|
||||
// Hand the already-bound listen socket to the worker thread via ctx.
|
||||
auto* ctx = new WorkerContext();
|
||||
ctx->isHost = false;
|
||||
ctx->sessionId = sessionId;
|
||||
ctx->tcpPort = localPort;
|
||||
ctx->accessToken = Win64LceLive::GetAccessToken();
|
||||
ctx->baseUrl = GetBaseUrl();
|
||||
|
||||
// Spawn a simplified version of JoinerWorkerProc that skips the bind step
|
||||
// (we already did it and stored listenSock in g_relay.listenSocket).
|
||||
// We use a lambda-like approach via a separate proc.
|
||||
|
||||
struct AcceptCtx {
|
||||
WorkerContext* ctx;
|
||||
SOCKET listenSock;
|
||||
};
|
||||
|
||||
auto* actx = new AcceptCtx();
|
||||
actx->ctx = ctx;
|
||||
actx->listenSock = listenSock;
|
||||
|
||||
HANDLE t = CreateThread(nullptr, 0, [](LPVOID param) -> DWORD {
|
||||
auto* actx = static_cast<AcceptCtx*>(param);
|
||||
WorkerContext* ctx = actx->ctx;
|
||||
SOCKET listenSock = actx->listenSock;
|
||||
delete actx;
|
||||
|
||||
// Connect relay WebSocket.
|
||||
HINTERNET hSession = nullptr, hConnect = nullptr;
|
||||
HINTERNET hWs = OpenRelayWebSocket(ctx->sessionId, false,
|
||||
ctx->accessToken, ctx->baseUrl, &hSession, &hConnect);
|
||||
|
||||
if (!hWs)
|
||||
{
|
||||
closesocket(listenSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = INVALID_SOCKET;
|
||||
g_relay.state = ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): WebSocket open failed";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
LCELOG("RELAY", "joiner WS failed");
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Accept the game's connection.
|
||||
DWORD timeout = 30000;
|
||||
setsockopt(listenSock, SOL_SOCKET, SO_RCVTIMEO,
|
||||
reinterpret_cast<const char*>(&timeout), sizeof(timeout));
|
||||
|
||||
const SOCKET gameSock = accept(listenSock, nullptr, nullptr);
|
||||
closesocket(listenSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = INVALID_SOCKET;
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
if (gameSock == INVALID_SOCKET)
|
||||
{
|
||||
WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
|
||||
WinHttpCloseHandle(hWs); WinHttpCloseHandle(hConnect); WinHttpCloseHandle(hSession);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.state = ERelayState::Failed;
|
||||
g_relay.lastError = "Relay (joiner): game never connected to proxy";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
LCELOG("RELAY", "joiner accept() timed out");
|
||||
delete ctx;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LCELOG("RELAY", "joiner WS+TCP ready — forwarding");
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.wsHandle = hWs;
|
||||
g_relay.wsSession = hSession;
|
||||
g_relay.wsConnect = hConnect;
|
||||
g_relay.tcpSocket = gameSock;
|
||||
StartForwarding();
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
delete ctx;
|
||||
return 0;
|
||||
}, actx, 0, nullptr);
|
||||
|
||||
if (!t)
|
||||
{
|
||||
delete actx; // Also deletes ctx
|
||||
closesocket(listenSock);
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
g_relay.listenSocket = INVALID_SOCKET;
|
||||
g_relay.state = ERelayState::Failed;
|
||||
g_relay.lastError = "Relay: failed to create joiner worker thread";
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return 0;
|
||||
}
|
||||
CloseHandle(t);
|
||||
|
||||
return localPort;
|
||||
}
|
||||
|
||||
void Close()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
CloseHandlesLocked();
|
||||
g_relay.state = ERelayState::Closed;
|
||||
g_relay.lastError.clear();
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
|
||||
LCELOG("RELAY", "closed");
|
||||
}
|
||||
|
||||
RelaySnapshot GetSnapshot()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
RelaySnapshot snap = {};
|
||||
|
||||
EnterCriticalSection(&g_relay.lock);
|
||||
snap.state = g_relay.state;
|
||||
|
||||
switch (g_relay.state)
|
||||
{
|
||||
case ERelayState::Idle:
|
||||
snap.statusMessage = L"Relay: idle.";
|
||||
break;
|
||||
case ERelayState::Connecting:
|
||||
snap.statusMessage = L"Relay: connecting to relay server...";
|
||||
break;
|
||||
case ERelayState::Relaying:
|
||||
snap.statusMessage = L"Relay: active (routing via server).";
|
||||
break;
|
||||
case ERelayState::Failed:
|
||||
snap.statusMessage = L"Relay: failed.";
|
||||
snap.errorMessage = std::wstring(g_relay.lastError.begin(), g_relay.lastError.end());
|
||||
break;
|
||||
case ERelayState::Closed:
|
||||
snap.statusMessage = L"Relay: closed.";
|
||||
break;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_relay.lock);
|
||||
return snap;
|
||||
}
|
||||
|
||||
} // namespace Win64LceLiveRelay
|
||||
|
||||
#endif // _WINDOWS64
|
||||
72
Minecraft.Client/Windows64/Windows64_LceLiveRelay.h
Normal file
72
Minecraft.Client/Windows64/Windows64_LceLiveRelay.h
Normal file
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include <string>
|
||||
|
||||
// ============================================================================
|
||||
// Win64LceLiveRelay — TCP-over-WebSocket relay client
|
||||
//
|
||||
// Routes Minecraft game traffic through the LCELive relay server when direct
|
||||
// TCP is blocked (symmetric NAT, campus/hotel WiFi, etc.).
|
||||
//
|
||||
// Host side:
|
||||
// Call HostOpen(sessionId, tcpPort) right after signaling HostConnect().
|
||||
// The relay client connects to the relay WebSocket as "host", then connects
|
||||
// a TCP socket to 127.0.0.1:tcpPort (the running game server).
|
||||
// All data is forwarded bidirectionally between the two.
|
||||
//
|
||||
// Joiner side:
|
||||
// Call JoinerOpen(sessionId) before JoinGameFromInviteInfo().
|
||||
// Returns a local TCP port. Tell the game to connect to 127.0.0.1:<port>.
|
||||
// The relay client accepts that connection, connects to the relay WebSocket
|
||||
// as "joiner", and forwards all data bidirectionally.
|
||||
// ============================================================================
|
||||
|
||||
namespace Win64LceLiveRelay
|
||||
{
|
||||
enum class ERelayState
|
||||
{
|
||||
Idle, // Not started.
|
||||
Connecting, // Opening WebSocket + TCP connections.
|
||||
Relaying, // Fully active; data is flowing.
|
||||
Failed, // Fatal error — see snapshot.errorMessage.
|
||||
Closed, // Cleanly torn down.
|
||||
};
|
||||
|
||||
struct RelaySnapshot
|
||||
{
|
||||
ERelayState state;
|
||||
std::wstring statusMessage;
|
||||
std::wstring errorMessage;
|
||||
};
|
||||
|
||||
// ---- Host side ----------------------------------------------------------
|
||||
|
||||
// Open the host relay for the given signaling session.
|
||||
// tcpGamePort: the local TCP port the game server is listening on (usually 25565).
|
||||
// Returns false if a relay is already active (call Close() first).
|
||||
bool HostOpen(const std::string& sessionId, int tcpGamePort);
|
||||
|
||||
// ---- Joiner side --------------------------------------------------------
|
||||
|
||||
// Open the joiner relay for the given signaling session.
|
||||
// Returns the local TCP port the game should connect to, or 0 on error.
|
||||
int JoinerOpen(const std::string& sessionId);
|
||||
|
||||
// ---- Common -------------------------------------------------------------
|
||||
|
||||
// Tear down any active relay connections and reset to Idle.
|
||||
void Close();
|
||||
|
||||
// Thread-safe snapshot of current relay state.
|
||||
RelaySnapshot GetSnapshot();
|
||||
}
|
||||
|
||||
// Set by the joiner when it tries direct TCP but has a relay proxy port ready as
|
||||
// fallback. The main Tick monitors WinsockNetLayer::GetJoinState(); if the direct
|
||||
// attempt fails it retries automatically via this port (mirrors Xbox Live's TURN
|
||||
// fallback when direct UDP hole-punch fails). Cleared after the fallback fires.
|
||||
extern int g_LceLiveRelayFallbackPort;
|
||||
|
||||
#endif // _WINDOWS64
|
||||
724
Minecraft.Client/Windows64/Windows64_LceLiveSignaling.cpp
Normal file
724
Minecraft.Client/Windows64/Windows64_LceLiveSignaling.cpp
Normal file
@@ -0,0 +1,724 @@
|
||||
#include "stdafx.h"
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <winhttp.h>
|
||||
#include <objbase.h> // CoCreateGuid
|
||||
|
||||
#include "Windows64_LceLiveSignaling.h"
|
||||
#include "Windows64_LceLive.h"
|
||||
#include "Windows64_LceLiveP2P.h"
|
||||
#include "Windows64_Log.h"
|
||||
#include "../../../Minecraft.Server/vendor/nlohmann/json.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#pragma comment(lib, "Winhttp.lib")
|
||||
|
||||
// ============================================================================
|
||||
// Internal implementation
|
||||
// ============================================================================
|
||||
|
||||
namespace
|
||||
{
|
||||
using Json = nlohmann::json;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
std::wstring Utf8ToWideLocal(const std::string& s)
|
||||
{
|
||||
if (s.empty()) return L"";
|
||||
const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0);
|
||||
if (n <= 0) return L"";
|
||||
std::wstring out(static_cast<size_t>(n), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &out[0], n);
|
||||
if (!out.empty() && out.back() == L'\0') out.pop_back();
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string WideToUtf8Local(const std::wstring& s)
|
||||
{
|
||||
if (s.empty()) return "";
|
||||
const int n = WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, nullptr, 0, nullptr, nullptr);
|
||||
if (n <= 0) return "";
|
||||
std::string out(static_cast<size_t>(n), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, &out[0], n, nullptr, nullptr);
|
||||
if (!out.empty() && out.back() == '\0') out.pop_back();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Generate a lowercase hyphenated UUID string (e.g. "550e8400-e29b-41d4-a716-446655440000").
|
||||
std::string GenerateUuid()
|
||||
{
|
||||
GUID guid = {};
|
||||
CoCreateGuid(&guid);
|
||||
|
||||
char buf[40] = {};
|
||||
snprintf(buf, sizeof(buf),
|
||||
"%08lx-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
||||
guid.Data1, guid.Data2, guid.Data3,
|
||||
guid.Data4[0], guid.Data4[1],
|
||||
guid.Data4[2], guid.Data4[3], guid.Data4[4],
|
||||
guid.Data4[5], guid.Data4[6], guid.Data4[7]);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Read the API base URL the same way Windows64_LceLive.cpp does:
|
||||
// LCELIVE_API_BASE_URL env var → lcelive.properties → localhost:5187.
|
||||
std::string GetBaseUrl()
|
||||
{
|
||||
char envValue[512] = {};
|
||||
const DWORD len = GetEnvironmentVariableA("LCELIVE_API_BASE_URL", envValue,
|
||||
static_cast<DWORD>(sizeof(envValue)));
|
||||
if (len > 0 && len < sizeof(envValue))
|
||||
return std::string(envValue);
|
||||
|
||||
// Try lcelive.properties next to the .exe
|
||||
char exePath[MAX_PATH] = {};
|
||||
GetModuleFileNameA(nullptr, exePath, MAX_PATH);
|
||||
std::string props(exePath);
|
||||
const size_t lastSlash = props.find_last_of("\\/");
|
||||
if (lastSlash != std::string::npos)
|
||||
props = props.substr(0, lastSlash + 1);
|
||||
props += "lcelive.properties";
|
||||
|
||||
FILE* f = nullptr;
|
||||
if (fopen_s(&f, props.c_str(), "rb") == 0 && f != nullptr)
|
||||
{
|
||||
char line[512] = {};
|
||||
while (fgets(line, sizeof(line), f) != nullptr)
|
||||
{
|
||||
std::string s(line);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back();
|
||||
if (s.substr(0, 12) == "api_base_url")
|
||||
{
|
||||
const size_t eq = s.find('=');
|
||||
if (eq != std::string::npos) { fclose(f); return s.substr(eq + 1); }
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
return "http://localhost:5187";
|
||||
}
|
||||
|
||||
// URL-percent-encode a string (for safe use in query parameters).
|
||||
std::string UrlEncode(const std::string& s)
|
||||
{
|
||||
std::string out;
|
||||
out.reserve(s.size() * 3);
|
||||
for (unsigned char c : s)
|
||||
{
|
||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
|
||||
out += static_cast<char>(c);
|
||||
else
|
||||
{
|
||||
char hex[4] = {};
|
||||
snprintf(hex, sizeof(hex), "%%%02X", c);
|
||||
out += hex;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the JSON candidate message we send over the signaling channel.
|
||||
std::string BuildCandidateJson(const std::string& ip, int port,
|
||||
Win64LceLiveP2P::EConnMethod method)
|
||||
{
|
||||
const char* methodStr =
|
||||
(method == Win64LceLiveP2P::EConnMethod::UPnP) ? "upnp" : "stun";
|
||||
|
||||
Json j;
|
||||
j["type"] = "candidate";
|
||||
j["ip"] = ip;
|
||||
j["port"] = port;
|
||||
j["method"] = methodStr;
|
||||
return j.dump();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worker context — passed to the background thread
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct WorkerContext
|
||||
{
|
||||
bool isHost;
|
||||
std::string sessionId;
|
||||
std::string ourIp;
|
||||
int ourPort;
|
||||
Win64LceLiveP2P::EConnMethod ourMethod;
|
||||
std::string accessToken;
|
||||
std::string baseUrl;
|
||||
|
||||
// Written by worker to signal results back to main thread.
|
||||
volatile bool workerDone;
|
||||
bool wsConnected; // WebSocket opened successfully
|
||||
bool peerReceived; // Peer candidate decoded
|
||||
std::string peerIp;
|
||||
int peerPort;
|
||||
bool peerNeedsHolePunch;
|
||||
std::string errorMessage;
|
||||
|
||||
// Handle the main thread can close to unblock a stuck WinHttpWebSocketReceive.
|
||||
volatile HINTERNET wsHandle;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct SignalingState
|
||||
{
|
||||
bool initialized;
|
||||
CRITICAL_SECTION lock;
|
||||
|
||||
Win64LceLiveSignaling::ESignalingState state;
|
||||
std::string sessionId;
|
||||
std::string peerIp;
|
||||
int peerPort;
|
||||
bool peerNeedsHolePunch;
|
||||
std::string lastError;
|
||||
|
||||
// Set by PrepareJoin(); cleared when JoinerConnect() fires.
|
||||
std::string pendingJoinerSessionId;
|
||||
|
||||
HANDLE workerThread;
|
||||
WorkerContext* workerCtx;
|
||||
};
|
||||
|
||||
static SignalingState g_sig = {};
|
||||
static INIT_ONCE g_initOnce = INIT_ONCE_STATIC_INIT;
|
||||
|
||||
BOOL CALLBACK InitSignalingState(PINIT_ONCE, PVOID, PVOID*)
|
||||
{
|
||||
InitializeCriticalSection(&g_sig.lock);
|
||||
g_sig.state = Win64LceLiveSignaling::ESignalingState::Idle;
|
||||
g_sig.initialized = true;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void EnsureInitialized()
|
||||
{
|
||||
InitOnceExecuteOnce(&g_initOnce, &InitSignalingState, nullptr, nullptr);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket worker thread
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
DWORD WINAPI SignalingWorkerProc(LPVOID param)
|
||||
{
|
||||
WorkerContext* ctx = static_cast<WorkerContext*>(param);
|
||||
|
||||
// ---- Parse the base URL ----
|
||||
const std::wstring baseUrlW = Utf8ToWideLocal(ctx->baseUrl);
|
||||
|
||||
std::vector<wchar_t> hostBuf(256, 0);
|
||||
std::vector<wchar_t> pathBuf(2048, 0);
|
||||
|
||||
URL_COMPONENTSW components = {};
|
||||
components.dwStructSize = sizeof(components);
|
||||
components.lpszHostName = hostBuf.data();
|
||||
components.dwHostNameLength = static_cast<DWORD>(hostBuf.size());
|
||||
components.lpszUrlPath = pathBuf.data();
|
||||
components.dwUrlPathLength = static_cast<DWORD>(pathBuf.size());
|
||||
|
||||
if (!WinHttpCrackUrl(baseUrlW.c_str(), static_cast<DWORD>(baseUrlW.size()), 0, &components))
|
||||
{
|
||||
ctx->errorMessage = "Signaling: WinHttpCrackUrl failed for base URL";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const bool secure = (components.nScheme == INTERNET_SCHEME_HTTPS);
|
||||
const std::wstring hostW(components.lpszHostName, components.dwHostNameLength);
|
||||
|
||||
// Build the WebSocket path including query params.
|
||||
// Auth goes in the Authorization header (server prefers that over ?token=).
|
||||
const std::wstring basePath = components.lpszUrlPath
|
||||
? std::wstring(components.lpszUrlPath, components.dwUrlPathLength)
|
||||
: L"";
|
||||
|
||||
const std::wstring wsPath = basePath + L"/api/signaling/ws"
|
||||
+ L"?sessionId=" + Utf8ToWideLocal(ctx->sessionId)
|
||||
+ L"&role=" + Utf8ToWideLocal(ctx->isHost ? "host" : "joiner");
|
||||
|
||||
LCELOG("SIG", "connecting to %s%ls (role=%s sessionId=%s)",
|
||||
secure ? "wss://" : "ws://",
|
||||
(hostW + wsPath).c_str(),
|
||||
ctx->isHost ? "host" : "joiner",
|
||||
ctx->sessionId.c_str());
|
||||
|
||||
// ---- Open WinHTTP session ----
|
||||
HINTERNET hSession = WinHttpOpen(
|
||||
L"MCLCE-LceLive/1.0",
|
||||
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
|
||||
WINHTTP_NO_PROXY_NAME,
|
||||
WINHTTP_NO_PROXY_BYPASS,
|
||||
0);
|
||||
|
||||
if (hSession == nullptr)
|
||||
{
|
||||
ctx->errorMessage = "Signaling: WinHttpOpen failed";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
WinHttpSetTimeouts(hSession, 10000, 10000, 30000, 30000);
|
||||
|
||||
HINTERNET hConnect = WinHttpConnect(hSession, hostW.c_str(), components.nPort, 0);
|
||||
if (hConnect == nullptr)
|
||||
{
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->errorMessage = "Signaling: WinHttpConnect failed";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- Open GET request (will be upgraded to WebSocket) ----
|
||||
const DWORD flags = secure ? WINHTTP_FLAG_SECURE : 0;
|
||||
HINTERNET hRequest = WinHttpOpenRequest(
|
||||
hConnect,
|
||||
L"GET",
|
||||
wsPath.c_str(),
|
||||
nullptr,
|
||||
WINHTTP_NO_REFERER,
|
||||
WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||
flags);
|
||||
|
||||
if (hRequest == nullptr)
|
||||
{
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->errorMessage = "Signaling: WinHttpOpenRequest failed";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Mark this request for WebSocket upgrade BEFORE sending.
|
||||
WinHttpSetOption(hRequest, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0);
|
||||
|
||||
// Add Authorization header.
|
||||
if (!ctx->accessToken.empty())
|
||||
{
|
||||
const std::wstring authHeader =
|
||||
L"Authorization: Bearer " + Utf8ToWideLocal(ctx->accessToken);
|
||||
WinHttpAddRequestHeaders(hRequest,
|
||||
authHeader.c_str(),
|
||||
static_cast<DWORD>(authHeader.size()),
|
||||
WINHTTP_ADDREQ_FLAG_ADD);
|
||||
}
|
||||
|
||||
// Send the upgrade request.
|
||||
if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
|
||||
WINHTTP_NO_REQUEST_DATA, 0, 0, 0))
|
||||
{
|
||||
WinHttpCloseHandle(hRequest);
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->errorMessage = "Signaling: WinHttpSendRequest failed (WSA "
|
||||
+ std::to_string(GetLastError()) + ")";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!WinHttpReceiveResponse(hRequest, nullptr))
|
||||
{
|
||||
WinHttpCloseHandle(hRequest);
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->errorMessage = "Signaling: WinHttpReceiveResponse failed — server may be down";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- Complete WebSocket upgrade ----
|
||||
HINTERNET hWs = WinHttpWebSocketCompleteUpgrade(hRequest, 0);
|
||||
WinHttpCloseHandle(hRequest);
|
||||
|
||||
if (hWs == nullptr)
|
||||
{
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->errorMessage = "Signaling: WebSocket upgrade failed — server returned non-101";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Make the handle visible to the main thread so Close() can abort the receive.
|
||||
ctx->wsHandle = hWs;
|
||||
ctx->wsConnected = true;
|
||||
|
||||
LCELOG("SIG", "WebSocket connected (session %s)", ctx->sessionId.c_str());
|
||||
|
||||
// ---- Send our P2P candidate ----
|
||||
const std::string candidateJson =
|
||||
BuildCandidateJson(ctx->ourIp, ctx->ourPort, ctx->ourMethod);
|
||||
|
||||
DWORD sendResult = WinHttpWebSocketSend(
|
||||
hWs,
|
||||
WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE,
|
||||
const_cast<PVOID>(reinterpret_cast<const void*>(candidateJson.c_str())),
|
||||
static_cast<DWORD>(candidateJson.size()));
|
||||
|
||||
if (sendResult != ERROR_SUCCESS)
|
||||
{
|
||||
WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
|
||||
WinHttpCloseHandle(hWs);
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->wsHandle = nullptr;
|
||||
ctx->errorMessage = "Signaling: failed to send candidate";
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
LCELOG("SIG", "candidate sent %s", candidateJson.c_str());
|
||||
|
||||
// ---- Receive loop: wait for peer's candidate ----
|
||||
// We also pass through joiner_connected notifications (host only) so the
|
||||
// log shows when the joiner arrives.
|
||||
std::vector<BYTE> recvBuf(8192);
|
||||
bool done = false;
|
||||
|
||||
while (!done)
|
||||
{
|
||||
DWORD bytesRead = 0;
|
||||
WINHTTP_WEB_SOCKET_BUFFER_TYPE bufType = WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE;
|
||||
|
||||
const DWORD recvErr = WinHttpWebSocketReceive(
|
||||
hWs,
|
||||
recvBuf.data(),
|
||||
static_cast<DWORD>(recvBuf.size() - 1),
|
||||
&bytesRead,
|
||||
&bufType);
|
||||
|
||||
if (recvErr != ERROR_SUCCESS)
|
||||
{
|
||||
// Closed from main thread (Close() called) or network error.
|
||||
LCELOG("SIG", "receive ended (%lu)", recvErr);
|
||||
break;
|
||||
}
|
||||
|
||||
if (bufType != WINHTTP_WEB_SOCKET_UTF8_MESSAGE_BUFFER_TYPE &&
|
||||
bufType != WINHTTP_WEB_SOCKET_UTF8_FRAGMENT_BUFFER_TYPE)
|
||||
continue; // binary or close frame — ignore
|
||||
|
||||
recvBuf[bytesRead] = 0;
|
||||
const std::string msg(reinterpret_cast<char*>(recvBuf.data()), bytesRead);
|
||||
|
||||
LCELOG("SIG", "recv %s", msg.c_str());
|
||||
|
||||
try
|
||||
{
|
||||
const Json j = Json::parse(msg);
|
||||
const std::string type = j.value("type", "");
|
||||
|
||||
if (type == "joiner_connected")
|
||||
{
|
||||
// Host-only: the joiner has arrived on the signaling channel.
|
||||
// Their candidate will follow shortly.
|
||||
LCELOG("SIG", "joiner connected on session %s", ctx->sessionId.c_str());
|
||||
}
|
||||
else if (type == "candidate")
|
||||
{
|
||||
const std::string peerIp = j.value("ip", "");
|
||||
const int peerPort = j.value("port", 0);
|
||||
const std::string methodStr = j.value("method", "stun");
|
||||
const bool holePunch = (methodStr == "stun");
|
||||
|
||||
if (!peerIp.empty() && peerPort > 0)
|
||||
{
|
||||
ctx->peerIp = peerIp;
|
||||
ctx->peerPort = peerPort;
|
||||
ctx->peerNeedsHolePunch = holePunch;
|
||||
ctx->peerReceived = true;
|
||||
|
||||
LCELOG("SIG", "peer endpoint %s:%d (method=%s)",
|
||||
peerIp.c_str(), peerPort, methodStr.c_str());
|
||||
|
||||
done = true; // Both candidates exchanged — we're done here.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...) {} // Malformed JSON — skip
|
||||
}
|
||||
|
||||
// ---- Clean up ----
|
||||
WinHttpWebSocketClose(hWs, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
|
||||
WinHttpCloseHandle(hWs);
|
||||
WinHttpCloseHandle(hConnect);
|
||||
WinHttpCloseHandle(hSession);
|
||||
ctx->wsHandle = nullptr;
|
||||
ctx->workerDone = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Common start helper (host & joiner share 90% of setup)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
bool StartWorker(bool isHost,
|
||||
const std::string& sessionId,
|
||||
const std::string& externalIp, int externalPort,
|
||||
Win64LceLiveP2P::EConnMethod method)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
if (g_sig.state != Win64LceLiveSignaling::ESignalingState::Idle)
|
||||
{
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
WorkerContext* ctx = new WorkerContext();
|
||||
ctx->isHost = isHost;
|
||||
ctx->sessionId = sessionId;
|
||||
ctx->ourIp = externalIp;
|
||||
ctx->ourPort = externalPort;
|
||||
ctx->ourMethod = method;
|
||||
ctx->accessToken = Win64LceLive::GetAccessToken();
|
||||
ctx->baseUrl = GetBaseUrl();
|
||||
ctx->workerDone = false;
|
||||
ctx->wsConnected = false;
|
||||
ctx->peerReceived = false;
|
||||
ctx->peerPort = 0;
|
||||
ctx->peerNeedsHolePunch = false;
|
||||
ctx->wsHandle = nullptr;
|
||||
|
||||
g_sig.state = Win64LceLiveSignaling::ESignalingState::Connecting;
|
||||
g_sig.sessionId = sessionId;
|
||||
g_sig.peerPort = 0;
|
||||
g_sig.lastError.clear();
|
||||
g_sig.peerIp.clear();
|
||||
g_sig.workerCtx = ctx;
|
||||
|
||||
g_sig.workerThread = CreateThread(nullptr, 0, &SignalingWorkerProc, ctx, 0, nullptr);
|
||||
if (g_sig.workerThread == nullptr)
|
||||
{
|
||||
g_sig.state = Win64LceLiveSignaling::ESignalingState::Failed;
|
||||
g_sig.lastError = "Signaling: failed to create worker thread";
|
||||
delete ctx;
|
||||
g_sig.workerCtx = nullptr;
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
namespace Win64LceLiveSignaling
|
||||
{
|
||||
bool HostConnect(const std::string& externalIp, int externalPort,
|
||||
Win64LceLiveP2P::EConnMethod method)
|
||||
{
|
||||
const std::string sessionId = GenerateUuid();
|
||||
LCELOG("SIG", "hosting session %s endpoint %s:%d",
|
||||
sessionId.c_str(), externalIp.c_str(), externalPort);
|
||||
return StartWorker(true, sessionId, externalIp, externalPort, method);
|
||||
}
|
||||
|
||||
bool JoinerConnect(const std::string& sessionId,
|
||||
const std::string& externalIp, int externalPort,
|
||||
Win64LceLiveP2P::EConnMethod method)
|
||||
{
|
||||
// Clear the pending ID — we're acting on it now.
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
g_sig.pendingJoinerSessionId.clear();
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
|
||||
LCELOG("SIG", "joining session %s our endpoint %s:%d",
|
||||
sessionId.c_str(), externalIp.c_str(), externalPort);
|
||||
return StartWorker(false, sessionId, externalIp, externalPort, method);
|
||||
}
|
||||
|
||||
void PrepareJoin(const std::string& sessionId)
|
||||
{
|
||||
EnsureInitialized();
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
g_sig.pendingJoinerSessionId = sessionId;
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
LCELOG("SIG", "joiner session ID stored (%s) — waiting for P2P", sessionId.c_str());
|
||||
}
|
||||
|
||||
std::string GetPendingJoinerSessionId()
|
||||
{
|
||||
EnsureInitialized();
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
const std::string id = g_sig.pendingJoinerSessionId;
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
return id;
|
||||
}
|
||||
|
||||
void Tick()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
|
||||
if (g_sig.workerCtx == nullptr ||
|
||||
(g_sig.state != ESignalingState::Connecting &&
|
||||
g_sig.state != ESignalingState::Connected))
|
||||
{
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
return;
|
||||
}
|
||||
|
||||
WorkerContext* ctx = g_sig.workerCtx;
|
||||
|
||||
// Promote Connecting → Connected once WebSocket is open.
|
||||
if (g_sig.state == ESignalingState::Connecting && ctx->wsConnected)
|
||||
g_sig.state = ESignalingState::Connected;
|
||||
|
||||
// Integrate completed peer exchange.
|
||||
if (ctx->workerDone)
|
||||
{
|
||||
HANDLE t = g_sig.workerThread;
|
||||
g_sig.workerThread = nullptr;
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
|
||||
if (t != nullptr)
|
||||
{
|
||||
WaitForSingleObject(t, INFINITE);
|
||||
CloseHandle(t);
|
||||
}
|
||||
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
if (ctx->peerReceived)
|
||||
{
|
||||
g_sig.state = ESignalingState::PeerKnown;
|
||||
g_sig.peerIp = ctx->peerIp;
|
||||
g_sig.peerPort = ctx->peerPort;
|
||||
g_sig.peerNeedsHolePunch = ctx->peerNeedsHolePunch;
|
||||
LCELOG("SIG", "peer known %s:%d (holePunch=%d)",
|
||||
ctx->peerIp.c_str(), ctx->peerPort, ctx->peerNeedsHolePunch ? 1 : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
g_sig.state = ESignalingState::Failed;
|
||||
g_sig.lastError = ctx->errorMessage.empty()
|
||||
? "Signaling: connection closed before peer candidate received"
|
||||
: ctx->errorMessage;
|
||||
LCELOG("SIG", "failed — %s", g_sig.lastError.c_str());
|
||||
}
|
||||
|
||||
delete ctx;
|
||||
g_sig.workerCtx = nullptr;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
}
|
||||
|
||||
SignalingSnapshot GetSnapshot()
|
||||
{
|
||||
EnsureInitialized();
|
||||
Tick();
|
||||
|
||||
SignalingSnapshot snap = {};
|
||||
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
snap.state = g_sig.state;
|
||||
snap.sessionId = g_sig.sessionId;
|
||||
snap.peerIp = g_sig.peerIp;
|
||||
snap.peerPort = g_sig.peerPort;
|
||||
snap.peerNeedsHolePunch = g_sig.peerNeedsHolePunch;
|
||||
|
||||
switch (g_sig.state)
|
||||
{
|
||||
case ESignalingState::Idle:
|
||||
snap.statusMessage = L"Signaling: idle.";
|
||||
break;
|
||||
case ESignalingState::Connecting:
|
||||
snap.statusMessage = L"Signaling: connecting to relay...";
|
||||
break;
|
||||
case ESignalingState::Connected:
|
||||
snap.statusMessage = L"Signaling: waiting for peer...";
|
||||
break;
|
||||
case ESignalingState::PeerKnown:
|
||||
{
|
||||
wchar_t buf[256] = {};
|
||||
swprintf_s(buf, L"Signaling: peer at %hs:%d (%s)",
|
||||
g_sig.peerIp.c_str(), g_sig.peerPort,
|
||||
g_sig.peerNeedsHolePunch ? L"hole punch" : L"direct");
|
||||
snap.statusMessage = buf;
|
||||
break;
|
||||
}
|
||||
case ESignalingState::Failed:
|
||||
snap.statusMessage = L"Signaling: failed.";
|
||||
snap.errorMessage = std::wstring(g_sig.lastError.begin(), g_sig.lastError.end());
|
||||
break;
|
||||
case ESignalingState::Closed:
|
||||
snap.statusMessage = L"Signaling: closed.";
|
||||
break;
|
||||
}
|
||||
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
return snap;
|
||||
}
|
||||
|
||||
void Close()
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
// Unblock any in-progress WinHttpWebSocketReceive by closing the handle.
|
||||
HINTERNET wsToClose = nullptr;
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
if (g_sig.workerCtx != nullptr)
|
||||
wsToClose = g_sig.workerCtx->wsHandle;
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
|
||||
if (wsToClose != nullptr)
|
||||
{
|
||||
// Sending a Close frame unblocks the receive on the worker thread.
|
||||
WinHttpWebSocketClose(wsToClose,
|
||||
WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
|
||||
}
|
||||
|
||||
// Now wait for the worker to finish.
|
||||
HANDLE t = nullptr;
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
t = g_sig.workerThread;
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
|
||||
if (t != nullptr)
|
||||
{
|
||||
WaitForSingleObject(t, 5000);
|
||||
CloseHandle(t);
|
||||
}
|
||||
|
||||
EnterCriticalSection(&g_sig.lock);
|
||||
if (g_sig.workerCtx != nullptr)
|
||||
{
|
||||
delete g_sig.workerCtx;
|
||||
g_sig.workerCtx = nullptr;
|
||||
}
|
||||
g_sig.workerThread = nullptr;
|
||||
g_sig.state = ESignalingState::Closed;
|
||||
g_sig.sessionId.clear();
|
||||
g_sig.peerIp.clear();
|
||||
g_sig.peerPort = 0;
|
||||
g_sig.peerNeedsHolePunch = false;
|
||||
g_sig.lastError.clear();
|
||||
g_sig.pendingJoinerSessionId.clear();
|
||||
LeaveCriticalSection(&g_sig.lock);
|
||||
|
||||
LCELOG("SIG", "closed");
|
||||
}
|
||||
|
||||
} // namespace Win64LceLiveSignaling
|
||||
|
||||
#endif // _WINDOWS64
|
||||
75
Minecraft.Client/Windows64/Windows64_LceLiveSignaling.h
Normal file
75
Minecraft.Client/Windows64/Windows64_LceLiveSignaling.h
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include <string>
|
||||
#include "Windows64_LceLiveP2P.h"
|
||||
|
||||
namespace Win64LceLiveSignaling
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
enum class ESignalingState
|
||||
{
|
||||
Idle,
|
||||
Connecting, // Worker thread in progress; WebSocket not yet open.
|
||||
Connected, // WebSocket open; own candidate sent; waiting for peer.
|
||||
PeerKnown, // Peer candidate received; ready for hole punching.
|
||||
Failed, // Unrecoverable error; see SignalingSnapshot::errorMessage.
|
||||
Closed, // Cleanly closed.
|
||||
};
|
||||
|
||||
struct SignalingSnapshot
|
||||
{
|
||||
ESignalingState state;
|
||||
std::string sessionId; // UUID; set once HostConnect() is called.
|
||||
std::string peerIp; // Set once PeerKnown.
|
||||
int peerPort; // Set once PeerKnown. 0 until then.
|
||||
bool peerNeedsHolePunch; // true = STUN; false = UPnP direct connect.
|
||||
std::wstring statusMessage; // Human-readable; always set.
|
||||
std::wstring errorMessage; // Non-empty only on Failed.
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Host: generate a session UUID, connect to the signaling server, publish
|
||||
// our P2P endpoint. Non-blocking — transition to Connecting is immediate.
|
||||
// Returns false if already active (call Close() first).
|
||||
bool HostConnect(const std::string& externalIp, int externalPort,
|
||||
Win64LceLiveP2P::EConnMethod method);
|
||||
|
||||
// Joiner: connect to an existing signaling session, exchange endpoints.
|
||||
// sessionId comes from the game invite. Non-blocking.
|
||||
// Returns false if already active.
|
||||
bool JoinerConnect(const std::string& sessionId,
|
||||
const std::string& externalIp, int externalPort,
|
||||
Win64LceLiveP2P::EConnMethod method);
|
||||
|
||||
// Joiner pre-connect: store a session ID so that the frame-loop Tick
|
||||
// can call JoinerConnect automatically once P2P discovery finishes.
|
||||
// Call this immediately after accepting an invite that carries a session ID.
|
||||
void PrepareJoin(const std::string& sessionId);
|
||||
|
||||
// Returns the pending joiner session ID set by PrepareJoin(), or empty if
|
||||
// none is pending. Cleared automatically when JoinerConnect() is called.
|
||||
std::string GetPendingJoinerSessionId();
|
||||
|
||||
// Drive the state machine. Call once per frame from the game loop,
|
||||
// same cadence as Win64LceLiveP2P::P2PTick().
|
||||
void Tick();
|
||||
|
||||
// Thread-safe snapshot of current state.
|
||||
// Calls Tick() internally.
|
||||
SignalingSnapshot GetSnapshot();
|
||||
|
||||
// Close the signaling connection and reset to Idle.
|
||||
// Blocks briefly to join the worker thread.
|
||||
void Close();
|
||||
|
||||
} // namespace Win64LceLiveSignaling
|
||||
|
||||
#endif // _WINDOWS64
|
||||
97
Minecraft.Client/Windows64/Windows64_Log.cpp
Normal file
97
Minecraft.Client/Windows64/Windows64_Log.cpp
Normal file
@@ -0,0 +1,97 @@
|
||||
#include "stdafx.h"
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
#include "Windows64_Log.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdarg>
|
||||
#include <cstring>
|
||||
|
||||
namespace LceLog
|
||||
{
|
||||
|
||||
static FILE* s_logFile = nullptr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — open latest.log (rotating the previous one).
|
||||
// ---------------------------------------------------------------------------
|
||||
void Init()
|
||||
{
|
||||
// Rotate: latest → previous
|
||||
rename("latest.log", "previous.log");
|
||||
|
||||
if (fopen_s(&s_logFile, "latest.log", "w") != 0 || !s_logFile)
|
||||
{
|
||||
s_logFile = nullptr;
|
||||
OutputDebugStringA("[LceLog] Failed to open latest.log\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write header so it's easy to tell sessions apart.
|
||||
SYSTEMTIME st = {};
|
||||
GetLocalTime(&st);
|
||||
char header[128];
|
||||
snprintf(header, sizeof(header),
|
||||
"=== LCELive session started %04d-%02d-%02d %02d:%02d:%02d ===\n",
|
||||
st.wYear, st.wMonth, st.wDay,
|
||||
st.wHour, st.wMinute, st.wSecond);
|
||||
fputs(header, s_logFile);
|
||||
fflush(s_logFile);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown — flush and close.
|
||||
// ---------------------------------------------------------------------------
|
||||
void Shutdown()
|
||||
{
|
||||
if (!s_logFile)
|
||||
return;
|
||||
|
||||
fputs("=== session ended ===\n", s_logFile);
|
||||
fflush(s_logFile);
|
||||
fclose(s_logFile);
|
||||
s_logFile = nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write — timestamped log entry. Safe to call before Init (silently skipped)
|
||||
// and from any thread (FILE* writes are internally serialised on MSVC CRT).
|
||||
// ---------------------------------------------------------------------------
|
||||
void Write(const char* fmt, ...)
|
||||
{
|
||||
if (!s_logFile && !IsDebuggerPresent())
|
||||
return;
|
||||
|
||||
// Build timestamp
|
||||
SYSTEMTIME st = {};
|
||||
GetLocalTime(&st);
|
||||
char timeBuf[24];
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d.%03d",
|
||||
st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
|
||||
|
||||
// Format the caller's message
|
||||
char msgBuf[2048];
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(msgBuf, sizeof(msgBuf) - 1, fmt, args);
|
||||
va_end(args);
|
||||
msgBuf[sizeof(msgBuf) - 1] = '\0';
|
||||
|
||||
// Full line: "[HH:MM:SS.mmm] message\n"
|
||||
char line[2048 + 32];
|
||||
snprintf(line, sizeof(line), "[%s] %s\n", timeBuf, msgBuf);
|
||||
|
||||
if (s_logFile)
|
||||
{
|
||||
fputs(line, s_logFile);
|
||||
fflush(s_logFile); // flush every write — we want to see crashes
|
||||
}
|
||||
|
||||
// Also send to VS Output window when a debugger is attached.
|
||||
OutputDebugStringA(line);
|
||||
}
|
||||
|
||||
} // namespace LceLog
|
||||
|
||||
#endif // _WINDOWS64
|
||||
37
Minecraft.Client/Windows64/Windows64_Log.h
Normal file
37
Minecraft.Client/Windows64/Windows64_Log.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef _WINDOWS64
|
||||
|
||||
// ============================================================================
|
||||
// LceLog — lightweight file logger
|
||||
//
|
||||
// Writes a timestamped latest.log next to the exe on every run.
|
||||
// Any previous run's log is renamed to previous.log.
|
||||
//
|
||||
// All writes use fputs/fflush directly (never printf) so the logger works in
|
||||
// every build configuration, including _FINAL_BUILD where printf is disabled.
|
||||
// Stdout is also redirected to the file so existing printf debug output in
|
||||
// non-final builds lands in the log automatically.
|
||||
//
|
||||
// Usage:
|
||||
// LceLog::Init(); // call once, early in WinMain
|
||||
// LceLog::Write("Connected to %s", ip); // any time
|
||||
// LceLog::Shutdown(); // call once, at exit
|
||||
//
|
||||
// Convenience macro with a fixed category tag:
|
||||
// LCELOG("RELAY", "host opened for session %s", sid.c_str());
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdarg>
|
||||
|
||||
namespace LceLog
|
||||
{
|
||||
void Init();
|
||||
void Shutdown();
|
||||
void Write(const char* fmt, ...);
|
||||
}
|
||||
|
||||
#define LCELOG(category, fmt, ...) \
|
||||
LceLog::Write("[" category "] " fmt, ##__VA_ARGS__)
|
||||
|
||||
#endif // _WINDOWS64
|
||||
@@ -48,6 +48,11 @@
|
||||
#include "../GameRenderer.h"
|
||||
#include "Network/WinsockNetLayer.h"
|
||||
#include "Windows64_Xuid.h"
|
||||
#include "Windows64_LceLive.h"
|
||||
#include "Windows64_LceLiveP2P.h"
|
||||
#include "Windows64_LceLiveSignaling.h"
|
||||
#include "Windows64_LceLiveRelay.h"
|
||||
#include "Windows64_Log.h"
|
||||
#include "Common/UI/UI.h"
|
||||
|
||||
// Forward-declare the internal Renderer class and its global instance from 4J_Render_PC_d.lib.
|
||||
@@ -1321,6 +1326,10 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance,
|
||||
if (pSlash) { *(pSlash + 1) = '\0'; SetCurrentDirectoryA(szExeDir); }
|
||||
}
|
||||
|
||||
// Open latest.log next to the exe so diagnostics are available in any
|
||||
// build type without needing a debugger attached.
|
||||
LceLog::Init();
|
||||
|
||||
// Declare DPI awareness so GetSystemMetrics returns physical pixels
|
||||
SetProcessDPIAware();
|
||||
// Use the native monitor resolution for the window and swap chain,
|
||||
@@ -1656,6 +1665,94 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance,
|
||||
g_NetworkManager.DoWork();
|
||||
PIXEndNamedEvent();
|
||||
|
||||
// LceLive platform service ticks — keep auth token fresh, drive P2P + signaling.
|
||||
Win64LceLive::Tick();
|
||||
Win64LceLiveP2P::P2PTick();
|
||||
Win64LceLiveSignaling::Tick();
|
||||
|
||||
// Auto-connect signaling once P2P discovery completes.
|
||||
// Host path: HostOpen() is called from PlatformNetworkManagerStub on HostGame().
|
||||
// On the first Ready frame while the session is active → HostConnect().
|
||||
// Joiner path: HostOpen() + PrepareJoin(sessionId) are called from
|
||||
// UIScene_LceLiveRequests when the invite is accepted.
|
||||
// JoinerConnect() fires on every Ready frame until the pending ID is
|
||||
// consumed — deliberately NOT gated on IsActive() because the relay TCP
|
||||
// connection may still be completing at the exact frame STUN finishes
|
||||
// (1-frame race that previously caused the signaling exchange to be
|
||||
// skipped entirely, leaving the host's WS to time out and drop the relay).
|
||||
{
|
||||
static Win64LceLiveP2P::EP2PState s_lastP2PState = Win64LceLiveP2P::EP2PState::Idle;
|
||||
const Win64LceLiveP2P::P2PSnapshot p2pSnap = Win64LceLiveP2P::GetP2PSnapshot();
|
||||
|
||||
if (p2pSnap.state == Win64LceLiveP2P::EP2PState::Ready)
|
||||
{
|
||||
const Win64LceLiveSignaling::ESignalingState sigState =
|
||||
Win64LceLiveSignaling::GetSnapshot().state;
|
||||
|
||||
// Host: edge-trigger on first Ready frame (requires active session).
|
||||
if (WinsockNetLayer::IsHosting() &&
|
||||
WinsockNetLayer::IsActive() &&
|
||||
s_lastP2PState != Win64LceLiveP2P::EP2PState::Ready &&
|
||||
sigState == Win64LceLiveSignaling::ESignalingState::Idle)
|
||||
{
|
||||
Win64LceLiveSignaling::HostConnect(
|
||||
p2pSnap.externalIp, p2pSnap.externalPort, p2pSnap.connMethod);
|
||||
|
||||
// Always open the relay channel — same as Xbox Live's TURN allocation.
|
||||
// If the joiner connects directly (UPnP worked on both sides) no game
|
||||
// data ever flows through the relay and the WebSocket sits idle until
|
||||
// the session ends. If the joiner's direct TCP fails the relay is
|
||||
// already waiting; the joiner Tick auto-retries through it without any
|
||||
// user interaction. Cost when unused: ~30 s keep-alive pings only.
|
||||
{
|
||||
const std::string relaySid = Win64LceLiveSignaling::GetSnapshot().sessionId;
|
||||
if (!relaySid.empty())
|
||||
Win64LceLiveRelay::HostOpen(relaySid, WinsockNetLayer::GetHostPort());
|
||||
}
|
||||
}
|
||||
// Joiner: level-trigger — fires every Ready frame until pending ID consumed.
|
||||
// No IsActive() guard: the game may still be mid-TCP-handshake through the
|
||||
// relay proxy at the exact millisecond STUN resolves.
|
||||
else if (!WinsockNetLayer::IsHosting() &&
|
||||
sigState == Win64LceLiveSignaling::ESignalingState::Idle)
|
||||
{
|
||||
const std::string pendingId =
|
||||
Win64LceLiveSignaling::GetPendingJoinerSessionId();
|
||||
if (!pendingId.empty())
|
||||
{
|
||||
Win64LceLiveSignaling::JoinerConnect(
|
||||
pendingId,
|
||||
p2pSnap.externalIp, p2pSnap.externalPort,
|
||||
p2pSnap.connMethod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s_lastP2PState = p2pSnap.state;
|
||||
}
|
||||
|
||||
// Relay auto-fallback (joiner side only).
|
||||
// Mirrors Xbox Live's TURN fallback: if the direct TCP attempt to the host
|
||||
// fails and we pre-opened a relay proxy port, automatically retry through
|
||||
// the relay — no user interaction required.
|
||||
{
|
||||
static WinsockNetLayer::eJoinState s_lastJoinState = WinsockNetLayer::eJoinState_Idle;
|
||||
const WinsockNetLayer::eJoinState joinState = WinsockNetLayer::GetJoinState();
|
||||
|
||||
if (joinState == WinsockNetLayer::eJoinState_Failed &&
|
||||
s_lastJoinState != WinsockNetLayer::eJoinState_Failed &&
|
||||
g_LceLiveRelayFallbackPort > 0)
|
||||
{
|
||||
// Direct TCP failed — silently retry via the relay proxy that was
|
||||
// already opened when the invite was accepted.
|
||||
const int fallbackPort = g_LceLiveRelayFallbackPort;
|
||||
g_LceLiveRelayFallbackPort = 0;
|
||||
WinsockNetLayer::BeginJoinGame("127.0.0.1", fallbackPort);
|
||||
}
|
||||
|
||||
s_lastJoinState = joinState;
|
||||
}
|
||||
|
||||
// LeaderboardManager::Instance()->Tick();
|
||||
// Render game graphics.
|
||||
if(app.GetGameStarted())
|
||||
@@ -1952,6 +2049,10 @@ int APIENTRY _tWinMain(_In_ HINSTANCE hInstance,
|
||||
}
|
||||
|
||||
// Free resources, unregister custom classes, and exit.
|
||||
Win64LceLiveSignaling::Close(); // close signaling WS if still open
|
||||
Win64LceLiveP2P::HostClose(); // joins background STUN thread if still running
|
||||
Win64LceLiveRelay::Close(); // tears down relay forwarding threads
|
||||
LceLog::Shutdown(); // flush and close latest.log
|
||||
// app.Uninit();
|
||||
g_pd3dDevice->Release();
|
||||
}
|
||||
|
||||
@@ -352,6 +352,14 @@ set(_MINECRAFT_CLIENT_WINDOWS_WINDOWS64
|
||||
"${BASE_DIR}/Windows64_App.h"
|
||||
"${BASE_DIR}/Windows64_LceLive.cpp"
|
||||
"${BASE_DIR}/Windows64_LceLive.h"
|
||||
"${BASE_DIR}/Windows64_LceLiveP2P.cpp"
|
||||
"${BASE_DIR}/Windows64_LceLiveP2P.h"
|
||||
"${BASE_DIR}/Windows64_LceLiveSignaling.cpp"
|
||||
"${BASE_DIR}/Windows64_LceLiveSignaling.h"
|
||||
"${BASE_DIR}/Windows64_LceLiveRelay.cpp"
|
||||
"${BASE_DIR}/Windows64_LceLiveRelay.h"
|
||||
"${BASE_DIR}/Windows64_Log.cpp"
|
||||
"${BASE_DIR}/Windows64_Log.h"
|
||||
"${BASE_DIR}/Windows64_UIController.cpp"
|
||||
"${BASE_DIR}/Windows64_UIController.h"
|
||||
"${BASE_DIR}/KeyboardMouseInput.cpp"
|
||||
|
||||
4
build-release-exclude.txt
Normal file
4
build-release-exclude.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
.pdb
|
||||
.ilk
|
||||
.lib
|
||||
.exp
|
||||
42
build-release.bat
Normal file
42
build-release.bat
Normal file
@@ -0,0 +1,42 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "DEST=E:\Minecraft.Client"
|
||||
set "DEBUG_DIR=%~dp0build\windows64\Minecraft.Client\Debug"
|
||||
set "RELEASE_EXE=%~dp0build\windows64\Minecraft.Client\Release\Minecraft.Client.exe"
|
||||
|
||||
echo [1/3] Building Minecraft.Client (Release)...
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat" -arch=x64 >nul 2>&1
|
||||
cmake --build --preset windows64-release --target Minecraft.Client
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo BUILD FAILED.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/3] Syncing game data to %DEST%...
|
||||
if not exist "%DEST%" mkdir "%DEST%"
|
||||
|
||||
xcopy "%DEBUG_DIR%\*" "%DEST%\" /E /Y /I /EXCLUDE:%~dp0build-release-exclude.txt >nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo XCOPY FAILED.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [3/3] Copying Release exe over Debug exe...
|
||||
copy /y "%RELEASE_EXE%" "%DEST%\Minecraft.Client.exe"
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo COPY FAILED.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Done. Game folder ready at %DEST%
|
||||
pause
|
||||
Reference in New Issue
Block a user