using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Needle.Engine.Utils; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; using Object = UnityEngine.Object; namespace Needle.Engine { // model: get license [Serializable] internal class LicenseInformation { public string user_id; public string org_id; public string user_name; public string user_email; public string needle_engine_license; public bool needle_engine_license_is_active; public string needle_engine_license_status; // active, canceled, incomplete, past_due, unpaid... } // model: get/user internal class UserInformation { public string id; public string name; public string email; [CanBeNull] public string profile_picture; public string default_org_id; public UserOrg[] orgs; internal class UserOrg { public string id; public string name; public string[] roles; public bool canWrite; } public static bool NotEqual(UserInformation currentUserInformation, UserInformation userInformation) { if (currentUserInformation == null && userInformation != null || currentUserInformation != null && userInformation == null) return true; if (currentUserInformation == null && userInformation == null) return false; if (currentUserInformation.id != userInformation.id) return true; if (currentUserInformation.name != userInformation.name) return true; if (currentUserInformation.email != userInformation.email) return true; if (currentUserInformation.profile_picture != userInformation.profile_picture) return true; if (currentUserInformation.orgs.Length != userInformation.orgs.Length) return true; if (currentUserInformation.orgs.Length <= 0) return false; for (var i = 0; i < currentUserInformation.orgs.Length; i++) { var o1 = currentUserInformation.orgs[i]; var o2 = userInformation.orgs[i]; if (o1.id != o2.id) return true; if (o1.name != o2.name) return true; if (o1.canWrite != o2.canWrite) return true; if (o1.roles.Length != o2.roles.Length) return true; if (o1.roles.Length <= 0) return false; for (var j = 0; j < o1.roles.Length; j++) { if (o1.roles[j] != o2.roles[j]) return true; } } return false; } } // model: get/deployments internal class UserDeployments { public Deployment[] deployments; internal class Deployment { public string name; public string id; public string org_id; public string url; public string updated_at; [CanBeNull] public string editUrl; } } #if UNITY_EDITOR internal static class Authentication { [InitializeOnLoadMethod] private static async void Init() { // After a domain reload, wait while the editor is updating or compiling do await Task.Delay(TimeSpan.FromSeconds(0.2)); while (EditorApplication.isUpdating || EditorApplication.isCompiling); // For debugging. Requires a domain reload to take effect if (NeedleDebug.IsEnabled(TracingScenario.AuthenticationState)) { UserInfoChanged += () => Debug.Log( $"User info changed: {_userInformation?.name ?? "null"} ({_userInformation?.profile_picture ?? "no profile picture"})"); SelectedTeamChanged += () => Debug.Log($"Selected team changed: {SelectedTeamName} ({SelectedTeam})"); LicenseChanged += () => Debug.Log($"License changed: {LicenseCheck.LastLicenseTypeResult}"); } #if UNITY_EDITOR LicenseChanged += UnityEditorInternal.InternalEditorUtility.RepaintAllViews; #endif #pragma warning disable CS0162 if (!UsePeriodicLicenseCheck) { if(ExportInfo.Get()) Update(); return; } #pragma warning restore CS0162 // Continuously check for license updates while (true) { if(ExportInfo.Get()) Update(); await Task.Delay(TimeSpan.FromSeconds(30)); } } private static DateTime lastLicenseCheckTime; private static async void Update(bool force = false) { if (!force && DateTime.Now - lastLicenseCheckTime < TimeSpan.FromSeconds(LicenseCheckIntervalSeconds)) return; lastLicenseCheckTime = DateTime.Now; await GetUserInformation(); await LicenseCheck.QueryLicense(false); } internal static async Task StartLicenseFlow_Pro() { var cmd = Authentication.baseCommand + " get-pro"; var selectedTeam = Authentication.SelectedTeam; if (selectedTeam != null) cmd += " --org " + selectedTeam; return await ProcessHelper.RunCommand(cmd, null); } internal static async Task StartLicenseFlow_Indie() => await ProcessHelper.RunCommand(Authentication.baseCommand + " get-indie", null); internal static Action SelectedTeamChanged; internal static Action UserInfoChanged; internal static Action LicenseChanged; internal static string SelectedTeam { get => EditorPrefs.GetString("Needle_Cloud_Team_ID", null); set { var current = SelectedTeam; if (value != current) { EditorPrefs.SetString("Needle_Cloud_Team_ID", value); SelectedTeamChanged?.Invoke(); LicenseCheck.QueryLicense(true); } } } internal static string SelectedTeamName { get { if (SelectedTeam == null || team_ids == null) return null; var index = Array.IndexOf(team_ids, SelectedTeam); if (index == -1) return null; return team_names[index]; } } internal static string[] team_ids; internal static string[] team_names; internal static UserInformation _userInformation { get; private set; } internal static string _userInformationError { get; private set; } internal static string baseCommand => NeedleDebug.DeveloperMode ? "needle-cloud" : "npx --yes needle-cloud@version-1"; private static Task serverStartCommand; private static DateTime lastServerStartTime = DateTime.MinValue; [MenuItem("Needle Engine/Internal/🔨 Restart Licensing Server", false, -1000)] private static async void RestartServer() { await EnsureServerIsRunning(true, true, true); } private static int npmCacheErrorCount = 0; internal static async Task EnsureServerIsRunning(bool force = false, bool restart = false, bool allowLogs = false ) { if (!force) { if (DateTime.Now - lastServerStartTime > TimeSpan.FromSeconds(10)) { } else if (serverStartCommand != null) return await serverStartCommand; else if (DateTime.Now - lastServerStartTime < TimeSpan.FromSeconds(5)) return true; } lastServerStartTime = DateTime.Now; while (EditorApplication.isUpdating || EditorApplication.isCompiling) { await Task.Delay(100); } var cmd = $"{baseCommand} start-server --integration unity"; if (restart) cmd += " --restart"; var npmErrorType = default(string); var foundNpmCacheError = false; // var source = new CancellationTokenSource(TimeSpan.FromSeconds(20)); var options = new ProcessHelper.RunOptions() { showWindow = false, silent = true, onLog = (_, log) => { if (log != null && log.StartsWith("npm error 404")) npmErrorType = "Package not found on NPM → make sure you're on https://registry.npmjs.org/"; NeedleDebug.Log(TracingScenario.AuthenticationState, "Start server log: " + log); } }; serverStartCommand = ProcessHelper.RunCommand(cmd, null, options).ContinueWith(t => { var result = t.Result; if (!result.success && result.npmCacheLines?.Count > 0) { // see https://linear.app/needle/issue/NE-6058 NeedleDebug.Log(TracingScenario.AuthenticationState, $"Npm cache error detected: trying to auto-repair"); var _deleteNpmDirectoriesFromLogs = Tools.DeleteNpmDirectoriesFromLogs(result.npmCacheLines, true); npmCacheErrorCount += 1; foundNpmCacheError = true; } return result.success; }, TaskScheduler.FromCurrentSynchronizationContext()); var startedServer = await serverStartCommand; NeedleDebug.Log(TracingScenario.AuthenticationState, "Server started? " + startedServer); // Try to reach the local server a couple of times. We try a maximum of 5 times var lastException = default(Exception); var canReachServer = false; for (var i = 0; i < 5; i++) { if (foundNpmCacheError) { // No need to try to ping in this round if we have found a cache issue break; } try { NeedleDebug.Log(TracingScenario.AuthenticationState, "Pinging server, attempt " + (i + 1)); var ping = await client .GetAsync(localServerUrl) .ContinueWith(x => x.Result); if (ping.IsSuccessStatusCode) { lastException = null; canReachServer = true; NeedleDebug.Log(TracingScenario.AuthenticationState, "Pinged server ✓"); break; } } catch (ObjectDisposedException e) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); lastException = e; _client = new HttpClient(); } catch (TaskCanceledException e) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); lastException = e; } catch (SocketException e) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); _client = new HttpClient(); } catch (AggregateException e) { // check if there is a SocketConnection somewhere if (e.InnerExceptions.Any(x => x is SocketException)) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); _client = new HttpClient(); } else { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); lastException = e; } } catch (Exception e) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); lastException = e; } try { // ContinueWith is here so that TaskCancelException isn't logged await Task.Delay(500).ContinueWith(_ => { }); } // there will be another task running already catch (TaskCanceledException e) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, e); lastException = e; break; } } // Wait a moment for deleting cache directories if (foundNpmCacheError) { await Task.Delay(1000); } serverStartCommand = null; if (!canReachServer) { if (foundNpmCacheError && npmCacheErrorCount <= 1) { // if we detected a server issue give it a little bit of time to self heal NeedleDebug.LogWarning(TracingScenario.AuthenticationState, "Restarting license server due to npm cache error. Trying again in a few seconds."); await Task.Delay(6000); return false; } // if(SessionState.GetBool("Needle_LicenseServerSelfHealing")) throw new ApplicationException( $"Can't reach local Needle License Server on port 8424 ({npmErrorType}). Please try again or restart Unity. If the issue persists please send a bugreport via the menu item \"Needle Engine/Report a Bug\". \n\n\nInner exception: {lastException}"); } return true; } private static HttpClient _client; private static HttpClient client => _client ??= new HttpClient(); internal const string localServerUrl = "http://localhost:8424"; private const bool UsePeriodicLicenseCheck = true; private const int LicenseCheckIntervalSeconds = 2; public static bool LoginCheckInProgress { get; private set; } public static bool UserInfoCheckInProgress { get; private set; } public static async Task LoginAsync(bool allowPrompt, bool silent = false) { var cmd = $"{baseCommand} login"; var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60)); var res = await ProcessHelper.RunCommand(cmd, null, null, true, !silent, cancellationToken: tokenSource.Token); UpdateUserInformation(); LoginCheckInProgress = false; return res; } public static async Task Logout() { var cmd = $"{baseCommand} logout --headless"; var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60)); var res = await ProcessHelper.RunCommand(cmd, null, null, true, true, cancellationToken: tokenSource.Token); _userInformation = null; UserInfoChanged?.Invoke(); if (res) Debug.Log("Logged out successfully"); else Debug.LogError("Failed to logout"); UpdateUserInformation(); } public static async Task OpenAccountPage() { var cmd = $"{baseCommand} open account"; var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(60)); var res = await ProcessHelper.RunCommand(cmd, null, null, true, false, null, tokenSource.Token); if (!res) { Application.OpenURL("https://cloud.needle.tools/account"); } } internal static bool IsLoggedIn() { return _userInformation != null; } [ItemCanBeNull] internal static async Task GetLicenseInformation(CancellationToken token) { LoginCheckInProgress = true; await EnsureServerIsRunning(); var path = "/api/license?integration=unity"; var currentTeam = Authentication.SelectedTeam; if (!string.IsNullOrWhiteSpace(currentTeam)) { path += "&org=" + currentTeam; } HttpResponseMessage res; try { res = await client .GetAsync(localServerUrl + path, token) .ContinueWith(x => x.Result, token); } catch (TaskCanceledException) { res = new HttpResponseMessage(HttpStatusCode.NotFound); } catch (SocketException) { _client = new HttpClient(); res = await client.GetAsync(localServerUrl + path, token); } if (res.IsSuccessStatusCode) { var json = await res.Content.ReadAsStringAsync(); var info = JsonConvert.DeserializeObject(json); LoginCheckInProgress = false; return info; } LoginCheckInProgress = false; return default; } internal static async void UpdateUserInformation() => await GetUserInformation(); [ItemCanBeNull] internal static async Task GetUserInformation() { await EnsureServerIsRunning(); HttpResponseMessage res; UserInfoCheckInProgress = true; NeedleDebug.Log(TracingScenario.AuthenticationState, "Checking user information..."); try { var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); res = await client.GetAsync(localServerUrl + "/api/user", tokenSource.Token).ContinueWith(x => x.Result, tokenSource.Token); } catch (SocketException) { _client = new HttpClient(); try { var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); res = await client.GetAsync(localServerUrl + "/api/user", tokenSource.Token) .ContinueWith(x => x.Result, tokenSource.Token); } catch (SocketException) { // Ignore res = new HttpResponseMessage(HttpStatusCode.BadRequest); } } catch (TaskCanceledException) { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, "User information request timed out."); return null; } var currentUserInformation = _userInformation; _userInformation = default; _userInformationError = default; NeedleDebug.Log(TracingScenario.AuthenticationState, "User information response status: " + (int)res.StatusCode); if (res.IsSuccessStatusCode) { var json = await res.Content.ReadAsStringAsync(); var info = JsonConvert.DeserializeObject(json); _userInformation = info; if (_userInformation != null) { var selected_team = SelectedTeam; var count = _userInformation.orgs.Length; if (team_ids?.Length == 0 || team_ids?.Length != count) { team_ids = new string[count]; team_names = new string[count]; } // team_ids[0] = _userInformation.id; // team_names[0] = "Personal"; for (var i = 0; i < _userInformation.orgs.Length; i++) { team_ids[i] = _userInformation.orgs[i].id; team_names[i] = _userInformation.orgs[i].name; } // if the user switched the account and the team id doesnt exist anymore for this user if (!team_ids.Contains(selected_team)) { SelectedTeam = team_ids.FirstOrDefault(); } #pragma warning disable CS4014 TryGetTexture(_userInformation.id, _userInformation.profile_picture); #pragma warning restore CS4014 } else { team_ids = Array.Empty(); team_names = Array.Empty(); } if (UserInformation.NotEqual(currentUserInformation, _userInformation)) { UserInfoChanged?.Invoke(); #pragma warning disable CS4014 LicenseCheck.QueryLicense(true); #pragma warning restore CS4014 } UserInfoCheckInProgress = false; return info; } if (res.StatusCode == (HttpStatusCode)403) { var json = await res.Content.ReadAsStringAsync(); NeedleDebug.LogWarning(TracingScenario.AuthenticationState, "Failed to get user information: " + res.StatusCode + " - " + json); if (json.TrimStart().StartsWith("{")) { var obj = JObject.Parse(json); if (obj.TryGetValue("error", out var errorToken)) { _userInformationError = errorToken.Value(); } } } else { NeedleDebug.LogWarning(TracingScenario.AuthenticationState, "Failed to get user information: " + res.StatusCode); } if (UserInformation.NotEqual(currentUserInformation, _userInformation)) { UserInfoChanged?.Invoke(); #pragma warning disable CS4014 LicenseCheck.QueryLicense(true); #pragma warning restore CS4014 } UserInfoCheckInProgress = false; return default; } internal static async Task GetDeployments() { await EnsureServerIsRunning(); var res = await client.GetAsync($"{localServerUrl}/api/deployments?org={SelectedTeam}"); if (res.IsSuccessStatusCode) { var json = await res.Content.ReadAsStringAsync(); var info = JsonConvert.DeserializeObject(json); return info; } return default; } internal static Texture2D TryGetUserProfileTexture() { if (_userInformation != null) { if (_textures.TryGetValue(_userInformation.id, out var tex)) return tex; } return null; } private static readonly Dictionary _textures = new Dictionary(); private static async Task TryGetTexture(string key, string url) { { if (_textures.TryGetValue(key, out var texture)) { return texture; } } if (!string.IsNullOrWhiteSpace(url)) { var targetDir = "Temp/Needle/.pictures"; var targetPath = targetDir + "/" + key; var firstLoad = false; if (!File.Exists(targetPath)) { Directory.CreateDirectory(targetDir); var fs = File.Create(targetPath); var res = await client.GetByteArrayAsync(url); await fs.WriteAsync(res); fs.Close(); await fs.DisposeAsync(); firstLoad = true; } if (File.Exists(targetPath)) { var bytes = File.ReadAllBytes(targetPath); var texture = new Texture2D(1, 1); texture.LoadImage(bytes); texture.name = key; _textures.TryAdd(key, texture); if (firstLoad) UserInfoChanged?.Invoke(); return texture; } } return null; } } #endif }