v1.3.0.1 (Epic Store release)

This commit is contained in:
Regalis11
2024-03-28 18:34:33 +02:00
parent 81ca8637be
commit 3791670c42
269 changed files with 13160 additions and 2966 deletions
@@ -0,0 +1,266 @@
#nullable enable
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Barotrauma;
namespace EosInterfacePrivate;
public static class AchievementsPrivate
{
public static async Task<Result<uint, EosInterface.AchievementUnlockError>> UnlockAchievements(params Identifier[] achievements)
{
if (CorePrivate.AchievementsInterface is not { } achievementsInterface) { return Result.Failure(EosInterface.AchievementUnlockError.EosNotInitialized); }
var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids();
if (loggedInUsers is not { Length: > 0 })
{
return Result.Failure(EosInterface.AchievementUnlockError.InvalidUser);
}
var loggedInUser = loggedInUsers[0];
var achievementUnlockWaiter = new CallbackWaiter<Epic.OnlineServices.Achievements.OnUnlockAchievementsCompleteCallbackInfo>();
var options = new Epic.OnlineServices.Achievements.UnlockAchievementsOptions
{
AchievementIds = achievements.Select(static i => new Epic.OnlineServices.Utf8String(i.Value.ToLowerInvariant())).ToArray(),
UserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value)
};
achievementsInterface.UnlockAchievements(options: ref options, clientData: null, completionDelegate: achievementUnlockWaiter.OnCompletion);
var resultOption = await achievementUnlockWaiter.Task;
if (!resultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.AchievementUnlockError.TimedOut);
}
return callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.Success => Result.Success(callbackResult.AchievementsCount),
Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.AchievementUnlockError.InvalidParameters),
Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.AchievementUnlockError.InvalidUser),
Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.AchievementUnlockError.NotFound),
var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.AchievementUnlockError.Unknown))
};
}
public static async Task<Result<ImmutableDictionary<AchievementStat, int>, EosInterface.QueryStatsError>> QueryStats(ImmutableArray<AchievementStat> stats)
{
if (CorePrivate.StatsInterface is not { } statsInterface) { return Result.Failure(EosInterface.QueryStatsError.EosNotInitialized); }
var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids();
if (loggedInUsers is not { Length: > 0 })
{
return Result.Failure(EosInterface.QueryStatsError.InvalidUser);
}
var loggedInUser = loggedInUsers[0];
var convertedUserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value);
var options = new Epic.OnlineServices.Stats.QueryStatsOptions
{
LocalUserId = convertedUserId,
TargetUserId = convertedUserId,
StatNames = stats.Any()
? stats.Select(static s => new Epic.OnlineServices.Utf8String(s.ToIdentifier().Value.ToLowerInvariant())).ToArray()
: default
};
var queryWaiter = new CallbackWaiter<Epic.OnlineServices.Stats.OnQueryStatsCompleteCallbackInfo>();
statsInterface.QueryStats(options: ref options, clientData: null, completionDelegate: queryWaiter.OnCompletion);
var resultOption = await queryWaiter.Task;
if (!resultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.QueryStatsError.TimedOut);
}
if (callbackResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.QueryStatsError.InvalidParameters),
Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.QueryStatsError.InvalidUser),
Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.QueryStatsError.NotFound),
var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.QueryStatsError.Unknown))
};
}
var builder = ImmutableDictionary.CreateBuilder<AchievementStat, int>();
if (stats.Length is 0)
{
var countOptions = new Epic.OnlineServices.Stats.GetStatCountOptions
{
TargetUserId = convertedUserId
};
uint count = statsInterface.GetStatsCount(ref countOptions);
for (uint i = 0; i < count; i++)
{
var copyIndexOptions = new Epic.OnlineServices.Stats.CopyStatByIndexOptions
{
TargetUserId = convertedUserId,
StatIndex = i
};
var copyResult = statsInterface.CopyStatByIndex(ref copyIndexOptions, out var statOut);
if (copyResult is Epic.OnlineServices.Result.Success && statOut is { Name: var name, Value: var value })
{
builder.Add(AchievementStatExtension.FromIdentifier(new Identifier(name)), value);
}
}
}
else
{
foreach (AchievementStat stat in stats)
{
var copyOptions = new Epic.OnlineServices.Stats.CopyStatByNameOptions
{
TargetUserId = convertedUserId,
Name = new Epic.OnlineServices.Utf8String(stat.ToString().ToLowerInvariant())
};
var copyResult = statsInterface.CopyStatByName(ref copyOptions, out var statOut);
if (copyResult is Epic.OnlineServices.Result.Success && statOut is { Name: var name, Value: var value })
{
builder.Add(AchievementStatExtension.FromIdentifier(new Identifier(name)), value);
}
}
}
return Result.Success(builder.ToImmutable());
}
public static async Task<Result<ImmutableDictionary<Identifier, double>, EosInterface.QueryAchievementsError>> QueryPlayerAchievements()
{
if (CorePrivate.AchievementsInterface is not { } achievementsInterface) { return Result.Failure(EosInterface.QueryAchievementsError.EosNotInitialized); }
var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids();
if (loggedInUsers is not { Length: > 0 })
{
return Result.Failure(EosInterface.QueryAchievementsError.InvalidUser);
}
var loggedInUser = loggedInUsers[0];
var convertedUserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value);
var options = new Epic.OnlineServices.Achievements.QueryPlayerAchievementsOptions
{
LocalUserId = convertedUserId,
TargetUserId = convertedUserId
};
var queryWaiter = new CallbackWaiter<Epic.OnlineServices.Achievements.OnQueryPlayerAchievementsCompleteCallbackInfo>();
achievementsInterface.QueryPlayerAchievements(options: ref options, clientData: null, completionDelegate: queryWaiter.OnCompletion);
var resultOption = await queryWaiter.Task;
if (!resultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.QueryAchievementsError.TimedOut);
}
if (callbackResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.QueryAchievementsError.InvalidParameters),
Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.QueryAchievementsError.InvalidUser),
Epic.OnlineServices.Result.InvalidProductUserID => Result.Failure(EosInterface.QueryAchievementsError.InvalidProductUserID),
Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.QueryAchievementsError.NotFound),
var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.QueryAchievementsError.Unknown))
};
}
var countOptions = new Epic.OnlineServices.Achievements.GetPlayerAchievementCountOptions
{
UserId = convertedUserId
};
uint count = achievementsInterface.GetPlayerAchievementCount(ref countOptions);
var builder = ImmutableDictionary.CreateBuilder<Identifier, double>();
for (uint i = 0; i < count; i++)
{
var copyIndexOptions = new Epic.OnlineServices.Achievements.CopyPlayerAchievementByIndexOptions
{
TargetUserId = convertedUserId,
LocalUserId = convertedUserId,
AchievementIndex = i
};
var copyResult = achievementsInterface.CopyPlayerAchievementByIndex(ref copyIndexOptions, out var achievementOut);
if (copyResult is Epic.OnlineServices.Result.Success && achievementOut is { AchievementId: var name, Progress: var value })
{
builder.Add(new Identifier(name), value);
}
}
return Result.Success(builder.ToImmutable());
}
public static async Task<Result<Unit, EosInterface.IngestStatError>> IngestStats(params (AchievementStat Stat, int IngestAmount)[] stats)
{
if (CorePrivate.StatsInterface is not { } statsInterface) { return Result.Failure(EosInterface.IngestStatError.EosNotInitialized); }
var loggedInUsers = IdQueriesPrivate.GetLoggedInPuids();
if (loggedInUsers is not { Length: > 0 })
{
return Result.Failure(EosInterface.IngestStatError.InvalidUser);
}
var loggedInUser = loggedInUsers[0];
var convertedUserId = Epic.OnlineServices.ProductUserId.FromString(loggedInUser.Value);
var options = new Epic.OnlineServices.Stats.IngestStatOptions
{
LocalUserId = convertedUserId,
TargetUserId = convertedUserId,
Stats = stats.Select(static s => new Epic.OnlineServices.Stats.IngestData
{
StatName = s.Stat.ToString().ToLowerInvariant(),
IngestAmount = s.IngestAmount
}).ToArray()
};
var ingestStatWaiter = new CallbackWaiter<Epic.OnlineServices.Stats.IngestStatCompleteCallbackInfo>();
statsInterface.IngestStat(options: ref options, clientData: null, completionDelegate: ingestStatWaiter.OnCompletion);
var resultOption = await ingestStatWaiter.Task;
if (!resultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.IngestStatError.TimedOut);
}
return callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.Success => Result.Success(Unit.Value),
Epic.OnlineServices.Result.InvalidParameters => Result.Failure(EosInterface.IngestStatError.InvalidParameters),
Epic.OnlineServices.Result.InvalidUser => Result.Failure(EosInterface.IngestStatError.InvalidUser),
Epic.OnlineServices.Result.NotFound => Result.Failure(EosInterface.IngestStatError.NotFound),
var unhandled => Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.IngestStatError.Unknown))
};
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Result<uint, EosInterface.AchievementUnlockError>> UnlockAchievements(params Identifier[] achievementIds)
=> TaskScheduler.Schedule(() => AchievementsPrivate.UnlockAchievements(achievementIds));
public override Task<Result<Unit, EosInterface.IngestStatError>> IngestStats(params (AchievementStat Stat, int IngestAmount)[] stats)
=> TaskScheduler.Schedule(() => AchievementsPrivate.IngestStats(stats));
public override Task<Result<ImmutableDictionary<AchievementStat, int>, EosInterface.QueryStatsError>> QueryStats(ImmutableArray<AchievementStat> stats)
=> TaskScheduler.Schedule(() => AchievementsPrivate.QueryStats(stats));
public override Task<Result<ImmutableDictionary<Identifier, double>, EosInterface.QueryAchievementsError>> QueryPlayerAchievements()
=> TaskScheduler.Schedule(AchievementsPrivate.QueryPlayerAchievements);
}
@@ -0,0 +1,176 @@
#nullable enable
using System;
using Barotrauma.Debugging;
using Microsoft.Xna.Framework;
using Barotrauma;
namespace EosInterfacePrivate;
static class CorePrivate
{
public static EosInterface.Core.Status CurrentStatus
=> platformInterface is null
? EosInterface.Core.Status.NotInitialized
: platformInterface.GetNetworkStatus() == Epic.OnlineServices.Platform.NetworkStatus.Online
? EosInterface.Core.Status.Online
: EosInterface.Core.Status.InitializedButOffline;
private static Epic.OnlineServices.Platform.Options platformInterfaceOptions;
public static Epic.OnlineServices.Platform.Options PlatformInterfaceOptions => platformInterfaceOptions;
private static Epic.OnlineServices.Platform.PlatformInterface? platformInterface;
public static Epic.OnlineServices.Platform.PlatformInterface? PlatformInterface => platformInterface;
public static Epic.OnlineServices.Connect.ConnectInterface? ConnectInterface
=> PlatformInterface?.GetConnectInterface();
public static Epic.OnlineServices.Auth.AuthInterface? EgsAuthInterface
=> PlatformInterface?.GetAuthInterface();
public static Epic.OnlineServices.Friends.FriendsInterface? EgsFriendsInterface
=> PlatformInterface?.GetFriendsInterface();
public static Epic.OnlineServices.UserInfo.UserInfoInterface? EgsUserInfoInterface
=> PlatformInterface?.GetUserInfoInterface();
public static Epic.OnlineServices.Presence.PresenceInterface? EgsPresenceInterface
=> PlatformInterface?.GetPresenceInterface();
public static Epic.OnlineServices.CustomInvites.CustomInvitesInterface? EgsCustomInvitesInterface
=> PlatformInterface?.GetCustomInvitesInterface();
public static Epic.OnlineServices.UI.UIInterface? EgsUiInterface
=> PlatformInterface?.GetUIInterface();
public static Epic.OnlineServices.Sessions.SessionsInterface? SessionsInterface
=> PlatformInterface?.GetSessionsInterface();
public static Epic.OnlineServices.P2P.P2PInterface? P2PInterface
=> PlatformInterface?.GetP2PInterface();
public static Epic.OnlineServices.Achievements.AchievementsInterface? AchievementsInterface
=> PlatformInterface?.GetAchievementsInterface();
public static Epic.OnlineServices.Stats.StatsInterface? StatsInterface
=> PlatformInterface?.GetStatsInterface();
public static Epic.OnlineServices.Ecom.EcomInterface? EcomInterface
=> PlatformInterface?.GetEcomInterface();
public static Result<Unit, EosInterface.Core.InitError> Init(ImplementationPrivate implementation, EosInterface.ApplicationCredentials applicationCredentials, bool enableOverlay)
{
var initializeOptions = new Epic.OnlineServices.Platform.InitializeOptions
{
ProductName = "Barotrauma",
ProductVersion = GameVersion.CurrentVersion.ToString(),
SystemInitializeOptions = IntPtr.Zero,
OverrideThreadAffinity = null,
AllocateMemoryFunction = IntPtr.Zero,
ReallocateMemoryFunction = IntPtr.Zero,
ReleaseMemoryFunction = IntPtr.Zero
};
var result = Epic.OnlineServices.Platform.PlatformInterface.Initialize(ref initializeOptions);
Console.WriteLine(
$"{nameof(Epic.OnlineServices.Platform.PlatformInterface)}.{nameof(Epic.OnlineServices.Platform.PlatformInterface.Initialize)} result: {result}");
platformInterfaceOptions = PlatformInterfaceOptionsPrivate.PlatformOptions[applicationCredentials];
if (enableOverlay)
{
// Some caveats:
// - Currently the overlay is not implemented on non-Windows platforms
// - If you try to initialize EOS after the window has already been created,
// enabling the overlay will result in a crash
// - The overlay doesn't do anything if you do not log into an Epic account
platformInterfaceOptions.Flags = Epic.OnlineServices.Platform.PlatformFlags.None;
}
platformInterface = Epic.OnlineServices.Platform.PlatformInterface.Create(ref platformInterfaceOptions);
if (ConnectInterface != null)
{
LoginPrivate.Init();
}
if (platformInterface is null) { return Result.Failure(EosInterface.Core.InitError.PlatformInterfaceNotCreated); }
PresencePrivate.Init(implementation);
var setLogCallbackResult = Epic.OnlineServices.Logging.LoggingInterface.SetCallback(LogCallback);
if (setLogCallbackResult == Epic.OnlineServices.Result.Success)
{
Epic.OnlineServices.Logging.LoggingInterface.SetLogLevel(
Epic.OnlineServices.Logging.LogCategory.AllCategories,
Epic.OnlineServices.Logging.LogLevel.VeryVerbose);
}
return Result.Success(default(Unit));
}
private static void LogCallback(ref Epic.OnlineServices.Logging.LogMessage msg)
{
DebugConsoleCore.Log($"[EOS {msg.Category} {msg.Level}] {msg.Message}");
}
public static Result<EosInterface.Core.WillRestartThroughLauncher, EosInterface.Core.CheckForLauncherAndRestartError> CheckForLauncherAndRestart()
{
if (platformInterface is null) { return Result.Failure(EosInterface.Core.CheckForLauncherAndRestartError.EosNotInitialized); }
var result = platformInterface.CheckForLauncherAndRestart();
if (result == Epic.OnlineServices.Result.Success) { return Result.Success(EosInterface.Core.WillRestartThroughLauncher.Yes); }
if (result == Epic.OnlineServices.Result.NoChange) { return Result.Success(EosInterface.Core.WillRestartThroughLauncher.No); }
return Result.Failure(result switch
{
Epic.OnlineServices.Result.UnexpectedError
=> EosInterface.Core.CheckForLauncherAndRestartError.UnexpectedError,
_
=> result.FailAndLogUnhandledError(EosInterface.Core.CheckForLauncherAndRestartError.UnhandledErrorCondition)
});
}
private static EosInterface.Core.Status prevTickStatus = EosInterface.Core.Status.NotInitialized;
public static void Update()
{
platformInterface?.Tick();
var currentStatus = CurrentStatus;
if (currentStatus == EosInterface.Core.Status.Online && prevTickStatus != currentStatus)
{
// We were offline, but now we are back online so let's update all sessions
OwnedSessionsPrivate.ForceUpdateAllOwnedSessions();
}
prevTickStatus = currentStatus;
}
public static void Quit()
{
PresencePrivate.Quit();
platformInterface?.Release();
platformInterface = null;
Epic.OnlineServices.Platform.PlatformInterface.Shutdown();
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override EosInterface.Core.Status CurrentStatus => CorePrivate.CurrentStatus;
public override string NativeLibraryName => Epic.OnlineServices.Config.LibraryName;
public override Result<Unit, EosInterface.Core.InitError> Init(EosInterface.ApplicationCredentials applicationCredentials, bool enableOverlay)
=> CorePrivate.Init(this, applicationCredentials, enableOverlay);
public override Result<EosInterface.Core.WillRestartThroughLauncher, EosInterface.Core.CheckForLauncherAndRestartError> CheckForLauncherAndRestart()
=> CorePrivate.CheckForLauncherAndRestart();
public override void Quit()
=> CorePrivate.Quit();
public override void Update()
{
CorePrivate.Update();
TaskScheduler.RunOnCurrentThread();
}
}
@@ -0,0 +1,67 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace EosInterfacePrivate;
internal sealed partial class ImplementationPrivate : Barotrauma.EosInterface.Implementation
{
/// <summary>
/// Custom TaskScheduler to force every EOS-related task to run on the main thread, because
/// the docs say the SDK is not thread-safe even though it's worked fine without this :/
///
/// See https://dev.epicgames.com/docs/epic-online-services/eos-get-started/eossdkc-sharp-getting-started#threading
/// </summary>
internal sealed class CustomTaskScheduler : TaskScheduler
{
private readonly ConcurrentQueue<Task> taskQueue = new ConcurrentQueue<Task>();
internal Task<T> Schedule<T>(Func<Task<T>> action)
{
return
Task.Factory.StartNew(
function: action,
cancellationToken: CancellationToken.None,
creationOptions: TaskCreationOptions.None,
scheduler: this).Unwrap();
}
internal Task Schedule(Func<Task> action)
{
return
Task.Factory.StartNew(
function: action,
cancellationToken: CancellationToken.None,
creationOptions: TaskCreationOptions.None,
scheduler: this).Unwrap();
}
internal void RunOnCurrentThread()
{
while (taskQueue.TryDequeue(out var task))
{
TryExecuteTask(task);
}
}
protected override IEnumerable<Task> GetScheduledTasks()
=> Enumerable.Empty<Task>();
protected override void QueueTask(Task task)
{
taskQueue.Enqueue(task);
}
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
// Never allow executing inline because that means it's not the main thread
return false;
}
}
internal readonly CustomTaskScheduler TaskScheduler = new CustomTaskScheduler();
}
@@ -0,0 +1,249 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading.Tasks;
using Barotrauma.Networking;
using Barotrauma;
using Barotrauma.Extensions;
namespace EosInterfacePrivate;
static class FriendsPrivate
{
internal static async Task<Result<Epic.OnlineServices.UserInfo.UserInfoData, EosInterface.Friends.GetFriendsError>> GetUserInfoData(
EpicAccountId selfEaid, EpicAccountId friendEaid)
{
if (CorePrivate.EgsUserInfoInterface is not { } egsUserInfoInterface) { return Result.Failure(EosInterface.Friends.GetFriendsError.EosNotInitialized); }
var selfEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(selfEaid.EosStringRepresentation);
var friendEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(friendEaid.EosStringRepresentation);
var queryUserInfoOptions = new Epic.OnlineServices.UserInfo.QueryUserInfoOptions
{
LocalUserId = selfEaidInternal,
TargetUserId = friendEaidInternal
};
var queryUserInfoWaiter = new CallbackWaiter<Epic.OnlineServices.UserInfo.QueryUserInfoCallbackInfo>();
egsUserInfoInterface.QueryUserInfo(options: ref queryUserInfoOptions, clientData: null, completionDelegate: queryUserInfoWaiter.OnCompletion);
var queryUserInfoResult = await queryUserInfoWaiter.Task;
if (!queryUserInfoResult.TryUnwrap(out var queryUserInfo))
{
return Result.Failure(EosInterface.Friends.GetFriendsError.UserInfoQueryTimedOut);
}
if (queryUserInfo.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.UserInfoQueryFailed);
}
var copyUserInfoOptions = new Epic.OnlineServices.UserInfo.CopyUserInfoOptions
{
LocalUserId = selfEaidInternal,
TargetUserId = friendEaidInternal
};
var copyUserInfoResult = egsUserInfoInterface.CopyUserInfo(ref copyUserInfoOptions, out var friendInfoNullable);
if (copyUserInfoResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.CopyUserInfoFailed);
}
if (friendInfoNullable is not { } friendInfo)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.CopyUserInfoFailed);
}
string displayName = friendInfo.Nickname ?? friendInfo.DisplayName ?? "";
if (string.IsNullOrEmpty(displayName))
{
return Result.Failure(EosInterface.Friends.GetFriendsError.DisplayNameIsEmpty);
}
return Result.Success(friendInfo);
}
internal static async Task<Result<EosInterface.EgsFriend, EosInterface.Friends.GetFriendsError>> GetPresenceFromUserInfoData(
EpicAccountId selfEaid, EpicAccountId friendEaid, Epic.OnlineServices.UserInfo.UserInfoData friendInfo)
{
if (CorePrivate.EgsPresenceInterface is not { } egsPresenceInterface) { return Result.Failure(EosInterface.Friends.GetFriendsError.EosNotInitialized); }
var selfEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(selfEaid.EosStringRepresentation);
var friendEaidInternal = friendInfo.UserId;
string displayName = friendInfo.Nickname ?? friendInfo.DisplayName ?? "";
var queryPresenceOptions = new Epic.OnlineServices.Presence.QueryPresenceOptions
{
LocalUserId = selfEaidInternal,
TargetUserId = friendEaidInternal
};
var queryPresenceWaiter = new CallbackWaiter<Epic.OnlineServices.Presence.QueryPresenceCallbackInfo>();
egsPresenceInterface.QueryPresence(options: ref queryPresenceOptions, clientData: null, completionDelegate: queryPresenceWaiter.OnCompletion);
var queryPresenceResult = await queryPresenceWaiter.Task;
if (!queryPresenceResult.TryUnwrap(out var queryPresence))
{
return Result.Failure(EosInterface.Friends.GetFriendsError.EgsPresenceQueryTimedOut);
}
if (queryPresence.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.EgsPresenceQueryFailed);
}
var copyPresenceOptions = new Epic.OnlineServices.Presence.CopyPresenceOptions
{
LocalUserId = selfEaidInternal,
TargetUserId = friendEaidInternal
};
var copyPresenceResult = egsPresenceInterface.CopyPresence(ref copyPresenceOptions, out var friendPresenceNullable);
if (copyPresenceResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.CopyPresenceFailed);
}
if (friendPresenceNullable is not { } friendPresence)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.CopyPresenceFailed);
}
string productId = friendPresence.ProductId ?? "";
var friendStatus = friendPresence.Status switch
{
Epic.OnlineServices.Presence.Status.Offline
=> FriendStatus.Offline,
_
=> productId == PlatformInterfaceOptionsPrivate.BasePlatformInterfaceOptions.ProductId
? FriendStatus.PlayingBarotrauma
: !string.IsNullOrEmpty(productId)
? FriendStatus.PlayingAnotherGame
: FriendStatus.NotPlaying
};
var records = friendPresence.Records ?? Array.Empty<Epic.OnlineServices.Presence.DataRecord>();
string getRecordValue(string key)
=> records
.FirstOrNone(r => string.Equals(r.Key, key, StringComparison.OrdinalIgnoreCase))
.Select(r => r.Value)
.Fallback("");
var connectCommand = getRecordValue("connectcommand");
var serverName = getRecordValue("servername");
return Result.Success(new EosInterface.EgsFriend(
DisplayName: displayName,
EpicAccountId: friendEaid,
Status: friendStatus,
ConnectCommand: connectCommand,
ServerName: serverName));
}
public static async Task<Result<EosInterface.EgsFriend, EosInterface.Friends.GetFriendsError>> GetSelfUserInfo(EpicAccountId epicAccount)
{
var getUserInfoDataResult = await GetUserInfoData(epicAccount, epicAccount);
if (getUserInfoDataResult.TryUnwrapFailure(out var error))
{
return Result.Failure(error);
}
if (!getUserInfoDataResult.TryUnwrapSuccess(out var friendInfo))
{
throw new UnreachableCodeException();
}
string displayName = friendInfo.Nickname ?? friendInfo.DisplayName ?? "";
return Result.Success(new EosInterface.EgsFriend(
DisplayName: displayName,
EpicAccountId: epicAccount,
Status: FriendStatus.PlayingBarotrauma,
ConnectCommand: "",
ServerName: ""));
}
public static async Task<Result<EosInterface.EgsFriend, EosInterface.Friends.GetFriendsError>> GetFriend(EpicAccountId selfEaid, EpicAccountId friendEaid)
{
var getUserInfoDataResult = await GetUserInfoData(selfEaid, friendEaid);
if (getUserInfoDataResult.TryUnwrapFailure(out var error))
{
return Result.Failure(error);
}
if (!getUserInfoDataResult.TryUnwrapSuccess(out var friendInfo))
{
throw new UnreachableCodeException();
}
return await GetPresenceFromUserInfoData(selfEaid, friendEaid, friendInfo);
}
public static async Task<Result<ImmutableArray<EosInterface.EgsFriend>, EosInterface.Friends.GetFriendsError>> GetFriends(EpicAccountId epicAccount)
{
if (CorePrivate.EgsFriendsInterface is not { } egsFriendsInterface) { return Result.Failure(EosInterface.Friends.GetFriendsError.EosNotInitialized); }
var selfEaidInternal = Epic.OnlineServices.EpicAccountId.FromString(epicAccount.EosStringRepresentation);
var queryFriendsOptions = new Epic.OnlineServices.Friends.QueryFriendsOptions
{
LocalUserId = selfEaidInternal
};
var queryFriendsWaiter = new CallbackWaiter<Epic.OnlineServices.Friends.QueryFriendsCallbackInfo>();
egsFriendsInterface.QueryFriends(options: ref queryFriendsOptions, clientData: null, completionDelegate: queryFriendsWaiter.OnCompletion);
var queryFriendsInfoResult = await queryFriendsWaiter.Task;
if (!queryFriendsInfoResult.TryUnwrap(out var queryFriendsInfo))
{
return Result.Failure(EosInterface.Friends.GetFriendsError.EgsFriendsQueryTimedOut);
}
if (queryFriendsInfo.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Friends.GetFriendsError.EgsFriendsQueryFailed);
}
var getFriendsCountOptions = new Epic.OnlineServices.Friends.GetFriendsCountOptions
{
LocalUserId = selfEaidInternal
};
var friendCount = egsFriendsInterface.GetFriendsCount(ref getFriendsCountOptions);
var friends = new List<EosInterface.EgsFriend>();
for (int i = 0; i < friendCount; i++)
{
var getFriendAtIndexOptions = new Epic.OnlineServices.Friends.GetFriendAtIndexOptions
{
LocalUserId = selfEaidInternal,
Index = i
};
var friendId = egsFriendsInterface.GetFriendAtIndex(ref getFriendAtIndexOptions);
if (friendId == null)
{
continue;
}
if (!EpicAccountId.Parse(friendId.ToString()).TryUnwrap(out var friendIdPublic))
{
continue;
}
var getUserInfoDataResult = await GetUserInfoData(epicAccount, friendIdPublic);
if (!getUserInfoDataResult.TryUnwrapSuccess(out var friendInfo))
{
continue;
}
var egsFriendPublicResult = await GetPresenceFromUserInfoData(epicAccount, friendIdPublic, friendInfo);
if (!egsFriendPublicResult.TryUnwrapSuccess(out var egsFriendPublic))
{
continue;
}
friends.Add(egsFriendPublic);
}
return Result.Success(friends.ToImmutableArray());
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Result<EosInterface.EgsFriend, EosInterface.Friends.GetFriendsError>> GetFriend(EpicAccountId selfEaid, EpicAccountId friendEaid)
=> TaskScheduler.Schedule(() => FriendsPrivate.GetFriend(selfEaid, friendEaid));
public override Task<Result<ImmutableArray<EosInterface.EgsFriend>, EosInterface.Friends.GetFriendsError>> GetFriends(EpicAccountId epicAccountId)
=> TaskScheduler.Schedule(() => FriendsPrivate.GetFriends(epicAccountId));
public override Task<Result<EosInterface.EgsFriend, EosInterface.Friends.GetFriendsError>> GetSelfUserInfo(EpicAccountId epicAccountId)
=> TaskScheduler.Schedule(() => FriendsPrivate.GetSelfUserInfo(epicAccountId));
}
@@ -0,0 +1,465 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Barotrauma;
using Barotrauma.Extensions;
using EpicAccountId = Barotrauma.Networking.EpicAccountId;
using Result = Barotrauma.Result;
namespace EosInterfacePrivate;
static class PresencePrivate
{
internal static readonly NamedEvent<EosInterface.Presence.JoinGameInfo> OnJoinGame = new NamedEvent<EosInterface.Presence.JoinGameInfo>();
private static ulong joinGameAcceptedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid;
internal static readonly NamedEvent<EosInterface.Presence.AcceptInviteInfo> OnInviteAccepted = new NamedEvent<EosInterface.Presence.AcceptInviteInfo>();
private static ulong inviteAcceptedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid;
private static ulong inviteRejectedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid;
internal static readonly NamedEvent<EosInterface.Presence.ReceiveInviteInfo> OnInviteReceived = new NamedEvent<EosInterface.Presence.ReceiveInviteInfo>();
private static ulong inviteReceivedNotificationId = Epic.OnlineServices.Common.InvalidNotificationid;
public static void Init(ImplementationPrivate implementation)
{
var presenceInterface = CorePrivate.EgsPresenceInterface;
var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface;
if (presenceInterface is null
|| customInvitesInterface is null)
{
return;
}
var boilerplate0 = new Epic.OnlineServices.Presence.AddNotifyJoinGameAcceptedOptions();
joinGameAcceptedNotificationId = presenceInterface.AddNotifyJoinGameAccepted(ref boilerplate0, null, OnJoinGameAcceptedEos);
var boilerplate1 = new Epic.OnlineServices.CustomInvites.AddNotifyCustomInviteAcceptedOptions();
inviteAcceptedNotificationId = customInvitesInterface.AddNotifyCustomInviteAccepted(ref boilerplate1, implementation, OnInviteAcceptedEos);
var boilerplate2 = new Epic.OnlineServices.CustomInvites.AddNotifyCustomInviteRejectedOptions();
inviteRejectedNotificationId = customInvitesInterface.AddNotifyCustomInviteRejected(ref boilerplate2, null, OnInviteRejectedEos);
var boilerplate3 = new Epic.OnlineServices.CustomInvites.AddNotifyCustomInviteReceivedOptions();
inviteReceivedNotificationId = customInvitesInterface.AddNotifyCustomInviteReceived(ref boilerplate3, implementation, OnInviteReceivedEos);
}
public static void Quit()
{
OnJoinGame.Dispose();
OnInviteAccepted.Dispose();
var presenceInterface = CorePrivate.EgsPresenceInterface;
var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface;
if (presenceInterface is null
|| customInvitesInterface is null)
{
return;
}
static void callRemover(Action<ulong> remover, ref ulong id)
{
remover(id);
id = Epic.OnlineServices.Common.InvalidNotificationid;
}
callRemover(presenceInterface.RemoveNotifyJoinGameAccepted, ref joinGameAcceptedNotificationId);
callRemover(customInvitesInterface.RemoveNotifyCustomInviteAccepted, ref inviteAcceptedNotificationId);
callRemover(customInvitesInterface.RemoveNotifyCustomInviteRejected, ref inviteRejectedNotificationId);
callRemover(customInvitesInterface.RemoveNotifyCustomInviteReceived, ref inviteReceivedNotificationId);
}
private static void OnJoinGameAcceptedEos(ref Epic.OnlineServices.Presence.JoinGameAcceptedCallbackInfo data)
{
if (data.UiEventId != Epic.OnlineServices.UI.UIInterface.EventidInvalid)
{
// What is this for? I have no idea.
// Documentation says it's important tho:
// https://dev.epicgames.com/docs/epic-account-services/social-overlay-overview/sdk-integration#invite-lifecycle-and-caveats
var egsUiInterface = CorePrivate.EgsUiInterface;
if (egsUiInterface != null)
{
var ack = new Epic.OnlineServices.UI.AcknowledgeEventIdOptions
{
UiEventId = data.UiEventId,
Result = Epic.OnlineServices.Result.Success
};
egsUiInterface.AcknowledgeEventId(ref ack);
}
}
var selfEpicIdOption = EpicAccountId.Parse(data.LocalUserId.ToString());
if (!selfEpicIdOption.TryUnwrap(out var selfEpicId)) { return; }
var joinCommandStr = data.JoinInfo;
OnJoinGame.Invoke(new EosInterface.Presence.JoinGameInfo(selfEpicId, joinCommandStr));
}
private static void OnInviteAcceptedEos(ref Epic.OnlineServices.CustomInvites.OnCustomInviteAcceptedCallbackInfo data)
{
if (data.LocalUserId is null) { return; }
if (data.ClientData is not ImplementationPrivate implementation) { return; }
RemoveInvite(
recipientPuid: new EosInterface.ProductUserId(data.LocalUserId.ToString()),
senderPuid: new EosInterface.ProductUserId(data.TargetUserId.ToString()));
var joinCommandStr = data.Payload;
var selfPuid = new EosInterface.ProductUserId(data.LocalUserId.ToString());
async Task<Option<EosInterface.Presence.AcceptInviteInfo>> prepareCallbackInfo()
{
var selfExternalAccountIdsTask = IdQueriesPrivate.GetExternalAccountIds(selfPuid, selfPuid);
await Task.WhenAll(selfExternalAccountIdsTask, selfExternalAccountIdsTask);
var selfExternalAccountIdsResult = await selfExternalAccountIdsTask;
if (!selfExternalAccountIdsResult.TryUnwrapSuccess(out var selfExternalAccountIds)
|| !selfExternalAccountIds.OfType<EpicAccountId>().FirstOrNone().TryUnwrap(out var selfEpicAccountId))
{
return Option.None;
}
return Option.Some(new EosInterface.Presence.AcceptInviteInfo(
selfEpicAccountId,
joinCommandStr));
}
TaskPool.Add(
$"AcceptedInviteFor{selfPuid.Value}",
implementation.TaskScheduler.Schedule(prepareCallbackInfo),
t =>
{
if (!t.TryGetResult(out Option<EosInterface.Presence.AcceptInviteInfo> infoOption)) { return; }
if (!infoOption.TryUnwrap(out var info)) { return; }
OnInviteAccepted.Invoke(info);
});
}
private static void OnInviteRejectedEos(ref Epic.OnlineServices.CustomInvites.CustomInviteRejectedCallbackInfo data)
{
if (data.LocalUserId is null) { return; }
RemoveInvite(
recipientPuid: new EosInterface.ProductUserId(data.LocalUserId.ToString()),
senderPuid: new EosInterface.ProductUserId(data.TargetUserId.ToString()));
}
private readonly record struct InviteId(
EpicAccountId RecipientEpicId,
EpicAccountId SenderEpicId,
EosInterface.ProductUserId RecipientPuid,
EosInterface.ProductUserId SenderPuid,
string IdValue);
private static readonly List<InviteId> ReceivedInviteIds = new List<InviteId>();
private static void RemoveInvite(EpicAccountId recipientEpicId, EpicAccountId senderEpicId)
{
RemoveInvites(ReceivedInviteIds.Where(id => id.RecipientEpicId == recipientEpicId && id.SenderEpicId == senderEpicId).ToImmutableArray());
}
private static void RemoveInvite(EosInterface.ProductUserId recipientPuid, EosInterface.ProductUserId senderPuid)
{
RemoveInvites(ReceivedInviteIds.Where(id => id.RecipientPuid == recipientPuid && id.SenderPuid == senderPuid).ToImmutableArray());
}
private static void RemoveInvites(ImmutableArray<InviteId> invites)
{
var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface;
if (customInvitesInterface == null) { return; }
foreach (var invite in invites)
{
ReceivedInviteIds.Remove(invite);
var targetUserId = Epic.OnlineServices.ProductUserId.FromString(invite.SenderPuid.Value);
var localUserId = Epic.OnlineServices.ProductUserId.FromString(invite.RecipientPuid.Value);
var finalizeInviteOptions = new Epic.OnlineServices.CustomInvites.FinalizeInviteOptions
{
TargetUserId = targetUserId,
LocalUserId = localUserId,
CustomInviteId = invite.IdValue,
ProcessingResult = Epic.OnlineServices.Result.Success
};
customInvitesInterface.FinalizeInvite(ref finalizeInviteOptions);
}
}
private static void OnInviteReceivedEos(ref Epic.OnlineServices.CustomInvites.OnCustomInviteReceivedCallbackInfo data)
{
if (data.ClientData is not ImplementationPrivate implementation) { return; }
var joinCommandStr = data.Payload;
var selfPuid = new EosInterface.ProductUserId(data.LocalUserId.ToString());
var senderPuid = new EosInterface.ProductUserId(data.TargetUserId.ToString());
var inviteIdValue = data.CustomInviteId;
// We can only have one invite for the same recipient-sender pair
RemoveInvite(
recipientPuid: selfPuid,
senderPuid: senderPuid);
async Task<Option<EosInterface.Presence.ReceiveInviteInfo>> prepareCallbackInfo()
{
var selfExternalAccountIdsTask = IdQueriesPrivate.GetExternalAccountIds(selfPuid, selfPuid);
var senderExternalAccountIdsTask = IdQueriesPrivate.GetExternalAccountIds(selfPuid, senderPuid);
await Task.WhenAll(selfExternalAccountIdsTask, selfExternalAccountIdsTask);
var selfExternalAccountIdsResult = await selfExternalAccountIdsTask;
var senderExternalAccountIdsResult = await senderExternalAccountIdsTask;
if (!selfExternalAccountIdsResult.TryUnwrapSuccess(out var selfExternalAccountIds)
|| !selfExternalAccountIds.OfType<EpicAccountId>().FirstOrNone().TryUnwrap(out var selfEpicAccountId))
{
return Option.None;
}
if (!senderExternalAccountIdsResult.TryUnwrapSuccess(out var senderExternalAccountIds)
|| !senderExternalAccountIds.OfType<EpicAccountId>().FirstOrNone().TryUnwrap(out var senderEpicAccountId))
{
return Option.None;
}
return Option.Some(new EosInterface.Presence.ReceiveInviteInfo(
selfEpicAccountId,
senderEpicAccountId,
joinCommandStr));
}
TaskPool.Add(
$"ReceivedInviteFrom{senderPuid.Value}",
implementation.TaskScheduler.Schedule(prepareCallbackInfo),
t =>
{
if (!t.TryGetResult(out Option<EosInterface.Presence.ReceiveInviteInfo> infoOption)) { return; }
if (!infoOption.TryUnwrap(out var info)) { return; }
ReceivedInviteIds.Add(new InviteId(
RecipientEpicId: info.RecipientId,
SenderEpicId: info.SenderId,
RecipientPuid: selfPuid,
SenderPuid: senderPuid,
IdValue: inviteIdValue));
OnInviteReceived.Invoke(info);
});
}
public static async Task<Result<Unit, EosInterface.Presence.SetJoinCommandError>> SetJoinCommand(
EpicAccountId epicAccountId,
string desc,
string serverName,
string joinCommand)
{
if (string.IsNullOrWhiteSpace(joinCommand))
{
desc = "";
}
if (desc.Length > Epic.OnlineServices.Presence.PresenceInterface.RichTextMaxValueLength)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.DescTooLong);
}
if (joinCommand.Length > Epic.OnlineServices.Presence.PresenceModification.PresencemodificationJoininfoMaxLength)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.JoinCommandTooLong);
}
if (serverName.Length > Epic.OnlineServices.Presence.PresenceInterface.DataMaxValueLength)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.ServerNameTooLong);
}
if (joinCommand.Length > Epic.OnlineServices.Presence.PresenceInterface.DataMaxValueLength)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.JoinCommandTooLong);
}
using var janitor = Janitor.Start();
var presenceInterface = CorePrivate.EgsPresenceInterface;
var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface;
if (presenceInterface is null
|| customInvitesInterface is null)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.EosNotInitialized);
}
var epicAccountIdInternal = Epic.OnlineServices.EpicAccountId.FromString(epicAccountId.EosStringRepresentation);
var puidResult = await IdQueriesPrivate.GetPuidForExternalId(epicAccountId);
if (!puidResult.TryUnwrapSuccess(out var puid))
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToGetPuid);
}
var puidInternal = Epic.OnlineServices.ProductUserId.FromString(puid.Value);
var setCustomInviteOptions = new Epic.OnlineServices.CustomInvites.SetCustomInviteOptions
{
LocalUserId = puidInternal,
Payload = joinCommand
};
var setCustomInviteResult = customInvitesInterface.SetCustomInvite(ref setCustomInviteOptions);
if (setCustomInviteResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetCustomInvite);
}
var createPresenceModificationOptions = new Epic.OnlineServices.Presence.CreatePresenceModificationOptions
{
LocalUserId = epicAccountIdInternal
};
var createPresenceModificationResult = presenceInterface.CreatePresenceModification(ref createPresenceModificationOptions, out var presenceModification);
janitor.AddAction(presenceModification.Release);
if (createPresenceModificationResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToCreatePresenceModification);
}
var setRichTextOptions = new Epic.OnlineServices.Presence.PresenceModificationSetRawRichTextOptions
{
RichText = desc
};
var setRichTextResult = presenceModification.SetRawRichText(ref setRichTextOptions);
if (setRichTextResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetRichText);
}
var setDataOptions = new Epic.OnlineServices.Presence.PresenceModificationSetDataOptions
{
Records = new[]
{
new Epic.OnlineServices.Presence.DataRecord
{
Key = "servername",
Value = serverName
},
new Epic.OnlineServices.Presence.DataRecord
{
Key = "connectcommand",
Value = joinCommand
}
}
};
var setDataResult = presenceModification.SetData(ref setDataOptions);
if (setDataResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetRecords);
}
// This is necessary to make the SDK not choke if given an empty, but not null, joinCommand
string? joinCommandNullable = string.IsNullOrWhiteSpace(joinCommand) ? null : joinCommand;
var setJoinInfoOptions = new Epic.OnlineServices.Presence.PresenceModificationSetJoinInfoOptions
{
JoinInfo = joinCommandNullable
};
var setJoinInfoResult = presenceModification.SetJoinInfo(ref setJoinInfoOptions);
if (setJoinInfoResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetJoinInfo);
}
var setPresenceOptions = new Epic.OnlineServices.Presence.SetPresenceOptions
{
LocalUserId = epicAccountIdInternal,
PresenceModificationHandle = presenceModification
};
var setPresenceWaiter = new CallbackWaiter<Epic.OnlineServices.Presence.SetPresenceCallbackInfo>();
presenceInterface.SetPresence(options: ref setPresenceOptions, clientData: null, completionDelegate: setPresenceWaiter.OnCompletion);
var setPresenceResultOption = await setPresenceWaiter.Task;
if (!setPresenceResultOption.TryUnwrap(out var setPresenceResult))
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.SetPresenceTimedOut);
}
if (setPresenceResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SetJoinCommandError.FailedToSetPresence);
}
return Result.Success(Unit.Value);
}
public static async Task<Result<Unit, EosInterface.Presence.SendInviteError>> SendInvite(EpicAccountId selfEpicAccountId, EpicAccountId remoteEpicAccountId)
{
var customInvitesInterface = CorePrivate.EgsCustomInvitesInterface;
if (customInvitesInterface is null)
{
return Result.Failure(EosInterface.Presence.SendInviteError.EosNotInitialized);
}
var selfPuidResult = await IdQueriesPrivate.GetPuidForExternalId(selfEpicAccountId);
if (!selfPuidResult.TryUnwrapSuccess(out var selfPuid))
{
return Result.Failure(EosInterface.Presence.SendInviteError.FailedToGetSelfPuid);
}
var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value);
var remotePuidResult = await IdQueriesPrivate.GetPuidForExternalId(remoteEpicAccountId);
if (!remotePuidResult.TryUnwrapSuccess(out var remotePuid))
{
return Result.Failure(EosInterface.Presence.SendInviteError.FailedToGetRemotePuid);
}
var remotePuidInternal = Epic.OnlineServices.ProductUserId.FromString(remotePuid.Value);
var sendCustomInviteOptions = new Epic.OnlineServices.CustomInvites.SendCustomInviteOptions
{
LocalUserId = selfPuidInternal,
TargetUserIds = new[]
{
remotePuidInternal
}
};
var callbackWaiter = new CallbackWaiter<Epic.OnlineServices.CustomInvites.SendCustomInviteCallbackInfo>();
customInvitesInterface.SendCustomInvite(options: ref sendCustomInviteOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion);
var callbackResultOption = await callbackWaiter.Task;
if (!callbackResultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.Presence.SendInviteError.TimedOut);
}
if (callbackResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Presence.SendInviteError.InternalError);
}
return Result.Success(Unit.Value);
}
public static void DeclineInvite(EpicAccountId selfEpicAccountId, EpicAccountId senderEpicAccountId)
{
RemoveInvite(
recipientEpicId: selfEpicAccountId,
senderEpicId: senderEpicAccountId);
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override NamedEvent<EosInterface.Presence.JoinGameInfo> OnJoinGame => PresencePrivate.OnJoinGame;
public override NamedEvent<EosInterface.Presence.AcceptInviteInfo> OnInviteAccepted => PresencePrivate.OnInviteAccepted;
public override NamedEvent<EosInterface.Presence.ReceiveInviteInfo> OnInviteReceived => PresencePrivate.OnInviteReceived;
public override Task<Result<Unit, EosInterface.Presence.SetJoinCommandError>> SetJoinCommand(
EpicAccountId epicAccountId, string desc, string serverName, string joinCommand)
=> TaskScheduler.Schedule(() => PresencePrivate.SetJoinCommand(epicAccountId, desc, serverName, joinCommand));
public override Task<Result<Unit, EosInterface.Presence.SendInviteError>> SendInvite(
EpicAccountId selfEpicAccountId, EpicAccountId remoteEpicAccountId)
=> TaskScheduler.Schedule(() => PresencePrivate.SendInvite(selfEpicAccountId, remoteEpicAccountId));
public override void DeclineInvite(EpicAccountId selfEpicAccountId, EpicAccountId senderEpicAccountId)
=> PresencePrivate.DeclineInvite(selfEpicAccountId, senderEpicAccountId);
}
@@ -0,0 +1,119 @@
#nullable enable
using System.Text.Json;
using System.Threading.Tasks;
using Barotrauma.Networking;
using Barotrauma;
namespace EosInterfacePrivate;
public sealed class EgsIdTokenPrivate : EosInterface.EgsIdToken
{
public override EpicAccountId AccountId { get; }
internal static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions
{
IncludeFields = true
};
internal readonly record struct TokenStruct(
string AccountId,
string JsonWebToken);
internal readonly Epic.OnlineServices.Auth.IdToken InternalToken;
internal EgsIdTokenPrivate(EpicAccountId accountId, Epic.OnlineServices.Auth.IdToken internalToken)
{
AccountId = accountId;
InternalToken = internalToken;
}
public new static Option<EgsIdTokenPrivate> Parse(string str)
{
try
{
if (JsonSerializer.Deserialize(
str,
returnType: typeof(TokenStruct),
options: JsonSerializerOptions)
is not TokenStruct tokenStruct)
{
return Option.None;
}
if (!EpicAccountId.Parse(tokenStruct.AccountId).TryUnwrap(out var accountId)) { return Option.None; }
var internalToken = new Epic.OnlineServices.Auth.IdToken
{
AccountId = Epic.OnlineServices.EpicAccountId.FromString(tokenStruct.AccountId),
JsonWebToken = tokenStruct.JsonWebToken
};
return Option.Some(new EgsIdTokenPrivate(accountId, internalToken));
}
catch
{
return Option.None;
}
}
public override string ToString()
{
var tokenStruct = new TokenStruct(
AccountId: InternalToken.AccountId.ToString(),
JsonWebToken: InternalToken.JsonWebToken);
return JsonSerializer.Serialize(tokenStruct, options: JsonSerializerOptions);
}
public static Result<EosInterface.EgsIdToken, EosInterface.GetEgsSelfIdTokenError> GetEgsIdTokenForEpicAccountId(EpicAccountId accountId)
{
var (success, failure) = Result<EosInterface.EgsIdToken, EosInterface.GetEgsSelfIdTokenError>.GetFactoryMethods();
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return failure(EosInterface.GetEgsSelfIdTokenError.EosNotInitialized); }
var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions
{
AccountId = Epic.OnlineServices.EpicAccountId.FromString(accountId.EosStringRepresentation)
};
var copyIdTokenResult = egsAuthInterface.CopyIdToken(ref copyIdTokenOptions, out var idTokenNullable);
if (copyIdTokenResult is Epic.OnlineServices.Result.NotFound) { return failure(EosInterface.GetEgsSelfIdTokenError.InvalidToken); }
if (copyIdTokenResult != Epic.OnlineServices.Result.Success) { return failure(EosInterface.GetEgsSelfIdTokenError.UnhandledErrorCondition); }
if (idTokenNullable is not { } idToken) { return failure(EosInterface.GetEgsSelfIdTokenError.UnhandledErrorCondition); }
return success(new EgsIdTokenPrivate(accountId, idToken));
}
public override async Task<EosInterface.VerifyEgsIdTokenResult> Verify(AccountId accountId)
{
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return EosInterface.VerifyEgsIdTokenResult.Failed; }
var verifyIdTokenOptions = new Epic.OnlineServices.Auth.VerifyIdTokenOptions
{
IdToken = InternalToken
};
var verifyIdTokenWaiter = new CallbackWaiter<Epic.OnlineServices.Auth.VerifyIdTokenCallbackInfo>();
egsAuthInterface.VerifyIdToken(options: ref verifyIdTokenOptions, clientData: null, completionDelegate: verifyIdTokenWaiter.OnCompletion);
var result = await verifyIdTokenWaiter.Task;
if (!result.TryUnwrap(out var callbackInfo)) { return EosInterface.VerifyEgsIdTokenResult.Failed; }
if (callbackInfo.ResultCode != Epic.OnlineServices.Result.Success
|| callbackInfo.ProductId != CorePrivate.PlatformInterfaceOptions.ProductId)
{
return EosInterface.VerifyEgsIdTokenResult.Failed;
}
var resultAccountId = IdQueriesPrivate.EosStringToAccountId(callbackInfo.ExternalAccountId, callbackInfo.ExternalAccountIdType);
return resultAccountId.TryUnwrap(out var resultId) && resultId == accountId
? EosInterface.VerifyEgsIdTokenResult.Verified
: EosInterface.VerifyEgsIdTokenResult.Failed;
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Option<EosInterface.EgsIdToken> ParseEgsIdToken(string str)
=> EgsIdTokenPrivate.Parse(str).Select(t => (EosInterface.EgsIdToken)t);
public override Result<EosInterface.EgsIdToken, EosInterface.GetEgsSelfIdTokenError> GetEgsIdTokenForEpicAccountId(EpicAccountId accountId)
=> EgsIdTokenPrivate.GetEgsIdTokenForEpicAccountId(accountId);
}
@@ -0,0 +1,73 @@
#nullable enable
using System.Threading.Tasks;
using Barotrauma.Networking;
using Barotrauma;
namespace EosInterfacePrivate;
static class EosIdTokenPrivate
{
public static Result<EosInterface.EosIdToken, EosInterface.GetEosSelfIdTokenError> GetEosIdTokenForProductUserId(EosInterface.ProductUserId puid)
{
var (success, failure) = Result<EosInterface.EosIdToken, EosInterface.GetEosSelfIdTokenError>.GetFactoryMethods();
if (CorePrivate.ConnectInterface is not { } connectInterface) { return failure(EosInterface.GetEosSelfIdTokenError.EosNotInitialized); }
var copyIdTokenOptions = new Epic.OnlineServices.Connect.CopyIdTokenOptions
{
LocalUserId = Epic.OnlineServices.ProductUserId.FromString(puid.Value)
};
var copyIdTokenResult = connectInterface.CopyIdToken(ref copyIdTokenOptions, out var idTokenNullable);
if (copyIdTokenResult is Epic.OnlineServices.Result.NotFound) { return failure(EosInterface.GetEosSelfIdTokenError.InvalidToken); }
if (copyIdTokenResult != Epic.OnlineServices.Result.Success) { return failure(EosInterface.GetEosSelfIdTokenError.UnhandledErrorCondition); }
if (idTokenNullable is not { } idToken) { return failure(EosInterface.GetEosSelfIdTokenError.UnhandledErrorCondition); }
if (!JsonWebToken.Parse(idToken.JsonWebToken).TryUnwrap(out var jsonWebToken)) { return failure(EosInterface.GetEosSelfIdTokenError.CouldNotParseJwt); }
return success(new EosInterface.EosIdToken(new EosInterface.ProductUserId(idToken.ProductUserId.ToString()), jsonWebToken));
}
public static async Task<Result<AccountId, EosInterface.VerifyEosIdTokenError>> Verify(EosInterface.EosIdToken token)
{
if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.VerifyEosIdTokenError.EosNotInitialized); }
var verifyIdTokenOptions = new Epic.OnlineServices.Connect.VerifyIdTokenOptions
{
IdToken = new Epic.OnlineServices.Connect.IdToken
{
ProductUserId = Epic.OnlineServices.ProductUserId.FromString(token.ProductUserId.Value),
JsonWebToken = token.JsonWebToken.ToString()
}
};
var verifyIdTokenWaiter = new CallbackWaiter<Epic.OnlineServices.Connect.VerifyIdTokenCallbackInfo>();
connectInterface.VerifyIdToken(options: ref verifyIdTokenOptions, clientData: null, completionDelegate: verifyIdTokenWaiter.OnCompletion);
var result = await verifyIdTokenWaiter.Task;
if (!result.TryUnwrap(out var callbackInfo)) { return Result.Failure(EosInterface.VerifyEosIdTokenError.TimedOut); }
if (callbackInfo.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.VerifyEosIdTokenError.UnhandledErrorCondition);
}
if (callbackInfo.ProductId != CorePrivate.PlatformInterfaceOptions.ProductId)
{
return Result.Failure(EosInterface.VerifyEosIdTokenError.ProductIdDidNotMatch);
}
var resultAccountId = IdQueriesPrivate.EosStringToAccountId(callbackInfo.AccountId, callbackInfo.AccountIdType);
return resultAccountId.TryUnwrap(out var resultId)
? Result.Success(resultId)
: Result.Failure(EosInterface.VerifyEosIdTokenError.CouldNotParseExternalAccountId);
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Result<AccountId, EosInterface.VerifyEosIdTokenError>> VerifyEosIdToken(EosInterface.EosIdToken token)
=> TaskScheduler.Schedule(() => EosIdTokenPrivate.Verify(token));
public override Result<EosInterface.EosIdToken, EosInterface.GetEosSelfIdTokenError> GetEosIdTokenForProductUserId(EosInterface.ProductUserId puid)
=> EosIdTokenPrivate.GetEosIdTokenForProductUserId(puid);
}
@@ -0,0 +1,222 @@
#nullable enable
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Barotrauma.Extensions;
using Barotrauma.Networking;
using Barotrauma;
namespace EosInterfacePrivate;
static class IdQueriesPrivate
{
public static ImmutableArray<EosInterface.ProductUserId> GetLoggedInPuids()
{
if (CorePrivate.ConnectInterface is not { } connectInterface) { return ImmutableArray<EosInterface.ProductUserId>.Empty; }
int count = connectInterface.GetLoggedInUsersCount();
var ids = new List<EosInterface.ProductUserId>();
foreach (int i in Enumerable.Range(0, count))
{
if (connectInterface.GetLoggedInUserByIndex(i) is not { } userId) { return ImmutableArray<EosInterface.ProductUserId>.Empty; }
var newPuid = new EosInterface.ProductUserId(userId.ToString());
if (!LoginPrivate.PuidToPrimaryExternalId.ContainsKey(newPuid)) { continue; }
ids.Add(newPuid);
}
return ids.ToImmutableArray();
}
public static ImmutableArray<EpicAccountId> GetLoggedInEpicIds()
{
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return ImmutableArray<EpicAccountId>.Empty; }
int count = egsAuthInterface.GetLoggedInAccountsCount();
var ids = new List<EpicAccountId>();
foreach (int i in Enumerable.Range(0, count))
{
if (egsAuthInterface.GetLoggedInAccountByIndex(i) is not { } userId) { return ImmutableArray<EpicAccountId>.Empty; }
var newEpicIdOption = EpicAccountId.Parse(userId.ToString());
if (!newEpicIdOption.TryUnwrap(out var newEpicId)) { return ImmutableArray<EpicAccountId>.Empty; }
ids.Add(newEpicId);
}
return ids.ToImmutableArray();
}
public static Task<Result<ImmutableArray<AccountId>, EosInterface.IdQueries.GetSelfExternalIdError>>
GetSelfExternalAccountIds(
EosInterface.ProductUserId productUserId)
=> GetExternalAccountIds(productUserId, productUserId);
internal static async Task<Result<ImmutableArray<AccountId>, EosInterface.IdQueries.GetSelfExternalIdError>>
GetExternalAccountIds(
EosInterface.ProductUserId selfPuid,
EosInterface.ProductUserId puidToGetIdsFor)
{
// If logged only into an Epic account, you cannot fetch SteamIDs.
// See Epic.OnlineServices.Connect.ExternalAccountInfo.AccountId
var (success, failure) = Result<ImmutableArray<AccountId>, EosInterface.IdQueries.GetSelfExternalIdError>.GetFactoryMethods();
if (CorePrivate.ConnectInterface is not { } connectInterface)
{
return failure(EosInterface.IdQueries.GetSelfExternalIdError.EosNotInitialized);
}
if (!LoginPrivate.PuidToPrimaryExternalId.ContainsKey(selfPuid))
{
return failure(EosInterface.IdQueries.GetSelfExternalIdError.Inaccessible);
}
var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value);
var otherPuidInternal = Epic.OnlineServices.ProductUserId.FromString(puidToGetIdsFor.Value);
var queryProductUserIdMappingsOptions = new Epic.OnlineServices.Connect.QueryProductUserIdMappingsOptions
{
LocalUserId = selfPuidInternal,
ProductUserIds = new[] { otherPuidInternal }
};
var queryWaiter = new CallbackWaiter<Epic.OnlineServices.Connect.QueryProductUserIdMappingsCallbackInfo>();
connectInterface.QueryProductUserIdMappings(options: ref queryProductUserIdMappingsOptions, clientData: null, completionDelegate: queryWaiter.OnCompletion);
var queryResultOption = await queryWaiter.Task;
if (!queryResultOption.TryUnwrap(out var queryResult))
{
return failure(EosInterface.IdQueries.GetSelfExternalIdError.Timeout);
}
if (queryResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return failure(queryResult.ResultCode switch
{
Epic.OnlineServices.Result.NotFound => EosInterface.IdQueries.GetSelfExternalIdError.InvalidUser,
Epic.OnlineServices.Result.InvalidUser => EosInterface.IdQueries.GetSelfExternalIdError.InvalidUser,
var unhandled => unhandled.FailAndLogUnhandledError(EosInterface.IdQueries.GetSelfExternalIdError.UnhandledErrorCondition)
});
}
var getProductUserExternalAccountCountOptions = new Epic.OnlineServices.Connect.GetProductUserExternalAccountCountOptions
{
TargetUserId = otherPuidInternal
};
uint count = connectInterface.GetProductUserExternalAccountCount(ref getProductUserExternalAccountCountOptions);
var accountIds = new AccountId[count];
foreach (int i in Enumerable.Range(0, (int)count))
{
var copyProductUserExternalAccountByIndexOptions = new Epic.OnlineServices.Connect.CopyProductUserExternalAccountByIndexOptions
{
TargetUserId = otherPuidInternal,
ExternalAccountInfoIndex = (uint)i
};
connectInterface.CopyProductUserExternalAccountByIndex(
ref copyProductUserExternalAccountByIndexOptions,
out var externalAccountInfoNullable);
if (!externalAccountInfoNullable.TryGetValue(out var externalAccountInfo))
{
return failure(EosInterface.IdQueries.GetSelfExternalIdError.InvalidUser);
}
var accountIdOption =
EosStringToAccountId(externalAccountInfo.AccountId, externalAccountInfo.AccountIdType);
if (!accountIdOption.TryUnwrap(out var accountId))
{
return failure(EosInterface.IdQueries.GetSelfExternalIdError.ParseError);
}
accountIds[i] = accountId;
}
return success(accountIds.ToImmutableArray());
}
internal static async Task<Result<EosInterface.ProductUserId, Epic.OnlineServices.Result>> GetPuidForExternalId(AccountId externalId)
{
var connectInterface = CorePrivate.ConnectInterface;
if (connectInterface is null)
{
return Result.Failure(Epic.OnlineServices.Result.NotConfigured);
}
var externalAccountType = externalId is EpicAccountId
? Epic.OnlineServices.ExternalAccountType.Epic
: Epic.OnlineServices.ExternalAccountType.Steam;
string externalAccountEosRepresentation = externalId.EosStringRepresentation;
Result<EosInterface.ProductUserId, Epic.OnlineServices.Result> lastError
= Result.Failure(Epic.OnlineServices.Result.UnexpectedError);
foreach (var selfPuid in GetLoggedInPuids()
.OrderByDescending(id => LoginPrivate.PuidToPrimaryExternalId[id].GetType() == externalId.GetType()))
{
var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value);
// See https://dev.epicgames.com/docs/en-US/api-ref/functions/eos-connect-query-external-account-mappings
// to learn why we need to call this function before we call GetExternalAccountMapping
var queryExternalAccountMappingsOptions = new Epic.OnlineServices.Connect.QueryExternalAccountMappingsOptions
{
LocalUserId = selfPuidInternal,
AccountIdType = externalAccountType,
ExternalAccountIds = new Epic.OnlineServices.Utf8String[]
{
externalAccountEosRepresentation
}
};
var queryExternalAccountMappingsWaiter = new CallbackWaiter<Epic.OnlineServices.Connect.QueryExternalAccountMappingsCallbackInfo>();
connectInterface.QueryExternalAccountMappings(options: ref queryExternalAccountMappingsOptions, clientData: null, completionDelegate: queryExternalAccountMappingsWaiter.OnCompletion);
var resultOption = await queryExternalAccountMappingsWaiter.Task;
if (!resultOption.TryUnwrap(out var result))
{
lastError = Result.Failure(Epic.OnlineServices.Result.TimedOut);
continue;
}
if (result.ResultCode != Epic.OnlineServices.Result.Success)
{
lastError = Result.Failure(result.ResultCode);
continue;
}
var getExternalAccountMappingsOptions = new Epic.OnlineServices.Connect.GetExternalAccountMappingsOptions
{
LocalUserId = selfPuidInternal,
AccountIdType = externalAccountType,
TargetExternalUserId = externalAccountEosRepresentation
};
var otherPuid = connectInterface.GetExternalAccountMapping(ref getExternalAccountMappingsOptions);
if (otherPuid is null)
{
lastError = Result.Failure(Epic.OnlineServices.Result.NotFound);
continue;
}
return Result.Success(new EosInterface.ProductUserId(otherPuid.ToString()));
}
return lastError;
}
public static Option<AccountId> EosStringToAccountId(
string stringRepresentation,
Epic.OnlineServices.ExternalAccountType accountType)
=> accountType switch
{
Epic.OnlineServices.ExternalAccountType.Steam => SteamId.Parse(stringRepresentation).Select(id => (AccountId)id),
Epic.OnlineServices.ExternalAccountType.Epic => EpicAccountId.Parse(stringRepresentation).Select(id => (AccountId)id),
_ => Option.None
};
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override ImmutableArray<EosInterface.ProductUserId> GetLoggedInPuids()
=> IdQueriesPrivate.GetLoggedInPuids();
public override ImmutableArray<EpicAccountId> GetLoggedInEpicIds()
=> IdQueriesPrivate.GetLoggedInEpicIds();
public override Task<Result<ImmutableArray<AccountId>, EosInterface.IdQueries.GetSelfExternalIdError>> GetSelfExternalAccountIds(EosInterface.ProductUserId puid)
=> TaskScheduler.Schedule(() => IdQueriesPrivate.GetSelfExternalAccountIds(puid));
}
@@ -0,0 +1,708 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Barotrauma.Debugging;
using Barotrauma.Networking;
using Barotrauma;
namespace EosInterfacePrivate;
static class LoginPrivate
{
private const string EosLoginSteamIdentity = "BarotraumaEosLogin";
private static Option<Steamworks.AuthTicketForWebApi> steamworksAuthTicket;
private static Option<ulong> eosConnectExpirationNotifyId, eosConnectStatusChangedNotifyId;
private static Option<ulong> egsAuthExpirationNotifyId;
internal static void Init()
{
if (CorePrivate.ConnectInterface is not { } connectInterface) { return; }
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return; }
ClearNotificationId(ref egsAuthExpirationNotifyId, egsAuthInterface.RemoveNotifyLoginStatusChanged);
var authExpirationOptions = new Epic.OnlineServices.Auth.AddNotifyLoginStatusChangedOptions();
ulong authExpirationNotifyId = egsAuthInterface.AddNotifyLoginStatusChanged(ref authExpirationOptions, null, OnEgsAuthStatusChanged);
StoreNotificationId(out egsAuthExpirationNotifyId, authExpirationNotifyId);
ClearNotificationId(ref eosConnectExpirationNotifyId, connectInterface.RemoveNotifyAuthExpiration);
var connectExpirationOptions = new Epic.OnlineServices.Connect.AddNotifyAuthExpirationOptions();
ulong connectExpirationNotifyId = connectInterface.AddNotifyAuthExpiration(ref connectExpirationOptions, null, OnConnectExpiration);
StoreNotificationId(out eosConnectExpirationNotifyId, connectExpirationNotifyId);
ClearNotificationId(ref eosConnectStatusChangedNotifyId, connectInterface.RemoveNotifyLoginStatusChanged);
var addNotifyConnectStatusChangedOptions = new Epic.OnlineServices.Connect.AddNotifyLoginStatusChangedOptions();
var connectChangedNotifyId = connectInterface.AddNotifyLoginStatusChanged(ref addNotifyConnectStatusChangedOptions, null, OnConnectStatusChanged);
StoreNotificationId(out eosConnectStatusChangedNotifyId, connectChangedNotifyId);
static void ClearNotificationId(ref Option<ulong> field, Action<ulong> clearAction)
{
if (field.TryUnwrap(out var notificationId))
{
clearAction(notificationId);
}
field = Option.None;
}
static void StoreNotificationId(out Option<ulong> field, ulong value)
{
bool isValid = value is not Epic.OnlineServices.Common.InvalidNotificationid;
field = isValid
? Option.Some(value)
: Option.None;
}
}
internal static readonly ConcurrentDictionary<EosInterface.ProductUserId, AccountId> PuidToPrimaryExternalId = new();
private readonly record struct LoginParams(
Epic.OnlineServices.Connect.Credentials Credentials,
AccountId ExternalAccountId);
private static async Task<Result<LoginParams, EosInterface.Login.LoginError>> GenCredentialsSteam()
{
if (!Steamworks.SteamClient.IsValid || !Steamworks.SteamClient.IsLoggedOn) { return Result.Failure(EosInterface.Login.LoginError.SteamNotLoggedIn); }
if (steamworksAuthTicket.TryUnwrap(out var oldTicket)) { oldTicket.Cancel(); }
var newTicketNullable = await Steamworks.SteamUser.GetAuthTicketForWebApi(EosLoginSteamIdentity);
if (newTicketNullable is not { Data: not null } ticket)
{
return Result.Failure(EosInterface.Login.LoginError.FailedToGetSteamSessionTicket);
}
return Result.Success(
new LoginParams(
Credentials: new Epic.OnlineServices.Connect.Credentials
{
Token = ToolBoxCore.ByteArrayToHexString(ticket.Data),
Type = Epic.OnlineServices.ExternalCredentialType.SteamSessionTicket
},
ExternalAccountId: new SteamId(Steamworks.SteamClient.SteamId)));
}
private static async Task<Result<Either<LoginParams, Epic.OnlineServices.ContinuanceToken>, EosInterface.Login.LoginError>> GenCredentialsEpic(
Epic.OnlineServices.Auth.LoginCredentialType credentialsType,
string? credentialsId,
string? credentialsToken,
Epic.OnlineServices.ExternalCredentialType credentialsExternalType,
EosInterface.Login.LoginEpicFlags flags)
{
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Result.Failure(EosInterface.Login.LoginError.EosNotInitialized); }
if (credentialsType is not (
Epic.OnlineServices.Auth.LoginCredentialType.ExternalAuth
or Epic.OnlineServices.Auth.LoginCredentialType.Developer
or Epic.OnlineServices.Auth.LoginCredentialType.ExchangeCode))
{
return Result.Failure(EosInterface.Login.LoginError.InvalidUser);
}
var authLoginOptions = new Epic.OnlineServices.Auth.LoginOptions
{
Credentials = new Epic.OnlineServices.Auth.Credentials
{
Id = credentialsId,
Token = credentialsToken,
Type = credentialsType,
SystemAuthCredentialsOptions = default,
ExternalType = credentialsExternalType
},
ScopeFlags =
Epic.OnlineServices.Auth.AuthScopeFlags.BasicProfile
| Epic.OnlineServices.Auth.AuthScopeFlags.Presence
| Epic.OnlineServices.Auth.AuthScopeFlags.FriendsList,
LoginFlags = flags.HasFlag(EosInterface.Login.LoginEpicFlags.FailWithoutOpeningBrowser)
? Epic.OnlineServices.Auth.LoginFlags.NoUserInterface
: Epic.OnlineServices.Auth.LoginFlags.None
};
var authLoginWaiter = new CallbackWaiter<Epic.OnlineServices.Auth.LoginCallbackInfo>();
egsAuthInterface.Login(options: ref authLoginOptions, clientData: null, completionDelegate: authLoginWaiter.OnCompletion);
// This can time out if authLoginOptions.ScopeFlags is set incorrectly,
// because the docs lied and this callback isn't guaranteed to be called
var authLoginCallbackInfoOption = await authLoginWaiter.Task;
if (!authLoginCallbackInfoOption.TryUnwrap(out var authLoginCallbackInfo))
{
return Result.Failure(EosInterface.Login.LoginError.EgsLoginTimeout);
}
if (authLoginCallbackInfo is { ResultCode: Epic.OnlineServices.Result.InvalidUser, ContinuanceToken: { } continuanceToken })
{
return Result.Success((Either<LoginParams, Epic.OnlineServices.ContinuanceToken>)continuanceToken);
}
if (authLoginCallbackInfo.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(authLoginCallbackInfo.ResultCode switch {
Epic.OnlineServices.Result.NotFound
=> EosInterface.Login.LoginError.EgsAccountNotFound,
Epic.OnlineServices.Result.AuthExchangeCodeNotFound
=> EosInterface.Login.LoginError.AuthExchangeCodeNotFound,
Epic.OnlineServices.Result.AuthUserInterfaceRequired
=> EosInterface.Login.LoginError.AuthRequiresOpeningBrowser,
Epic.OnlineServices.Result.AccessDenied
=> EosInterface.Login.LoginError.EgsAccessDenied,
_
=> EosInterface.Login.LoginError.UnhandledFailureCondition
});
}
if (!EpicAccountId.Parse(authLoginCallbackInfo.LocalUserId.ToString()).TryUnwrap(out var externalAccountId))
{
return Result.Failure(EosInterface.Login.LoginError.FailedToParseEgsId);
}
var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions
{
AccountId = authLoginCallbackInfo.LocalUserId
};
var tokenCopyResult = egsAuthInterface.CopyIdToken(ref copyIdTokenOptions, out var tokenNullable);
if (tokenCopyResult != Epic.OnlineServices.Result.Success)
{
Result.Failure(EosInterface.Login.LoginError.FailedToGetEgsIdToken);
}
if (tokenNullable is not { } token) { return Result.Failure(EosInterface.Login.LoginError.FailedToGetEgsIdToken); }
return Result.Success(
(Either<LoginParams, Epic.OnlineServices.ContinuanceToken>)new LoginParams(
Credentials: new Epic.OnlineServices.Connect.Credentials
{
Token = token.JsonWebToken,
Type = Epic.OnlineServices.ExternalCredentialType.EpicIdToken
},
ExternalAccountId: externalAccountId));
}
public static async Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> LoginSteam()
{
var credentialsSteamResult = await GenCredentialsSteam();
if (credentialsSteamResult.TryUnwrapFailure(out var error))
{
return Result.Failure(error);
}
if (!credentialsSteamResult.TryUnwrapSuccess(out var loginParams))
{
return Result.Failure(EosInterface.Login.LoginError.InvalidUser);
}
var result = await Login(loginParams);
if (steamworksAuthTicket.TryUnwrap(out var ticket)) { ticket.Cancel(); }
steamworksAuthTicket = Option.None;
return result;
}
public static async Task<Result<OneOf<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken, EosInterface.EgsAuthContinuanceToken>, EosInterface.Login.LoginError>> LoginEpicWithLinkedSteamAccount(EosInterface.Login.LoginEpicFlags flags)
{
if (steamworksAuthTicket.TryUnwrap(out var oldTicket)) { oldTicket.Cancel(); }
var newTicketNullable = await Steamworks.SteamUser.GetAuthTicketForWebApi(EosLoginSteamIdentity);
if (newTicketNullable is not { Data: not null } ticket)
{
return Result.Failure(EosInterface.Login.LoginError.FailedToGetSteamSessionTicket);
}
var epicCredentialsOption = await GenCredentialsEpic(
credentialsType: Epic.OnlineServices.Auth.LoginCredentialType.ExternalAuth,
credentialsId: null,
credentialsToken: ToolBoxCore.ByteArrayToHexString(ticket.Data),
credentialsExternalType: Epic.OnlineServices.ExternalCredentialType.SteamSessionTicket,
flags: flags);
if (epicCredentialsOption.TryUnwrapFailure(out var epicCredentialsFail))
{
return Result.Failure(epicCredentialsFail);
}
if (!epicCredentialsOption.TryUnwrapSuccess(out var loginParamsOrContinuanceToken))
{
return Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition);
}
if (loginParamsOrContinuanceToken.TryGet(out Epic.OnlineServices.ContinuanceToken continuanceToken))
{
return Result.Success((OneOf<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken, EosInterface.EgsAuthContinuanceToken>)
new EosInterface.EgsAuthContinuanceToken(continuanceToken.InnerHandle, ExtractExpiryTimeFromContinuanceToken(continuanceToken, EosInterface.EgsAuthContinuanceToken.Duration)));
}
if (!loginParamsOrContinuanceToken.TryGet(out LoginParams loginParams))
{
return Result.Failure(EosInterface.Login.LoginError.UnexpectedContinuanceToken);
}
var loginResult = await Login(loginParams);
if (loginResult.TryUnwrapSuccess(out var loginSuccess))
{
return loginSuccess.TryGet(out EosInterface.EosConnectContinuanceToken eosContinuanceToken)
? Result.Success((OneOf<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken, EosInterface.EgsAuthContinuanceToken>)eosContinuanceToken)
: loginSuccess.TryGet(out EosInterface.ProductUserId puid)
? Result.Success((OneOf<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken, EosInterface.EgsAuthContinuanceToken>)puid)
: Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition);
}
return loginResult.TryUnwrapFailure(out var loginFailure)
? Result.Failure(loginFailure)
: Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition);
}
public static async Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> LoginEpicExchangeCode(string exchangeCode)
{
var epicCredentialsOption = await GenCredentialsEpic(
credentialsType: Epic.OnlineServices.Auth.LoginCredentialType.ExchangeCode,
credentialsId: "",
credentialsToken: exchangeCode,
credentialsExternalType: Epic.OnlineServices.ExternalCredentialType.Epic,
flags: EosInterface.Login.LoginEpicFlags.None);
if (epicCredentialsOption.TryUnwrapFailure(out var epicCredentialsFail))
{
return Result.Failure(epicCredentialsFail);
}
if (!epicCredentialsOption.TryUnwrapSuccess(out var loginParamsOrContinuanceToken))
{
return Result.Failure(EosInterface.Login.LoginError.UnhandledFailureCondition);
}
if (!loginParamsOrContinuanceToken.TryGet(out LoginParams loginParams))
{
return Result.Failure(EosInterface.Login.LoginError.UnexpectedContinuanceToken);
}
var result = await Login(loginParams);
return result;
}
public static async Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> LoginEpicIdToken(EosInterface.EgsIdToken egsIdToken)
{
if (egsIdToken is not EgsIdTokenPrivate privateEgsIdToken) { return Result.Failure(EosInterface.Login.LoginError.InvalidUser); }
var credentials = new Epic.OnlineServices.Connect.Credentials
{
Token = privateEgsIdToken.InternalToken.JsonWebToken,
Type = Epic.OnlineServices.ExternalCredentialType.EpicIdToken
};
return await Login(new LoginParams(credentials, privateEgsIdToken.AccountId));
}
private static DateTime ExtractExpiryTimeFromContinuanceToken(Epic.OnlineServices.ContinuanceToken continuanceToken, TimeSpan fallbackDuration)
{
// Not the exact expiry time, but it's a pretty close guess should we fail to decode the continuance token
var expiryTime = DateTime.Now + fallbackDuration;
// This method exists to replace Epic.OnlineServices.ContinuanceToken.ToString because
// the generated code is broken, and I don't want to modify it because we risk undoing
// a fix when we update the SDK.
static string continuanceTokenToString(Epic.OnlineServices.ContinuanceToken continuanceToken)
{
int inOutBufferLength = 1024;
System.IntPtr outBufferAddress = Epic.OnlineServices.Helper.AddAllocation(inOutBufferLength);
var funcResult = Epic.OnlineServices.Bindings.EOS_ContinuanceToken_ToString(continuanceToken.InnerHandle, outBufferAddress, ref inOutBufferLength);
if (funcResult == Epic.OnlineServices.Result.LimitExceeded)
{
// Buffer wasn't large enough to copy the string.
// inOutBufferLength was updated by the last call to be the actual length required.
// Generate a new buffer and try again.
Epic.OnlineServices.Helper.Dispose(ref outBufferAddress);
outBufferAddress = Epic.OnlineServices.Helper.AddAllocation(inOutBufferLength);
funcResult = Epic.OnlineServices.Bindings.EOS_ContinuanceToken_ToString(continuanceToken.InnerHandle, outBufferAddress, ref inOutBufferLength);
if (funcResult != Epic.OnlineServices.Result.Success)
{
DebugConsoleCore.Log($"EOS_ContinuanceToken_ToString failed with result {funcResult}");
}
}
Epic.OnlineServices.Utf8String outBuffer = "EOS_ContinuanceToken_ToString failed";
if (funcResult == Epic.OnlineServices.Result.Success)
{
Epic.OnlineServices.Helper.Get(outBufferAddress, out outBuffer);
}
Epic.OnlineServices.Helper.Dispose(ref outBufferAddress);
return outBuffer;
}
var ctDecode = JsonWebToken.Parse(continuanceTokenToString(continuanceToken));
if (ctDecode.TryUnwrap(out var jwt))
{
string decodedPayload = jwt.PayloadDecoded;
try
{
// Ugly regex hack to get expiry time. The right thing to do would be to parse the payload as JSON,
// but I don't really care because we're extracting one field out of this whole thing.
string expiryTimeUnix = Regex.Match(decodedPayload, @"""exp""\s*:\s*([0-9]+)").Groups[1].Value;
expiryTime = UnixTime.ParseUtc(expiryTimeUnix).Fallback(UnixTime.UtcEpoch).ToLocalTime();
}
catch
{
// could not extract expiry time, oh well!
}
}
return expiryTime;
}
private static async Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> Login(LoginParams loginParams)
{
static Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError> success(EosInterface.ProductUserId id)
=> Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>.Success(id);
static Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError> continuance(EosInterface.EosConnectContinuanceToken token)
=> Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>.Success(token);
static Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError> failure(EosInterface.Login.LoginError error)
=> Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>.Failure(error);
if (CorePrivate.ConnectInterface is not { } connectInterface) { return failure(EosInterface.Login.LoginError.EosNotInitialized); }
var loginOptions = new Epic.OnlineServices.Connect.LoginOptions
{
Credentials = loginParams.Credentials,
UserLoginInfo = null
};
AccountId primaryExternalId = loginParams.ExternalAccountId;
var loginWaiter = new CallbackWaiter<Epic.OnlineServices.Connect.LoginCallbackInfo>();
connectInterface.Login(options: ref loginOptions, clientData: null, completionDelegate: loginWaiter.OnCompletion);
var callbackResultOption = await loginWaiter.Task;
if (!callbackResultOption.TryUnwrap(out var callbackResult))
{
return failure(EosInterface.Login.LoginError.Timeout);
}
if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success)
{
var retVal = new EosInterface.ProductUserId(callbackResult.LocalUserId.ToString());
PuidToPrimaryExternalId[retVal] = primaryExternalId;
return success(retVal);
}
if (callbackResult is { ResultCode: Epic.OnlineServices.Result.InvalidUser, ContinuanceToken: { } continuanceToken })
{
var expiryTime = ExtractExpiryTimeFromContinuanceToken(continuanceToken, EosInterface.EosConnectContinuanceToken.Duration);
return continuance(new EosInterface.EosConnectContinuanceToken(callbackResult.ContinuanceToken.InnerHandle, primaryExternalId, expiryTime));
}
return callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.InvalidUser
=> failure(EosInterface.Login.LoginError.InvalidUser),
Epic.OnlineServices.Result.AccessDenied
=> failure(EosInterface.Login.LoginError.EosAccessDenied),
var unhandled
=> failure(unhandled.FailAndLogUnhandledError(EosInterface.Login.LoginError.UnhandledFailureCondition))
};
}
private static void OnEgsAuthStatusChanged(ref Epic.OnlineServices.Auth.LoginStatusChangedCallbackInfo info)
{
var eaidOption = EpicAccountId.Parse(info.LocalUserId.ToString());
if (!eaidOption.TryUnwrap(out var eaid)) { return; }
if (info.CurrentStatus == Epic.OnlineServices.LoginStatus.NotLoggedIn)
{
TaskPool.Add(
"UnlogPuidLinkedToEaid",
IdQueriesPrivate.GetPuidForExternalId(eaid),
t =>
{
if (!t.TryGetResult(out Result<EosInterface.ProductUserId, Epic.OnlineServices.Result>? result)) { return; }
if (!result.TryUnwrapSuccess(out var puid)) { return; }
MarkAsInaccessible(puid);
});
}
}
public static void OnConnectExpiration(ref Epic.OnlineServices.Connect.AuthExpirationCallbackInfo info)
{
var puid = new EosInterface.ProductUserId(info.LocalUserId.ToString());
DebugConsoleCore.Log($"OnAuthExpirationNotification {puid}");
if (!PuidToPrimaryExternalId.TryGetValue(puid, out var externalId)) { return; }
switch (externalId)
{
case SteamId:
{
static async Task RelogSteam()
{
var steamCredentialsResult = await GenCredentialsSteam();
if (!steamCredentialsResult.TryUnwrapSuccess(out var loginParams)) { return; }
await Relog(loginParams);
}
TaskPool.Add(
"EosReLoginSteam",
RelogSteam(),
TaskPool.IgnoredCallback);
break;
}
case EpicAccountId epicAccountId:
{
if (CopyEpicIdToken(epicAccountId).TryUnwrap(out var token))
{
var epicLoginCredentials = new Epic.OnlineServices.Connect.Credentials
{
Token = token.JsonWebToken,
Type = Epic.OnlineServices.ExternalCredentialType.EpicIdToken
};
var reLogParams = new LoginParams(Credentials: epicLoginCredentials, ExternalAccountId: externalId);
TaskPool.Add("OnAuthExpirationNotification", Relog(reLogParams), onCompletion: TaskPool.IgnoredCallback);
}
break;
}
}
static async Task Relog(LoginParams loginParams)
{
var loginOptions = new Epic.OnlineServices.Connect.LoginOptions
{
Credentials = loginParams.Credentials,
UserLoginInfo = null
};
var connectLoginWaiter = new CallbackWaiter<Epic.OnlineServices.Connect.LoginCallbackInfo>();
CorePrivate.ConnectInterface?.Login(options: ref loginOptions, clientData: null, completionDelegate: connectLoginWaiter.OnCompletion);
var resultOption = await connectLoginWaiter.Task;
if (resultOption.TryUnwrap(out var result))
{
string s = $"EOS relog result: {result.ResultCode}";
if (result.LocalUserId != null)
{
s += " : " + result.LocalUserId;
}
if (result.ContinuanceToken != null)
{
s += " ; " + result.ContinuanceToken;
}
DebugConsoleCore.Log(s);
}
else
{
DebugConsoleCore.Log("EOS relog timed out");
}
}
}
private static void OnConnectStatusChanged(ref Epic.OnlineServices.Connect.LoginStatusChangedCallbackInfo info)
{
var puid = new EosInterface.ProductUserId(info.LocalUserId.ToString());
DebugConsoleCore.Log($"OnLoginStatusChangedNotification {puid} {info.CurrentStatus}");
if (info.CurrentStatus == Epic.OnlineServices.LoginStatus.NotLoggedIn)
{
PuidToPrimaryExternalId.TryRemove(puid, out _);
}
}
public static async Task<Result<EpicAccountId, EosInterface.Login.LinkExternalAccountToEpicAccountError>> LinkExternalAccountToEpicAccount(EosInterface.EgsAuthContinuanceToken continuanceToken)
{
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.EosNotInitialized); }
var linkOptions = new Epic.OnlineServices.Auth.LinkAccountOptions
{
LinkAccountFlags = Epic.OnlineServices.Auth.LinkAccountFlags.NoFlags,
ContinuanceToken = new Epic.OnlineServices.ContinuanceToken(continuanceToken.Spend()),
LocalUserId = null
};
var callbackWaiter = new CallbackWaiter<Epic.OnlineServices.Auth.LinkAccountCallbackInfo>(timeout: TimeSpan.FromMinutes(5));
egsAuthInterface.LinkAccount(options: ref linkOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion);
var resultOption = await callbackWaiter.Task;
if (!resultOption.TryUnwrap(out var result))
{
return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.TimedOut);
}
if (result.ResultCode == Epic.OnlineServices.Result.Success)
{
if (!EpicAccountId.Parse(result.SelectedAccountId.ToString()).TryUnwrap(out var epicAccountId))
{
return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.FailedToParseEgsAccountId);
}
return Result.Success(epicAccountId);
}
return Result.Failure(EosInterface.Login.LinkExternalAccountToEpicAccountError.UnhandledErrorCondition);
}
public static async Task<Result<Unit, EosInterface.Login.LogoutEpicAccountError>> LogoutEpicAccount(EpicAccountId egsId)
{
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Result.Failure(EosInterface.Login.LogoutEpicAccountError.EosNotInitialized); }
var logoutOptions = new Epic.OnlineServices.Auth.LogoutOptions
{
LocalUserId = Epic.OnlineServices.EpicAccountId.FromString(egsId.EosStringRepresentation)
};
var callbackWaiter = new CallbackWaiter<Epic.OnlineServices.Auth.LogoutCallbackInfo>();
egsAuthInterface.Logout(options: ref logoutOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion);
var logoutResultOption = await callbackWaiter.Task;
if (!logoutResultOption.TryUnwrap(out var logoutResult))
{
return Result.Failure(EosInterface.Login.LogoutEpicAccountError.TimedOut);
}
if (logoutResult.ResultCode == Epic.OnlineServices.Result.Success) { return Result.Success(Unit.Value); }
return Result.Failure(logoutResult.ResultCode switch
{
_
=> EosInterface.Login.LogoutEpicAccountError.UnhandledErrorCondition
});
}
public static void MarkAsInaccessible(EosInterface.ProductUserId puid)
{
PuidToPrimaryExternalId.TryRemove(puid, out _);
}
private static Option<Epic.OnlineServices.Auth.IdToken> CopyEpicIdToken(EpicAccountId epicAccountId)
{
if (CorePrivate.EgsAuthInterface is not { } egsAuthInterface) { return Option.None; }
var copyIdTokenOptions = new Epic.OnlineServices.Auth.CopyIdTokenOptions
{
AccountId = Epic.OnlineServices.EpicAccountId.FromString(epicAccountId.EosStringRepresentation)
};
var result = egsAuthInterface.CopyIdToken(ref copyIdTokenOptions, out var tokenNullable);
if (result is Epic.OnlineServices.Result.Success && tokenNullable is { } token)
{
return Option.Some(token);
}
return Option.None;
}
public static async Task<Result<EosInterface.ProductUserId, EosInterface.Login.CreateProductAccountError>> CreateProductAccount(EosInterface.EosConnectContinuanceToken eosContinuanceToken)
{
if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.Login.CreateProductAccountError.EosNotInitialized); }
if (eosContinuanceToken is not { IsValid: true }) { return Result.Failure(EosInterface.Login.CreateProductAccountError.InvalidContinuanceToken); }
var internalContinuanceToken = new Epic.OnlineServices.ContinuanceToken(eosContinuanceToken.Spend());
var options = new Epic.OnlineServices.Connect.CreateUserOptions
{
ContinuanceToken = internalContinuanceToken
};
var createUserWaiter = new CallbackWaiter<Epic.OnlineServices.Connect.CreateUserCallbackInfo>();
connectInterface.CreateUser(options: ref options, clientData: null, completionDelegate: createUserWaiter.OnCompletion);
var callbackResultOption = await createUserWaiter.Task;
if (!callbackResultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.Login.CreateProductAccountError.Timeout);
}
if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success)
{
var retVal = new EosInterface.ProductUserId(callbackResult.LocalUserId.ToString());
PuidToPrimaryExternalId[retVal] = eosContinuanceToken.ExternalAccountId;
return Result.Success(retVal);
}
return Result.Failure(EosInterface.Login.CreateProductAccountError.UnhandledErrorCondition);
}
public static async Task<Result<Unit, EosInterface.Login.LinkExternalAccountError>> LinkExternalAccount(EosInterface.ProductUserId puid, EosInterface.EosConnectContinuanceToken eosContinuanceToken)
{
if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.Login.LinkExternalAccountError.EosNotInitialized); }
if (eosContinuanceToken is not { IsValid: true }) { return Result.Failure(EosInterface.Login.LinkExternalAccountError.InvalidContinuanceToken); }
var internalContinuanceToken = new Epic.OnlineServices.ContinuanceToken(eosContinuanceToken.Spend());
var internalPuid = Epic.OnlineServices.ProductUserId.FromString(puid.Value);
var options = new Epic.OnlineServices.Connect.LinkAccountOptions
{
LocalUserId = internalPuid,
ContinuanceToken = internalContinuanceToken
};
var linkAccountAwaiter = new CallbackWaiter<Epic.OnlineServices.Connect.LinkAccountCallbackInfo>();
connectInterface.LinkAccount(options: ref options, clientData: null, completionDelegate: linkAccountAwaiter.OnCompletion);
var callbackResultOption = await linkAccountAwaiter.Task;
if (!callbackResultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.Login.LinkExternalAccountError.Timeout);
}
if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success)
{
return Result.Success(Unit.Value);
}
return Result.Failure(callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.ConnectLinkAccountFailed
=> EosInterface.Login.LinkExternalAccountError.CannotLink,
_
=> EosInterface.Login.LinkExternalAccountError.UnhandledErrorCondition
});
}
public static async Task<Result<Unit, EosInterface.Login.UnlinkExternalAccountError>> UnlinkExternalAccount(EosInterface.ProductUserId puid)
{
if (CorePrivate.ConnectInterface is not { } connectInterface) { return Result.Failure(EosInterface.Login.UnlinkExternalAccountError.EosNotInitialized); }
var internalPuid = Epic.OnlineServices.ProductUserId.FromString(puid.Value);
var options = new Epic.OnlineServices.Connect.UnlinkAccountOptions
{
LocalUserId = internalPuid
};
var unlinkAccountAwaiter = new CallbackWaiter<Epic.OnlineServices.Connect.UnlinkAccountCallbackInfo>();
connectInterface.UnlinkAccount(options: ref options, clientData: null, completionDelegate: unlinkAccountAwaiter.OnCompletion);
var callbackResultOption = await unlinkAccountAwaiter.Task;
if (!callbackResultOption.TryUnwrap(out var callbackResult))
{
return Result.Failure(EosInterface.Login.UnlinkExternalAccountError.Timeout);
}
if (callbackResult.ResultCode == Epic.OnlineServices.Result.Success)
{
PuidToPrimaryExternalId.TryRemove(puid, out _);
return Result.Success(Unit.Value);
}
return Result.Failure(callbackResult.ResultCode switch
{
Epic.OnlineServices.Result.InvalidUser
=> EosInterface.Login.UnlinkExternalAccountError.InvalidUser,
_
=> EosInterface.Login.UnlinkExternalAccountError.UnhandledErrorCondition
});
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Result<EosInterface.ProductUserId, EosInterface.Login.CreateProductAccountError>> CreateProductAccount(EosInterface.EosConnectContinuanceToken eosContinuanceToken)
=> TaskScheduler.Schedule(() => LoginPrivate.CreateProductAccount(eosContinuanceToken));
public override Task<Result<Unit, EosInterface.Login.LinkExternalAccountError>> LinkExternalAccount(EosInterface.ProductUserId puid, EosInterface.EosConnectContinuanceToken eosContinuanceToken)
=> TaskScheduler.Schedule(() => LoginPrivate.LinkExternalAccount(puid, eosContinuanceToken));
public override Task<Result<Unit, EosInterface.Login.UnlinkExternalAccountError>> UnlinkExternalAccount(EosInterface.ProductUserId puid)
=> TaskScheduler.Schedule(() => LoginPrivate.UnlinkExternalAccount(puid));
public override Task<Result<OneOf<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken, EosInterface.EgsAuthContinuanceToken>, EosInterface.Login.LoginError>> LoginEpicWithLinkedSteamAccount(EosInterface.Login.LoginEpicFlags flags)
=> TaskScheduler.Schedule(() => LoginPrivate.LoginEpicWithLinkedSteamAccount(flags));
public override Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> LoginEpicExchangeCode(string exchangeCode)
=> TaskScheduler.Schedule(() => LoginPrivate.LoginEpicExchangeCode(exchangeCode));
public override Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> LoginEpicIdToken(EosInterface.EgsIdToken token)
=> TaskScheduler.Schedule(() => LoginPrivate.LoginEpicIdToken(token));
public override Task<Result<Either<EosInterface.ProductUserId, EosInterface.EosConnectContinuanceToken>, EosInterface.Login.LoginError>> LoginSteam()
=> TaskScheduler.Schedule(LoginPrivate.LoginSteam);
public override Task<Result<EpicAccountId, EosInterface.Login.LinkExternalAccountToEpicAccountError>> LinkExternalAccountToEpicAccount(EosInterface.EgsAuthContinuanceToken continuanceToken)
=> TaskScheduler.Schedule(() => LoginPrivate.LinkExternalAccountToEpicAccount(continuanceToken));
public override Task<Result<Unit, EosInterface.Login.LogoutEpicAccountError>> LogoutEpicAccount(EpicAccountId egsId)
=> LoginPrivate.LogoutEpicAccount(egsId);
public override void MarkAsInaccessible(EosInterface.ProductUserId puid)
=> LoginPrivate.MarkAsInaccessible(puid);
public override void TestEosSessionTimeoutRecovery(EosInterface.ProductUserId puid)
{
var info = new Epic.OnlineServices.Connect.AuthExpirationCallbackInfo
{
ClientData = null,
LocalUserId = Epic.OnlineServices.ProductUserId.FromString(puid.Value)
};
LoginPrivate.OnConnectExpiration(ref info);
}
}
@@ -0,0 +1,158 @@
#nullable enable
using System;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Barotrauma.Networking;
using Barotrauma;
namespace EosInterfacePrivate;
static partial class OwnershipPrivate
{
internal static async Task<Option<EosInterface.Ownership.Token>> GetGameOwnershipToken(EpicAccountId selfEpicAccountId)
{
if (CorePrivate.EcomInterface is not { } ecomInterface) { return Option.None; }
var epicAccountIdInternal =
Epic.OnlineServices.EpicAccountId.FromString(selfEpicAccountId.EosStringRepresentation);
var queryOwnershipTokenOptions = new Epic.OnlineServices.Ecom.QueryOwnershipTokenOptions
{
LocalUserId = epicAccountIdInternal,
CatalogItemIds = new Epic.OnlineServices.Utf8String[]
{
AudienceItemId,
//"Completely arbitrary string!"
// IDEA:
// As of 2023-06-21, QueryOwnershipToken will succeed even if given obviously fake catalog item IDs.
// This could be useful to us! We could use this to add an audience parameter to this method and fix
// the impersonation exploit without requiring our own persistent service.
// We should ask Epic about this before actually trying it, this is certainly a hack and might get patched.
}
};
var callbackWaiter = new CallbackWaiter<Epic.OnlineServices.Ecom.QueryOwnershipTokenCallbackInfo>();
ecomInterface.QueryOwnershipToken(options: ref queryOwnershipTokenOptions, clientData: null, completionDelegate: callbackWaiter.OnCompletion);
var queryOwnershipTokenResultOption = await callbackWaiter.Task;
if (!queryOwnershipTokenResultOption.TryUnwrap(out var queryOwnershipTokenResult)) { return Option.None; }
if (queryOwnershipTokenResult.ResultCode != Epic.OnlineServices.Result.Success) { return Option.None; }
var jwtOption = JsonWebToken.Parse(queryOwnershipTokenResult.OwnershipToken);
return jwtOption.Select(jwt => new EosInterface.Ownership.Token(jwt));
}
internal static async Task<Option<EpicAccountId>> VerifyGameOwnershipToken(EosInterface.Ownership.Token token)
{
JsonWebToken jwt = token.Jwt;
// Decode header
string kidProperty;
string algProperty;
try
{
var jsonDoc = JsonDocument.Parse(jwt.HeaderDecoded);
kidProperty = jsonDoc.RootElement.GetProperty("kid").GetString() ?? "";
algProperty = jsonDoc.RootElement.GetProperty("alg").GetString() ?? "";
}
catch
{
// Header JSON decode failed, can't verify token
return Option.None;
}
// Basic header sanity checks
if (algProperty != "RS512") { return Option.None; }
if (!kidProperty.IsBase64Url()) { return Option.None; }
// Decode payload
string epicAccountIdStr;
string catalogItemId;
Option<DateTime> expirationOption;
bool owned;
try
{
var jsonDoc = JsonDocument.Parse(jwt.PayloadDecoded);
epicAccountIdStr = jsonDoc.RootElement.GetProperty("sub").GetString() ?? "";
var entProperty = jsonDoc.RootElement.GetProperty("ent").EnumerateArray().First();
catalogItemId = entProperty.GetProperty("catalogItemId").GetString() ?? "";
expirationOption = UnixTime.ParseUtc(jsonDoc.RootElement.GetProperty("exp").GetUInt64().ToString());
owned = entProperty.GetProperty("owned").GetBoolean();
}
catch
{
// Payload JSON decode failed, can't verify token
return Option.None;
}
// Check that the payload is actually what we want
if (catalogItemId != AudienceItemId) { return Option.None; }
if (!owned) { return Option.None; }
if (!expirationOption.TryUnwrap(out var expiration)) { return Option.None; }
if (DateTime.UtcNow >= expiration) { return Option.None; }
// Get the public key required to verify this token
string modulus;
string exponent;
try
{
string url =
"https://ecommerceintegration-public-service-ecomprod02.ol.epicgames.com/ecommerceintegration/api/public/publickeys/"
+ kidProperty;
using var httpClient = new HttpClient();
var response = await httpClient.SendAsync(new HttpRequestMessage(
HttpMethod.Get,
new Uri(url)));
if (!response.IsSuccessStatusCode) { return Option.None; }
var responseStr = await response.Content.ReadAsStringAsync();
var responseJsonDoc = JsonDocument.Parse(responseStr);
if (kidProperty != responseJsonDoc.RootElement.GetProperty("kid").GetString()) { return Option.None; }
modulus = responseJsonDoc.RootElement.GetProperty("n").GetString() ?? "";
exponent = responseJsonDoc.RootElement.GetProperty("e").GetString() ?? "";
}
catch
{
// Failed to query EG Ecom web API, can't verify token
return Option.None;
}
// Prepare RSA-SHA512 and verify token
var modulusBytesOption = Base64Url.DecodeBytes(modulus);
if (!modulusBytesOption.TryUnwrap(out var modulusBytes)) { return Option.None; }
var exponentBytesOption = Base64Url.DecodeBytes(exponent);
if (!exponentBytesOption.TryUnwrap(out var exponentBytes)) { return Option.None; }
var signatureBytesOption = Base64Url.DecodeBytes(jwt.Signature);
if (!signatureBytesOption.TryUnwrap(out var signatureBytes)) { return Option.None; }
using var rsa = RSA.Create();
using var sha = SHA512.Create();
rsa.ImportParameters(new RSAParameters
{
Exponent = exponentBytes.ToArray(),
Modulus = modulusBytes.ToArray(),
});
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(jwt.Header + "." + jwt.Payload));
var deformatter = new RSAPKCS1SignatureDeformatter(rsa);
deformatter.SetHashAlgorithm("SHA512");
bool verified = deformatter.VerifySignature(hash, signatureBytes.ToArray());
if (!verified) { return Option.None; }
return EpicAccountId.Parse(epicAccountIdStr);
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Option<EosInterface.Ownership.Token>> GetGameOwnershipToken(EpicAccountId selfEpicAccountId)
=> TaskScheduler.Schedule(() => OwnershipPrivate.GetGameOwnershipToken(selfEpicAccountId));
public override Task<Option<EpicAccountId>> VerifyGameOwnershipToken(EosInterface.Ownership.Token token)
=> TaskScheduler.Schedule(() => OwnershipPrivate.VerifyGameOwnershipToken(token));
}
@@ -0,0 +1,252 @@
#nullable enable
using Barotrauma.Networking;
using System;
using System.Collections.Generic;
using Barotrauma;
namespace EosInterfacePrivate;
public sealed class P2PSocketPrivate : EosInterface.P2PSocket
{
private readonly record struct CallbackIds(
ulong OnConnectionRequested,
ulong OnConnectionClosed);
private CallbackIds callbackIds;
private readonly Epic.OnlineServices.P2P.SocketId socketIdInternal;
private readonly Epic.OnlineServices.ProductUserId selfPuid;
private P2PSocketPrivate(Epic.OnlineServices.P2P.SocketId socketIdInternal, Epic.OnlineServices.ProductUserId selfPuid)
{
this.socketIdInternal = socketIdInternal;
this.selfPuid = selfPuid;
}
internal static Result<EosInterface.P2PSocket, CreationError> CreatePrivate(EosInterface.ProductUserId selfPuid, EosInterface.SocketId socketId)
{
var p2pInterface = CorePrivate.P2PInterface;
if (p2pInterface is null) { return Result.Failure(CreationError.EosNotInitialized); }
var socketIdInternal = new Epic.OnlineServices.P2P.SocketId { SocketName = socketId.SocketName };
var selfPuidInternal = Epic.OnlineServices.ProductUserId.FromString(selfPuid.Value);
using var janitor = Janitor.Start();
var socket = new P2PSocketPrivate(socketIdInternal, selfPuidInternal);
var addNotifyPeerConnectionRequestOptions = new Epic.OnlineServices.P2P.AddNotifyPeerConnectionRequestOptions
{
LocalUserId = selfPuidInternal,
SocketId = socketIdInternal
};
var onConnectionRequestCallbackId = p2pInterface.AddNotifyPeerConnectionRequest(
ref addNotifyPeerConnectionRequestOptions,
socket,
ConnectionRequestHandler);
if (onConnectionRequestCallbackId == Epic.OnlineServices.Common.InvalidNotificationid)
{
return Result.Failure(CreationError.RequestBindFailed);
}
janitor.AddAction(() => p2pInterface.RemoveNotifyPeerConnectionRequest(onConnectionRequestCallbackId));
var addNotifyPeerConnectionClosedOptions = new Epic.OnlineServices.P2P.AddNotifyPeerConnectionClosedOptions
{
LocalUserId = selfPuidInternal,
SocketId = socketIdInternal
};
var onConnectionClosedCallbackId = p2pInterface.AddNotifyPeerConnectionClosed(
ref addNotifyPeerConnectionClosedOptions,
socket,
ConnectionClosedHandler);
if (onConnectionClosedCallbackId == Epic.OnlineServices.Common.InvalidNotificationid)
{
return Result.Failure(CreationError.CloseBindFailed);
}
janitor.AddAction(() => p2pInterface.RemoveNotifyPeerConnectionClosed(onConnectionClosedCallbackId));
socket.callbackIds = new CallbackIds(
OnConnectionRequested: onConnectionRequestCallbackId,
OnConnectionClosed: onConnectionClosedCallbackId);
janitor.Dismiss();
return Result.Success<EosInterface.P2PSocket>(socket);
}
private static void ConnectionRequestHandler(ref Epic.OnlineServices.P2P.OnIncomingConnectionRequestInfo info)
{
if (info.ClientData is P2PSocketPrivate p2pSocket
&& string.Equals(info.SocketId?.SocketName, p2pSocket.socketIdInternal.SocketName))
{
p2pSocket.HandleIncomingConnection.Invoke(new IncomingConnectionRequest(
Socket: p2pSocket,
RemoteUserId: new EosInterface.ProductUserId(info.RemoteUserId.ToString())));
}
}
private static void ConnectionClosedHandler(ref Epic.OnlineServices.P2P.OnRemoteConnectionClosedInfo info)
{
if (info.ClientData is P2PSocketPrivate p2pSocket
&& string.Equals(info.SocketId?.SocketName, p2pSocket.socketIdInternal.SocketName))
{
p2pSocket.HandleClosedConnection.Invoke(new RemoteConnectionClosed(
RemoteUserId: new EosInterface.ProductUserId(info.RemoteUserId.ToString()),
Reason: info.Reason switch
{
Epic.OnlineServices.P2P.ConnectionClosedReason.Unknown
=> RemoteConnectionClosed.ConnectionClosedReason.Unknown,
Epic.OnlineServices.P2P.ConnectionClosedReason.ClosedByLocalUser
=> RemoteConnectionClosed.ConnectionClosedReason.ClosedByLocalUser,
Epic.OnlineServices.P2P.ConnectionClosedReason.ClosedByPeer
=> RemoteConnectionClosed.ConnectionClosedReason.ClosedByPeer,
Epic.OnlineServices.P2P.ConnectionClosedReason.TimedOut
=> RemoteConnectionClosed.ConnectionClosedReason.TimedOut,
Epic.OnlineServices.P2P.ConnectionClosedReason.TooManyConnections
=> RemoteConnectionClosed.ConnectionClosedReason.TooManyConnections,
Epic.OnlineServices.P2P.ConnectionClosedReason.InvalidMessage
=> RemoteConnectionClosed.ConnectionClosedReason.InvalidMessage,
Epic.OnlineServices.P2P.ConnectionClosedReason.InvalidData
=> RemoteConnectionClosed.ConnectionClosedReason.InvalidData,
Epic.OnlineServices.P2P.ConnectionClosedReason.ConnectionFailed
=> RemoteConnectionClosed.ConnectionClosedReason.ConnectionFailed,
Epic.OnlineServices.P2P.ConnectionClosedReason.ConnectionClosed
=> RemoteConnectionClosed.ConnectionClosedReason.ConnectionClosed,
Epic.OnlineServices.P2P.ConnectionClosedReason.NegotiationFailed
=> RemoteConnectionClosed.ConnectionClosedReason.NegotiationFailed,
Epic.OnlineServices.P2P.ConnectionClosedReason.UnexpectedError
=> RemoteConnectionClosed.ConnectionClosedReason.UnexpectedError,
_
=> RemoteConnectionClosed.ConnectionClosedReason.Unhandled
}));
}
}
public override void AcceptConnectionRequest(IncomingConnectionRequest request)
{
var remoteUserIdInternal = Epic.OnlineServices.ProductUserId.FromString(request.RemoteUserId.Value);
var acceptConnectionOptions = new Epic.OnlineServices.P2P.AcceptConnectionOptions
{
LocalUserId = selfPuid,
RemoteUserId = remoteUserIdInternal,
SocketId = socketIdInternal
};
CorePrivate.P2PInterface?.AcceptConnection(ref acceptConnectionOptions);
}
public override void CloseConnection(EosInterface.ProductUserId remoteUserId)
{
var remoteUserIdInternal = Epic.OnlineServices.ProductUserId.FromString(remoteUserId.Value);
var closeConnectionOptions = new Epic.OnlineServices.P2P.CloseConnectionOptions
{
LocalUserId = selfPuid,
RemoteUserId = remoteUserIdInternal,
SocketId = socketIdInternal
};
CorePrivate.P2PInterface?.CloseConnection(ref closeConnectionOptions);
}
public override IEnumerable<IncomingMessage> GetMessageBatch()
{
var p2pInterface = CorePrivate.P2PInterface;
if (p2pInterface is null) { yield break; }
var packetQueueOptions = new Epic.OnlineServices.P2P.GetPacketQueueInfoOptions();
p2pInterface.GetPacketQueueInfo(ref packetQueueOptions, out var packetQueueInfo);
byte[] buf = new byte[Epic.OnlineServices.P2P.P2PInterface.MaxPacketSize];
for (ulong i = 0; i < packetQueueInfo.IncomingPacketQueueCurrentPacketCount; i++)
{
var receivePacketOptions = new Epic.OnlineServices.P2P.ReceivePacketOptions
{
LocalUserId = selfPuid,
MaxDataSizeBytes = (uint)buf.Length,
RequestedChannel = null
};
var result = p2pInterface.ReceivePacket(
ref receivePacketOptions,
out var senderId,
out var senderSocketId,
out _,
buf,
out uint bytesWritten);
if (result != Epic.OnlineServices.Result.Success) { continue; }
if (senderSocketId.SocketName != socketIdInternal.SocketName) { continue; }
yield return new IncomingMessage(
buf, (int)bytesWritten, new EosInterface.ProductUserId(senderId.ToString()));
}
}
public override Result<Unit, SendError> SendMessage(OutgoingMessage msg)
{
var p2pInterface = CorePrivate.P2PInterface;
if (p2pInterface is null) { return Result.Failure(SendError.EosNotInitialized); }
var reliability = msg.DeliveryMethod switch
{
DeliveryMethod.Reliable
=> Epic.OnlineServices.P2P.PacketReliability.ReliableOrdered,
_
=> Epic.OnlineServices.P2P.PacketReliability.UnreliableUnordered
};
var sendPacketOptions = new Epic.OnlineServices.P2P.SendPacketOptions
{
LocalUserId = selfPuid,
RemoteUserId = Epic.OnlineServices.ProductUserId.FromString(msg.Destination.Value),
SocketId = socketIdInternal,
Channel = 0,
Data = new ArraySegment<byte>(array: msg.Buffer, offset: 0, count: msg.ByteLength),
AllowDelayedDelivery = true,
Reliability = reliability,
DisableAutoAcceptConnection = false
};
var result = p2pInterface.SendPacket(ref sendPacketOptions);
return result switch
{
Epic.OnlineServices.Result.Success
=> Result.Success(Unit.Value),
Epic.OnlineServices.Result.InvalidParameters
=> Result.Failure(SendError.InvalidParameters),
Epic.OnlineServices.Result.LimitExceeded
=> Result.Failure(SendError.LimitExceeded),
Epic.OnlineServices.Result.NoConnection
=> Result.Failure(SendError.NoConnection),
_
=> Result.Failure(SendError.UnhandledErrorCondition)
};
}
public override void Dispose()
{
var p2pInterface = CorePrivate.P2PInterface;
if (p2pInterface is null) { return; }
var closeConnectionsOptions = new Epic.OnlineServices.P2P.CloseConnectionsOptions
{
LocalUserId = selfPuid,
SocketId = socketIdInternal
};
p2pInterface.RemoveNotifyPeerConnectionRequest(callbackIds.OnConnectionRequested);
p2pInterface.RemoveNotifyPeerConnectionClosed(callbackIds.OnConnectionClosed);
p2pInterface.CloseConnections(ref closeConnectionsOptions);
callbackIds = default;
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Result<EosInterface.P2PSocket, EosInterface.P2PSocket.CreationError> CreateP2PSocket(EosInterface.ProductUserId puid, EosInterface.SocketId socketId)
=> P2PSocketPrivate.CreatePrivate(puid, socketId);
}
@@ -0,0 +1,319 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Barotrauma;
namespace EosInterfacePrivate;
static class OwnedSessionsPrivate
{
private static readonly Random rng = new Random();
private static readonly ConcurrentDictionary<Identifier, EosInterface.Sessions.OwnedSession> liveOwnedSessions = new ConcurrentDictionary<Identifier, EosInterface.Sessions.OwnedSession>();
private static Epic.OnlineServices.Utf8String IdentifierToAttributeKey(Identifier id)
{
// Attribute keys are always uppercase in the EOS developer page,
// so to minimize surprises let's match that here
return id.Value.ToUpperInvariant();
}
public static async Task<Result<EosInterface.Sessions.OwnedSession, EosInterface.Sessions.CreateError>> Create(Option<EosInterface.ProductUserId> selfUserIdOption, Identifier internalId, int maxPlayers)
{
var (success, failure) = Result<EosInterface.Sessions.OwnedSession, EosInterface.Sessions.CreateError>.GetFactoryMethods();
if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return failure(EosInterface.Sessions.CreateError.EosNotInitialized); }
if (liveOwnedSessions.ContainsKey(internalId)) { return failure(EosInterface.Sessions.CreateError.SessionAlreadyExists); }
using var janitor = Janitor.Start();
var bucketIndex = rng.Next(EosInterface.Sessions.MinBucketIndex, EosInterface.Sessions.MaxBucketIndex + 1);
string bucketName = EosInterface.Sessions.DefaultBucketName + bucketIndex;
var createSessionModificationOptions = new Epic.OnlineServices.Sessions.CreateSessionModificationOptions
{
SessionName = internalId.Value.ToUpperInvariant(),
BucketId = bucketName,
MaxPlayers = (uint)maxPlayers,
LocalUserId = selfUserIdOption.TryUnwrap(out var selfUserId)
? Epic.OnlineServices.ProductUserId.FromString(selfUserId.Value)
: null,
PresenceEnabled = false,
SessionId = null,
SanctionsEnabled = false
};
var sessionCreateResult = sessionsInterface.CreateSessionModification(ref createSessionModificationOptions, out var sessionModificationHandle);
if (sessionCreateResult != Epic.OnlineServices.Result.Success)
{
return failure(sessionCreateResult switch
{
Epic.OnlineServices.Result.InvalidUser => EosInterface.Sessions.CreateError.InvalidUser,
Epic.OnlineServices.Result.SessionsSessionAlreadyExists => EosInterface.Sessions.CreateError.SessionAlreadyExists,
_ => EosInterface.Sessions.CreateError.UnhandledErrorCondition
});
}
janitor.AddAction(sessionModificationHandle.Release);
var updateSessionOptions = new Epic.OnlineServices.Sessions.UpdateSessionOptions
{
SessionModificationHandle = sessionModificationHandle
};
var updateSessionWaiter = new CallbackWaiter<Epic.OnlineServices.Sessions.UpdateSessionCallbackInfo>();
sessionsInterface.UpdateSession(options: ref updateSessionOptions, clientData: null, completionDelegate: updateSessionWaiter.OnCompletion);
var updateSessionResultOption = await updateSessionWaiter.Task;
if (!updateSessionResultOption.TryUnwrap(out var updateSessionResult)) { return failure(EosInterface.Sessions.CreateError.TimedOut); }
if (updateSessionResult.ResultCode == Epic.OnlineServices.Result.Success)
{
var newSession = new EosInterface.Sessions.OwnedSession(
BucketId: bucketName,
InternalId: updateSessionResult.SessionName.ToIdentifier(),
GlobalId: updateSessionResult.SessionId.ToIdentifier(),
Attributes: new Dictionary<Identifier, string>());
liveOwnedSessions.TryAdd(internalId, newSession);
return success(newSession);
}
return failure(updateSessionResult.ResultCode.FailAndLogUnhandledError(EosInterface.Sessions.CreateError.UnhandledErrorCondition));
}
public static async Task<Result<Unit, EosInterface.Sessions.AttributeUpdateError>> UpdateOwnedSessionAttributes(EosInterface.Sessions.OwnedSession session)
{
if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.AttributeUpdateError.EosNotInitialized); }
using var janitor = Janitor.Start();
var updateSessionModificationOptions = new Epic.OnlineServices.Sessions.UpdateSessionModificationOptions
{
SessionName = session.InternalId.Value.ToUpperInvariant()
};
var sessionCreateResult = sessionsInterface.UpdateSessionModification(ref updateSessionModificationOptions, out var sessionModificationHandle);
if (sessionCreateResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(EosInterface.Sessions.AttributeUpdateError.FailedToCreateSessionModificationHandle);
}
janitor.AddAction(() => sessionModificationHandle.Release());
var keysToRemove = session.SyncedAttributes
.Except(session.Attributes)
.Select(kvp => kvp.Key)
.ToArray();
var attributesToAdd = session.Attributes
.Except(session.SyncedAttributes)
.ToArray();
var setBucketIdOptions = new Epic.OnlineServices.Sessions.SessionModificationSetBucketIdOptions
{
BucketId = session.BucketId
};
sessionModificationHandle.SetBucketId(ref setBucketIdOptions);
if (session.HostAddress.TryUnwrap(out var hostAddress))
{
var setHostAddressOptions = new Epic.OnlineServices.Sessions.SessionModificationSetHostAddressOptions
{
HostAddress = hostAddress
};
sessionModificationHandle.SetHostAddress(ref setHostAddressOptions);
}
foreach (Identifier key in keysToRemove)
{
var removeAttributeOptions = new Epic.OnlineServices.Sessions.SessionModificationRemoveAttributeOptions
{
Key = IdentifierToAttributeKey(key)
};
var removeResult = sessionModificationHandle.RemoveAttribute(ref removeAttributeOptions);
if (removeResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(
removeResult switch
{
Epic.OnlineServices.Result.InvalidParameters
=> EosInterface.Sessions.AttributeUpdateError.InvalidParametersForRemoveAttribute,
Epic.OnlineServices.Result.IncompatibleVersion
=> EosInterface.Sessions.AttributeUpdateError.IncompatibleVersionForRemoveAttribute,
_
=> EosInterface.Sessions.AttributeUpdateError.UnhandledErrorConditionForRemoveAttribute
});
}
}
foreach (var kvp in attributesToAdd)
{
// EOS doesn't like empty values so let's skip those
if (kvp.Value.IsNullOrEmpty()) { continue; }
var addAttributeOptions = new Epic.OnlineServices.Sessions.SessionModificationAddAttributeOptions
{
SessionAttribute = new Epic.OnlineServices.Sessions.AttributeData
{
Key = IdentifierToAttributeKey(kvp.Key),
Value = kvp.Value
},
AdvertisementType = Epic.OnlineServices.Sessions.SessionAttributeAdvertisementType.Advertise
};
var addResult = sessionModificationHandle.AddAttribute(ref addAttributeOptions);
if (addResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(
addResult switch
{
Epic.OnlineServices.Result.InvalidParameters
=> EosInterface.Sessions.AttributeUpdateError.InvalidParametersForAddAttribute,
Epic.OnlineServices.Result.IncompatibleVersion
=> EosInterface.Sessions.AttributeUpdateError.IncompatibleVersionForAddAttribute,
_
=> EosInterface.Sessions.AttributeUpdateError.UnhandledErrorConditionForAddAttribute
});
}
}
var updateSessionOptions = new Epic.OnlineServices.Sessions.UpdateSessionOptions
{
SessionModificationHandle = sessionModificationHandle
};
var updateSessionWaiter = new CallbackWaiter<Epic.OnlineServices.Sessions.UpdateSessionCallbackInfo>();
sessionsInterface.UpdateSession(options: ref updateSessionOptions, clientData: null, completionDelegate: updateSessionWaiter.OnCompletion);
var updateSessionResultOption = await updateSessionWaiter.Task;
if (!updateSessionResultOption.TryUnwrap(out var updateSessionResult)) { Result.Failure(EosInterface.Sessions.AttributeUpdateError.TimedOut); }
if (updateSessionResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return updateSessionResult.ResultCode switch
{
Epic.OnlineServices.Result.InvalidParameters
=> Result.Failure(EosInterface.Sessions.AttributeUpdateError.InvalidParametersForSessionUpdate),
Epic.OnlineServices.Result.SessionsOutOfSync
=> Result.Failure(EosInterface.Sessions.AttributeUpdateError.SessionsOutOfSync),
Epic.OnlineServices.Result.NotFound
=> Result.Failure(EosInterface.Sessions.AttributeUpdateError.SessionNotFound),
Epic.OnlineServices.Result.NoConnection
=> Result.Failure(EosInterface.Sessions.AttributeUpdateError.NoConnection),
var unhandled
=> Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.Sessions.AttributeUpdateError.UnhandledErrorCondition))
};
}
session.SyncedAttributes = session.Attributes.ToImmutableDictionary();
return Result.Success(Unit.Value);
}
public static async Task<Result<Unit, EosInterface.Sessions.CloseError>> CloseOwnedSession(EosInterface.Sessions.OwnedSession session)
{
if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.CloseError.EosNotInitialized); }
liveOwnedSessions.TryRemove(session.InternalId, out _);
var options = new Epic.OnlineServices.Sessions.DestroySessionOptions
{
SessionName = session.InternalId.Value.ToUpperInvariant()
};
var callbackWaiter = new CallbackWaiter<Epic.OnlineServices.Sessions.DestroySessionCallbackInfo>();
sessionsInterface.DestroySession(options: ref options, clientData: null, completionDelegate: callbackWaiter.OnCompletion);
var resultOption = await callbackWaiter.Task;
if (!resultOption.TryUnwrap(out var result)) { return Result.Failure(EosInterface.Sessions.CloseError.TimedOut); }
return result.ResultCode switch
{
Epic.OnlineServices.Result.Success
=> Result.Success(Unit.Value),
Epic.OnlineServices.Result.InvalidParameters
=> Result.Failure(EosInterface.Sessions.CloseError.InvalidParameters),
Epic.OnlineServices.Result.AlreadyPending
=> Result.Failure(EosInterface.Sessions.CloseError.AlreadyPending),
Epic.OnlineServices.Result.NotFound
=> Result.Failure(EosInterface.Sessions.CloseError.NotFound),
var unhandled
=> Result.Failure(unhandled.FailAndLogUnhandledError(EosInterface.Sessions.CloseError.UnhandledErrorCondition))
};
}
public static Task CloseAllOwnedSessions()
{
return Task.WhenAll(liveOwnedSessions.Values
.ToArray()
.Select(CloseOwnedSession));
}
public static Task ForceUpdateAllOwnedSessions()
{
var sessionsToUpdate = liveOwnedSessions.Values.ToArray();
foreach (var session in sessionsToUpdate)
{
session.SyncedAttributes = ImmutableDictionary<Identifier, string>.Empty;
}
return Task.WhenAll(sessionsToUpdate
.Select(UpdateOwnedSessionAttributes));
}
public static async Task<Result<ImmutableArray<EosInterface.ProductUserId>, EosInterface.Sessions.RegisterError>> RegisterPlayers(EosInterface.Sessions.OwnedSession session, params EosInterface.ProductUserId[] puids)
{
if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.RegisterError.EosNotInitialized); }
var registerPlayersOptions = new Epic.OnlineServices.Sessions.RegisterPlayersOptions
{
SessionName = session.InternalId.Value.ToUpperInvariant(),
PlayersToRegister = puids.Select(puid => Epic.OnlineServices.ProductUserId.FromString(puid.Value)).ToArray()
};
var registerPlayersWaiter = new CallbackWaiter<Epic.OnlineServices.Sessions.RegisterPlayersCallbackInfo>();
sessionsInterface.RegisterPlayers(options: ref registerPlayersOptions, clientData: null, completionDelegate: registerPlayersWaiter.OnCompletion);
var registerResultOption = await registerPlayersWaiter.Task;
if (!registerResultOption.TryUnwrap(out var registerResult)) { return Result.Failure(EosInterface.Sessions.RegisterError.TimedOut); }
if (registerResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(registerResult.ResultCode.FailAndLogUnhandledError(EosInterface.Sessions.RegisterError.UnhandledErrorCondition));
}
return Result.Success(registerResult.RegisteredPlayers.Select(puid => new EosInterface.ProductUserId(puid.ToString())).ToImmutableArray());
}
public static async Task<Result<ImmutableArray<EosInterface.ProductUserId>, EosInterface.Sessions.UnregisterError>> UnregisterPlayers(EosInterface.Sessions.OwnedSession session, params EosInterface.ProductUserId[] puids)
{
if (CorePrivate.SessionsInterface is not { } sessionsInterface) { return Result.Failure(EosInterface.Sessions.UnregisterError.EosNotInitialized); }
var unregisterPlayersOptions = new Epic.OnlineServices.Sessions.UnregisterPlayersOptions
{
SessionName = session.InternalId.Value.ToUpperInvariant(),
PlayersToUnregister = puids.Select(puid => Epic.OnlineServices.ProductUserId.FromString(puid.Value)).ToArray()
};
var unregisterPlayersWaiter = new CallbackWaiter<Epic.OnlineServices.Sessions.UnregisterPlayersCallbackInfo>();
sessionsInterface.UnregisterPlayers(options: ref unregisterPlayersOptions, clientData: null, completionDelegate: unregisterPlayersWaiter.OnCompletion);
var unregisterResultOption = await unregisterPlayersWaiter.Task;
if (!unregisterResultOption.TryUnwrap(out var unregisterResult)) { return Result.Failure(EosInterface.Sessions.UnregisterError.TimedOut); }
if (unregisterResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(unregisterResult.ResultCode.FailAndLogUnhandledError(EosInterface.Sessions.UnregisterError.UnhandledErrorCondition));
}
return Result.Success(unregisterResult.UnregisteredPlayers.Select(puid => new EosInterface.ProductUserId(puid.ToString())).ToImmutableArray());
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Result<EosInterface.Sessions.OwnedSession, EosInterface.Sessions.CreateError>> CreateSession(Option<EosInterface.ProductUserId> selfUserIdOption, Identifier internalId, int maxPlayers)
=> TaskScheduler.Schedule(() => OwnedSessionsPrivate.Create(selfUserIdOption, internalId, maxPlayers));
public override Task<Result<Unit, EosInterface.Sessions.AttributeUpdateError>> UpdateOwnedSessionAttributes(EosInterface.Sessions.OwnedSession session)
=> TaskScheduler.Schedule(() => OwnedSessionsPrivate.UpdateOwnedSessionAttributes(session));
public override Task<Result<Unit, EosInterface.Sessions.CloseError>> CloseOwnedSession(EosInterface.Sessions.OwnedSession session)
=> TaskScheduler.Schedule(() => OwnedSessionsPrivate.CloseOwnedSession(session));
public override Task CloseAllOwnedSessions()
=> TaskScheduler.Schedule(OwnedSessionsPrivate.CloseAllOwnedSessions);
}
@@ -0,0 +1,159 @@
#nullable enable
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using Barotrauma;
namespace EosInterfacePrivate;
static class RemoteSessionsPrivate
{
/// <summary>
/// Largest number that can be passed to CreateSessionSearchOptions.MaxSearchResults
/// before it will immediately result in an InvalidParameters error.
/// </summary>
private const uint MaxResultsUpperBound = Epic.OnlineServices.Sessions.SessionsInterface.MaxSearchResults;
public static async Task<Result<ImmutableArray<EosInterface.Sessions.RemoteSession>, EosInterface.Sessions.RemoteSession.Query.Error>> RunQuery(EosInterface.Sessions.RemoteSession.Query query)
{
if (CorePrivate.SessionsInterface is not { } sessionsInterface)
{
return Result.Failure(EosInterface.Sessions.RemoteSession.Query.Error.EosNotInitialized);
}
using var janitor = Janitor.Start();
var createSessionSearchOptions = new Epic.OnlineServices.Sessions.CreateSessionSearchOptions
{
MaxSearchResults = query.MaxResults
};
var createSessionSearchResult = sessionsInterface.CreateSessionSearch(ref createSessionSearchOptions, out var sessionSearchHandle);
if (createSessionSearchResult != Epic.OnlineServices.Result.Success)
{
return Result.Failure(
createSessionSearchResult switch
{
Epic.OnlineServices.Result.InvalidParameters when query.MaxResults > MaxResultsUpperBound
=> EosInterface.Sessions.RemoteSession.Query.Error.ExceededMaxAllowedResults,
Epic.OnlineServices.Result.InvalidParameters
=> EosInterface.Sessions.RemoteSession.Query.Error.InvalidParameters,
_
=> createSessionSearchResult.FailAndLogUnhandledError(EosInterface.Sessions.RemoteSession.Query.Error.UnhandledErrorCondition)
});
}
janitor.AddAction(sessionSearchHandle.Release);
var setParameterOptions = new Epic.OnlineServices.Sessions.SessionSearchSetParameterOptions
{
Parameter = new Epic.OnlineServices.Sessions.AttributeData
{
Key = Epic.OnlineServices.Sessions.SessionsInterface.SearchBucketId,
Value = new Epic.OnlineServices.Sessions.AttributeDataValue
{
AsUtf8 = EosInterface.Sessions.DefaultBucketName + query.BucketIndex
}
},
ComparisonOp = Epic.OnlineServices.ComparisonOp.Equal
};
sessionSearchHandle.SetParameter(ref setParameterOptions);
var findOptions = new Epic.OnlineServices.Sessions.SessionSearchFindOptions
{
LocalUserId = Epic.OnlineServices.ProductUserId.FromString(query.LocalUserId.Value)
};
var findCallbackWaiter = new CallbackWaiter<Epic.OnlineServices.Sessions.SessionSearchFindCallbackInfo>();
sessionSearchHandle.Find(options: ref findOptions, clientData: null, completionDelegate: findCallbackWaiter.OnCompletion);
var findResultOption = await findCallbackWaiter.Task;
if (!findResultOption.TryUnwrap(out var findResult))
{
return Result.Failure(EosInterface.Sessions.RemoteSession.Query.Error.TimedOut);
}
if (findResult.ResultCode != Epic.OnlineServices.Result.Success)
{
return Result.Failure(
findResult.ResultCode switch
{
Epic.OnlineServices.Result.NotFound
=> EosInterface.Sessions.RemoteSession.Query.Error.NotFound,
Epic.OnlineServices.Result.InvalidParameters
=> EosInterface.Sessions.RemoteSession.Query.Error.InvalidParameters,
_
=> EosInterface.Sessions.RemoteSession.Query.Error.EosNotInitialized
});
}
var boilerplate1 = new Epic.OnlineServices.Sessions.SessionSearchGetSearchResultCountOptions();
uint resultCount = sessionSearchHandle.GetSearchResultCount(ref boilerplate1);
var sessions = new List<EosInterface.Sessions.RemoteSession>();
foreach (int sessionIndex in Enumerable.Range(0, (int)resultCount))
{
var attributes = new Dictionary<Identifier, string>();
var copySessionDetailsOptions = new Epic.OnlineServices.Sessions.SessionSearchCopySearchResultByIndexOptions
{
SessionIndex = (uint)sessionIndex
};
var detailsCopyResult = sessionSearchHandle.CopySearchResultByIndex(ref copySessionDetailsOptions, out var sessionDetails);
if (detailsCopyResult != Epic.OnlineServices.Result.Success) { break; }
janitor.AddAction(sessionDetails.Release);
var copyInfoOptions = new Epic.OnlineServices.Sessions.SessionDetailsCopyInfoOptions();
var infoCopyResult = sessionDetails.CopyInfo(ref copyInfoOptions, out var sessionInfo);
if (infoCopyResult != Epic.OnlineServices.Result.Success) { break; }
if (sessionInfo is not
{
Settings:
{
BucketId: { } bucketId,
NumPublicConnections: var numPublicConnections
},
NumOpenPublicConnections: var numOpenPublicConnections,
SessionId: { } sessionId,
HostAddress: { } hostAddress
})
{
break;
}
var boilerplate2 = new Epic.OnlineServices.Sessions.SessionDetailsGetSessionAttributeCountOptions();
var attributeCount = sessionDetails.GetSessionAttributeCount(ref boilerplate2);
foreach (var attributeIndex in Enumerable.Range(0, (int)attributeCount))
{
var copyAttributeOptions =
new Epic.OnlineServices.Sessions.SessionDetailsCopySessionAttributeByIndexOptions
{
AttrIndex = (uint)attributeIndex
};
var attributeCopyResult = sessionDetails.CopySessionAttributeByIndex(ref copyAttributeOptions, out var attributeNullable);
if (attributeCopyResult != Epic.OnlineServices.Result.Success) { break; }
if (attributeNullable?.Data is not { } attributeData
|| attributeData.Value.ValueType != Epic.OnlineServices.AttributeType.String)
{
break;
}
attributes.Add(attributeData.Key.ToIdentifier(), attributeData.Value.AsUtf8);
}
sessions.Add(new EosInterface.Sessions.RemoteSession(
SessionId: sessionId,
HostAddress: hostAddress,
CurrentPlayers: (int)(numPublicConnections - numOpenPublicConnections),
MaxPlayers: (int)numPublicConnections,
Attributes: attributes.ToImmutableDictionary(),
BucketId: bucketId));
}
return Result.Success(sessions.ToImmutableArray());
}
}
internal sealed partial class ImplementationPrivate : EosInterface.Implementation
{
public override Task<Result<ImmutableArray<EosInterface.Sessions.RemoteSession>, EosInterface.Sessions.RemoteSession.Query.Error>> RunRemoteSessionQuery(EosInterface.Sessions.RemoteSession.Query query)
=> TaskScheduler.Schedule(() => RemoteSessionsPrivate.RunQuery(query));
}
@@ -0,0 +1,50 @@
#nullable enable
using System;
using System.Threading.Tasks;
using Barotrauma;
namespace EosInterfacePrivate;
/// <summary>
/// Creates a task that returns the result of a callback.
/// This is meant to be used with EOS' asynchronous methods,
/// which are all callback-based because this is a C library.
/// </summary>
internal class CallbackWaiter<T> where T : notnull
{
private readonly object mutex = new object();
private Option<T> result = Option.None;
private readonly DateTime timeout;
public readonly Task<Option<T>> Task;
public CallbackWaiter(TimeSpan timeout = default)
{
this.timeout = DateTime.Now + (timeout == default
? TimeSpan.FromSeconds(60)
: timeout);
this.Task = System.Threading.Tasks.Task.Run(RunTask);
}
public void OnCompletion(ref T result)
{
lock (mutex)
{
this.result = Option<T>.Some(result);
}
}
private async Task<Option<T>> RunTask()
{
while (DateTime.Now < timeout)
{
lock (mutex)
{
if (result.IsSome()) { return result; }
}
await System.Threading.Tasks.Task.Delay(32);
}
return Option.None;
}
}
@@ -0,0 +1,14 @@
using System.Runtime.CompilerServices;
using Barotrauma.Debugging;
using Microsoft.Xna.Framework;
namespace EosInterfacePrivate;
public static class ResultExtension
{
public static T FailAndLogUnhandledError<T>(this Epic.OnlineServices.Result result, T unknown, [CallerMemberName] string caller = null)
{
DebugConsoleCore.NewMessage($"Result \"{result}\" was not handled by \"{caller}\".", Color.Red);
return unknown;
}
}