Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
Size: Mime:
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
		
	}
}