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
}