Repository URL to install this package:
|
Version:
1.0.0 ▾
|
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Fluctio.FluctioSim.Common.Configuration;
using Fluctio.FluctioSim.Common.Icons;
using Fluctio.FluctioSim.EditorUtils.EditorGeneral;
using Fluctio.FluctioSim.EditorUtils.Http;
using Fluctio.FluctioSim.Utils.General;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Results;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SettingsManagement;
using UnityEngine;
using UnityEngine.Networking;
using static Fluctio.FluctioSim.EditorUtils.SettingsManagement.SettingsManager;
using Ping = System.Net.NetworkInformation.Ping;
namespace Fluctio.FluctioSim.EditorCore.AccountManagement
{
internal static class Account
{
#region Dependencies
private static readonly HttpServer CallbackServer = new(new UriBuilder("http://localhost/"), ContinueLogIn, Debug.LogException, Config.AuthCallbackPort);
private static readonly OidcClient OidcClient = new(Config.AuthOptions);
private static readonly HttpClient HttpClient = new()
{
BaseAddress = Config.ApiBaseUrl,
};
#endregion
#region Account info
[InitializeOnLoadMethod] private static void InitSettings() => AddExecutingAssembly();
private static readonly UserSetting<bool> LoggedInSetting = Preference("Account.LoggedIn", false);
private static readonly UserSetting<AccountInfo> InfoSetting = Preference("Account.Info", default(AccountInfo));
public static bool IsLoggedIn => LoggedInSetting.value && InfoSetting.value != null;
public static event Action InfoChanged;
public static AccountInfo Info
{
get
{
if (!LoggedInSetting.value || InfoSetting.value == null)
{
throw new InvalidOperationException("Not logged in");
}
return InfoSetting.value;
}
private set
{
InfoSetting.value = value;
LoggedInSetting.value = (value != null);
//TODO: use event and refactor to separate classes
_accountChangedCancellation.Cancel();
_accountChangedCancellation = new CancellationTokenSource();
LoadProfilePicture();
ScheduleRefreshTokens();
if (IsLoggedIn)
{
UpdateLicenseInfoAsync();
}
InfoChanged?.Invoke();
}
}
private static void ApplyInfoProperties()
{
InfoChanged?.Invoke();
InfoSetting.ApplyModifiedProperties();
}
#endregion
#region Log in process state
private static AuthorizeState _authorizeState;
private static CancellationTokenSource _logInCancellation;
private static CancellationTokenSource _accountChangedCancellation = new();
public static bool IsLogInInProgress { get; private set; } = false;
#endregion
#region Token refreshing
private static readonly SemaphoreSlim TokenRefreshSemaphore = new(1, 1);
[InitializeOnLoadMethod]
private static void ScheduleRefreshTokens()
{
if (!IsLoggedIn)
{
return;
}
Util.ExecuteAfter(
RefreshTokens,
Info.TokensValidTill,
cancellationToken: _accountChangedCancellation.Token);
}
private static async void RefreshTokens()
{
try
{
await TokenRefreshSemaphore.WaitAsync(_accountChangedCancellation.Token);
}
catch (TaskCanceledException)
{
return;
}
try
{
RefreshTokenResult refreshTokenResult;
try
{
refreshTokenResult = await OidcClient.RefreshTokenAsync(
Info.RefreshToken,
cancellationToken: _accountChangedCancellation.Token);
}
catch (TaskCanceledException)
{
return;
}
if (refreshTokenResult.IsError)
{
var messageBuilder = new StringBuilder();
messageBuilder.AppendFormat("You were signed out of {0} account due to unexpected error",
Config.RawName);
if (Config.IsDebug)
{
messageBuilder.AppendFormat("\nDebug info: {0} / {1}", refreshTokenResult.Error,
refreshTokenResult.ErrorDescription);
}
Debug.LogWarning(messageBuilder.ToString());
Info = null;
return;
}
Info.SetTokens(refreshTokenResult);
ApplyInfoProperties();
}
finally
{
TokenRefreshSemaphore.Release();
}
}
#endregion
#region Login flow
public static async void StartLogIn()
{
if (IsLoggedIn)
{
throw new InvalidOperationException("Already logged in");
}
if (IsLogInInProgress)
{
if (_authorizeState == null)
{
return;
}
Application.OpenURL(_authorizeState.StartUrl);
return;
}
try
{
_logInCancellation = new CancellationTokenSource();
IsLogInInProgress = true;
CallbackServer.Start();
EditorApplication.LockReloadAssemblies();
OidcClient.Options.RedirectUri = CallbackServer.Prefix.AbsoluteUri;
_authorizeState = await OidcClient.PrepareLoginAsync(Config.AuthLoginParams, _logInCancellation.Token);
Application.OpenURL(_authorizeState.StartUrl);
}
catch (Exception exception)
{
StopLogIn();
if (exception is not OperationCanceledException)
{
throw;
}
}
}
private static async Task ContinueLogIn(HttpListenerContext context)
{
try
{
var loginResult = await OidcClient.ProcessResponseAsync(context.Request.RawUrl, _authorizeState, Config.AuthLoginParams, _logInCancellation.Token);
if (loginResult.IsError)
{
SendHttpResponse(
context,
false,
additionalInfo: loginResult.ErrorDescription,
debugInfo: $"{loginResult.Error}\n{loginResult.ErrorDescription}");
return;
}
Info = new AccountInfo(loginResult);
SendHttpResponse(context, true, debugInfo: Info.GetDebugInfo());
StopLogIn();
}
catch (Exception exception)
{
SendHttpResponse(context, false, debugInfo: exception.ToString());
}
}
private static void SendHttpResponse(HttpListenerContext context, bool isSuccess, string additionalInfo = null, string debugInfo = null)
{
context.SetStatusCode(isSuccess ? HttpStatusCode.OK : HttpStatusCode.BadRequest);
using (context.Html("en"))
{
using (context.Head())
{
context.Title(Config.RawName);
context.CharacterFavicon(Config.Emoji);
using (context.Style())
{
using (context.StyleBlock("html"))
{
context.StyleDeclaration("height", "100%");
context.StyleDeclaration("margin", "0");
context.StyleDeclaration("display", "flex");
context.StyleDeclaration("justify-content", "center");
context.StyleDeclaration("align-items", "center");
context.StyleDeclaration("text-align", "center");
if (Config.IsDebug)
{
context.StyleDeclaration("word-break", "break-all");
}
}
}
}
using (context.Body())
{
context.H_(1, isSuccess ? "Logged in successfully" : "Error during log in");
if (additionalInfo != null)
{
context.P(additionalInfo);
}
context.P(isSuccess ? "Close this tab and return to Unity" : "Close this tab and try again");
if (!isSuccess)
{
var email = Config.PackageInfo.author.email;
context.P($"If the problem persists, please contact us at {email}");
}
if (Config.IsDebug && debugInfo != null)
{
context.H_(2, "Debug info:");
context.P(debugInfo);
}
}
}
}
public static void StopLogIn()
{
_logInCancellation?.Cancel();
_logInCancellation = null;
_authorizeState = null;
CallbackServer.Stop();
EditorApplication.UnlockReloadAssemblies();
IsLogInInProgress = false;
}
public static void LogOut()
{
Info = null;
OidcClient.LogoutAsync();
}
#endregion
#region General API functions
internal static async Task<string> MakeApiCallRaw(HttpMethod method, string endpoint, bool isEndpointPublic = false)
{
if (!isEndpointPublic && !IsLoggedIn)
{
throw new InvalidOperationException("Not logged in");
}
if (!isEndpointPublic)
{
await TokenRefreshSemaphore.WaitWithoutLockAsync();
}
HttpResponseMessage response;
using (var request = new HttpRequestMessage(method, endpoint))
{
if (!isEndpointPublic)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Info.AccessToken);
}
var cancellationToken = isEndpointPublic ? default : _accountChangedCancellation.Token;
response = await HttpClient.SendAsync(request, cancellationToken);
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
internal static async Task<T> MakeApiCallJson<T>(HttpMethod method, string endpoint, bool isEndpointPublic = false)
{
var responseString = await MakeApiCallRaw(method, endpoint, isEndpointPublic);
try
{
return JsonConvert.DeserializeObject<T>(responseString);
}
catch (JsonReaderException exception)
{
throw new HttpRequestException($"Wrong json string\n{responseString}", exception);
}
}
/// <summary>
/// Useful for waking up API after long time of inactivity
/// </summary>
internal static async Task<bool> PingAPI()
{
try
{
await MakeApiCallRaw(HttpMethod.Get, "ping", true);
return true;
}
catch (WebException)
{
return false;
}
}
#endregion
#region Connectivity check
private static readonly TimeSpan PingTimeout = TimeSpan.FromSeconds(2);
private static readonly TimeSpan ConnectivityCheckCacheTime = TimeSpan.FromSeconds(5);
private static readonly Ping ConnectivityPingSender = new();
private static DateTime _lastConnectivityCheckTime = DateTime.MinValue;
private static bool _cachedIsOnline = true;
private static bool _isCheckingConnectivity = false;
public static bool IsOnline
{
get
{
if (DateTime.UtcNow - _lastConnectivityCheckTime > ConnectivityCheckCacheTime)
{
UpdateIsOnline();
}
return _cachedIsOnline;
}
}
private static async void UpdateIsOnline()
{
if (_isCheckingConnectivity)
{
return;
}
try
{
_isCheckingConnectivity = true;
_cachedIsOnline = await CheckConnectivity();
_lastConnectivityCheckTime = DateTime.UtcNow;
}
finally
{
_isCheckingConnectivity = false;
}
}
private static async Task<bool> CheckConnectivity()
{
// Using Ping.SendPingAsync blocks UI thread for some reason.
// Possibly, call to PingReply.Status waits synchronously for the end of request.
// Using Ping.Send together with Task.Run works fine.
return await Task.Run(() =>
{
try
{
var connectionCheckHost = Config.ApiBaseUrl.Host;
var pingReply = ConnectivityPingSender.Send(connectionCheckHost, (int)PingTimeout.TotalMilliseconds);
return pingReply is { Status: IPStatus.Success };
}
catch (SocketException)
{
return false;
}
});
}
#endregion
#region License refreshing
private static CancellationTokenSource _licenseUpdateCancellation;
public static bool IsLicenseUpdating { get; private set; }
private static void ScheduleLicenseUpdate()
{
_licenseUpdateCancellation?.Cancel();
_licenseUpdateCancellation = new CancellationTokenSource();
var mixedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
_accountChangedCancellation.Token,
_licenseUpdateCancellation.Token);
Util.ExecuteAfter(
UpdateLicenseInfoAsync,
Info.LicenseValidTill,
cancellationToken: mixedCancellation.Token);
}
[InitializeOnLoadMethod]
private static void InitializeLicenseInfo()
{
if (EditorUtil.DidEditorJustOpen && IsLoggedIn)
{
UpdateLicenseInfoAsync();
}
}
public static async void UpdateLicenseInfoAsync()
{
if (IsLicenseUpdating)
{
return;
}
try
{
IsLicenseUpdating = true;
var licenseInfo = await MakeApiCallJson<LicenseInfo>(HttpMethod.Get, "get_license_info");
Info.SetLicenseInfo(licenseInfo);
ApplyInfoProperties();
ScheduleLicenseUpdate();
}
catch (OperationCanceledException)
{
// do nothing, this is intended
}
finally
{
IsLicenseUpdating = false;
}
}
#endregion
#region Profile picture
public static Texture2D ProfilePicture { get; private set; } = DefinedIcons.DefaultProfilePicture;
[InitializeOnLoadMethod]
private static async void LoadProfilePicture()
{
ProfilePicture = DefinedIcons.DefaultProfilePicture;
if (!IsLoggedIn || Info.PictureUrl == "")
{
return;
}
try
{
var cancellationToken = _accountChangedCancellation.Token;
cancellationToken.ThrowIfCancellationRequested();
var request = UnityWebRequestTexture.GetTexture(Info.PictureUrl);
await request.SendWebRequest();
cancellationToken.ThrowIfCancellationRequested();
if (request.result != UnityWebRequest.Result.Success)
{
return;
}
var profilePicture = DownloadHandlerTexture.GetContent(request);
cancellationToken.ThrowIfCancellationRequested();
ProfilePicture = profilePicture;
//TODO: save to disk
}
catch (OperationCanceledException)
{
// do nothing, this is intended
}
}
#endregion
}
}