From e2fd04276b59dd1bb9c4e0a9b5fb879f0202b84c Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Tue, 8 Sep 2026 13:42:22 +0200 Subject: [PATCH 01/11] Initial errors --- Editor/AGXUnityEditor/LicenseWarnings.cs | 94 +++++++ Editor/AGXUnityEditor/LicenseWarnings.cs.meta | 11 + Editor/AGXUnityEditor/Manager.cs | 8 +- .../Windows/LicenseManagerWindow.cs | 246 +++++++++++++----- 4 files changed, 289 insertions(+), 70 deletions(-) create mode 100644 Editor/AGXUnityEditor/LicenseWarnings.cs create mode 100644 Editor/AGXUnityEditor/LicenseWarnings.cs.meta diff --git a/Editor/AGXUnityEditor/LicenseWarnings.cs b/Editor/AGXUnityEditor/LicenseWarnings.cs new file mode 100644 index 00000000..a156f223 --- /dev/null +++ b/Editor/AGXUnityEditor/LicenseWarnings.cs @@ -0,0 +1,94 @@ +using AGXUnity; +using System; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace AGXUnityEditor +{ + // Keep the result of loading separate from file queries, which may change + // the native status without changing the currently loaded license. + internal static class LicenseWarnings + { + internal static LicenseInfo CurrentLicense { get; private set; } + + internal static void Capture( LicenseInfo info ) + { + CurrentLicense = info; + } + + internal static bool IsFloating( LicenseInfo info ) + { + return info.IsFloating || + ( info.Status?.IndexOf( "floating", StringComparison.OrdinalIgnoreCase ) ?? -1 ) >= 0; + } + + internal static string GetWarningMessage( LicenseInfo info, bool hasFloatingLicense = false ) + { + if ( info.IsValid ) + return string.Empty; + + var message = "No valid AGX Dynamics license is available."; + if ( !string.IsNullOrWhiteSpace( info.Status ) ) + message += "\n\n" + info.Status.Trim(); + + return message + "\n\n" + ( IsFloating( info ) || hasFloatingLicense ? + "Check the connection to your floating license server and that a license seat is available." : + "Activate a license or import an existing license file in the License Manager." ); + } + + internal static string GetActivationError( LicenseInfo info ) + { + // A failed request can leave a previously valid license loaded. Its + // status must not be presented as the reason activation failed. + return "License activation failed.\n\n" + + ( !info.IsValid && !string.IsNullOrWhiteSpace( info.Status ) ? + info.Status.Trim() : + "Check the Console for details, then verify your license ID, activation password, and connection before retrying." ); + } + + internal static void ScheduleStartupWarning() + { + if ( Application.isBatchMode || CurrentLicense.IsValid || + SessionState.GetBool( s_startupWarningShownKey, false ) ) + return; + + EditorApplication.update -= ShowStartupWarning; + EditorApplication.update += ShowStartupWarning; + } + + private static void ShowStartupWarning() + { + if ( Application.isBatchMode || SessionState.GetBool( s_startupWarningShownKey, false ) ) { + EditorApplication.update -= ShowStartupWarning; + return; + } + + if ( EditorApplication.isCompiling || EditorApplication.isUpdating || + EditorApplication.isPlayingOrWillChangePlaymode || LicenseManager.IsBusy || + Resources.FindObjectsOfTypeAll() + .Any( window => window.IsUpdatingLicenseInformation ) ) + return; + + EditorApplication.update -= ShowStartupWarning; + if ( !NativeHandler.HasInstance || !NativeHandler.Instance.Initialized ) + return; + + // Observe validity only: checking a warning must never acquire or return + // a floating seat. Retain the captured failure status if still invalid. + if ( agx.Runtime.instance().isValid() ) { + Capture( LicenseInfo.Create() ); + return; + } + + SessionState.SetBool( s_startupWarningShownKey, true ); + if ( EditorUtility.DisplayDialog( "AGX Dynamics license", + GetWarningMessage( CurrentLicense ), + "Open License Manager", + "Dismiss" ) ) + Windows.LicenseManagerWindow.Open(); + } + + private const string s_startupWarningShownKey = "AGXUnity.LicenseWarningShown"; + } +} diff --git a/Editor/AGXUnityEditor/LicenseWarnings.cs.meta b/Editor/AGXUnityEditor/LicenseWarnings.cs.meta new file mode 100644 index 00000000..7603aef3 --- /dev/null +++ b/Editor/AGXUnityEditor/LicenseWarnings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a8e1be33b7b4a9e949e02e53e6c7b16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/AGXUnityEditor/Manager.cs b/Editor/AGXUnityEditor/Manager.cs index a8e3d7c0..4536b803 100644 --- a/Editor/AGXUnityEditor/Manager.cs +++ b/Editor/AGXUnityEditor/Manager.cs @@ -785,12 +785,16 @@ internal static EnvironmentState ConfigureEnvironment() return EnvironmentState.Uninitialized; } - // This validate is only for "license status" window so - // the user will be noticed when something is wrong. + // Complete license loading (including floating checkout) before deciding + // whether to notify the user. Native initialization failures are separate. try { AGXUnity.LicenseManager.LoadFile(); AGXUnity.NativeHandler.Instance.ValidateLicense(); + if ( AGXUnity.NativeHandler.Instance.Initialized ) { + LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); + LicenseWarnings.ScheduleStartupWarning(); + } } catch ( Exception ) { return EnvironmentState.Uninitialized; diff --git a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs index f535571d..19e87290 100644 --- a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs +++ b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs @@ -1,4 +1,4 @@ -using AGXUnity.Utils; +using AGXUnity.Utils; using System.Collections.Generic; using System.IO; using System.Linq; @@ -39,11 +39,15 @@ private set } } - public bool IsUpdatingLicenseInformation { get { return m_updateLicenseInfoTask != null; } } + public bool IsUpdatingLicenseInformation => m_updateLicenseInformationRequested || m_updateLicenseInfoTask != null; + + private bool IsBusy => AGXUnity.LicenseManager.IsBusy || m_licenseOperationTask != null; private void OnEnable() { m_activeLicenseStyle = null; + m_checkLicenseValidity = true; + EditorApplication.update += OnEditorUpdate; ValidateLicenseDirectory(); @@ -52,13 +56,24 @@ private void OnEnable() private void OnDisable() { - AGXUnity.LicenseManager.AwaitTasks(); - m_updateLicenseInfoTask?.Wait( 0 ); + EditorApplication.update -= OnEditorUpdate; + // Native operations may finish after the window closes. Their callbacks + // only complete a task and never retain or access this window. + m_licenseOperationTask = null; + } + + private void OnFocus() + { + m_checkLicenseValidity = true; } private void OnGUI() { ValidateLicenseDirectory(); + if ( m_clearFocus ) { + UnityEngine.GUI.FocusControl( "" ); + m_clearFocus = false; + } using ( GUI.AlignBlock.Center ) GUILayout.Box( IconManager.GetAGXUnityLogo(), @@ -75,17 +90,25 @@ private void OnGUI() if ( IsUpdatingLicenseInformation ) ShowNotification( GUI.MakeLabel( "Reading..." ) ); - else if ( AGXUnity.LicenseManager.IsBusy ) - ShowNotification( GUI.MakeLabel( AGXUnity.LicenseManager.IsActivating ? + else if ( IsBusy ) + ShowNotification( GUI.MakeLabel( AGXUnity.LicenseManager.IsActivating || m_operationIsActivation ? "Activating..." : "Refreshing..." ) ); - if ( !AGXUnity.LicenseManager.LicenseInfo.IsValid ) { - EditorGUILayout.HelpBox( "If you have activated your license previously on this machine, consider importing the .lfx file instead of reactivating the license.", MessageType.Info, true ); + if ( !IsUpdatingLicenseInformation && !IsBusy && + AGXUnity.NativeHandler.HasInstance && AGXUnity.NativeHandler.Instance.Initialized && + !LicenseWarnings.CurrentLicense.IsValid ) { + var hasFloatingLicense = m_licenseData.Any( data => LicenseWarnings.IsFloating( data.LicenseInfo ) ); + EditorGUILayout.HelpBox( LicenseWarnings.GetWarningMessage( LicenseWarnings.CurrentLicense, hasFloatingLicense ), MessageType.Warning, true ); + if ( !hasFloatingLicense && !LicenseWarnings.IsFloating( LicenseWarnings.CurrentLicense ) ) + EditorGUILayout.HelpBox( "If you have activated your license previously on this machine, consider importing the .lfx file instead of reactivating the license.", MessageType.Info, true ); GUILayout.Space( 6 ); } - using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !AGXUnity.LicenseManager.IsBusy ) ) { + if ( !string.IsNullOrEmpty( m_licenseReadError ) ) + EditorGUILayout.HelpBox( m_licenseReadError, MessageType.Error, true ); + + using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !IsBusy ) ) { for ( int i = 0; i < m_licenseData.Count; ++i ) { var data = m_licenseData[ i ]; LicenseDataGUI( data ); @@ -118,7 +141,7 @@ private void OnGUI() EditorGUILayout.EndScrollView(); - if ( AGXUnity.LicenseManager.IsBusy || IsUpdatingLicenseInformation ) + if ( IsBusy || IsUpdatingLicenseInformation ) Repaint(); } @@ -147,6 +170,9 @@ private void ActivateLicenseGUI() "Cancel" ) ) { try { File.Copy( sourceLicense, targetLicense, false ); + if ( AGXUnity.LicenseManager.LoadFile( targetLicense ) ) + m_activationError = null; + LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); StartUpdateLicenseInformation(); GUIUtility.ExitGUI(); } @@ -189,38 +215,58 @@ private void ActivateLicenseGUI() LicenseDirectory = newDirectory; } ); - using ( new GUI.EnabledBlock( UnityEngine.GUI.enabled && - m_licenseActivateData.Id.Length > 0 && + var validId = int.TryParse( m_licenseActivateData.Id, out var licenseId ) && licenseId > 0; + if ( m_licenseActivateData.Id.Length > 0 && !validId ) + EditorGUILayout.HelpBox( "Enter a license ID between 1 and 2147483647.", MessageType.Error, true ); + + if ( !string.IsNullOrEmpty( m_activationError ) ) + EditorGUILayout.HelpBox( m_activationError, MessageType.Error, true ); + + using ( new GUI.EnabledBlock( UnityEngine.GUI.enabled && validId && m_licenseActivateData.Password.Length > 0 ) ) { // It isn't possible to press this button during activation. if ( UnityEngine.GUI.Button( EditorGUI.IndentedRect( EditorGUILayout.GetControlRect() ), - GUI.MakeLabel( AGXUnity.LicenseManager.IsBusy ? + GUI.MakeLabel( IsBusy ? "Activating..." : "Activate" ), InspectorEditor.Skin.Button ) ) { - AGXUnity.LicenseManager.ActivateAsync( System.Convert.ToInt32( m_licenseActivateData.Id ), - m_licenseActivateData.Password, - LicenseDirectory, - success => { - if ( success ) - m_licenseActivateData = IdPassword.Empty(); - else - Debug.LogError( "License Error: ".Color( Color.red ) + AGXUnity.LicenseManager.LicenseInfo.Status ); - - StartUpdateLicenseInformation(); - - UnityEngine.GUI.FocusControl( "" ); - } ); + ActivateLicense( licenseId ); } } } } + private void ActivateLicense( int licenseId ) + { + m_activationError = null; + m_operationIsActivation = true; + var completion = new TaskCompletionSource(); + m_licenseOperationTask = completion.Task; + try { + AGXUnity.LicenseManager.ActivateAsync( licenseId, + m_licenseActivateData.Password, + LicenseDirectory, + success => { + var info = AGXUnity.LicenseInfo.Create(); + if ( !success ) + Debug.LogError( "License Error: ".Color( Color.red ) + LicenseWarnings.GetActivationError( info ) ); + completion.TrySetResult( new LicenseOperationResult { Success = success, LicenseInfo = info } ); + } ); + } + catch ( System.Exception e ) { + Debug.LogException( e ); + completion.TrySetResult( new LicenseOperationResult { + LicenseInfo = AGXUnity.LicenseInfo.Create(), + Error = LicenseWarnings.GetActivationError( new AGXUnity.LicenseInfo() ) + } ); + } + } + private void LicenseDataGUI( LicenseData data ) { var highlight = m_licenseData.Count > 1 && !IsUpdatingLicenseInformation && - !AGXUnity.LicenseManager.IsBusy && + !IsBusy && data.LicenseInfo.UniqueId == AGXUnity.LicenseManager.LicenseInfo.UniqueId; if ( highlight && m_activeLicenseStyle == null ) m_activeLicenseStyle = new GUIStyle( InspectorEditor.Skin.Label ); @@ -266,8 +312,10 @@ private void LicenseDataGUI( LicenseData data ) else if ( deleteOnly ) AGXUnity.LicenseManager.DeleteFile( data.Filename ); - if ( deactivateAndDelete || deleteOnly ) + if ( deactivateAndDelete || deleteOnly ) { + LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); StartUpdateLicenseInformation(); + } }, UnityEngine.GUI.enabled, "Deactivate and erase license file from project." ) @@ -312,60 +360,108 @@ private void ValidateLicenseDirectory() private void StartUpdateLicenseInformation() { - if ( IsUpdatingLicenseInformation ) + m_updateLicenseInformationRequested = true; + m_licenseReadError = null; + } + + private void OnEditorUpdate() + { + // ActivateAsync and RefreshAsync invoke callbacks from worker threads. + // Wait until the native task has finished before reading files or UI state. + if ( AGXUnity.LicenseManager.IsBusy ) return; - var currentLicense = AGXUnity.LicenseManager.LicenseInfo.UniqueId; - var licenseData = new List(); - m_updateLicenseInfoTask = Task.Run( () => { - foreach ( var licenseFile in AGXUnity.LicenseManager.FindLicenseFiles() ) { - var info = AGXUnity.LicenseManager.QueryInfo( licenseFile ); - - licenseData.Add( new LicenseData() - { - Filename = licenseFile, - LicenseInfo = info - } ); + if ( m_licenseOperationTask != null && m_licenseOperationTask.IsCompleted ) { + var result = m_licenseOperationTask.Result; + m_licenseOperationTask = null; + LicenseWarnings.Capture( result.LicenseInfo ); + if ( m_operationIsActivation ) { + if ( result.Success ) { + m_licenseActivateData = IdPassword.Empty(); + m_activationError = null; + } + else + m_activationError = result.Error ?? LicenseWarnings.GetActivationError( result.LicenseInfo ); + m_clearFocus = true; } + else if ( result.Success && result.LicenseInfo.IsValid ) + m_activationError = null; + m_operationIsActivation = false; + RemoveNotification(); + StartUpdateLicenseInformation(); + Repaint(); + } - return licenseData; - } ); - - EditorApplication.update += OnUpdateLicenseInformation; - } + if ( IsBusy ) + return; - private void OnUpdateLicenseInformation() - { if ( m_updateLicenseInfoTask != null && m_updateLicenseInfoTask.IsCompleted ) { - m_licenseData = m_updateLicenseInfoTask.Result; + var licenseData = m_updateLicenseInfoTask.Result; m_updateLicenseInfoTask = null; + if ( licenseData != null ) + m_licenseData = licenseData; + else + m_licenseReadError = "Unable to read license information. Check the Console for details."; + RemoveNotification(); + Repaint(); } - if ( m_updateLicenseInfoTask == null ) - EditorApplication.update -= OnUpdateLicenseInformation; - } - - private void RefreshLicense( LicenseData licenseData ) - { - var prevLicense = m_licenseData.Find( data => data.LicenseInfo.UniqueId == AGXUnity.LicenseManager.LicenseInfo.UniqueId ); - AGXUnity.LicenseManager.RefreshAsync( licenseData.Filename, - success => { - if ( !success ) - Debug.LogError( "License Error: ".Color( Color.red ) + AGXUnity.LicenseManager.LicenseInfo.Status ); + if ( m_updateLicenseInfoTask != null ) + return; - UpdateLicenseInfo( licenseData.Filename, AGXUnity.LicenseManager.LicenseInfo ); - if ( prevLicense.Filename != licenseData.Filename ) - AGXUnity.LicenseManager.LoadFile( prevLicense.Filename ); + if ( m_checkLicenseValidity && AGXUnity.NativeHandler.HasInstance && AGXUnity.NativeHandler.Instance.Initialized ) { + m_checkLicenseValidity = false; + var info = AGXUnity.LicenseInfo.Create(); + if ( info.IsValid != LicenseWarnings.CurrentLicense.IsValid ) { + LicenseWarnings.Capture( info ); + if ( info.IsValid ) + m_activationError = null; + Repaint(); + } + } - StartUpdateLicenseInformation(); - } ); + if ( m_updateLicenseInformationRequested ) { + m_updateLicenseInformationRequested = false; + m_updateLicenseInfoTask = Task.Run( () => { + try { + var licenseData = new List(); + foreach ( var licenseFile in AGXUnity.LicenseManager.FindLicenseFiles() ) { + var info = AGXUnity.LicenseManager.QueryInfo( licenseFile ); + licenseData.Add( new LicenseData { Filename = licenseFile, LicenseInfo = info } ); + } + return licenseData; + } + catch ( System.Exception e ) { + Debug.LogException( e ); + return null; + } + } ); + } } - private void UpdateLicenseInfo( string filename, AGXUnity.LicenseInfo licenseInfo ) + private void RefreshLicense( LicenseData licenseData ) { - for ( int i = 0; i < m_licenseData.Count; ++i ) { - if ( m_licenseData[ i ].Filename == filename ) - m_licenseData[ i ] = new LicenseData() { Filename = filename, LicenseInfo = licenseInfo }; + var prevLicense = m_licenseData.Find( data => data.LicenseInfo.UniqueId == AGXUnity.LicenseManager.LicenseInfo.UniqueId ); + m_operationIsActivation = false; + var completion = new TaskCompletionSource(); + m_licenseOperationTask = completion.Task; + try { + AGXUnity.LicenseManager.RefreshAsync( licenseData.Filename, + success => { + try { + if ( !success ) + Debug.LogError( "License Error: ".Color( Color.red ) + AGXUnity.LicenseManager.LicenseInfo.Status ); + if ( !string.IsNullOrEmpty( prevLicense.Filename ) && prevLicense.Filename != licenseData.Filename ) + AGXUnity.LicenseManager.LoadFile( prevLicense.Filename ); + } + finally { + completion.TrySetResult( new LicenseOperationResult { Success = success, LicenseInfo = AGXUnity.LicenseInfo.Create() } ); + } + } ); + } + catch ( System.Exception e ) { + Debug.LogException( e ); + completion.TrySetResult( new LicenseOperationResult { LicenseInfo = AGXUnity.LicenseInfo.Create() } ); } } @@ -382,11 +478,25 @@ private struct LicenseData public AGXUnity.LicenseInfo LicenseInfo; } + private struct LicenseOperationResult + { + public bool Success; + public AGXUnity.LicenseInfo LicenseInfo; + public string Error; + } + private IdPassword m_licenseActivateData = IdPassword.Empty(); private Vector2 m_scroll = Vector2.zero; [System.NonSerialized] private List m_licenseData = new List(); private Task> m_updateLicenseInfoTask = null; + private Task m_licenseOperationTask = null; + private bool m_updateLicenseInformationRequested = false; + private bool m_operationIsActivation = false; + private bool m_checkLicenseValidity = false; + private bool m_clearFocus = false; + private string m_activationError = null; + private string m_licenseReadError = null; [System.NonSerialized] private GUIStyle m_activeLicenseStyle = null; From 5ffe08393afb9c46964038b2c833d93b1bb08ede Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Tue, 8 Sep 2026 16:35:43 +0200 Subject: [PATCH 02/11] Connection handling in licensse manager --- AGXUnity/LicenseInfo.cs | 10 +- AGXUnity/LicenseManager.cs | 524 +++++++++++++----- .../BuildProcessCallbacksHandler.cs | 3 +- Editor/AGXUnityEditor/LicenseWarnings.cs | 7 +- Editor/AGXUnityEditor/Manager.cs | 2 +- .../Windows/LicenseManagerWindow.cs | 275 ++++++--- Tests/LicenseManager~/README.md | 9 + Tests/LicenseManager~/Run.ps1 | 32 ++ Tests/LicenseManager~/Stubs.cs | 318 +++++++++++ Tests/LicenseManager~/Tests.cs | 489 ++++++++++++++++ 10 files changed, 1437 insertions(+), 232 deletions(-) create mode 100644 Tests/LicenseManager~/README.md create mode 100644 Tests/LicenseManager~/Run.ps1 create mode 100644 Tests/LicenseManager~/Stubs.cs create mode 100644 Tests/LicenseManager~/Tests.cs diff --git a/AGXUnity/LicenseInfo.cs b/AGXUnity/LicenseInfo.cs index 8b9bcbb0..7ca8e1d3 100644 --- a/AGXUnity/LicenseInfo.cs +++ b/AGXUnity/LicenseInfo.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; -using UnityEditor; using Math = System.Math; namespace AGXUnity @@ -89,10 +88,8 @@ public static LicenseInfo Create() { var info = new LicenseInfo(); -#if UNITY_EDITOR - if ( AssetDatabase.IsAssetImportWorkerProcess() ) + if ( !LicenseManager.CanAccessRuntime ) return info; -#endif try { info.Version = agx.agxSWIG.agxGetVersion( false ); @@ -130,7 +127,7 @@ public static LicenseInfo Create() if ( string.IsNullOrEmpty( info.TypeDescription ) ) info.TypeDescription = "Unknown"; - if ( agx.Runtime.instance().hasKey( "InstallationID" ) ) { + if ( info.IsFloating || agx.Runtime.instance().hasKey( "InstallationID" ) ) { info.Type = LicenseType.Service; info.UniqueId = agx.Runtime.instance().readValue( "InstallationID" ); } @@ -158,7 +155,8 @@ public static LicenseInfo FromNative( agx.LicenseInfo native ) info.IsValid = native.licenseType != -1; LicenseInfo.ParseDate( ref info, native.endDate ); - info.Type = LicenseInfo.LicenseType.Service; + info.Type = native.licenseType == -1 ? LicenseType.Unknown : LicenseType.Service; + info.TypeDescription = "Unknown"; var subscriptionType = native.product.Split("-"); if ( subscriptionType.Length == 2 ) info.TypeDescription = subscriptionType[ 1 ].Trim(); diff --git a/AGXUnity/LicenseManager.cs b/AGXUnity/LicenseManager.cs index 979adb9b..b9f4f3df 100644 --- a/AGXUnity/LicenseManager.cs +++ b/AGXUnity/LicenseManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using UnityEngine; @@ -86,6 +87,8 @@ public static LicenseInfo LicenseInfo { get { + if ( !CanAccessRuntime ) + return new LicenseInfo(); if ( !s_licenseInfo.IsParsed ) LicenseInfo = LicenseInfo.Create(); return s_licenseInfo; @@ -93,11 +96,13 @@ public static LicenseInfo LicenseInfo private set { s_licenseInfo = value; + if ( !value.IsFloating ) + s_activeFloatingLicenseFilename = null; // We could end up here during instantiation of the // NativeHandler. Only validate license in the native // handler if it has an instance. - if ( NativeHandler.HasInstance ) + if ( CanAccessRuntime && NativeHandler.HasInstance ) NativeHandler.Instance.ValidateLicense(); } } @@ -121,35 +126,97 @@ public static IEnumerable LicenseTypes /// /// True if a license is being refreshed. /// - public static bool IsRefreshing => s_refreshTask != null && !s_refreshTask.IsCompleted; + public static bool IsRefreshing => IsOperationRunning( LicenseOperation.Refresh ); /// /// True if a license is being activated. /// - public static bool IsActivating => s_activationTask != null && !s_activationTask.IsCompleted; + public static bool IsActivating => IsOperationRunning( LicenseOperation.Activate ); + + public static bool IsConnecting => IsOperationRunning( LicenseOperation.Connect ); + + public static bool IsReturning => IsOperationRunning( LicenseOperation.Return ); + + /// + /// True while any asynchronous license operation or its callback is running. + /// + public static bool IsBusy + { + get { lock ( s_operationLock ) return s_operationTask != null && !s_operationTask.IsCompleted; } + } + + /// + /// Whether AGX has a floating session, including one opened outside the plugin. + /// + public static bool HasFloatingSession => CanAccessRuntime && Runtime.isFloatingLicense(); + + /// + /// Normalized absolute source path of a floating session opened by this plugin. + /// Null when no session is held or its source is unknown. License IDs cannot + /// identify a floating file: they may be empty or shared by multiple files. + /// + public static string ActiveFloatingLicenseFilename + { + get + { + if ( !IsBusy && !HasFloatingSession ) + s_activeFloatingLicenseFilename = null; + return s_activeFloatingLicenseFilename; + } + } + + /// + /// Failure details captured before querying license information changes native status. + /// + public static string LastOperationError { get; private set; } /// - /// True if the activation task is running, otherwise false. + /// Manual return disables automatic checkout for the remainder of this editor + /// session, until an explicit Connect succeeds. Standalone defaults are unchanged. /// - public static bool IsBusy => IsActivating || IsRefreshing; + public static bool AutomaticFloatingCheckoutEnabled + { + get + { +#if UNITY_EDITOR + var pending = Volatile.Read( ref s_pendingFloatingSessionState ); + return pending == 2 || ( pending == 0 && !UnityEditor.SessionState.GetBool( s_floatingReturnedKey, false ) ); +#else + return true; +#endif + } + } /// - /// Load license file (service or legacy) located in any directory - /// under application/project root. Service (*.lfx) is searched - /// for before legacy (*.lic). The first valid license found is loaded. + /// Keep an already valid native license, or load a license file (service + /// or legacy) located under application/project root. Service (*.lfx) is + /// searched for before legacy (*.lic). The first valid file found is loaded. /// /// - /// True if successful, false if license files weren't found - /// or if the loaded license isn't valid. + /// True if an existing valid license was retained or a valid file was loaded, + /// otherwise false. /// public static bool LoadFile() { - if ( !CanAccessRuntime ) + return LoadFile( allowFloating: true ); + } + + /// + /// Keep an already valid native license, otherwise search for a license, + /// optionally excluding floating checkout and its fallback. An existing + /// floating session is preserved; return it before replacing it. + /// + public static bool LoadFile( bool allowFloating ) + { + if ( !CanAccessRuntime || IsBusy ) return false; - // This is potentially a license unlock by a script. It's not - // possible to know if it exists scripts that manually unlocks AGX. - var potentialScriptLoaded = LicenseInfo.Create(); + // Scripts or native initialization may have already loaded a license. + // Preserve the actual native license instead of clearing it and later + // restoring only a snapshot of its information. + var currentLicense = UpdateLicenseInformation(); + if ( currentLicense.IsValid || HasFloatingSession ) + return currentLicense.IsValid; Reset(); @@ -157,18 +224,12 @@ public static bool LoadFile() foreach ( var licenseFile in licenseFiles ) { var file = licenseFile.PrettyPath(); if ( LoadFile( file, - $"License file \"{file}\" found in search from application root." ) ) + $"License file \"{file}\" found in search from application root.", + allowFloating ) ) return true; } - // Recover the last license if parsed, i.e., there were a license - // loaded before calling this method. Note that all license files - // (if any) failed to load before this. - if ( potentialScriptLoaded.IsParsed ) { - LicenseInfo = potentialScriptLoaded; - return LicenseInfo.IsValid; - } - + UpdateLicenseInformation(); return false; } @@ -329,6 +390,8 @@ public static bool GenerateEncryptedRuntime( int runtimeLicenseId, /// True if successfully activated, otherwise false. public static bool ActivateEncryptedRuntime( string targetDirectory ) { + if ( !CanAccessRuntime ) + return false; var filename = FindRuntimeActivationFiles().FirstOrDefault(); if ( string.IsNullOrEmpty( filename ) ) { IssueLoadWarning( "Unable to activate runtime license, license file not found.", @@ -350,6 +413,8 @@ public static bool ActivateEncryptedRuntime( string targetDirectory ) /// True if successfully activated and written, otherwise false. public static bool ActivateEncryptedRuntime( string filename, string targetDirectory ) { + if ( !CanAccessRuntime ) + return false; if ( string.IsNullOrEmpty( filename ) || !File.Exists( filename ) ) { IssueLoadWarning( "Unable to activate runtime license, filename not given or doesn't exist.", $"Explicit runtime activation with filename: \"{filename}\"" ); @@ -437,6 +502,9 @@ public static bool CreateOfflineLicense( string webResponseFilenameOrContent, var success = false; try { + if ( IsBusy ) + throw new AGXUnity.Exception( "The license manager is busy." ); + RequireNoFloatingSession(); if ( Path.GetExtension( licenseFilename ) != GetLicenseExtension( LicenseInfo.LicenseType.Service ) ) licenseFilename += GetLicenseExtension( LicenseInfo.LicenseType.Service ); @@ -486,32 +554,11 @@ public static void ActivateAsync( int licenseId, string targetDirectory, Action onDone ) { - if ( !CanAccessRuntime ) { - onDone?.Invoke( false ); - return; - } - - if ( IsBusy ) { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to activate license with id {licenseId} - activation is still in progress." ); - onDone?.Invoke( false ); - return; - } - - var licenseFilename = IO.Environment.FindUniqueFilename( $"{targetDirectory}/agx{GetLicenseExtension( LicenseInfo.LicenseType.Service )}" ); - s_activationTask = Task.Run( () => { - var success = false; - try { - success = Runtime.activateAgxLicense( licenseId, - licensePassword, - licenseFilename ); - UpdateLicenseInformation(); - } - catch ( Exception e ) { - Debug.LogException( e ); - success = false; - } - onDone?.Invoke( success ); - } ); + StartOperation( LicenseOperation.Activate, () => { + RequireNoFloatingSession(); + var licenseFilename = IO.Environment.FindUniqueFilename( $"{targetDirectory}/agx{GetLicenseExtension( LicenseInfo.LicenseType.Service )}" ); + return Runtime.activateAgxLicense( licenseId, licensePassword, licenseFilename ); + }, onDone ); } /// @@ -527,8 +574,14 @@ public static bool Activate( int licenseId, string targetDirectory ) { var success = false; - ActivateAsync( licenseId, licensePassword, targetDirectory, isSuccess => success = isSuccess ); - s_activationTask?.Wait(); + Task task; + lock ( s_operationLock ) { + if ( !CanAccessRuntime || IsBusy ) + return false; + ActivateAsync( licenseId, licensePassword, targetDirectory, result => success = result ); + task = s_operationTask; + } + task.GetAwaiter().GetResult(); return success; } @@ -541,45 +594,15 @@ public static bool Activate( int licenseId, public static void RefreshAsync( string filename, Action onDone ) { - if ( !CanAccessRuntime ) { - onDone?.Invoke( false ); - return; - } - - var isBusy = IsBusy; - var seemsValid = !string.IsNullOrEmpty( filename ) && - !isBusy && - File.Exists( filename ) && - GetLicenseType( filename ) == LicenseInfo.LicenseType.Service; - if ( !seemsValid ) { - var warning = $"AGXUnity.LicenseManager: Unable to refresh license file \"{filename}\" - "; - if ( string.IsNullOrEmpty( filename ) ) - warning += "the license file is null or empty."; - else if ( isBusy ) - warning += "the license manager is busy activating or refreshing another license."; - else if ( !File.Exists( filename ) ) - warning += "the license file doesn't exist."; - else - warning += "the license file doesn't support refresh."; - - Debug.LogWarning( warning ); - onDone?.Invoke( false ); - - return; - } - - s_refreshTask = Task.Run( () => { - var success = false; - try { - success = Runtime.loadLicenseFile( filename, true ); - UpdateLicenseInformation(); - } - catch ( Exception e ) { - Debug.LogException( e ); - success = false; - } - onDone?.Invoke( success ); - } ); + StartOperation( LicenseOperation.Refresh, () => { + RequireNoFloatingSession(); + var info = QueryInfo( filename ); + if ( info.IsFloating ) + throw new AGXUnity.Exception( "Use Connect to check out a floating license seat." ); + if ( GetLicenseType( filename ) != LicenseInfo.LicenseType.Service || !File.Exists( filename ) ) + throw new AGXUnity.Exception( "The license file doesn't support refresh or doesn't exist." ); + return Runtime.loadLicenseFile( filename, true ); + }, onDone ); } /// @@ -590,11 +613,44 @@ public static void RefreshAsync( string filename, public static bool Refresh( string filename ) { var success = false; - RefreshAsync( filename, isSuccess => success = isSuccess ); - s_refreshTask?.Wait(); + Task task; + lock ( s_operationLock ) { + if ( !CanAccessRuntime || IsBusy ) + return false; + RefreshAsync( filename, result => success = result ); + task = s_operationTask; + } + task.GetAwaiter().GetResult(); return success; } + /// + /// Explicitly connect using floating file metadata, without activation or refresh. + /// The callback runs on the worker thread (or immediately if busy). + /// + public static void ConnectFloatingAsync( string filename, Action onDone ) + { + StartOperation( LicenseOperation.Connect, () => { + RequireNoFloatingSession(); + if ( !QueryInfo( filename ).IsFloating ) + throw new AGXUnity.Exception( "The selected file isn't an identified floating license." ); + return OpenFloatingSession( filename ); + }, onDone ); + } + + /// + /// Return the current floating seat, including sessions whose source is unknown. + /// The callback runs on the worker thread (or immediately if busy). + /// + public static void ReturnFloatingAsync( Action onDone ) + { + StartOperation( LicenseOperation.Return, () => { + if ( !HasFloatingSession ) + throw new AGXUnity.Exception( "There is no floating license seat to return." ); + return CloseFloatingSession(); + }, onDone ); + } + /// /// Deactivates the license and deletes the license file. Legacy licenses /// are only deleted. @@ -603,6 +659,9 @@ public static bool Refresh( string filename ) /// True if the license were successfully deactivated and deleted. public static bool DeactivateAndDelete( string filename ) { + if ( !CanAccessRuntime || IsBusy ) + return false; + if ( !File.Exists( filename ) ) { Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate and delete license {filename} - file doesn't exist." ); return false; @@ -614,6 +673,20 @@ public static bool DeactivateAndDelete( string filename ) return false; } + // Inspect the file before loading it: a failed checkout must never turn + // into deactivation of a different, currently loaded license. + try { + var info = QueryInfo( filename ); + if ( info.IsFloating || !info.IsParsed || HasFloatingSession ) { + Debug.LogWarning( "AGXUnity.LicenseManager: Cannot deactivate an unidentified or floating license, or replace a held floating seat." ); + return false; + } + } + catch ( System.Exception e ) { + Debug.LogException( e ); + return false; + } + if ( licenseType == LicenseInfo.LicenseType.Legacy ) { Reset(); return DeleteFile( filename ); @@ -640,6 +713,10 @@ public static bool DeactivateLoaded() { if ( !CanAccessRuntime ) return false; + if ( IsBusy || HasFloatingSession || UpdateLicenseInformation().Type != LicenseInfo.LicenseType.Service ) { + Debug.LogWarning( "AGXUnity.LicenseManager: Only an identified non-floating service license can be deactivated." ); + return false; + } var success = false; try { @@ -650,7 +727,7 @@ public static bool DeactivateLoaded() else Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate loaded license - {Runtime.getStatus()}" ); } - catch ( Exception e ) { + catch ( System.Exception e ) { Debug.LogException( e ); } return success; @@ -661,13 +738,12 @@ public static bool DeactivateLoaded() /// public static void Reset() { - if ( !CanAccessRuntime ) + if ( !CanAccessRuntime || IsBusy || HasFloatingSession ) return; - try { Runtime.clear(); } - catch ( Exception ) { + catch ( System.Exception ) { } finally { LicenseInfo = new LicenseInfo(); @@ -752,9 +828,10 @@ public static string GetRuntimeActivationExtension() /// public static void AwaitTasks() { - if ( s_activationTask != null && s_activationTask.Status == TaskStatus.Running ) - s_activationTask.Wait(); - s_activationTask = null; + Task task; + lock ( s_operationLock ) + task = s_operationTask; + task?.GetAwaiter().GetResult(); } public enum LicenseContentType @@ -794,6 +871,17 @@ public static LicenseContentType FindLicenseContentType( string licenseContent ) public static bool DeleteFile( string filename ) { try { + if ( !CanAccessRuntime || IsBusy ) + return false; + if ( HasFloatingSession && QueryInfo( filename ).IsFloating ) { + var active = ActiveFloatingLicenseFilename; + if ( string.IsNullOrEmpty( active ) || + string.Equals( Path.GetFullPath( filename ).Replace( '\\', '/' ), active, + Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal ) ) { + Debug.LogWarning( "AGXUnity.LicenseManager: Return the current floating seat before deleting this file." ); + return false; + } + } File.Delete( filename ); #if UNITY_EDITOR if ( File.Exists( $"{filename}.meta" ) ) { @@ -802,8 +890,9 @@ public static bool DeleteFile( string filename ) } #endif } - catch ( Exception e ) { + catch ( System.Exception e ) { Debug.LogException( e ); + return false; } return !File.Exists( filename ); @@ -815,11 +904,14 @@ public static bool DeleteFile( string filename ) /// Filename, including path, to load. /// Context of the call to this method. /// True if successfully loaded, otherwise false. - private static bool LoadFile( string filename, string context ) + private static bool LoadFile( string filename, string context, bool allowFloating = true ) { if ( !CanAccessRuntime ) return false; - + if ( IsBusy || HasFloatingSession ) { + IssueLoadWarning( "Return the floating seat and wait for any pending operation before loading a license.", context ); + return false; + } if ( string.IsNullOrEmpty( filename ) ) { IssueLoadWarning( "Filename is null or empty.", context ); return false; @@ -833,8 +925,10 @@ private static bool LoadFile( string filename, string context ) var text = string.Empty; try { text = File.ReadAllText( filename ); + if ( GetLicenseType( filename ) == LicenseInfo.LicenseType.Service && QueryInfo( filename ).IsFloating ) + return allowFloating && LoadFloating( filename, context ); } - catch ( Exception e ) { + catch ( System.Exception e ) { IssueLoadWarning( $"Caught exception reading text from file: {filename}.", context ); Debug.LogException( e ); return false; @@ -842,6 +936,11 @@ private static bool LoadFile( string filename, string context ) var loadSuccess = Load( text, context ); + // Runtime activation owns its output file. Its request must survive a + // failure without being rewritten or used for a floating checkout. + if ( FindLicenseContentType( text ) == LicenseContentType.EncryptedRuntimeService ) + return loadSuccess; + // If the license has been refreshed we have to write the // new license content to 'filename' independent of 'loadSuccess'. try { @@ -860,13 +959,14 @@ private static bool LoadFile( string filename, string context ) Debug.LogError( $"AGXUnity.LicenseManager: Unable to write updated license information to {filename} - write access is required." ); } } - catch ( Exception e ) { + catch ( System.Exception e ) { Debug.LogException( e ); } - // We can't know prior to loading the license whether a license is floating or not, if the license loading fails and the status mentions 'floating' - // we check if the license is indeed a floating license and if so we update the license info - if ( !loadSuccess && LicenseInfo.Status.Contains( "floating" ) ) { + // Compatibility fallback for native versions that cannot query the file. + // Never use it when automatic floating checkout has been disabled. + if ( allowFloating && !loadSuccess && + ( LicenseInfo.Status?.IndexOf( "floating", StringComparison.OrdinalIgnoreCase ) ?? -1 ) >= 0 ) { loadSuccess = LoadFloating( filename, context ); } @@ -883,7 +983,10 @@ private static bool Load( string licenseContent, string context ) { if ( !CanAccessRuntime ) return false; - + if ( IsBusy || HasFloatingSession ) { + IssueLoadWarning( "Return the floating seat and wait for any pending operation before loading a license.", context ); + return false; + } if ( string.IsNullOrEmpty( licenseContent ) ) { IssueLoadWarning( "License content is null or empty.", context ); return false; @@ -893,15 +996,15 @@ private static bool Load( string licenseContent, string context ) if ( licenseContentType == LicenseContentType.Unknown ) { IssueLoadWarning( $"Unknown license content: \"{licenseContent}\".", context ); return false; - - } + var success = false; try { + s_activeFloatingLicenseFilename = null; // Service type. if ( licenseContentType == LicenseContentType.Service ) { - Runtime.loadLicenseString( licenseContent ); - LoadInfo( $"Loading service license successful: {Runtime.isValid()}.", + success = Runtime.loadLicenseString( licenseContent ); + LoadInfo( $"Loading service license successful: {success && Runtime.isValid()}.", context ); } // Runtime license activation. @@ -910,75 +1013,210 @@ private static bool Load( string licenseContent, string context ) // to support non-ASCII input paths. var cwd = Directory.GetCurrentDirectory(); agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).pushbackPath( cwd ); - Runtime.activateEncryptedRuntime( licenseContent, context ); - agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).removeFilePath( cwd ); - - LoadInfo( $"Activating encrypted runtime license \"{context}\" successful: {Runtime.isValid()}.", + try { + success = Runtime.activateEncryptedRuntime( licenseContent, context ); + } + finally { + agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).removeFilePath( cwd ); + } + + LoadInfo( $"Activating encrypted runtime license \"{context}\" successful: {success && Runtime.isValid()}.", context ); } // Legacy type. else if ( licenseContentType == LicenseContentType.Legacy ) { - Runtime.unlock( licenseContent ); - LoadInfo( $"Loading legacy license successful: {Runtime.isValid()}.", + success = Runtime.unlock( licenseContent ); + LoadInfo( $"Loading legacy license successful: {success && Runtime.isValid()}.", context ); } // Assume obfuscated legacy. else { - Runtime.verifyAndUnlock( licenseContent ); - LoadInfo( $"Loading obfuscated legacy license successful: {Runtime.isValid()}.", + success = Runtime.verifyAndUnlock( licenseContent ); + LoadInfo( $"Loading obfuscated legacy license successful: {success && Runtime.isValid()}.", context ); } } - catch ( Exception e ) { + catch ( System.Exception e ) { IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); Debug.LogException( e ); - return false; + success = false; + } + finally { + UpdateInformationAfterOperation(); } - UpdateLicenseInformation(); - - return LicenseInfo.IsValid; + return success && LicenseInfo.IsValid; } private static bool LoadFloating( string file, string context ) { if ( !CanAccessRuntime ) return false; - + var success = false; try { - Runtime.openNetworkSession( file ); - LoadInfo( $"Loading floating license successful: {Runtime.isValid()}.", context ); + success = OpenFloatingSession( file ); + LastOperationError = success ? null : GetNativeOperationError( LicenseOperation.Connect ); + LoadInfo( $"Loading floating license successful: {success}.", context ); } - catch ( Exception e ) { + catch ( System.Exception e ) { + LastOperationError = e.Message; IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); Debug.LogException( e ); - return false; } - - UpdateLicenseInformation(); - - return LicenseInfo.IsValid; + finally { + UpdateInformationAfterOperation(); + } + return success; } public static bool ReturnFloating( string context ) { - if ( !CanAccessRuntime ) + if ( !CanAccessRuntime || IsBusy ) return false; - - bool success; + var success = false; try { - success = Runtime.closeNetworkSession(); + success = CloseFloatingSession(); + LastOperationError = success ? null : GetNativeOperationError( LicenseOperation.Return ); LoadInfo( $"Returning floating license successful: {success}.", context ); } - catch ( Exception e ) { + catch ( System.Exception e ) { + LastOperationError = e.Message; IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); Debug.LogException( e ); - return false; } + finally { + UpdateInformationAfterOperation(); + } + return success; + } + private static bool OpenFloatingSession( string filename ) + { + var normalized = Path.GetFullPath( filename ).Replace( '\\', '/' ); + var success = Runtime.openNetworkSession( normalized ); + if ( success ) + s_activeFloatingLicenseFilename = normalized; return success; } + private static bool CloseFloatingSession() + { + var success = Runtime.closeNetworkSession(); + if ( success ) + s_activeFloatingLicenseFilename = null; + return success; + } + + private static void RequireNoFloatingSession() + { + if ( HasFloatingSession ) + throw new AGXUnity.Exception( "Return the current floating license seat before connecting or loading another license." ); + } + + private enum LicenseOperation { Activate, Refresh, Connect, Return } + + private static bool IsOperationRunning( LicenseOperation operation ) + { + lock ( s_operationLock ) + return IsBusy && s_operation == operation; + } + + private static void StartOperation( LicenseOperation operation, Func execute, Action onDone ) + { + // Check on the calling thread so no license task is started by an asset + // import worker, and normal editor tasks use the cached process state. + if ( !CanAccessRuntime ) { + onDone?.Invoke( false ); + return; + } + lock ( s_operationLock ) { + if ( !IsBusy ) { + s_operation = operation; + // Publish the task before scheduling so two callers cannot both start. + s_operationTask = new Task( () => { + var success = false; + LastOperationError = null; + try { + success = execute(); + if ( !success ) + LastOperationError = GetNativeOperationError( operation ); +#if UNITY_EDITOR + if ( success && ( operation == LicenseOperation.Connect || operation == LicenseOperation.Return ) ) + Interlocked.Exchange( ref s_pendingFloatingSessionState, operation == LicenseOperation.Return ? 1 : 2 ); +#endif + } + catch ( System.Exception e ) { + LastOperationError = $"{operation} failed. {e.Message}"; + Debug.LogException( e ); + } + finally { + UpdateInformationAfterOperation(); + } + if ( !success ) + Debug.LogWarning( $"AGXUnity.LicenseManager: {LastOperationError}" ); + onDone?.Invoke( success ); + } ); + s_operationTask.Start( TaskScheduler.Default ); + return; + } + } + Debug.LogWarning( "AGXUnity.LicenseManager: Another license operation is in progress." ); + onDone?.Invoke( false ); + } + + private static void UpdateInformationAfterOperation() + { + try { + UpdateLicenseInformation(); + } + catch ( System.Exception e ) { + // Deliver the native result even if shutdown races with information + // refresh. ReturnFloating is also called by NativeHandler's finalizer. + Debug.LogException( e ); + } + } + + private static string GetNativeOperationError( LicenseOperation operation ) + { + var status = Runtime.getStatus(); + var extended = Runtime.getExtendedStatus(); + return $"{operation} failed." + + ( string.IsNullOrWhiteSpace( status ) ? string.Empty : $"\n\n{status.Trim()}" ) + + ( string.IsNullOrWhiteSpace( extended ) || extended == status ? string.Empty : $"\n\n{extended.Trim()}" ); + } + +#if UNITY_EDITOR + [UnityEditor.InitializeOnLoadMethod] + private static void InitializeEditorSessionHandling() + { + if ( !CanAccessRuntime ) + return; + UnityEditor.EditorApplication.update += ApplyFloatingSessionState; + UnityEditor.AssemblyReloadEvents.beforeAssemblyReload += () => { + AwaitTasks(); + ApplyFloatingSessionState(); + }; + UnityEditor.EditorApplication.playModeStateChanged += state => { + if ( state == UnityEditor.PlayModeStateChange.ExitingEditMode || + state == UnityEditor.PlayModeStateChange.ExitingPlayMode ) { + AwaitTasks(); + ApplyFloatingSessionState(); + } + }; + } + + // Runs even if the License Manager window was closed during the request. + private static void ApplyFloatingSessionState() + { + var pending = Interlocked.Exchange( ref s_pendingFloatingSessionState, 0 ); + if ( pending != 0 ) + UnityEditor.SessionState.SetBool( s_floatingReturnedKey, pending == 1 ); + } + + private const string s_floatingReturnedKey = "AGXUnity.FloatingLicenseReturned"; + private static int s_pendingFloatingSessionState; +#endif + private static void LoadInfo( string info, string context ) { if ( !Application.isEditor ) @@ -995,7 +1233,9 @@ private static void IssueLoadWarning( string warning, string context ) private static string[] s_licenseExtensions = new string[] { null, ".lfx", ".lic" }; private static string s_runtimeActivationExtension = ".rtlfx"; private static LicenseInfo s_licenseInfo = new LicenseInfo(); - private static Task s_activationTask = null; - private static Task s_refreshTask = null; + private static readonly object s_operationLock = new object(); + private static Task s_operationTask; + private static LicenseOperation s_operation; + private static string s_activeFloatingLicenseFilename; } } diff --git a/Editor/AGXUnityEditor/BuildTools/BuildProcessCallbacksHandler.cs b/Editor/AGXUnityEditor/BuildTools/BuildProcessCallbacksHandler.cs index edfad83b..916c240e 100644 --- a/Editor/AGXUnityEditor/BuildTools/BuildProcessCallbacksHandler.cs +++ b/Editor/AGXUnityEditor/BuildTools/BuildProcessCallbacksHandler.cs @@ -405,8 +405,7 @@ private static void VerifyBuild( BuildTarget target, string targetDataPath ) private static void CheckGenerateEncryptedRuntime( FileInfo targetExecutableFileInfo ) { - if ( AGXUnity.LicenseManager.LicenseInfo.Type == AGXUnity.LicenseInfo.LicenseType.Service && - !Application.isBatchMode && + if ( !Application.isBatchMode && AskGenerateRuntimeLicense ) { var generateEncryptedRuntimeActivation = EditorUtility.DisplayDialogComplex( "AGX Dynamics for Unity - Runtime license", "Open activation window to generate Runtime license for this build?", diff --git a/Editor/AGXUnityEditor/LicenseWarnings.cs b/Editor/AGXUnityEditor/LicenseWarnings.cs index a156f223..6fe33ef9 100644 --- a/Editor/AGXUnityEditor/LicenseWarnings.cs +++ b/Editor/AGXUnityEditor/LicenseWarnings.cs @@ -25,7 +25,7 @@ internal static bool IsFloating( LicenseInfo info ) internal static string GetWarningMessage( LicenseInfo info, bool hasFloatingLicense = false ) { - if ( info.IsValid ) + if ( info.IsValid || !LicenseManager.AutomaticFloatingCheckoutEnabled ) return string.Empty; var message = "No valid AGX Dynamics license is available."; @@ -49,7 +49,7 @@ internal static string GetActivationError( LicenseInfo info ) internal static void ScheduleStartupWarning() { - if ( Application.isBatchMode || CurrentLicense.IsValid || + if ( Application.isBatchMode || CurrentLicense.IsValid || !LicenseManager.AutomaticFloatingCheckoutEnabled || SessionState.GetBool( s_startupWarningShownKey, false ) ) return; @@ -59,7 +59,8 @@ internal static void ScheduleStartupWarning() private static void ShowStartupWarning() { - if ( Application.isBatchMode || SessionState.GetBool( s_startupWarningShownKey, false ) ) { + if ( Application.isBatchMode || !LicenseManager.AutomaticFloatingCheckoutEnabled || + SessionState.GetBool( s_startupWarningShownKey, false ) ) { EditorApplication.update -= ShowStartupWarning; return; } diff --git a/Editor/AGXUnityEditor/Manager.cs b/Editor/AGXUnityEditor/Manager.cs index 7fa4b423..0523095e 100644 --- a/Editor/AGXUnityEditor/Manager.cs +++ b/Editor/AGXUnityEditor/Manager.cs @@ -791,7 +791,7 @@ internal static EnvironmentState ConfigureEnvironment() // Complete license loading (including floating checkout) before deciding // whether to notify the user. Native initialization failures are separate. try { - AGXUnity.LicenseManager.LoadFile(); + AGXUnity.LicenseManager.LoadFile( allowFloating: AGXUnity.LicenseManager.AutomaticFloatingCheckoutEnabled ); AGXUnity.NativeHandler.Instance.ValidateLicense(); if ( AGXUnity.NativeHandler.Instance.Initialized ) { diff --git a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs index 19e87290..2960848d 100644 --- a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs +++ b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs @@ -91,15 +91,16 @@ private void OnGUI() if ( IsUpdatingLicenseInformation ) ShowNotification( GUI.MakeLabel( "Reading..." ) ); else if ( IsBusy ) - ShowNotification( GUI.MakeLabel( AGXUnity.LicenseManager.IsActivating || m_operationIsActivation ? - "Activating..." : - "Refreshing..." ) ); + ShowNotification( GUI.MakeLabel( m_operation == LicenseOperation.Connect || AGXUnity.LicenseManager.IsConnecting ? "Connecting..." : + m_operation == LicenseOperation.Return || AGXUnity.LicenseManager.IsReturning ? "Returning seat..." : + m_operation == LicenseOperation.Activate || AGXUnity.LicenseManager.IsActivating ? "Activating..." : "Refreshing..." ) ); if ( !IsUpdatingLicenseInformation && !IsBusy && AGXUnity.NativeHandler.HasInstance && AGXUnity.NativeHandler.Instance.Initialized && - !LicenseWarnings.CurrentLicense.IsValid ) { - var hasFloatingLicense = m_licenseData.Any( data => LicenseWarnings.IsFloating( data.LicenseInfo ) ); - EditorGUILayout.HelpBox( LicenseWarnings.GetWarningMessage( LicenseWarnings.CurrentLicense, hasFloatingLicense ), MessageType.Warning, true ); + !LicenseWarnings.CurrentLicense.IsValid && AGXUnity.LicenseManager.AutomaticFloatingCheckoutEnabled ) { + var hasFloatingLicense = m_licenseData.Any( data => data.LicenseInfo.IsFloating ); + EditorGUILayout.HelpBox( LicenseWarnings.GetWarningMessage( LicenseWarnings.CurrentLicense, hasFloatingLicense ), + MessageType.Warning, true ); if ( !hasFloatingLicense && !LicenseWarnings.IsFloating( LicenseWarnings.CurrentLicense ) ) EditorGUILayout.HelpBox( "If you have activated your license previously on this machine, consider importing the .lfx file instead of reactivating the license.", MessageType.Info, true ); GUILayout.Space( 6 ); @@ -107,8 +108,22 @@ private void OnGUI() if ( !string.IsNullOrEmpty( m_licenseReadError ) ) EditorGUILayout.HelpBox( m_licenseReadError, MessageType.Error, true ); + if ( !string.IsNullOrEmpty( m_operationError ) ) + EditorGUILayout.HelpBox( m_operationError, MessageType.Error, true ); + if ( !string.IsNullOrEmpty( m_importMessage ) ) + EditorGUILayout.HelpBox( m_importMessage, MessageType.Info, true ); using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !IsBusy ) ) { + if ( LicenseWarnings.CurrentLicense.IsFloating ) { + EditorGUILayout.HelpBox( "A floating license seat is held. Return it before connecting, activating, refreshing, or loading another license.", MessageType.Info, true ); + using ( new GUI.EnabledBlock( UnityEngine.GUI.enabled && !EditorApplication.isPlayingOrWillChangePlaymode ) ) { + if ( GUILayout.Button( GUI.MakeLabel( "Return Seat", false, "Return the current floating seat to the server." ), InspectorEditor.Skin.Button ) ) + ReturnSeat(); + } + GUILayout.Space( 6 ); + } + if ( EditorApplication.isPlayingOrWillChangePlaymode ) + EditorGUILayout.HelpBox( "Exit play mode to change license connections.", MessageType.Info, true ); for ( int i = 0; i < m_licenseData.Count; ++i ) { var data = m_licenseData[ i ]; LicenseDataGUI( data ); @@ -155,8 +170,8 @@ private void ActivateLicenseGUI() selectLicenseRect.y -= EditorGUIUtility.standardVerticalSpacing; var selectLicensePressed = InspectorGUI.Button( selectLicenseRect, MiscIcon.Locate, - UnityEngine.GUI.enabled, - "Select license file on this computer"); + UnityEngine.GUI.enabled && !EditorApplication.isPlayingOrWillChangePlaymode, + "Copy and inspect a license file. Floating files are connected using Connect."); if ( selectLicensePressed ) { var sourceLicense = EditorUtility.OpenFilePanel( "Copy AGX Dynamics license file", ".", @@ -170,8 +185,13 @@ private void ActivateLicenseGUI() "Cancel" ) ) { try { File.Copy( sourceLicense, targetLicense, false ); - if ( AGXUnity.LicenseManager.LoadFile( targetLicense ) ) + var info = AGXUnity.LicenseManager.QueryInfo( targetLicense ); + m_importMessage = info.IsFloating ? "Floating license imported. Select Connect to request a seat." : + !info.IsParsed ? "License copied, but its type could not be identified. Check the file and the Console for details." : null; + if ( info.IsParsed && !info.IsFloating && !AGXUnity.LicenseManager.HasFloatingSession && AGXUnity.LicenseManager.LoadFile( targetLicense ) ) m_activationError = null; + else if ( !info.IsFloating && AGXUnity.LicenseManager.HasFloatingSession ) + m_importMessage = "License copied. Return the current floating seat before loading this license."; LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); StartUpdateLicenseInformation(); GUIUtility.ExitGUI(); @@ -223,6 +243,7 @@ private void ActivateLicenseGUI() EditorGUILayout.HelpBox( m_activationError, MessageType.Error, true ); using ( new GUI.EnabledBlock( UnityEngine.GUI.enabled && validId && + !LicenseWarnings.CurrentLicense.IsFloating && !EditorApplication.isPlayingOrWillChangePlaymode && m_licenseActivateData.Password.Length > 0 ) ) { // It isn't possible to press this button during activation. if ( UnityEngine.GUI.Button( EditorGUI.IndentedRect( EditorGUILayout.GetControlRect() ), @@ -239,7 +260,8 @@ private void ActivateLicenseGUI() private void ActivateLicense( int licenseId ) { m_activationError = null; - m_operationIsActivation = true; + m_operationError = null; + m_operation = LicenseOperation.Activate; var completion = new TaskCompletionSource(); m_licenseOperationTask = completion.Task; try { @@ -267,7 +289,8 @@ private void LicenseDataGUI( LicenseData data ) var highlight = m_licenseData.Count > 1 && !IsUpdatingLicenseInformation && !IsBusy && - data.LicenseInfo.UniqueId == AGXUnity.LicenseManager.LicenseInfo.UniqueId; + ( data.LicenseInfo.IsFloating ? IsActiveFloatingFile( data.Filename ) : + data.Filename == FindActiveNonFloatingFile() ); if ( highlight && m_activeLicenseStyle == null ) m_activeLicenseStyle = new GUIStyle( InspectorEditor.Skin.Label ); // The texture is deleted when hitting stop in the editor while @@ -279,53 +302,40 @@ private void LicenseDataGUI( LicenseData data ) Color.green, 0.025f ) ); - var licenseFileButtons = new InspectorGUI.MiscButtonData[] - { - InspectorGUI.MiscButtonData.Create( MiscIcon.Update, - () => - { - RefreshLicense( data ); - }, - UnityEngine.GUI.enabled && - // Until AGX provides information on invalid (expired) licenses, we - // check the filename. Otherwise data.LicenseInfo.Type == Service would - // be the correct check here. - AGXUnity.LicenseManager.GetLicenseType( data.Filename ) == AGXUnity.LicenseInfo.LicenseType.Service, - "Refresh license from server." ), - InspectorGUI.MiscButtonData.Create( MiscIcon.EntryRemove, - () => - { - // 0: Deactivate and Delete - // 1: Cancel - // 2: Delete - var filename = Path.GetFileName( data.Filename ); - var choice = EditorUtility.DisplayDialogComplex( $"Delete or deactivate and delete \"{filename}\"?", - $"Would you like to delete or deactivate and delete \"{filename}\"?\n\n" + - $"Deactivating the license will contact the license server and remove " + - $"the current installation, meaning the license can be activated again on " + - $"other hardware or on this machine again later using License ID and Password.", - "Deactivate and Delete", "Cancel", "Delete" ); - var deactivateAndDelete = choice == 0; - var deleteOnly = choice == 2; - if ( deactivateAndDelete ) - AGXUnity.LicenseManager.DeactivateAndDelete( data.Filename ); - else if ( deleteOnly ) - AGXUnity.LicenseManager.DeleteFile( data.Filename ); - - if ( deactivateAndDelete || deleteOnly ) { - LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); - StartUpdateLicenseInformation(); - } - }, - UnityEngine.GUI.enabled, - "Deactivate and erase license file from project." ) - }; + var canChange = UnityEngine.GUI.enabled && !EditorApplication.isPlayingOrWillChangePlaymode; + var floating = data.LicenseInfo.IsFloating; + var identifiedService = data.LicenseInfo.Type == AGXUnity.LicenseInfo.LicenseType.Service && !floating; + var unknownFloatingSource = floating && LicenseWarnings.CurrentLicense.IsFloating && + string.IsNullOrEmpty( AGXUnity.LicenseManager.ActiveFloatingLicenseFilename ); + var licenseFileButtons = new List(); + if ( !floating ) + licenseFileButtons.Add( InspectorGUI.MiscButtonData.Create( MiscIcon.Update, + () => RefreshLicense( data ), + canChange && !LicenseWarnings.CurrentLicense.IsFloating && + AGXUnity.LicenseManager.GetLicenseType( data.Filename ) == AGXUnity.LicenseInfo.LicenseType.Service, + LicenseWarnings.CurrentLicense.IsFloating ? "Return the floating seat before refreshing a license." : "Refresh license from server." ) ); + licenseFileButtons.Add( InspectorGUI.MiscButtonData.Create( MiscIcon.EntryRemove, + () => DeleteLicense( data ), + canChange && !unknownFloatingSource, + unknownFloatingSource ? "Return the current seat before deleting floating files: its source is unknown." : + floating ? "Delete floating license file. A seat held from this file must be returned first." : + identifiedService && !LicenseWarnings.CurrentLicense.IsFloating ? "Delete or deactivate and delete license file." : "Delete license file." ) ); var highlightScope = highlight ? new EditorGUILayout.VerticalScope( m_activeLicenseStyle ) : null; InspectorGUI.SelectableTextField( GUI.MakeLabel( "License file" ), data.Filename, - licenseFileButtons ); - InspectorGUI.SelectableTextField( GUI.MakeLabel( "License type" ), data.LicenseInfo.TypeDescription ); + licenseFileButtons.ToArray() ); + InspectorGUI.SelectableTextField( GUI.MakeLabel( "License type" ), + floating ? "Floating" : data.LicenseInfo.TypeDescription ); + + if ( floating ) { + using ( new GUI.EnabledBlock( canChange && !LicenseWarnings.CurrentLicense.IsFloating ) ) { + if ( GUILayout.Button( GUI.MakeLabel( "Connect", false, + LicenseWarnings.CurrentLicense.IsFloating ? "Return the current seat before connecting." : "Request a floating license seat from this file's server." ), + InspectorEditor.Skin.Button ) ) + ConnectLicense( data.Filename ); + } + } InspectorGUI.Separator( 1, 6 ); @@ -346,6 +356,92 @@ private void LicenseDataGUI( LicenseData data ) highlightScope?.Dispose(); } + private static bool IsActiveFloatingFile( string filename ) + { + var active = AGXUnity.LicenseManager.ActiveFloatingLicenseFilename; + return !string.IsNullOrEmpty( active ) && + string.Equals( Path.GetFullPath( filename ).Replace( '\\', '/' ), active, + Path.DirectorySeparatorChar == '\\' ? System.StringComparison.OrdinalIgnoreCase : System.StringComparison.Ordinal ); + } + + private string FindActiveNonFloatingFile() + { + var active = LicenseWarnings.CurrentLicense; + if ( active.IsFloating || string.IsNullOrEmpty( active.UniqueId ) ) + return null; + var matches = m_licenseData.Where( data => !data.LicenseInfo.IsFloating && data.LicenseInfo.IsParsed && + data.LicenseInfo.UniqueId == active.UniqueId ).ToArray(); + return matches.Length == 1 ? matches[ 0 ].Filename : null; + } + + private void DeleteLicense( LicenseData data ) + { + var filename = Path.GetFileName( data.Filename ); + m_operationError = null; + // Query again at the point of action in case the file changed after scanning. + var info = AGXUnity.LicenseManager.QueryInfo( data.Filename ); + if ( info.IsFloating && AGXUnity.LicenseManager.HasFloatingSession ) { + if ( string.IsNullOrEmpty( AGXUnity.LicenseManager.ActiveFloatingLicenseFilename ) ) { + m_operationError = "Return the current floating seat before deleting floating files. Its source file is unknown."; + return; + } + if ( IsActiveFloatingFile( data.Filename ) ) { + if ( EditorUtility.DisplayDialog( $"Delete \"{filename}\"?", + "Return the floating seat to the server, then delete this license file? The file will be preserved if the return fails.", + "Return Seat and Delete", "Cancel" ) ) + ReturnSeat( data.Filename ); + return; + } + } + + if ( info.Type == AGXUnity.LicenseInfo.LicenseType.Service && !info.IsFloating && !AGXUnity.LicenseManager.HasFloatingSession ) { + var choice = EditorUtility.DisplayDialogComplex( $"Delete or deactivate and delete \"{filename}\"?", + "Deactivating contacts the license server and removes this installation, allowing the license to be activated again using its License ID and Password.", + "Deactivate and Delete", "Cancel", "Delete" ); + if ( choice == 1 ) + return; + if ( !( choice == 0 ? AGXUnity.LicenseManager.DeactivateAndDelete( data.Filename ) : AGXUnity.LicenseManager.DeleteFile( data.Filename ) ) ) + m_operationError = $"Unable to remove \"{filename}\". Check the Console for details."; + } + else { + if ( !EditorUtility.DisplayDialog( $"Delete \"{filename}\"?", "Delete this license file from the project?", "Delete", "Cancel" ) ) + return; + if ( !AGXUnity.LicenseManager.DeleteFile( data.Filename ) ) + m_operationError = $"Unable to delete \"{filename}\". Check the Console for details."; + } + LicenseWarnings.Capture( AGXUnity.LicenseManager.UpdateLicenseInformation() ); + StartUpdateLicenseInformation(); + } + + private void ConnectLicense( string filename ) + { + BeginFloatingOperation( LicenseOperation.Connect, done => AGXUnity.LicenseManager.ConnectFloatingAsync( filename, done ) ); + } + + private void ReturnSeat( string deleteAfterReturn = null ) + { + m_deleteAfterReturn = deleteAfterReturn; + BeginFloatingOperation( LicenseOperation.Return, AGXUnity.LicenseManager.ReturnFloatingAsync ); + } + + private void BeginFloatingOperation( LicenseOperation operation, System.Action> start ) + { + m_operation = operation; + m_operationError = null; + var completion = new TaskCompletionSource(); + m_licenseOperationTask = completion.Task; + try { + start( success => completion.TrySetResult( new LicenseOperationResult { + Success = success, + Error = success ? null : AGXUnity.LicenseManager.LastOperationError ?? $"{operation} failed. Check the Console for details.", + LicenseInfo = AGXUnity.LicenseInfo.Create() + } ) ); + } + catch ( System.Exception e ) { + completion.TrySetResult( new LicenseOperationResult { Error = $"{operation} failed. {e.Message}", LicenseInfo = AGXUnity.LicenseInfo.Create() } ); + } + } + private static EditorDataEntry GetLicenseDirectoryData() { return EditorData.Instance.GetStaticData( "LicenseManagerWindow_LicenseFilename", @@ -366,7 +462,7 @@ private void StartUpdateLicenseInformation() private void OnEditorUpdate() { - // ActivateAsync and RefreshAsync invoke callbacks from worker threads. + // License operations invoke callbacks from worker threads. // Wait until the native task has finished before reading files or UI state. if ( AGXUnity.LicenseManager.IsBusy ) return; @@ -375,7 +471,7 @@ private void OnEditorUpdate() var result = m_licenseOperationTask.Result; m_licenseOperationTask = null; LicenseWarnings.Capture( result.LicenseInfo ); - if ( m_operationIsActivation ) { + if ( m_operation == LicenseOperation.Activate ) { if ( result.Success ) { m_licenseActivateData = IdPassword.Empty(); m_activationError = null; @@ -384,9 +480,27 @@ private void OnEditorUpdate() m_activationError = result.Error ?? LicenseWarnings.GetActivationError( result.LicenseInfo ); m_clearFocus = true; } + else if ( m_operation == LicenseOperation.Connect || m_operation == LicenseOperation.Return ) { + m_operationError = result.Success ? null : result.Error; + if ( result.Success ) { + m_activationError = null; + m_importMessage = null; + if ( m_operation == LicenseOperation.Return && !string.IsNullOrEmpty( m_deleteAfterReturn ) && + !AGXUnity.LicenseManager.DeleteFile( m_deleteAfterReturn ) ) + m_operationError = "The seat was returned, but the license file could not be deleted. Check the Console for details."; + } + m_deleteAfterReturn = null; + } else if ( result.Success && result.LicenseInfo.IsValid ) m_activationError = null; - m_operationIsActivation = false; + else if ( !result.Success ) + m_operationError = result.Error; + if ( m_operation == LicenseOperation.Refresh && !string.IsNullOrEmpty( m_restoreAfterRefresh ) ) { + AGXUnity.LicenseManager.LoadFile( m_restoreAfterRefresh ); + LicenseWarnings.Capture( AGXUnity.LicenseManager.UpdateLicenseInformation() ); + m_restoreAfterRefresh = null; + } + m_operation = LicenseOperation.None; RemoveNotification(); StartUpdateLicenseInformation(); Repaint(); @@ -409,15 +523,16 @@ private void OnEditorUpdate() if ( m_updateLicenseInfoTask != null ) return; - if ( m_checkLicenseValidity && AGXUnity.NativeHandler.HasInstance && AGXUnity.NativeHandler.Instance.Initialized ) { + if ( AGXUnity.NativeHandler.HasInstance && AGXUnity.NativeHandler.Instance.Initialized && + ( m_checkLicenseValidity || + LicenseWarnings.CurrentLicense.IsFloating != AGXUnity.LicenseManager.HasFloatingSession || + LicenseWarnings.CurrentLicense.IsValid != agx.Runtime.instance().isValid() ) ) { m_checkLicenseValidity = false; - var info = AGXUnity.LicenseInfo.Create(); - if ( info.IsValid != LicenseWarnings.CurrentLicense.IsValid ) { - LicenseWarnings.Capture( info ); - if ( info.IsValid ) - m_activationError = null; - Repaint(); - } + var info = AGXUnity.LicenseManager.UpdateLicenseInformation(); + LicenseWarnings.Capture( info ); + if ( info.IsValid ) + m_activationError = null; + Repaint(); } if ( m_updateLicenseInformationRequested ) { @@ -441,27 +556,25 @@ private void OnEditorUpdate() private void RefreshLicense( LicenseData licenseData ) { - var prevLicense = m_licenseData.Find( data => data.LicenseInfo.UniqueId == AGXUnity.LicenseManager.LicenseInfo.UniqueId ); - m_operationIsActivation = false; + var previous = FindActiveNonFloatingFile(); + m_restoreAfterRefresh = previous != licenseData.Filename ? previous : null; + m_operation = LicenseOperation.Refresh; + m_operationError = null; var completion = new TaskCompletionSource(); m_licenseOperationTask = completion.Task; try { AGXUnity.LicenseManager.RefreshAsync( licenseData.Filename, success => { - try { - if ( !success ) - Debug.LogError( "License Error: ".Color( Color.red ) + AGXUnity.LicenseManager.LicenseInfo.Status ); - if ( !string.IsNullOrEmpty( prevLicense.Filename ) && prevLicense.Filename != licenseData.Filename ) - AGXUnity.LicenseManager.LoadFile( prevLicense.Filename ); - } - finally { - completion.TrySetResult( new LicenseOperationResult { Success = success, LicenseInfo = AGXUnity.LicenseInfo.Create() } ); - } + completion.TrySetResult( new LicenseOperationResult { + Success = success, + Error = success ? null : AGXUnity.LicenseManager.LastOperationError, + LicenseInfo = AGXUnity.LicenseInfo.Create() + } ); } ); } catch ( System.Exception e ) { Debug.LogException( e ); - completion.TrySetResult( new LicenseOperationResult { LicenseInfo = AGXUnity.LicenseInfo.Create() } ); + completion.TrySetResult( new LicenseOperationResult { LicenseInfo = AGXUnity.LicenseInfo.Create(), Error = e.Message } ); } } @@ -485,6 +598,8 @@ private struct LicenseOperationResult public string Error; } + private enum LicenseOperation { None, Activate, Refresh, Connect, Return } + private IdPassword m_licenseActivateData = IdPassword.Empty(); private Vector2 m_scroll = Vector2.zero; [System.NonSerialized] @@ -492,7 +607,11 @@ private struct LicenseOperationResult private Task> m_updateLicenseInfoTask = null; private Task m_licenseOperationTask = null; private bool m_updateLicenseInformationRequested = false; - private bool m_operationIsActivation = false; + private LicenseOperation m_operation = LicenseOperation.None; + private string m_deleteAfterReturn; + private string m_restoreAfterRefresh; + private string m_operationError; + private string m_importMessage; private bool m_checkLicenseValidity = false; private bool m_clearFocus = false; private string m_activationError = null; diff --git a/Tests/LicenseManager~/README.md b/Tests/LicenseManager~/README.md new file mode 100644 index 00000000..eb7574ab --- /dev/null +++ b/Tests/LicenseManager~/README.md @@ -0,0 +1,9 @@ +Run `pwsh -NoProfile -File Tests/LicenseManager~/Run.ps1` from the repository root with a .NET SDK installed. + +This harness compiles the actual license manager, metadata parser, warning logic, and editor window against simulated AGX and Unity APIs. It uses temporary synthetic license files and never contacts a license server. Unity ignores the `~` directory, so these API substitutes are not included in project assemblies. Compiler outputs and synthetic files go to the system temporary directory. + +The checks exercise asynchronous success, failure, exceptions and overlap; manual return across simulated reload and play transitions; source path identity; deletion ordering; imports; non-floating actions; warning suppression; and the controls emitted by the window. They do not verify native server behavior or Unity's visual layout. Check Connect/Return and server seat counts against authorized infrastructure, and inspect the window in Unity before release. + +Loading regressions cover rejected service and legacy loads while an older license remains valid, preservation of failed runtime activation requests, cache refresh after exceptions, and automatic searches preserving valid native licenses without restoring stale metadata after failure. + +Asset-import worker checks reject all license operations before native runtime access. The simulated editor API also rejects calls from background threads, exercising the cached process check used by asynchronous operations. diff --git a/Tests/LicenseManager~/Run.ps1 b/Tests/LicenseManager~/Run.ps1 new file mode 100644 index 00000000..5657dec6 --- /dev/null +++ b/Tests/LicenseManager~/Run.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = 'Stop' +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) +$testOutput = Join-Path ([IO.Path]::GetTempPath()) ('agx-license-tests-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $testOutput | Out-Null +$sdk = (dotnet --list-sdks | Select-Object -Last 1) +if ($sdk -notmatch '^([^ ]+) \[(.+)\]$') { throw 'A .NET SDK is required.' } +$sdkDirectory = Join-Path $Matches[2] $Matches[1] +$dotnetRoot = [IO.Path]::GetFullPath((Join-Path $Matches[2] '..')) +$referencePack = Get-ChildItem (Join-Path $dotnetRoot 'packs/Microsoft.NETCore.App.Ref') -Directory | + Sort-Object { [version]$_.Name } | Select-Object -Last 1 +$framework = Get-ChildItem (Join-Path $referencePack.FullName 'ref') -Directory | Select-Object -First 1 +$runtimeVersion = [version]$referencePack.Name +$outputDll = Join-Path $testOutput 'LicenseTests.dll' +$compilerArguments = @('-nologo', '-target:exe', '-define:UNITY_EDITOR', '-langversion:9', "-out:`"$outputDll`"") +$compilerArguments += Get-ChildItem $framework.FullName -Filter '*.dll' | ForEach-Object { "-r:`"$($_.FullName)`"" } +$compilerArguments += @( + 'AGXUnity/LicenseInfo.cs', + 'AGXUnity/LicenseManager.cs', + 'Editor/AGXUnityEditor/LicenseWarnings.cs', + 'Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs', + 'Tests/LicenseManager~/Stubs.cs', + 'Tests/LicenseManager~/Tests.cs' +) | ForEach-Object { '"' + (Join-Path $repoRoot $_) + '"' } +$responseFile = Join-Path $testOutput 'compile.rsp' +Set-Content -LiteralPath $responseFile -Value $compilerArguments +& dotnet (Join-Path $sdkDirectory 'Roslyn/bincore/csc.dll') "@$responseFile" +if ($LASTEXITCODE -ne 0) { throw 'Test compilation failed.' } +@{ runtimeOptions = @{ tfm = $framework.Name; framework = @{ name = 'Microsoft.NETCore.App'; version = "$($runtimeVersion.Major).$($runtimeVersion.Minor).0" } } } | + ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $testOutput 'LicenseTests.runtimeconfig.json') +& dotnet $outputDll +if ($LASTEXITCODE -ne 0) { throw 'License lifecycle tests failed.' } +Write-Output "Test artifacts: $testOutput" diff --git a/Tests/LicenseManager~/Stubs.cs b/Tests/LicenseManager~/Stubs.cs new file mode 100644 index 00000000..85e48ddf --- /dev/null +++ b/Tests/LicenseManager~/Stubs.cs @@ -0,0 +1,318 @@ +// Test-only substitutes. The production source files are compiled unchanged. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace agx +{ + public class LicenseInfo + { + public int licenseType; + public string endDate = "2099-01-01", product = "AGX - Professional", user = "Test", contact = "Test", installationID = ""; + public string[] modules = { "AgX" }; + } + public static class agxSWIG { public static string agxGetVersion(bool unused) => "agx-test"; } + public class Runtime + { + public static Runtime Current = new Runtime(); + public static int InstanceCalls; + public static Runtime instance() + { + Interlocked.Increment(ref InstanceCalls); + if (UnityEditor.AssetDatabase.WorkerProcess) + throw new InvalidOperationException("Native runtime accessed by an asset import worker"); + return Current; + } + public int OpenCalls, CloseCalls, LoadCalls, RefreshCalls, ActivateCalls, DeactivateCalls, QueryCalls; + public int CurrentType = -1; + public string CurrentId = "", Status = "", Failure = "Server unreachable", Extended = "Native operation detail"; + public bool Valid, OpenResult = true, CloseResult = true, LoadResult = true, RuntimeActivationResult = true, ValidAfterLoad = true, IsRefreshed; + public Action BeforeOpen, BeforeClose, BeforeLoad; + public bool isFloatingLicense() => CurrentType == 1; + public bool isValid() => Valid; + public string getStatus() => Status; + public string getExtendedStatus() => Extended; + public bool hasKey(string key) => key == "InstallationID" && CurrentType >= 0 && CurrentId.Length > 0; + public string readValue(string key) => key == "InstallationID" ? CurrentId : key == "EndDate" ? "2099-01-01" : key == "Product" ? "AGX - Professional" : ""; + public string[] getEnabledModules() => new[] { "AgX" }; + public LicenseInfo queryLicenseInformation(string content) + { + ++QueryCalls; + Status = "Status overwritten by metadata query"; + return new LicenseInfo { licenseType = content.Contains("floating") ? 1 : content.Contains("service") ? 0 : -1, + installationID = content.Contains(":") ? content.Split(':').Last() : "" }; + } + public bool openNetworkSession(string file) + { + ++OpenCalls; + BeforeOpen?.Invoke(); + if (!OpenResult) { Status = Failure; return false; } + CurrentType = 1; Valid = true; CurrentId = File.ReadAllText(file).Split(':').Last(); Status = ""; + return true; + } + public bool closeNetworkSession() + { + ++CloseCalls; + BeforeClose?.Invoke(); + if (!CloseResult) { Status = Failure; return false; } + clear(); + return true; + } + public void clear() { CurrentType = -1; Valid = false; CurrentId = ""; Status = ""; } + public bool loadLicenseString(string content) + { + ++LoadCalls; + BeforeLoad?.Invoke(); + if (!LoadResult) { Status = Failure; return false; } + if (content.Contains("fallback")) { Status = "floating license requires a session"; return false; } + CurrentType = 0; Valid = ValidAfterLoad; CurrentId = content.Split(':').Last(); Status = ""; return true; + } + public bool loadLicenseFile(string file, bool refresh) { ++RefreshCalls; return loadLicenseString(File.ReadAllText(file)); } + public bool isLicenseRefreshed() => IsRefreshed; + public string readEncryptedLicense() => "service:generated"; + public bool activateAgxLicense(int id, string password, string file) + { ++ActivateCalls; File.WriteAllText(file, readEncryptedLicense()); return loadLicenseString(readEncryptedLicense()); } + public bool deactivateAgxLicense() { ++DeactivateCalls; clear(); return true; } + public bool unlock(string content) => loadLicenseString(content); + public bool verifyAndUnlock(string content) => loadLicenseString(content); + public bool activateEncryptedRuntime(string content, string file) + { + if (!RuntimeActivationResult) { Status = Failure; return false; } + return activateAgxLicense(1, "", file); + } + public string encryptRuntimeActivation(int id, string password, string reference) => "RT=synthetic"; + public string generateOfflineActivationRequest(int id, string password) => "synthetic"; + public bool processOfflineActivationRequest(string content) => loadLicenseString(content); + } +} +namespace agxIO +{ + public class Environment + { + public enum Type { RESOURCE_PATH } + public static Environment instance() => new Environment(); + public Environment getFilePath(Type type) => this; + public void pushbackPath(string path) { } + public void removeFilePath(string path) { } + } +} +namespace UnityEngine +{ + public class Object { } + public class HelpURLAttribute : Attribute { public HelpURLAttribute(string url) { } } + public class ExitGUIException : System.Exception { } + public class UnityException : System.Exception { public UnityException(string message) : base(message) { } } + public static class Application { public static bool isEditor = true, isBatchMode; public static string dataPath = "."; public static void OpenURL(string url) { } } + public static class Debug + { + public static void Log(object message) { } + public static void LogWarning(object message) { } + public static void LogError(object message, Object context = null) { } + public static void LogException(System.Exception error) { } + } + public struct Vector2 { public Vector2(float x, float y) { } public static Vector2 zero => default; } + public struct Rect { public float x, y, width, height; } + public struct Color { public static Color red, green; public static Color Lerp(Color a, Color b, float t) => a; } + public class GUIStyleState { public object background; } + public class GUIStyle { public GUIStyle() { } public GUIStyle(GUIStyle style) { } public GUIStyleState normal = new GUIStyleState(); } + public class GUISkin { public GUIStyle[] customStyles = { null, null, null, null }; } + public class GUIContent { public string text, tooltip; public GUIContent(string t, string tip = "") { text = t; tooltip = tip; } } + public static class GUI + { + public static bool enabled = true; + public static void FocusControl(string control) { } + public static bool Button(Rect rect, GUIContent content, GUIStyle style) => TestUI.Button(content); + } + public static class GUILayout + { + public static object Width(float width) => null; + public static object Height(float height) => null; + public static void Box(object content, GUIStyle style, params object[] options) { } + public static void Space(float space) { } + public static void Label(GUIContent content, GUIStyle style) { } + public static bool Button(GUIContent content, GUIStyle style) => TestUI.Button(content); + } + public static class GUILayoutUtility { public static Rect GetLastRect() => default; } + public static class GUIUtility { public static void ExitGUI() => throw new ExitGUIException(); } + public static class Resources { public static T[] FindObjectsOfTypeAll() => Array.Empty(); } +} +namespace UnityEditor +{ + using UnityEngine; + public class InitializeOnLoadMethodAttribute : Attribute { } + public enum PlayModeStateChange { ExitingEditMode, EnteredPlayMode, ExitingPlayMode, EnteredEditMode } + public enum MessageType { Info, Warning, Error } + public static class SessionState + { + public static int MainThread = Thread.CurrentThread.ManagedThreadId; + public static Dictionary Values = new Dictionary(); + public static bool GetBool(string key, bool fallback) { CheckThread(); return Values.TryGetValue(key, out var value) ? value : fallback; } + public static void SetBool(string key, bool value) { CheckThread(); Values[key] = value; } + private static void CheckThread() { if (Thread.CurrentThread.ManagedThreadId != MainThread) throw new System.Exception("SessionState accessed from worker"); } + } + public static class EditorApplication + { + public static event Action update; + public static event Action playModeStateChanged; + public static bool isCompiling, isUpdating, isPlayingOrWillChangePlaymode; + public static void Tick() => update?.Invoke(); + public static void PlayTransition(PlayModeStateChange state) => playModeStateChanged?.Invoke(state); + } + public static class AssemblyReloadEvents { public static event Action beforeAssemblyReload; public static void Reload() => beforeAssemblyReload?.Invoke(); } + public static class AssetDatabase + { + public static bool WorkerProcess; + public static bool IsAssetImportWorkerProcess() + { + if (Thread.CurrentThread.ManagedThreadId != SessionState.MainThread) + throw new UnityException("AssetDatabase accessed outside the editor thread"); + return WorkerProcess; + } + public static void Refresh() { } + } + public class EditorWindow : Object + { + public Vector2 minSize; + public Rect position; + public static T GetWindow(bool utility, string title, bool focus) where T : new() => new T(); + public void ShowNotification(GUIContent message) { } + public void RemoveNotification() { } + public void Repaint() { } + } + public static class EditorGUIUtility { public static float standardVerticalSpacing; } + public static class EditorGUI { public static Rect IndentedRect(Rect rect) => rect; } + public static class EditorGUILayout + { + public sealed class VerticalScope : IDisposable { public VerticalScope(GUIStyle style) { } public void Dispose() { } } + public static void LabelField(string text, GUIStyle style) { } + public static void HelpBox(string text, MessageType type, bool wide) => TestUI.Messages.Add((text, type)); + public static Vector2 BeginScrollView(Vector2 value) => value; + public static void EndScrollView() { } + public static string TextField(GUIContent label, string value, GUIStyle style) => value; + public static string PasswordField(GUIContent label, string value) => value; + public static Rect GetControlRect() => default; + public static void EnumFlagsField(GUIContent label, Enum value, bool includeObsolete, GUIStyle style) { } + } + public static class EditorUtility + { + public static string OpenFilePanel(string title, string directory, string extension) => TestUI.ImportFile; + public static bool DisplayDialog(string title, string text, string ok, string cancel) + { TestUI.Dialogs.Add(string.Join(" | ", title, text, ok, cancel)); return TestUI.ConfirmDialog; } + public static int DisplayDialogComplex(string title, string text, string ok, string cancel, string alternate) + { TestUI.Dialogs.Add(string.Join(" | ", title, text, ok, cancel, alternate)); return TestUI.DialogChoice; } + } +} +namespace AGXUnity +{ + public class Exception : System.Exception { public Exception(string text) : base(text) { } } + public class NativeHandler + { + public static bool HasInstance = true; + public static NativeHandler Instance = new NativeHandler(); + public bool Initialized = true, HasValidLicense; + public void ValidateLicense() => HasValidLicense = agx.Runtime.instance().isValid(); + } +} +namespace AGXUnity.IO +{ + public static class Environment + { + public static string FindUniqueFilename(string file) => File.Exists(file) ? file + ".copy" + Path.GetExtension(file) : file; + public static string GetPlayerPluginPath(string path) => path; + public static bool CanWriteToExisting(string file) => true; + } +} +namespace AGXUnity.Utils +{ + using UnityEngine; + public static class Extensions + { + public static string PrettyPath(this string path) => path.Replace('\\', '/'); + public static string MakeRelative(this string path, string root, bool unused) => Path.GetRelativePath(root, path); + public static string SplitCamelCase(this string text) => text; + public static string Color(this string text, Color color) => text; + } + public static class GUI + { + public sealed class EnabledBlock : IDisposable + { + private readonly bool previous = UnityEngine.GUI.enabled; + public EnabledBlock(bool value) { UnityEngine.GUI.enabled = value; } + public void Dispose() { UnityEngine.GUI.enabled = previous; } + } + public sealed class Scope : IDisposable { public void Dispose() { } } + public static class AlignBlock { public static Scope Center => new Scope(); } + public static GUISkin Skin = new GUISkin(); + public static GUIContent MakeLabel(string text, bool bold = false, string tooltip = "") => new GUIContent(text, tooltip); + public static object CreateColoredTexture(int w, int h, Color color) => new object(); + } +} +namespace AGXUnityEditor +{ + using UnityEngine; + public enum MiscIcon { Locate, Update, EntryRemove } + public static class IconManager { public static object GetAGXUnityLogo() => null; } + public static class InspectorEditor + { + public static SkinData Skin = new SkinData(); + public class SkinData { public GUIStyle Label, LabelMiddleCenter, TextField, Button, Popup; } + } + public static class InspectorGUISkin { public static Color BrandColorBlue; } + public static class InspectorGUI + { + public static Color BackgroundColor; + public static class IndentScope { public static AGXUnity.Utils.GUI.Scope Single => new AGXUnity.Utils.GUI.Scope(); } + public struct MiscButtonData + { + public MiscIcon Icon; + public Action Action; + public bool Enabled; + public string Tooltip; + public static MiscButtonData Create(MiscIcon icon, Action action, bool enabled, string tooltip) => new MiscButtonData { Icon = icon, Action = action, Enabled = enabled, Tooltip = tooltip }; + } + public static void BrandSeparator(int a, int b) { } + public static void Separator(int a, int b) { } + public static void Separator(int a, int b, Color c) { } + public static void LicenseEndDateField(AGXUnity.LicenseInfo info) { } + public static bool Link(GUIContent content) => false; + public static bool Button(Rect rect, MiscIcon icon, bool enabled, string tooltip) => enabled && TestUI.ImportFile != null; + public static void SelectFolder(GUIContent label, string path, string title, Action selected) { } + public static void SelectableTextField(GUIContent label, string value, params MiscButtonData[] buttons) + { + TestUI.Fields.Add((label.text, value)); + TestUI.MiscButtons.AddRange(buttons); + } + } + public class EditorDataEntry { public string String; } + public class EditorData + { + public static EditorData Instance = new EditorData(); + public EditorDataEntry Entry = new EditorDataEntry { String = "Assets" }; + public EditorDataEntry GetStaticData(string key, Action init) => Entry; + } +} +namespace AGXUnityEditor.IO { public static class Utils { public static bool IsValidProjectFolder(string path) => true; } } +public static class TestUI +{ + public static List<(string Text, bool Enabled, string Tooltip)> Buttons = new List<(string, bool, string)>(); + public static List<(string Text, UnityEditor.MessageType Type)> Messages = new List<(string, UnityEditor.MessageType)>(); + public static List<(string Label, string Value)> Fields = new List<(string, string)>(); + public static List MiscButtons = new List(); + public static List Dialogs = new List(); + public static bool ConfirmDialog; + public static int DialogChoice = 1; + public static string PressButton, ImportFile; + public static bool Button(UnityEngine.GUIContent content) + { + Buttons.Add((content.text, UnityEngine.GUI.enabled, content.tooltip)); + if (UnityEngine.GUI.enabled && PressButton == content.text) { PressButton = null; return true; } + return false; + } + public static void Reset() + { + Buttons.Clear(); Messages.Clear(); Fields.Clear(); MiscButtons.Clear(); Dialogs.Clear(); + ConfirmDialog = false; DialogChoice = 1; PressButton = null; ImportFile = null; UnityEngine.GUI.enabled = true; + } +} diff --git a/Tests/LicenseManager~/Tests.cs b/Tests/LicenseManager~/Tests.cs new file mode 100644 index 00000000..5045ca91 --- /dev/null +++ b/Tests/LicenseManager~/Tests.cs @@ -0,0 +1,489 @@ +using AGXUnity; +using AGXUnityEditor; +using AGXUnityEditor.Windows; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; + +public static class Tests +{ + private static readonly List Windows = new List(); + private const BindingFlags Private = BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic; + private static agx.Runtime Native => agx.Runtime.Current; + private static void Check(bool condition, string message) { if (!condition) throw new System.Exception(message); } + private static object Call(object target, string method, params object[] args) + { + try { return (target as Type ?? target.GetType()).GetMethod(method, Private).Invoke(target is Type ? null : target, args); } + catch (TargetInvocationException error) { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } + } + private static object Get(object target, string name) => (target as Type ?? target.GetType()).GetField(name, Private).GetValue(target is Type ? null : target); + private static void Set(object target, string name, object value) => (target as Type ?? target.GetType()).GetField(name, Private).SetValue(target is Type ? null : target, value); + private static string FileFor(string name, string content = "floating:shared") + { + var path = Path.GetFullPath(name); + File.WriteAllText(path, content); File.WriteAllText(path + ".meta", "synthetic meta"); + return path; + } + private static bool Operation(Action> start) + { + var result = new TaskCompletionSource(); + start(value => result.SetResult(value)); + Check(result.Task.Wait(5000), "Operation callback timed out"); + LicenseManager.AwaitTasks(); + return result.Task.Result; + } + private static bool Connect(string path) => Operation(done => LicenseManager.ConnectFloatingAsync(path, done)); + private static bool Return() => Operation(LicenseManager.ReturnFloatingAsync); + private static LicenseManagerWindow Window() + { + var window = new LicenseManagerWindow(); Windows.Add(window); + Call(window, "OnEnable"); Wait(window); return window; + } + private static void Wait(LicenseManagerWindow window) + { + LicenseManager.AwaitTasks(); + var until = DateTime.UtcNow.AddSeconds(5); + do { + EditorApplication.Tick(); + Call(window, "OnEditorUpdate"); + if (!window.IsUpdatingLicenseInformation && Get(window, "m_licenseOperationTask") == null) return; + Thread.Sleep(1); + } while (DateTime.UtcNow < until); + throw new System.Exception("Window update timed out"); + } + private static object Data(string path) + { + var type = typeof(LicenseManagerWindow).GetNestedType("LicenseData", BindingFlags.NonPublic); + var data = Activator.CreateInstance(type); + type.GetField("Filename").SetValue(data, path); + type.GetField("LicenseInfo").SetValue(data, LicenseManager.QueryInfo(path)); + return data; + } + private static void Capture() => LicenseWarnings.Capture(LicenseManager.UpdateLicenseInformation()); + + public static int Main() + { + SessionState.MainThread = Thread.CurrentThread.ManagedThreadId; + Call(typeof(LicenseManager), "InitializeEditorSessionHandling"); + var cases = new Action[] { + MetadataDoesNotCheckout, ConnectUsesOnlyNetworkSession, ConnectionFailuresPersist, + ReturnUpdatesValidityAndPreservesFile, FailedReturnPreservesSession, NativeExceptionsComplete, + ConcurrentOperationsAreRejected, ReturnSurvivesEditorTransitions, DisabledLoadingSkipsFallback, + DeactivationRejectsFloatingAndUnknown, ActiveDeletionReturnsFirst, FailedDeletionReturnKeepsFiles, + InactiveDeletionUsesPaths, UnknownSourceRequiresReturn, ControlsFollowSelectedMetadata, + PlayModeDisablesChanges, ImportsInspectBeforeConnect, NonFloatingWorkflows, + RuntimeGenerationWhileFloating, ClosingWindowDuringReturn, ExternalSessionChangesAreObserved, + HeldSeatRejectsReplacementOperations, ReloadWaitsForPendingReturn, + RejectedLoadsDoNotBorrowExistingValidity, FailedRuntimeActivationPreservesRequest, + FailedRuntimeActivationSkipsRefreshAndFloatingFallback, SuccessfulLoadsAndRuntimeActivation, + AcceptedLoadRequiresValidLicense, LoadExceptionsRefreshValidity, + SearchPreservesValidNativeLicense, FailedSearchUsesNativeState, EmptySearchUsesNativeState, + AssetImportWorkersCannotAccessLicensing + }; + var root = Path.Combine(Path.GetTempPath(), "agx-license-fixtures-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var originalDirectory = Directory.GetCurrentDirectory(); + var failures = 0; + foreach (var test in cases) { + try { + var directory = Path.Combine(root, test.Method.Name); + Directory.CreateDirectory(Path.Combine(directory, "Assets")); + Directory.SetCurrentDirectory(directory); + LicenseManager.AwaitTasks(); + agx.Runtime.Current = new agx.Runtime(); + AssetDatabase.WorkerProcess = false; + Set(typeof(LicenseManager), "s_isAssetImportWorkerProcess", null); + SessionState.Values.Clear(); + Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); + Set(typeof(LicenseManager), "s_activeFloatingLicenseFilename", null); + Capture(); TestUI.Reset(); + EditorApplication.isPlayingOrWillChangePlaymode = false; + test(); + Console.WriteLine("PASS " + test.Method.Name); + } + catch (System.Exception error) { ++failures; Console.WriteLine("FAIL " + test.Method.Name + ": " + error); } + finally { foreach (var window in Windows) Call(window, "OnDisable"); Windows.Clear(); } + } + Directory.SetCurrentDirectory(originalDirectory); + Console.WriteLine($"{cases.Length - failures}/{cases.Length} cases passed. Fixtures: {root}"); + return failures == 0 ? 0 : 1; + } + + private static void MetadataDoesNotCheckout() + { + Native.CurrentType = 0; Native.CurrentId = "service"; Native.Valid = true; + var path = FileFor("floating.lfx"); + Check(LicenseManager.QueryInfo(path).IsFloating, "Floating metadata lost with service active"); + Check(!LicenseManager.QueryInfo(FileFor("unknown.lfx", "invalid")).IsParsed, "Unknown metadata identified as service"); + Check(Native.OpenCalls == 0 && Native.LoadCalls == 0, "Metadata query acquired a seat"); + } + private static void ConnectUsesOnlyNetworkSession() + { + var path = FileFor("floating.lfx"); + Check(Connect("./floating.lfx"), "Connect failed"); + Check(LicenseManager.ActiveFloatingLicenseFilename == path.Replace('\\', '/'), "Source path not normalized"); + Check(LicenseManager.LicenseInfo.IsFloating && NativeHandler.Instance.HasValidLicense, "Cached/native validity not updated"); + Check(Native.OpenCalls == 1 && Native.ActivateCalls == 0 && Native.RefreshCalls == 0 && Native.LoadCalls == 0 && Native.DeactivateCalls == 0, "Connect used other license operations"); + Check(!Connect(path), "Connect switched an active session"); + Check(!LicenseManager.LoadFile(FileFor("service.lfx", "service:other")), "Load replaced a held seat"); + } + private static void ConnectionFailuresPersist() + { + var path = FileFor("floating.lfx"); + var window = Window(); Native.OpenResult = false; + foreach (var failure in new[] { "Server unreachable", "No seats available" }) { + Native.Failure = failure; + Call(window, "ConnectLicense", path); Wait(window); + var error = (string)Get(window, "m_operationError"); + Check(error.Contains(failure) && error.Contains("Native operation detail"), "Failure details lost before scan"); + var attempts = Native.OpenCalls; + for (int i = 0; i < 5; ++i) { Call(window, "OnGUI"); Call(window, "StartUpdateLicenseInformation"); Wait(window); } + Check(Native.OpenCalls == attempts && (string)Get(window, "m_operationError") == error, "Scan/repaint retried or erased error"); + Check(TestUI.Fields.Any(f => f.Label == "License type" && f.Value == "Floating"), "Failed checkout not labeled Floating"); + } + } + private static void ReturnUpdatesValidityAndPreservesFile() + { + var path = FileFor("floating.lfx"); Check(Connect(path), "Connect failed"); Check(Return(), "Return failed"); + Check(!LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense, "Return left valid cache"); + Check(LicenseManager.ActiveFloatingLicenseFilename == null && !LicenseManager.AutomaticFloatingCheckoutEnabled, "Return did not clear source/disable auto checkout"); + Check(File.Exists(path) && File.Exists(path + ".meta") && Native.DeactivateCalls == 0, "Return deleted or deactivated"); + } + private static void FailedReturnPreservesSession() + { + var path = FileFor("floating.lfx"); Connect(path); Native.CloseResult = false; + Check(!Return(), "Return should fail"); + Check(LicenseManager.ActiveFloatingLicenseFilename != null && LicenseManager.LicenseInfo.IsFloating && LicenseManager.AutomaticFloatingCheckoutEnabled, "Failed return discarded session or disabled checkout"); + } + private static void NativeExceptionsComplete() + { + var path = FileFor("floating.lfx"); + Native.BeforeOpen = () => throw new IOException("synthetic connection exception"); + Check(!Connect(path) && LicenseManager.LastOperationError.Contains("synthetic connection exception"), "Connect exception lost"); + Native.BeforeOpen = null; Connect(path); + Native.BeforeClose = () => throw new IOException("synthetic return exception"); + Check(!Return() && LicenseManager.LastOperationError.Contains("synthetic return exception") && NativeHandler.Instance.HasValidLicense, "Return exception lost/session invalidated"); + } + private static void ConcurrentOperationsAreRejected() + { + var path = FileFor("floating.lfx"); + using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); + Native.BeforeOpen = () => { entered.Set(); Check(release.Wait(5000), "Blocked open timed out"); }; + var result = new TaskCompletionSource(); + LicenseManager.ConnectFloatingAsync(path, success => result.SetResult(success)); + try { + Check(entered.Wait(5000) && LicenseManager.IsBusy && LicenseManager.IsConnecting, "Busy state missing"); + var rejected = 0; + Action reject = success => { Check(!success, "Overlapping operation accepted"); ++rejected; }; + LicenseManager.ReturnFloatingAsync(reject); LicenseManager.ConnectFloatingAsync(path, reject); + LicenseManager.RefreshAsync(path, reject); LicenseManager.ActivateAsync(1, "password", ".", reject); + Check(rejected == 4 && Native.OpenCalls == 1 && Native.CloseCalls == 0 && Native.ActivateCalls == 0, "Overlapping native request executed"); + } + finally { release.Set(); } + Check(result.Task.Wait(5000) && result.Task.Result, "Original request lost"); + LicenseManager.AwaitTasks(); Check(!LicenseManager.IsBusy, "Busy state stuck"); + } + private static void ReturnSurvivesEditorTransitions() + { + var path = FileFor("floating.lfx"); Connect(path); Return(); + AssemblyReloadEvents.Reload(); + Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); // New domain; SessionState survives. + Check(!LicenseManager.AutomaticFloatingCheckoutEnabled, "Reload forgot manual return"); + Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled), "Reload acquired a seat"); + EditorApplication.PlayTransition(PlayModeStateChange.ExitingEditMode); + EditorApplication.PlayTransition(PlayModeStateChange.ExitingPlayMode); + Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled) && Native.OpenCalls == 1, "Play transition acquired a seat"); + Capture(); LicenseWarnings.ScheduleStartupWarning(); EditorApplication.Tick(); + Check(TestUI.Dialogs.Count == 0 && string.IsNullOrEmpty(LicenseWarnings.GetWarningMessage(LicenseWarnings.CurrentLicense)), "Intentional return displayed a persistent message or startup warning"); + Native.OpenResult = false; Check(!Connect(path) && !LicenseManager.AutomaticFloatingCheckoutEnabled, "Failed connect enabled auto checkout"); + Native.OpenResult = true; Check(Connect(path), "Explicit reconnect failed"); EditorApplication.Tick(); + Check(LicenseManager.AutomaticFloatingCheckoutEnabled, "Explicit connect did not enable checkout"); + Return(); EditorApplication.Tick(); SessionState.Values.Clear(); // Editor restart. + Check(LicenseManager.AutomaticFloatingCheckoutEnabled && LicenseManager.LoadFile(), "Fresh session did not auto-connect"); + } + private static void DisabledLoadingSkipsFallback() + { + FileFor("fallback.lfx", "fallback"); + Check(!LicenseManager.LoadFile(allowFloating: false) && Native.OpenCalls == 0, "Disabled fallback checked out"); + Check(LicenseManager.LoadFile() && Native.OpenCalls == 1, "Default fallback changed"); + } + private static void DeactivationRejectsFloatingAndUnknown() + { + Native.CurrentType = 0; Native.Valid = true; Native.CurrentId = "service"; + foreach (var path in new[] { FileFor("floating.lfx"), FileFor("unknown.lfx", "invalid") }) + Check(!LicenseManager.DeactivateAndDelete(path) && File.Exists(path) && File.Exists(path + ".meta"), "Unsafe file deactivation/deletion"); + Check(Native.LoadCalls == 0 && Native.DeactivateCalls == 0 && Native.CurrentType == 0, "Guard loaded/deactivated another license"); + Native.CurrentType = 1; Capture(); Check(!LicenseManager.DeactivateLoaded() && Native.DeactivateCalls == 0, "Floating session deactivated"); + } + private static void ActiveDeletionReturnsFirst() + { + var path = FileFor("floating.lfx"); Connect(path); var window = Window(); + TestUI.ConfirmDialog = false; Call(window, "DeleteLicense", Data(path)); + Check(Native.CloseCalls == 0 && File.Exists(path), "Cancel changed session"); + TestUI.ConfirmDialog = true; + Native.BeforeClose = () => Check(File.Exists(path) && File.Exists(path + ".meta"), "File removed before return"); + Call(window, "DeleteLicense", Data(path)); Wait(window); + Check(!File.Exists(path) && !File.Exists(path + ".meta") && Native.CloseCalls == 1 && Native.DeactivateCalls == 0, "Return/delete failed"); + Check(TestUI.Dialogs.All(d => !d.ToLowerInvariant().Contains("deactivat")) && TestUI.Dialogs.Last().Contains("Return Seat and Delete"), "Floating dialog offered deactivation"); + } + private static void FailedDeletionReturnKeepsFiles() + { + var path = FileFor("floating.lfx"); Connect(path); var window = Window(); + Native.CloseResult = false; TestUI.ConfirmDialog = true; + Call(window, "DeleteLicense", Data(path)); Wait(window); + Check(File.Exists(path) && File.Exists(path + ".meta") && ((string)Get(window, "m_operationError")).Contains(Native.Failure), "Failed return deleted file or lost error"); + Call(window, "StartUpdateLicenseInformation"); Wait(window); + Check(!string.IsNullOrEmpty((string)Get(window, "m_operationError")), "Rescan erased return failure"); + } + private static void InactiveDeletionUsesPaths() + { + foreach (var id in new[] { "shared", "" }) { + var active = FileFor("a" + id + ".lfx", "floating:" + id); + var inactive = FileFor("b" + id + ".lfx", "floating:" + id); + Connect(active); var window = Window(); TestUI.ConfirmDialog = true; + var returns = Native.CloseCalls; + Call(window, "DeleteLicense", Data(inactive)); Wait(window); + Check(!File.Exists(inactive) && Native.CloseCalls == returns && LicenseManager.HasFloatingSession, "Duplicate/empty ID returned another file's seat"); + Check(!LicenseManager.DeleteFile(active), "Raw deletion removed active file"); + Return(); + } + } + private static void UnknownSourceRequiresReturn() + { + var path = FileFor("floating.lfx", "floating:"); + Native.CurrentType = 1; Native.Valid = true; Native.CurrentId = ""; Capture(); + var window = Window(); Call(window, "OnGUI"); + Check(LicenseManager.ActiveFloatingLicenseFilename == null, "Guessed external session source"); + Check(TestUI.Buttons.Any(b => b.Text == "Return Seat" && b.Enabled), "Unknown source has no Return Seat"); + Check(TestUI.MiscButtons.Where(b => b.Icon == MiscIcon.EntryRemove).All(b => !b.Enabled), "Unknown source permits floating deletion"); + Call(window, "DeleteLicense", Data(path)); Check(File.Exists(path) && TestUI.Dialogs.Count == 0, "Unknown source deletion prompted or deleted"); + Call(window, "ReturnSeat", new object[] { null }); Wait(window); + Check(!LicenseManager.HasFloatingSession && File.Exists(path), "Unknown source return failed"); + } + private static void ControlsFollowSelectedMetadata() + { + var floating = FileFor("floating.lfx"); var service = FileFor("service.lfx", "service:service"); var unknown = FileFor("unknown.lfx", "invalid"); + var window = Window(); + foreach (var path in new[] { floating, service, unknown }) { + TestUI.Reset(); Call(window, "LicenseDataGUI", Data(path)); + Check(TestUI.MiscButtons.Any(b => b.Tooltip.Contains("deactivate")) == (path == service), "Wrong deactivation tooltip"); + Check(TestUI.Buttons.Any(b => b.Text == "Connect") == (path == floating), "Wrong Connect control"); + Check(TestUI.MiscButtons.Any(b => b.Icon == MiscIcon.Update) == (path != floating), "Floating refresh control exposed"); + } + Connect(floating); Capture(); TestUI.Reset(); Call(window, "OnGUI"); + Check(TestUI.Buttons.Where(b => b.Text == "Connect" || b.Text == "Activate").All(b => !b.Enabled), "Held seat permits replacement"); + Check(TestUI.MiscButtons.Where(b => b.Icon == MiscIcon.Update).All(b => !b.Enabled), "Held seat permits refresh"); + } + private static void PlayModeDisablesChanges() + { + var path = FileFor("floating.lfx"); Connect(path); var window = Window(); + EditorApplication.isPlayingOrWillChangePlaymode = true; TestUI.Reset(); Call(window, "OnGUI"); + Check(TestUI.Buttons.All(b => !b.Enabled) && TestUI.MiscButtons.All(b => !b.Enabled), "Play mode enables license changes"); + } + private static void ImportsInspectBeforeConnect() + { + var path = FileFor("source.lfx"); var window = Window(); + TestUI.ImportFile = path; TestUI.ConfirmDialog = true; + try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } + TestUI.ImportFile = null; Wait(window); + Check(File.Exists("Assets/source.lfx") && Native.OpenCalls == 0 && Native.LoadCalls == 0, "Floating import checked out a seat"); + Check(((string)Get(window, "m_importMessage")).Contains("Connect"), "Import gave no Connect guidance"); + TestUI.ImportFile = FileFor("unidentified.lfx", "fallback"); + try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } + TestUI.ImportFile = null; Wait(window); + Check(File.Exists("Assets/unidentified.lfx") && Native.OpenCalls == 0 && Native.LoadCalls == 0, "Unidentified import used checkout fallback"); + } + private static void NonFloatingWorkflows() + { + var service = FileFor("service.lfx", "service:service"); + Check(LicenseManager.LoadFile(service), "Service load failed"); + Check(LicenseManager.Refresh(service) && Native.RefreshCalls == 1, "Service refresh failed"); + Check(LicenseManager.Activate(123, "synthetic", "Assets") && Native.ActivateCalls == 1, "Activation failed"); + Check(LicenseManager.DeactivateAndDelete(service) && Native.DeactivateCalls == 1, "Service deactivation failed"); + var imported = FileFor("import.lfx", "service:imported"); var window = Window(); + TestUI.ImportFile = imported; TestUI.ConfirmDialog = true; + try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } + TestUI.ImportFile = null; Wait(window); Check(Native.CurrentId == "imported", "Service import failed to load"); + } + private static void RuntimeGenerationWhileFloating() + { + var path = FileFor("floating.lfx"); Connect(path); + File.WriteAllText("application.exe", "synthetic reference"); + Check(LicenseManager.GenerateEncryptedRuntime(42, "synthetic", Directory.GetCurrentDirectory(), "application.exe") && File.Exists("agx.rtlfx"), "Runtime generation gated on floating editor license"); + Check(LicenseManager.HasFloatingSession && Native.OpenCalls == 1 && Native.CloseCalls == 0, "Runtime generation changed editor seat"); + } + private static void ClosingWindowDuringReturn() + { + var path = FileFor("floating.lfx"); Connect(path); var window = Window(); + using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); + Native.BeforeClose = () => { entered.Set(); Check(release.Wait(5000), "Return wait timed out"); }; + Call(window, "ReturnSeat", path); + try { Check(entered.Wait(5000), "Return did not start"); Call(window, "OnDisable"); } + finally { release.Set(); } + LicenseManager.AwaitTasks(); EditorApplication.Tick(); + Check(!LicenseManager.AutomaticFloatingCheckoutEnabled && !LicenseManager.HasFloatingSession, "Closing window lost return state"); + Check(File.Exists(path) && File.Exists(path + ".meta"), "Closed window deleted files"); + } + private static void ExternalSessionChangesAreObserved() + { + var path = FileFor("floating.lfx"); var window = Window(); + Native.CurrentType = 1; Native.Valid = true; + Call(window, "OnEditorUpdate"); Call(window, "OnGUI"); + Check(TestUI.Buttons.Any(b => b.Text == "Return Seat" && b.Enabled) && LicenseManager.ActiveFloatingLicenseFilename == null, "External session not observed"); + Native.clear(); Call(window, "OnEditorUpdate"); + Check(!LicenseWarnings.CurrentLicense.IsFloating, "External return not observed"); + Connect(path); Native.clear(); + Check(LicenseManager.ActiveFloatingLicenseFilename == null, "Ended session kept source filename"); + Native.CurrentType = 1; Capture(); + Check(LicenseManager.ActiveFloatingLicenseFilename == null, "New external session inherited old source"); + } + private static void HeldSeatRejectsReplacementOperations() + { + var floating = FileFor("floating.lfx"); var service = FileFor("service.lfx", "service:service"); + Check(!LicenseManager.Refresh(floating) && Native.RefreshCalls == 0 && Native.OpenCalls == 0, "Floating file refreshed/connected via refresh"); + Connect(floating); + Check(!LicenseManager.Activate(1, "synthetic", "Assets") && Native.ActivateCalls == 0, "Activation replaced seat"); + Check(!LicenseManager.Refresh(service) && Native.RefreshCalls == 0, "Refresh replaced seat"); + Check(!LicenseManager.Load("service:service") && Native.LoadCalls == 0, "Content load replaced seat"); + Check(!LicenseManager.CreateOfflineLicense("synthetic", "offline.lfx", false) && !File.Exists("offline.lfx"), "Offline activation replaced seat"); + var window = Window(); TestUI.ImportFile = service; TestUI.ConfirmDialog = true; + try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } + TestUI.ImportFile = null; Wait(window); + Check(File.Exists("Assets/service.lfx") && Native.LoadCalls == 0, "Import replaced seat"); + Check(LicenseManager.HasFloatingSession && Native.CloseCalls == 0, "Replacement attempt returned seat"); + } + private static void ReloadWaitsForPendingReturn() + { + var path = FileFor("floating.lfx"); Connect(path); EditorApplication.Tick(); + using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); + Native.BeforeClose = () => { entered.Set(); Check(release.Wait(5000), "Pending return timed out"); }; + LicenseManager.ReturnFloatingAsync(null); + Check(entered.Wait(5000) && LicenseManager.IsReturning, "Return busy state missing"); + var releaseTask = Task.Run(() => { Thread.Sleep(25); release.Set(); }); + AssemblyReloadEvents.Reload(); + Check(!LicenseManager.IsBusy && !LicenseManager.HasFloatingSession && SessionState.GetBool("AGXUnity.FloatingLicenseReturned", false), "Reload failed to await and persist return"); + releaseTask.Wait(); + } + + private static void RejectedLoadsDoNotBorrowExistingValidity() + { + Native.CurrentType = 0; Native.CurrentId = "old-license"; Native.Valid = true; Capture(); + Native.LoadResult = false; + foreach (var content in new[] { "service:rejected", "Key { invalid legacy }", "invalid-obfuscated" }) { + Check(!LicenseManager.Load(content), "Rejected content reported success using the old license"); + var file = FileFor("rejected" + (content.StartsWith("") ? ".lfx" : ".lic"), content); + Check(!LicenseManager.LoadFile(file) && File.ReadAllText(file) == content, "Rejected file reported success or changed"); + Check(LicenseManager.LicenseInfo.IsValid && NativeHandler.Instance.HasValidLicense && Native.CurrentId == "old-license", "Rejected load misrepresented or cleared the surviving license"); + } + } + + private static void FailedRuntimeActivationPreservesRequest() + { + Native.CurrentType = 0; Native.CurrentId = "old-license"; Native.Valid = true; Capture(); + Native.RuntimeActivationResult = false; + var request = FileFor("activation.rtlfx", "RT=synthetic-rejected"); + Check(!LicenseManager.ActivateEncryptedRuntime(request, "Assets"), "Rejected runtime activation borrowed old license validity"); + Check(File.ReadAllText(request) == "RT=synthetic-rejected" && File.Exists(request + ".meta") && !File.Exists("Assets/agx.lfx"), "Rejected runtime activation lost the request or created a license"); + Check(Native.CurrentId == "old-license" && NativeHandler.Instance.HasValidLicense, "Rejected runtime activation changed the surviving license"); + } + + private static void FailedRuntimeActivationSkipsRefreshAndFloatingFallback() + { + Native.RuntimeActivationResult = false; Native.IsRefreshed = true; + Native.Failure = "Cannot activate a floating license"; + var request = FileFor("activation.rtlfx", "RT=synthetic-rejected"); + Check(!LicenseManager.ActivateEncryptedRuntime(request, "Assets"), "Failed activation used another operation's success"); + Check(File.ReadAllText(request) == "RT=synthetic-rejected" && Native.OpenCalls == 0, "Failed runtime activation rewrote the request or tried floating checkout"); + } + + private static void SuccessfulLoadsAndRuntimeActivation() + { + foreach (var content in new[] { "service:valid", "Key { synthetic legacy }", "synthetic-obfuscated" }) + Check(LicenseManager.Load(content) && LicenseManager.LicenseInfo.IsValid && NativeHandler.Instance.HasValidLicense, "Successful load failed"); + var request = FileFor("activation.rtlfx", "RT=synthetic-valid"); + Check(LicenseManager.ActivateEncryptedRuntime(request, "Assets") && File.Exists("Assets/agx.lfx") && !File.Exists(request), "Successful runtime activation did not replace its request with a license"); + } + + private static void AcceptedLoadRequiresValidLicense() + { + Native.ValidAfterLoad = false; + Check(!LicenseManager.Load("service:invalid") && !LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense, "Native acceptance was treated as a valid license"); + } + + private static void LoadExceptionsRefreshValidity() + { + Native.CurrentType = 0; Native.CurrentId = "old-license"; Native.Valid = true; Capture(); + Native.BeforeLoad = () => { Native.clear(); throw new IOException("synthetic load exception after native state change"); }; + Check(!LicenseManager.Load("service:rejected"), "Load exception reported success"); + Check(!NativeHandler.Instance.HasValidLicense && !LicenseManager.LicenseInfo.IsValid, "Load exception left stale managed validity"); + } + + private static void SearchPreservesValidNativeLicense() + { + Native.CurrentType = 0; Native.CurrentId = "script-loaded-license"; Native.Valid = true; Capture(); + Native.LoadResult = false; + Check(LicenseManager.LoadFile(), "Search rejected a valid native license with no files"); + Check(Native.isValid() && Native.CurrentId == "script-loaded-license" && NativeHandler.Instance.HasValidLicense, "Search cleared a script-loaded license and restored only its metadata"); + FileFor("invalid.lfx", "service:rejected"); + Check(LicenseManager.LoadFile() && Native.LoadCalls == 0 && Native.isValid(), "Search replaced an already valid native license"); + } + + private static void FailedSearchUsesNativeState() + { + Native.CurrentType = 0; Native.CurrentId = "expired-old-license"; Native.Status = "Old expiration error"; Capture(); + Native.LoadResult = false; Native.Failure = "Current file rejected"; + FileFor("invalid.lfx", "service:rejected"); + Check(!LicenseManager.LoadFile(), "Failed search reported success"); + Check(!LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense && LicenseManager.LicenseInfo.Status == Native.Failure, "Failed search restored previous license information instead of native state"); + } + + private static void EmptySearchUsesNativeState() + { + Native.CurrentType = 0; Native.CurrentId = "expired-old-license"; Native.Status = "Old expiration error"; Capture(); + Check(!LicenseManager.LoadFile(), "Empty search reported success"); + Check(!LicenseManager.LicenseInfo.IsParsed && LicenseManager.LicenseInfo.Status == Native.Status && !NativeHandler.Instance.HasValidLicense, "Empty search restored stale metadata"); + } + + private static void AssetImportWorkersCannotAccessLicensing() + { + var floating = FileFor("floating.lfx"); + var runtime = FileFor("runtime.rtlfx", "RT=synthetic"); + AssetDatabase.WorkerProcess = true; + Set(typeof(LicenseManager), "s_isAssetImportWorkerProcess", null); + agx.Runtime.InstanceCalls = 0; + try { + Check(!LicenseManager.CanAccessRuntime && !LicenseManager.HasFloatingSession, "Asset worker can access native session state"); + Check(!LicenseInfo.Create().IsParsed && !LicenseManager.LicenseInfo.IsParsed && !LicenseManager.UpdateLicenseInformation().IsParsed, "Asset worker obtained license information"); + Check(!LicenseManager.QueryInfo(floating).IsParsed, "Asset worker queried a license"); + Check(!LicenseManager.LoadFile() && !LicenseManager.LoadFile(false) && !LicenseManager.LoadFile(floating) && !LicenseManager.Load("service:synthetic"), "Asset worker loaded a license"); + var previousTask = Get(typeof(LicenseManager), "s_operationTask"); + var rejected = 0; + Action reject = success => { Check(!success, "Asset worker operation succeeded"); ++rejected; }; + LicenseManager.ConnectFloatingAsync(floating, reject); + LicenseManager.ReturnFloatingAsync(reject); + LicenseManager.ActivateAsync(1, "synthetic", "Assets", reject); + LicenseManager.RefreshAsync(floating, reject); + Check(rejected == 4 && !LicenseManager.IsBusy && ReferenceEquals(previousTask, Get(typeof(LicenseManager), "s_operationTask")), "Asset worker scheduled a licensing task or missed a callback"); + Check(!LicenseManager.Activate(1, "synthetic", "Assets") && !LicenseManager.Refresh(floating), "Asset worker activated or refreshed synchronously"); + Check(!LicenseManager.ActivateEncryptedRuntime("Assets") && !LicenseManager.ActivateEncryptedRuntime(runtime, "Assets"), "Asset worker activated a runtime license"); + Check(!LicenseManager.GenerateEncryptedRuntime(1, "synthetic", Directory.GetCurrentDirectory(), floating), "Asset worker generated runtime activation data"); + Check(!LicenseManager.GenerateOfflineActivation(1, "synthetic", "offline-request.txt") && !LicenseManager.CreateOfflineLicense("synthetic", "offline.lfx"), "Asset worker used offline licensing"); + Check(!LicenseManager.DeactivateLoaded() && !LicenseManager.DeactivateAndDelete(floating) && !LicenseManager.DeleteFile(floating), "Asset worker removed a license"); + LicenseManager.Reset(); + Check(!LicenseManager.ReturnFloating("Asset worker shutdown"), "Asset worker returned a session"); + Check(File.Exists(floating) && File.Exists(floating + ".meta") && File.Exists(runtime) && !File.Exists("Assets/agx.lfx"), "Asset worker modified licensing files"); + Check(agx.Runtime.InstanceCalls == 0, "Asset worker reached the native runtime"); + } + finally { + AssetDatabase.WorkerProcess = false; + Set(typeof(LicenseManager), "s_isAssetImportWorkerProcess", null); + } + } +} From ea57742a6a1e1206382bd8818802540af193c65f Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Tue, 8 Sep 2026 17:01:49 +0200 Subject: [PATCH 03/11] Automatic connection on start --- AGXUnity/LicenseManager.cs | 27 ++++++-- Editor/AGXUnityEditor/Manager.cs | 5 +- .../Windows/LicenseManagerWindow.cs | 4 +- Tests/LicenseManager~/README.md | 2 + Tests/LicenseManager~/Tests.cs | 67 +++++++++++++++++-- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/AGXUnity/LicenseManager.cs b/AGXUnity/LicenseManager.cs index b9f4f3df..e244bf42 100644 --- a/AGXUnity/LicenseManager.cs +++ b/AGXUnity/LicenseManager.cs @@ -207,6 +207,17 @@ public static bool LoadFile() /// floating session is preserved; return it before replacing it. /// public static bool LoadFile( bool allowFloating ) + { + return LoadFile( allowFloating, preferFloating: false ); + } + + /// + /// Search for a license, optionally preferring a project floating file over + /// a valid non-floating license discovered by native initialization. Existing + /// floating sessions are always preserved. Used by editor startup so an + /// installed AGX license doesn't prevent checkout of a project floating file. + /// + public static bool LoadFile( bool allowFloating, bool preferFloating ) { if ( !CanAccessRuntime || IsBusy ) return false; @@ -215,22 +226,26 @@ public static bool LoadFile( bool allowFloating ) // Preserve the actual native license instead of clearing it and later // restoring only a snapshot of its information. var currentLicense = UpdateLicenseInformation(); - if ( currentLicense.IsValid || HasFloatingSession ) + if ( HasFloatingSession || ( currentLicense.IsValid && !( allowFloating && preferFloating ) ) ) return currentLicense.IsValid; - Reset(); + if ( !currentLicense.IsValid ) + Reset(); var licenseFiles = FindLicenseFiles(); foreach ( var licenseFile in licenseFiles ) { var file = licenseFile.PrettyPath(); if ( LoadFile( file, $"License file \"{file}\" found in search from application root.", - allowFloating ) ) + allowFloating, + floatingOnly: currentLicense.IsValid ) ) return true; } UpdateLicenseInformation(); - return false; + // With no floating candidate, retain the native fallback. A failed + // checkout may clear it; always use its actual validity after the search. + return currentLicense.IsValid && LicenseInfo.IsValid; } public static LicenseInfo QueryInfo( string filename ) @@ -904,7 +919,7 @@ public static bool DeleteFile( string filename ) /// Filename, including path, to load. /// Context of the call to this method. /// True if successfully loaded, otherwise false. - private static bool LoadFile( string filename, string context, bool allowFloating = true ) + private static bool LoadFile( string filename, string context, bool allowFloating = true, bool floatingOnly = false ) { if ( !CanAccessRuntime ) return false; @@ -927,6 +942,8 @@ private static bool LoadFile( string filename, string context, bool allowFloatin text = File.ReadAllText( filename ); if ( GetLicenseType( filename ) == LicenseInfo.LicenseType.Service && QueryInfo( filename ).IsFloating ) return allowFloating && LoadFloating( filename, context ); + if ( floatingOnly ) + return false; } catch ( System.Exception e ) { IssueLoadWarning( $"Caught exception reading text from file: {filename}.", context ); diff --git a/Editor/AGXUnityEditor/Manager.cs b/Editor/AGXUnityEditor/Manager.cs index 0523095e..c4fd44d8 100644 --- a/Editor/AGXUnityEditor/Manager.cs +++ b/Editor/AGXUnityEditor/Manager.cs @@ -791,7 +791,10 @@ internal static EnvironmentState ConfigureEnvironment() // Complete license loading (including floating checkout) before deciding // whether to notify the user. Native initialization failures are separate. try { - AGXUnity.LicenseManager.LoadFile( allowFloating: AGXUnity.LicenseManager.AutomaticFloatingCheckoutEnabled ); + // Native initialization may find a license in the AGX installation. + // Prefer a floating file in this project unless the seat was returned. + AGXUnity.LicenseManager.LoadFile( allowFloating: AGXUnity.LicenseManager.AutomaticFloatingCheckoutEnabled, + preferFloating: true ); AGXUnity.NativeHandler.Instance.ValidateLicense(); if ( AGXUnity.NativeHandler.Instance.Initialized ) { diff --git a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs index 2960848d..6e28b2fb 100644 --- a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs +++ b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs @@ -117,7 +117,7 @@ private void OnGUI() if ( LicenseWarnings.CurrentLicense.IsFloating ) { EditorGUILayout.HelpBox( "A floating license seat is held. Return it before connecting, activating, refreshing, or loading another license.", MessageType.Info, true ); using ( new GUI.EnabledBlock( UnityEngine.GUI.enabled && !EditorApplication.isPlayingOrWillChangePlaymode ) ) { - if ( GUILayout.Button( GUI.MakeLabel( "Return Seat", false, "Return the current floating seat to the server." ), InspectorEditor.Skin.Button ) ) + if ( GUILayout.Button( GUI.MakeLabel( "Return Seat", false, "Temporarily release the floating seat until you select Connect or restart the editor." ), InspectorEditor.Skin.Button ) ) ReturnSeat(); } GUILayout.Space( 6 ); @@ -331,7 +331,7 @@ private void LicenseDataGUI( LicenseData data ) if ( floating ) { using ( new GUI.EnabledBlock( canChange && !LicenseWarnings.CurrentLicense.IsFloating ) ) { if ( GUILayout.Button( GUI.MakeLabel( "Connect", false, - LicenseWarnings.CurrentLicense.IsFloating ? "Return the current seat before connecting." : "Request a floating license seat from this file's server." ), + LicenseWarnings.CurrentLicense.IsFloating ? "Return the current seat before connecting." : "Request a floating license seat from this file's server. Floating licenses connect automatically when the editor starts." ), InspectorEditor.Skin.Button ) ) ConnectLicense( data.Filename ); } diff --git a/Tests/LicenseManager~/README.md b/Tests/LicenseManager~/README.md index eb7574ab..aa8c7f60 100644 --- a/Tests/LicenseManager~/README.md +++ b/Tests/LicenseManager~/README.md @@ -6,4 +6,6 @@ The checks exercise asynchronous success, failure, exceptions and overlap; manua Loading regressions cover rejected service and legacy loads while an older license remains valid, preservation of failed runtime activation requests, cache refresh after exceptions, and automatic searches preserving valid native licenses without restoring stale metadata after failure. +The editor lifecycle checks use the same loading options as editor initialization: a floating file under Assets connects at startup even if native initialization found a valid non-floating license in the AGX installation. Repeated initialization preserves an existing seat, and manual return pauses checkout until Connect succeeds or a simulated editor restart clears session storage. Checks also cover preserving the native fallback when checkout is disabled or no floating file exists, and refreshing actual native validity when checkout fails after clearing that fallback. + Asset-import worker checks reject all license operations before native runtime access. The simulated editor API also rejects calls from background threads, exercising the cached process check used by asynchronous operations. diff --git a/Tests/LicenseManager~/Tests.cs b/Tests/LicenseManager~/Tests.cs index 5045ca91..e22beb94 100644 --- a/Tests/LicenseManager~/Tests.cs +++ b/Tests/LicenseManager~/Tests.cs @@ -85,7 +85,9 @@ public static int Main() FailedRuntimeActivationSkipsRefreshAndFloatingFallback, SuccessfulLoadsAndRuntimeActivation, AcceptedLoadRequiresValidLicense, LoadExceptionsRefreshValidity, SearchPreservesValidNativeLicense, FailedSearchUsesNativeState, EmptySearchUsesNativeState, - AssetImportWorkersCannotAccessLicensing + AssetImportWorkersCannotAccessLicensing, + EditorStartupPrefersProjectFloating, EditorStartupPreservesNativeFallback, + EditorStartupFloatingFailureUsesNativeState }; var root = Path.Combine(Path.GetTempPath(), "agx-license-fixtures-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); @@ -192,21 +194,72 @@ private static void ConcurrentOperationsAreRejected() } private static void ReturnSurvivesEditorTransitions() { - var path = FileFor("floating.lfx"); Connect(path); Return(); + var path = FileFor("Assets/floating.lfx"); + Check(LicenseManager.AutomaticFloatingCheckoutEnabled, "Fresh editor disabled auto checkout"); + Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Editor startup did not auto-connect"); + Check(Native.OpenCalls == 1 && Native.LoadCalls == 0 && Native.ActivateCalls == 0 && Native.RefreshCalls == 0 && NativeHandler.Instance.HasValidLicense, "Startup did not acquire a valid floating seat using a network session"); + Check(LicenseManager.ActiveFloatingLicenseFilename == path.Replace('\\', '/'), "Startup lost the floating source path"); + Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true) && Native.OpenCalls == 1, "Repeated initialization acquired another seat"); + Check(Return(), "Temporarily returning the startup seat failed"); AssemblyReloadEvents.Reload(); Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); // New domain; SessionState survives. Check(!LicenseManager.AutomaticFloatingCheckoutEnabled, "Reload forgot manual return"); - Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled), "Reload acquired a seat"); + Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Reload acquired a seat"); EditorApplication.PlayTransition(PlayModeStateChange.ExitingEditMode); EditorApplication.PlayTransition(PlayModeStateChange.ExitingPlayMode); - Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled) && Native.OpenCalls == 1, "Play transition acquired a seat"); + Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true) && Native.OpenCalls == 1, "Play transition acquired a seat"); Capture(); LicenseWarnings.ScheduleStartupWarning(); EditorApplication.Tick(); Check(TestUI.Dialogs.Count == 0 && string.IsNullOrEmpty(LicenseWarnings.GetWarningMessage(LicenseWarnings.CurrentLicense)), "Intentional return displayed a persistent message or startup warning"); Native.OpenResult = false; Check(!Connect(path) && !LicenseManager.AutomaticFloatingCheckoutEnabled, "Failed connect enabled auto checkout"); Native.OpenResult = true; Check(Connect(path), "Explicit reconnect failed"); EditorApplication.Tick(); Check(LicenseManager.AutomaticFloatingCheckoutEnabled, "Explicit connect did not enable checkout"); - Return(); EditorApplication.Tick(); SessionState.Values.Clear(); // Editor restart. - Check(LicenseManager.AutomaticFloatingCheckoutEnabled && LicenseManager.LoadFile(), "Fresh session did not auto-connect"); + Return(); EditorApplication.Tick(); + // Editor restart clears session storage and creates a new native runtime. + SessionState.Values.Clear(); + agx.Runtime.Current = new agx.Runtime(); + Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); + Set(typeof(LicenseManager), "s_activeFloatingLicenseFilename", null); + Capture(); + Check(LicenseManager.AutomaticFloatingCheckoutEnabled && LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Fresh session did not auto-connect"); + Check(Native.OpenCalls == 1 && NativeHandler.Instance.HasValidLicense, "Restart did not acquire a valid seat"); + } + private static void EditorStartupPrefersProjectFloating() + { + Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); + FileFor("service.lfx", "service:project"); + var floating = FileFor("Assets/floating.lfx"); + Check(LicenseManager.LoadFile() && Native.OpenCalls == 0, "Default search replaced a valid native license"); + Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Editor startup rejected project floating license"); + Check(Native.OpenCalls == 1 && Native.LoadCalls == 0 && Native.RefreshCalls == 0 && Native.ActivateCalls == 0, "Editor startup skipped checkout or loaded a non-floating file"); + Check(LicenseManager.HasFloatingSession && NativeHandler.Instance.HasValidLicense && LicenseManager.ActiveFloatingLicenseFilename == floating.Replace('\\', '/'), "Native fallback prevented project checkout"); + FileFor("another-floating.lfx"); + Check(LicenseManager.LoadFile(true, true) && Native.OpenCalls == 1 && Native.CloseCalls == 0, "Repeated startup replaced a held seat"); + } + private static void EditorStartupPreservesNativeFallback() + { + Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); + FileFor("service.lfx", "service:project"); + Check(LicenseManager.LoadFile(true, true) && Native.CurrentId == "native-installation-fallback" && Native.LoadCalls == 0, "Search without a floating file replaced the native fallback"); + var floating = FileFor("Assets/floating.lfx"); + Check(Connect(floating) && Return(), "Unable to temporarily return a seat"); + AssemblyReloadEvents.Reload(); + Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); + // Native initialization on reload finds a valid non-floating fallback again. + Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); + Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Disabled checkout discarded the native fallback"); + Check(!LicenseManager.HasFloatingSession && Native.OpenCalls == 1 && Native.CurrentId == "native-installation-fallback", "Native fallback caused a returned seat to be reacquired"); + } + private static void EditorStartupFloatingFailureUsesNativeState() + { + Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); + var floating = FileFor("Assets/floating.lfx"); + Native.OpenResult = false; Native.BeforeOpen = Native.clear; // Native open clears the previous license before connecting. + Check(!LicenseManager.LoadFile(true, true), "Failed startup checkout borrowed fallback validity"); + Check(Native.OpenCalls == 1 && !LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense, "Failed checkout left a stale valid license"); + Check(LicenseManager.LastOperationError.Contains(Native.Failure) && File.Exists(floating) && File.Exists(floating + ".meta"), "Failed startup checkout lost its error or license file"); + var window = Window(); + for (int i = 0; i < 3; ++i) { Call(window, "OnGUI"); Call(window, "StartUpdateLicenseInformation"); Wait(window); } + Check(Native.OpenCalls == 1, "Scanning after startup failure retried checkout"); } private static void DisabledLoadingSkipsFallback() { @@ -462,7 +515,7 @@ private static void AssetImportWorkersCannotAccessLicensing() Check(!LicenseManager.CanAccessRuntime && !LicenseManager.HasFloatingSession, "Asset worker can access native session state"); Check(!LicenseInfo.Create().IsParsed && !LicenseManager.LicenseInfo.IsParsed && !LicenseManager.UpdateLicenseInformation().IsParsed, "Asset worker obtained license information"); Check(!LicenseManager.QueryInfo(floating).IsParsed, "Asset worker queried a license"); - Check(!LicenseManager.LoadFile() && !LicenseManager.LoadFile(false) && !LicenseManager.LoadFile(floating) && !LicenseManager.Load("service:synthetic"), "Asset worker loaded a license"); + Check(!LicenseManager.LoadFile() && !LicenseManager.LoadFile(false) && !LicenseManager.LoadFile(true, true) && !LicenseManager.LoadFile(floating) && !LicenseManager.Load("service:synthetic"), "Asset worker loaded a license"); var previousTask = Get(typeof(LicenseManager), "s_operationTask"); var rejected = 0; Action reject = success => { Check(!success, "Asset worker operation succeeded"); ++rejected; }; From 16772289df3f4e5492823d1a14ff0c4f251b0b00 Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Mon, 14 Sep 2026 08:23:35 +0200 Subject: [PATCH 04/11] Reverted tests --- Tests/LicenseManager~/README.md | 11 - Tests/LicenseManager~/Run.ps1 | 32 -- Tests/LicenseManager~/Stubs.cs | 318 ------------------- Tests/LicenseManager~/Tests.cs | 542 -------------------------------- 4 files changed, 903 deletions(-) delete mode 100644 Tests/LicenseManager~/README.md delete mode 100644 Tests/LicenseManager~/Run.ps1 delete mode 100644 Tests/LicenseManager~/Stubs.cs delete mode 100644 Tests/LicenseManager~/Tests.cs diff --git a/Tests/LicenseManager~/README.md b/Tests/LicenseManager~/README.md deleted file mode 100644 index aa8c7f60..00000000 --- a/Tests/LicenseManager~/README.md +++ /dev/null @@ -1,11 +0,0 @@ -Run `pwsh -NoProfile -File Tests/LicenseManager~/Run.ps1` from the repository root with a .NET SDK installed. - -This harness compiles the actual license manager, metadata parser, warning logic, and editor window against simulated AGX and Unity APIs. It uses temporary synthetic license files and never contacts a license server. Unity ignores the `~` directory, so these API substitutes are not included in project assemblies. Compiler outputs and synthetic files go to the system temporary directory. - -The checks exercise asynchronous success, failure, exceptions and overlap; manual return across simulated reload and play transitions; source path identity; deletion ordering; imports; non-floating actions; warning suppression; and the controls emitted by the window. They do not verify native server behavior or Unity's visual layout. Check Connect/Return and server seat counts against authorized infrastructure, and inspect the window in Unity before release. - -Loading regressions cover rejected service and legacy loads while an older license remains valid, preservation of failed runtime activation requests, cache refresh after exceptions, and automatic searches preserving valid native licenses without restoring stale metadata after failure. - -The editor lifecycle checks use the same loading options as editor initialization: a floating file under Assets connects at startup even if native initialization found a valid non-floating license in the AGX installation. Repeated initialization preserves an existing seat, and manual return pauses checkout until Connect succeeds or a simulated editor restart clears session storage. Checks also cover preserving the native fallback when checkout is disabled or no floating file exists, and refreshing actual native validity when checkout fails after clearing that fallback. - -Asset-import worker checks reject all license operations before native runtime access. The simulated editor API also rejects calls from background threads, exercising the cached process check used by asynchronous operations. diff --git a/Tests/LicenseManager~/Run.ps1 b/Tests/LicenseManager~/Run.ps1 deleted file mode 100644 index 5657dec6..00000000 --- a/Tests/LicenseManager~/Run.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -$ErrorActionPreference = 'Stop' -$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..')) -$testOutput = Join-Path ([IO.Path]::GetTempPath()) ('agx-license-tests-' + [Guid]::NewGuid().ToString('N')) -New-Item -ItemType Directory -Path $testOutput | Out-Null -$sdk = (dotnet --list-sdks | Select-Object -Last 1) -if ($sdk -notmatch '^([^ ]+) \[(.+)\]$') { throw 'A .NET SDK is required.' } -$sdkDirectory = Join-Path $Matches[2] $Matches[1] -$dotnetRoot = [IO.Path]::GetFullPath((Join-Path $Matches[2] '..')) -$referencePack = Get-ChildItem (Join-Path $dotnetRoot 'packs/Microsoft.NETCore.App.Ref') -Directory | - Sort-Object { [version]$_.Name } | Select-Object -Last 1 -$framework = Get-ChildItem (Join-Path $referencePack.FullName 'ref') -Directory | Select-Object -First 1 -$runtimeVersion = [version]$referencePack.Name -$outputDll = Join-Path $testOutput 'LicenseTests.dll' -$compilerArguments = @('-nologo', '-target:exe', '-define:UNITY_EDITOR', '-langversion:9', "-out:`"$outputDll`"") -$compilerArguments += Get-ChildItem $framework.FullName -Filter '*.dll' | ForEach-Object { "-r:`"$($_.FullName)`"" } -$compilerArguments += @( - 'AGXUnity/LicenseInfo.cs', - 'AGXUnity/LicenseManager.cs', - 'Editor/AGXUnityEditor/LicenseWarnings.cs', - 'Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs', - 'Tests/LicenseManager~/Stubs.cs', - 'Tests/LicenseManager~/Tests.cs' -) | ForEach-Object { '"' + (Join-Path $repoRoot $_) + '"' } -$responseFile = Join-Path $testOutput 'compile.rsp' -Set-Content -LiteralPath $responseFile -Value $compilerArguments -& dotnet (Join-Path $sdkDirectory 'Roslyn/bincore/csc.dll') "@$responseFile" -if ($LASTEXITCODE -ne 0) { throw 'Test compilation failed.' } -@{ runtimeOptions = @{ tfm = $framework.Name; framework = @{ name = 'Microsoft.NETCore.App'; version = "$($runtimeVersion.Major).$($runtimeVersion.Minor).0" } } } | - ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $testOutput 'LicenseTests.runtimeconfig.json') -& dotnet $outputDll -if ($LASTEXITCODE -ne 0) { throw 'License lifecycle tests failed.' } -Write-Output "Test artifacts: $testOutput" diff --git a/Tests/LicenseManager~/Stubs.cs b/Tests/LicenseManager~/Stubs.cs deleted file mode 100644 index 85e48ddf..00000000 --- a/Tests/LicenseManager~/Stubs.cs +++ /dev/null @@ -1,318 +0,0 @@ -// Test-only substitutes. The production source files are compiled unchanged. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; - -namespace agx -{ - public class LicenseInfo - { - public int licenseType; - public string endDate = "2099-01-01", product = "AGX - Professional", user = "Test", contact = "Test", installationID = ""; - public string[] modules = { "AgX" }; - } - public static class agxSWIG { public static string agxGetVersion(bool unused) => "agx-test"; } - public class Runtime - { - public static Runtime Current = new Runtime(); - public static int InstanceCalls; - public static Runtime instance() - { - Interlocked.Increment(ref InstanceCalls); - if (UnityEditor.AssetDatabase.WorkerProcess) - throw new InvalidOperationException("Native runtime accessed by an asset import worker"); - return Current; - } - public int OpenCalls, CloseCalls, LoadCalls, RefreshCalls, ActivateCalls, DeactivateCalls, QueryCalls; - public int CurrentType = -1; - public string CurrentId = "", Status = "", Failure = "Server unreachable", Extended = "Native operation detail"; - public bool Valid, OpenResult = true, CloseResult = true, LoadResult = true, RuntimeActivationResult = true, ValidAfterLoad = true, IsRefreshed; - public Action BeforeOpen, BeforeClose, BeforeLoad; - public bool isFloatingLicense() => CurrentType == 1; - public bool isValid() => Valid; - public string getStatus() => Status; - public string getExtendedStatus() => Extended; - public bool hasKey(string key) => key == "InstallationID" && CurrentType >= 0 && CurrentId.Length > 0; - public string readValue(string key) => key == "InstallationID" ? CurrentId : key == "EndDate" ? "2099-01-01" : key == "Product" ? "AGX - Professional" : ""; - public string[] getEnabledModules() => new[] { "AgX" }; - public LicenseInfo queryLicenseInformation(string content) - { - ++QueryCalls; - Status = "Status overwritten by metadata query"; - return new LicenseInfo { licenseType = content.Contains("floating") ? 1 : content.Contains("service") ? 0 : -1, - installationID = content.Contains(":") ? content.Split(':').Last() : "" }; - } - public bool openNetworkSession(string file) - { - ++OpenCalls; - BeforeOpen?.Invoke(); - if (!OpenResult) { Status = Failure; return false; } - CurrentType = 1; Valid = true; CurrentId = File.ReadAllText(file).Split(':').Last(); Status = ""; - return true; - } - public bool closeNetworkSession() - { - ++CloseCalls; - BeforeClose?.Invoke(); - if (!CloseResult) { Status = Failure; return false; } - clear(); - return true; - } - public void clear() { CurrentType = -1; Valid = false; CurrentId = ""; Status = ""; } - public bool loadLicenseString(string content) - { - ++LoadCalls; - BeforeLoad?.Invoke(); - if (!LoadResult) { Status = Failure; return false; } - if (content.Contains("fallback")) { Status = "floating license requires a session"; return false; } - CurrentType = 0; Valid = ValidAfterLoad; CurrentId = content.Split(':').Last(); Status = ""; return true; - } - public bool loadLicenseFile(string file, bool refresh) { ++RefreshCalls; return loadLicenseString(File.ReadAllText(file)); } - public bool isLicenseRefreshed() => IsRefreshed; - public string readEncryptedLicense() => "service:generated"; - public bool activateAgxLicense(int id, string password, string file) - { ++ActivateCalls; File.WriteAllText(file, readEncryptedLicense()); return loadLicenseString(readEncryptedLicense()); } - public bool deactivateAgxLicense() { ++DeactivateCalls; clear(); return true; } - public bool unlock(string content) => loadLicenseString(content); - public bool verifyAndUnlock(string content) => loadLicenseString(content); - public bool activateEncryptedRuntime(string content, string file) - { - if (!RuntimeActivationResult) { Status = Failure; return false; } - return activateAgxLicense(1, "", file); - } - public string encryptRuntimeActivation(int id, string password, string reference) => "RT=synthetic"; - public string generateOfflineActivationRequest(int id, string password) => "synthetic"; - public bool processOfflineActivationRequest(string content) => loadLicenseString(content); - } -} -namespace agxIO -{ - public class Environment - { - public enum Type { RESOURCE_PATH } - public static Environment instance() => new Environment(); - public Environment getFilePath(Type type) => this; - public void pushbackPath(string path) { } - public void removeFilePath(string path) { } - } -} -namespace UnityEngine -{ - public class Object { } - public class HelpURLAttribute : Attribute { public HelpURLAttribute(string url) { } } - public class ExitGUIException : System.Exception { } - public class UnityException : System.Exception { public UnityException(string message) : base(message) { } } - public static class Application { public static bool isEditor = true, isBatchMode; public static string dataPath = "."; public static void OpenURL(string url) { } } - public static class Debug - { - public static void Log(object message) { } - public static void LogWarning(object message) { } - public static void LogError(object message, Object context = null) { } - public static void LogException(System.Exception error) { } - } - public struct Vector2 { public Vector2(float x, float y) { } public static Vector2 zero => default; } - public struct Rect { public float x, y, width, height; } - public struct Color { public static Color red, green; public static Color Lerp(Color a, Color b, float t) => a; } - public class GUIStyleState { public object background; } - public class GUIStyle { public GUIStyle() { } public GUIStyle(GUIStyle style) { } public GUIStyleState normal = new GUIStyleState(); } - public class GUISkin { public GUIStyle[] customStyles = { null, null, null, null }; } - public class GUIContent { public string text, tooltip; public GUIContent(string t, string tip = "") { text = t; tooltip = tip; } } - public static class GUI - { - public static bool enabled = true; - public static void FocusControl(string control) { } - public static bool Button(Rect rect, GUIContent content, GUIStyle style) => TestUI.Button(content); - } - public static class GUILayout - { - public static object Width(float width) => null; - public static object Height(float height) => null; - public static void Box(object content, GUIStyle style, params object[] options) { } - public static void Space(float space) { } - public static void Label(GUIContent content, GUIStyle style) { } - public static bool Button(GUIContent content, GUIStyle style) => TestUI.Button(content); - } - public static class GUILayoutUtility { public static Rect GetLastRect() => default; } - public static class GUIUtility { public static void ExitGUI() => throw new ExitGUIException(); } - public static class Resources { public static T[] FindObjectsOfTypeAll() => Array.Empty(); } -} -namespace UnityEditor -{ - using UnityEngine; - public class InitializeOnLoadMethodAttribute : Attribute { } - public enum PlayModeStateChange { ExitingEditMode, EnteredPlayMode, ExitingPlayMode, EnteredEditMode } - public enum MessageType { Info, Warning, Error } - public static class SessionState - { - public static int MainThread = Thread.CurrentThread.ManagedThreadId; - public static Dictionary Values = new Dictionary(); - public static bool GetBool(string key, bool fallback) { CheckThread(); return Values.TryGetValue(key, out var value) ? value : fallback; } - public static void SetBool(string key, bool value) { CheckThread(); Values[key] = value; } - private static void CheckThread() { if (Thread.CurrentThread.ManagedThreadId != MainThread) throw new System.Exception("SessionState accessed from worker"); } - } - public static class EditorApplication - { - public static event Action update; - public static event Action playModeStateChanged; - public static bool isCompiling, isUpdating, isPlayingOrWillChangePlaymode; - public static void Tick() => update?.Invoke(); - public static void PlayTransition(PlayModeStateChange state) => playModeStateChanged?.Invoke(state); - } - public static class AssemblyReloadEvents { public static event Action beforeAssemblyReload; public static void Reload() => beforeAssemblyReload?.Invoke(); } - public static class AssetDatabase - { - public static bool WorkerProcess; - public static bool IsAssetImportWorkerProcess() - { - if (Thread.CurrentThread.ManagedThreadId != SessionState.MainThread) - throw new UnityException("AssetDatabase accessed outside the editor thread"); - return WorkerProcess; - } - public static void Refresh() { } - } - public class EditorWindow : Object - { - public Vector2 minSize; - public Rect position; - public static T GetWindow(bool utility, string title, bool focus) where T : new() => new T(); - public void ShowNotification(GUIContent message) { } - public void RemoveNotification() { } - public void Repaint() { } - } - public static class EditorGUIUtility { public static float standardVerticalSpacing; } - public static class EditorGUI { public static Rect IndentedRect(Rect rect) => rect; } - public static class EditorGUILayout - { - public sealed class VerticalScope : IDisposable { public VerticalScope(GUIStyle style) { } public void Dispose() { } } - public static void LabelField(string text, GUIStyle style) { } - public static void HelpBox(string text, MessageType type, bool wide) => TestUI.Messages.Add((text, type)); - public static Vector2 BeginScrollView(Vector2 value) => value; - public static void EndScrollView() { } - public static string TextField(GUIContent label, string value, GUIStyle style) => value; - public static string PasswordField(GUIContent label, string value) => value; - public static Rect GetControlRect() => default; - public static void EnumFlagsField(GUIContent label, Enum value, bool includeObsolete, GUIStyle style) { } - } - public static class EditorUtility - { - public static string OpenFilePanel(string title, string directory, string extension) => TestUI.ImportFile; - public static bool DisplayDialog(string title, string text, string ok, string cancel) - { TestUI.Dialogs.Add(string.Join(" | ", title, text, ok, cancel)); return TestUI.ConfirmDialog; } - public static int DisplayDialogComplex(string title, string text, string ok, string cancel, string alternate) - { TestUI.Dialogs.Add(string.Join(" | ", title, text, ok, cancel, alternate)); return TestUI.DialogChoice; } - } -} -namespace AGXUnity -{ - public class Exception : System.Exception { public Exception(string text) : base(text) { } } - public class NativeHandler - { - public static bool HasInstance = true; - public static NativeHandler Instance = new NativeHandler(); - public bool Initialized = true, HasValidLicense; - public void ValidateLicense() => HasValidLicense = agx.Runtime.instance().isValid(); - } -} -namespace AGXUnity.IO -{ - public static class Environment - { - public static string FindUniqueFilename(string file) => File.Exists(file) ? file + ".copy" + Path.GetExtension(file) : file; - public static string GetPlayerPluginPath(string path) => path; - public static bool CanWriteToExisting(string file) => true; - } -} -namespace AGXUnity.Utils -{ - using UnityEngine; - public static class Extensions - { - public static string PrettyPath(this string path) => path.Replace('\\', '/'); - public static string MakeRelative(this string path, string root, bool unused) => Path.GetRelativePath(root, path); - public static string SplitCamelCase(this string text) => text; - public static string Color(this string text, Color color) => text; - } - public static class GUI - { - public sealed class EnabledBlock : IDisposable - { - private readonly bool previous = UnityEngine.GUI.enabled; - public EnabledBlock(bool value) { UnityEngine.GUI.enabled = value; } - public void Dispose() { UnityEngine.GUI.enabled = previous; } - } - public sealed class Scope : IDisposable { public void Dispose() { } } - public static class AlignBlock { public static Scope Center => new Scope(); } - public static GUISkin Skin = new GUISkin(); - public static GUIContent MakeLabel(string text, bool bold = false, string tooltip = "") => new GUIContent(text, tooltip); - public static object CreateColoredTexture(int w, int h, Color color) => new object(); - } -} -namespace AGXUnityEditor -{ - using UnityEngine; - public enum MiscIcon { Locate, Update, EntryRemove } - public static class IconManager { public static object GetAGXUnityLogo() => null; } - public static class InspectorEditor - { - public static SkinData Skin = new SkinData(); - public class SkinData { public GUIStyle Label, LabelMiddleCenter, TextField, Button, Popup; } - } - public static class InspectorGUISkin { public static Color BrandColorBlue; } - public static class InspectorGUI - { - public static Color BackgroundColor; - public static class IndentScope { public static AGXUnity.Utils.GUI.Scope Single => new AGXUnity.Utils.GUI.Scope(); } - public struct MiscButtonData - { - public MiscIcon Icon; - public Action Action; - public bool Enabled; - public string Tooltip; - public static MiscButtonData Create(MiscIcon icon, Action action, bool enabled, string tooltip) => new MiscButtonData { Icon = icon, Action = action, Enabled = enabled, Tooltip = tooltip }; - } - public static void BrandSeparator(int a, int b) { } - public static void Separator(int a, int b) { } - public static void Separator(int a, int b, Color c) { } - public static void LicenseEndDateField(AGXUnity.LicenseInfo info) { } - public static bool Link(GUIContent content) => false; - public static bool Button(Rect rect, MiscIcon icon, bool enabled, string tooltip) => enabled && TestUI.ImportFile != null; - public static void SelectFolder(GUIContent label, string path, string title, Action selected) { } - public static void SelectableTextField(GUIContent label, string value, params MiscButtonData[] buttons) - { - TestUI.Fields.Add((label.text, value)); - TestUI.MiscButtons.AddRange(buttons); - } - } - public class EditorDataEntry { public string String; } - public class EditorData - { - public static EditorData Instance = new EditorData(); - public EditorDataEntry Entry = new EditorDataEntry { String = "Assets" }; - public EditorDataEntry GetStaticData(string key, Action init) => Entry; - } -} -namespace AGXUnityEditor.IO { public static class Utils { public static bool IsValidProjectFolder(string path) => true; } } -public static class TestUI -{ - public static List<(string Text, bool Enabled, string Tooltip)> Buttons = new List<(string, bool, string)>(); - public static List<(string Text, UnityEditor.MessageType Type)> Messages = new List<(string, UnityEditor.MessageType)>(); - public static List<(string Label, string Value)> Fields = new List<(string, string)>(); - public static List MiscButtons = new List(); - public static List Dialogs = new List(); - public static bool ConfirmDialog; - public static int DialogChoice = 1; - public static string PressButton, ImportFile; - public static bool Button(UnityEngine.GUIContent content) - { - Buttons.Add((content.text, UnityEngine.GUI.enabled, content.tooltip)); - if (UnityEngine.GUI.enabled && PressButton == content.text) { PressButton = null; return true; } - return false; - } - public static void Reset() - { - Buttons.Clear(); Messages.Clear(); Fields.Clear(); MiscButtons.Clear(); Dialogs.Clear(); - ConfirmDialog = false; DialogChoice = 1; PressButton = null; ImportFile = null; UnityEngine.GUI.enabled = true; - } -} diff --git a/Tests/LicenseManager~/Tests.cs b/Tests/LicenseManager~/Tests.cs deleted file mode 100644 index e22beb94..00000000 --- a/Tests/LicenseManager~/Tests.cs +++ /dev/null @@ -1,542 +0,0 @@ -using AGXUnity; -using AGXUnityEditor; -using AGXUnityEditor.Windows; -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Threading.Tasks; -using UnityEditor; - -public static class Tests -{ - private static readonly List Windows = new List(); - private const BindingFlags Private = BindingFlags.Static | BindingFlags.Instance | BindingFlags.NonPublic; - private static agx.Runtime Native => agx.Runtime.Current; - private static void Check(bool condition, string message) { if (!condition) throw new System.Exception(message); } - private static object Call(object target, string method, params object[] args) - { - try { return (target as Type ?? target.GetType()).GetMethod(method, Private).Invoke(target is Type ? null : target, args); } - catch (TargetInvocationException error) { ExceptionDispatchInfo.Capture(error.InnerException).Throw(); throw; } - } - private static object Get(object target, string name) => (target as Type ?? target.GetType()).GetField(name, Private).GetValue(target is Type ? null : target); - private static void Set(object target, string name, object value) => (target as Type ?? target.GetType()).GetField(name, Private).SetValue(target is Type ? null : target, value); - private static string FileFor(string name, string content = "floating:shared") - { - var path = Path.GetFullPath(name); - File.WriteAllText(path, content); File.WriteAllText(path + ".meta", "synthetic meta"); - return path; - } - private static bool Operation(Action> start) - { - var result = new TaskCompletionSource(); - start(value => result.SetResult(value)); - Check(result.Task.Wait(5000), "Operation callback timed out"); - LicenseManager.AwaitTasks(); - return result.Task.Result; - } - private static bool Connect(string path) => Operation(done => LicenseManager.ConnectFloatingAsync(path, done)); - private static bool Return() => Operation(LicenseManager.ReturnFloatingAsync); - private static LicenseManagerWindow Window() - { - var window = new LicenseManagerWindow(); Windows.Add(window); - Call(window, "OnEnable"); Wait(window); return window; - } - private static void Wait(LicenseManagerWindow window) - { - LicenseManager.AwaitTasks(); - var until = DateTime.UtcNow.AddSeconds(5); - do { - EditorApplication.Tick(); - Call(window, "OnEditorUpdate"); - if (!window.IsUpdatingLicenseInformation && Get(window, "m_licenseOperationTask") == null) return; - Thread.Sleep(1); - } while (DateTime.UtcNow < until); - throw new System.Exception("Window update timed out"); - } - private static object Data(string path) - { - var type = typeof(LicenseManagerWindow).GetNestedType("LicenseData", BindingFlags.NonPublic); - var data = Activator.CreateInstance(type); - type.GetField("Filename").SetValue(data, path); - type.GetField("LicenseInfo").SetValue(data, LicenseManager.QueryInfo(path)); - return data; - } - private static void Capture() => LicenseWarnings.Capture(LicenseManager.UpdateLicenseInformation()); - - public static int Main() - { - SessionState.MainThread = Thread.CurrentThread.ManagedThreadId; - Call(typeof(LicenseManager), "InitializeEditorSessionHandling"); - var cases = new Action[] { - MetadataDoesNotCheckout, ConnectUsesOnlyNetworkSession, ConnectionFailuresPersist, - ReturnUpdatesValidityAndPreservesFile, FailedReturnPreservesSession, NativeExceptionsComplete, - ConcurrentOperationsAreRejected, ReturnSurvivesEditorTransitions, DisabledLoadingSkipsFallback, - DeactivationRejectsFloatingAndUnknown, ActiveDeletionReturnsFirst, FailedDeletionReturnKeepsFiles, - InactiveDeletionUsesPaths, UnknownSourceRequiresReturn, ControlsFollowSelectedMetadata, - PlayModeDisablesChanges, ImportsInspectBeforeConnect, NonFloatingWorkflows, - RuntimeGenerationWhileFloating, ClosingWindowDuringReturn, ExternalSessionChangesAreObserved, - HeldSeatRejectsReplacementOperations, ReloadWaitsForPendingReturn, - RejectedLoadsDoNotBorrowExistingValidity, FailedRuntimeActivationPreservesRequest, - FailedRuntimeActivationSkipsRefreshAndFloatingFallback, SuccessfulLoadsAndRuntimeActivation, - AcceptedLoadRequiresValidLicense, LoadExceptionsRefreshValidity, - SearchPreservesValidNativeLicense, FailedSearchUsesNativeState, EmptySearchUsesNativeState, - AssetImportWorkersCannotAccessLicensing, - EditorStartupPrefersProjectFloating, EditorStartupPreservesNativeFallback, - EditorStartupFloatingFailureUsesNativeState - }; - var root = Path.Combine(Path.GetTempPath(), "agx-license-fixtures-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - var originalDirectory = Directory.GetCurrentDirectory(); - var failures = 0; - foreach (var test in cases) { - try { - var directory = Path.Combine(root, test.Method.Name); - Directory.CreateDirectory(Path.Combine(directory, "Assets")); - Directory.SetCurrentDirectory(directory); - LicenseManager.AwaitTasks(); - agx.Runtime.Current = new agx.Runtime(); - AssetDatabase.WorkerProcess = false; - Set(typeof(LicenseManager), "s_isAssetImportWorkerProcess", null); - SessionState.Values.Clear(); - Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); - Set(typeof(LicenseManager), "s_activeFloatingLicenseFilename", null); - Capture(); TestUI.Reset(); - EditorApplication.isPlayingOrWillChangePlaymode = false; - test(); - Console.WriteLine("PASS " + test.Method.Name); - } - catch (System.Exception error) { ++failures; Console.WriteLine("FAIL " + test.Method.Name + ": " + error); } - finally { foreach (var window in Windows) Call(window, "OnDisable"); Windows.Clear(); } - } - Directory.SetCurrentDirectory(originalDirectory); - Console.WriteLine($"{cases.Length - failures}/{cases.Length} cases passed. Fixtures: {root}"); - return failures == 0 ? 0 : 1; - } - - private static void MetadataDoesNotCheckout() - { - Native.CurrentType = 0; Native.CurrentId = "service"; Native.Valid = true; - var path = FileFor("floating.lfx"); - Check(LicenseManager.QueryInfo(path).IsFloating, "Floating metadata lost with service active"); - Check(!LicenseManager.QueryInfo(FileFor("unknown.lfx", "invalid")).IsParsed, "Unknown metadata identified as service"); - Check(Native.OpenCalls == 0 && Native.LoadCalls == 0, "Metadata query acquired a seat"); - } - private static void ConnectUsesOnlyNetworkSession() - { - var path = FileFor("floating.lfx"); - Check(Connect("./floating.lfx"), "Connect failed"); - Check(LicenseManager.ActiveFloatingLicenseFilename == path.Replace('\\', '/'), "Source path not normalized"); - Check(LicenseManager.LicenseInfo.IsFloating && NativeHandler.Instance.HasValidLicense, "Cached/native validity not updated"); - Check(Native.OpenCalls == 1 && Native.ActivateCalls == 0 && Native.RefreshCalls == 0 && Native.LoadCalls == 0 && Native.DeactivateCalls == 0, "Connect used other license operations"); - Check(!Connect(path), "Connect switched an active session"); - Check(!LicenseManager.LoadFile(FileFor("service.lfx", "service:other")), "Load replaced a held seat"); - } - private static void ConnectionFailuresPersist() - { - var path = FileFor("floating.lfx"); - var window = Window(); Native.OpenResult = false; - foreach (var failure in new[] { "Server unreachable", "No seats available" }) { - Native.Failure = failure; - Call(window, "ConnectLicense", path); Wait(window); - var error = (string)Get(window, "m_operationError"); - Check(error.Contains(failure) && error.Contains("Native operation detail"), "Failure details lost before scan"); - var attempts = Native.OpenCalls; - for (int i = 0; i < 5; ++i) { Call(window, "OnGUI"); Call(window, "StartUpdateLicenseInformation"); Wait(window); } - Check(Native.OpenCalls == attempts && (string)Get(window, "m_operationError") == error, "Scan/repaint retried or erased error"); - Check(TestUI.Fields.Any(f => f.Label == "License type" && f.Value == "Floating"), "Failed checkout not labeled Floating"); - } - } - private static void ReturnUpdatesValidityAndPreservesFile() - { - var path = FileFor("floating.lfx"); Check(Connect(path), "Connect failed"); Check(Return(), "Return failed"); - Check(!LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense, "Return left valid cache"); - Check(LicenseManager.ActiveFloatingLicenseFilename == null && !LicenseManager.AutomaticFloatingCheckoutEnabled, "Return did not clear source/disable auto checkout"); - Check(File.Exists(path) && File.Exists(path + ".meta") && Native.DeactivateCalls == 0, "Return deleted or deactivated"); - } - private static void FailedReturnPreservesSession() - { - var path = FileFor("floating.lfx"); Connect(path); Native.CloseResult = false; - Check(!Return(), "Return should fail"); - Check(LicenseManager.ActiveFloatingLicenseFilename != null && LicenseManager.LicenseInfo.IsFloating && LicenseManager.AutomaticFloatingCheckoutEnabled, "Failed return discarded session or disabled checkout"); - } - private static void NativeExceptionsComplete() - { - var path = FileFor("floating.lfx"); - Native.BeforeOpen = () => throw new IOException("synthetic connection exception"); - Check(!Connect(path) && LicenseManager.LastOperationError.Contains("synthetic connection exception"), "Connect exception lost"); - Native.BeforeOpen = null; Connect(path); - Native.BeforeClose = () => throw new IOException("synthetic return exception"); - Check(!Return() && LicenseManager.LastOperationError.Contains("synthetic return exception") && NativeHandler.Instance.HasValidLicense, "Return exception lost/session invalidated"); - } - private static void ConcurrentOperationsAreRejected() - { - var path = FileFor("floating.lfx"); - using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); - Native.BeforeOpen = () => { entered.Set(); Check(release.Wait(5000), "Blocked open timed out"); }; - var result = new TaskCompletionSource(); - LicenseManager.ConnectFloatingAsync(path, success => result.SetResult(success)); - try { - Check(entered.Wait(5000) && LicenseManager.IsBusy && LicenseManager.IsConnecting, "Busy state missing"); - var rejected = 0; - Action reject = success => { Check(!success, "Overlapping operation accepted"); ++rejected; }; - LicenseManager.ReturnFloatingAsync(reject); LicenseManager.ConnectFloatingAsync(path, reject); - LicenseManager.RefreshAsync(path, reject); LicenseManager.ActivateAsync(1, "password", ".", reject); - Check(rejected == 4 && Native.OpenCalls == 1 && Native.CloseCalls == 0 && Native.ActivateCalls == 0, "Overlapping native request executed"); - } - finally { release.Set(); } - Check(result.Task.Wait(5000) && result.Task.Result, "Original request lost"); - LicenseManager.AwaitTasks(); Check(!LicenseManager.IsBusy, "Busy state stuck"); - } - private static void ReturnSurvivesEditorTransitions() - { - var path = FileFor("Assets/floating.lfx"); - Check(LicenseManager.AutomaticFloatingCheckoutEnabled, "Fresh editor disabled auto checkout"); - Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Editor startup did not auto-connect"); - Check(Native.OpenCalls == 1 && Native.LoadCalls == 0 && Native.ActivateCalls == 0 && Native.RefreshCalls == 0 && NativeHandler.Instance.HasValidLicense, "Startup did not acquire a valid floating seat using a network session"); - Check(LicenseManager.ActiveFloatingLicenseFilename == path.Replace('\\', '/'), "Startup lost the floating source path"); - Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true) && Native.OpenCalls == 1, "Repeated initialization acquired another seat"); - Check(Return(), "Temporarily returning the startup seat failed"); - AssemblyReloadEvents.Reload(); - Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); // New domain; SessionState survives. - Check(!LicenseManager.AutomaticFloatingCheckoutEnabled, "Reload forgot manual return"); - Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Reload acquired a seat"); - EditorApplication.PlayTransition(PlayModeStateChange.ExitingEditMode); - EditorApplication.PlayTransition(PlayModeStateChange.ExitingPlayMode); - Check(!LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true) && Native.OpenCalls == 1, "Play transition acquired a seat"); - Capture(); LicenseWarnings.ScheduleStartupWarning(); EditorApplication.Tick(); - Check(TestUI.Dialogs.Count == 0 && string.IsNullOrEmpty(LicenseWarnings.GetWarningMessage(LicenseWarnings.CurrentLicense)), "Intentional return displayed a persistent message or startup warning"); - Native.OpenResult = false; Check(!Connect(path) && !LicenseManager.AutomaticFloatingCheckoutEnabled, "Failed connect enabled auto checkout"); - Native.OpenResult = true; Check(Connect(path), "Explicit reconnect failed"); EditorApplication.Tick(); - Check(LicenseManager.AutomaticFloatingCheckoutEnabled, "Explicit connect did not enable checkout"); - Return(); EditorApplication.Tick(); - // Editor restart clears session storage and creates a new native runtime. - SessionState.Values.Clear(); - agx.Runtime.Current = new agx.Runtime(); - Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); - Set(typeof(LicenseManager), "s_activeFloatingLicenseFilename", null); - Capture(); - Check(LicenseManager.AutomaticFloatingCheckoutEnabled && LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Fresh session did not auto-connect"); - Check(Native.OpenCalls == 1 && NativeHandler.Instance.HasValidLicense, "Restart did not acquire a valid seat"); - } - private static void EditorStartupPrefersProjectFloating() - { - Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); - FileFor("service.lfx", "service:project"); - var floating = FileFor("Assets/floating.lfx"); - Check(LicenseManager.LoadFile() && Native.OpenCalls == 0, "Default search replaced a valid native license"); - Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Editor startup rejected project floating license"); - Check(Native.OpenCalls == 1 && Native.LoadCalls == 0 && Native.RefreshCalls == 0 && Native.ActivateCalls == 0, "Editor startup skipped checkout or loaded a non-floating file"); - Check(LicenseManager.HasFloatingSession && NativeHandler.Instance.HasValidLicense && LicenseManager.ActiveFloatingLicenseFilename == floating.Replace('\\', '/'), "Native fallback prevented project checkout"); - FileFor("another-floating.lfx"); - Check(LicenseManager.LoadFile(true, true) && Native.OpenCalls == 1 && Native.CloseCalls == 0, "Repeated startup replaced a held seat"); - } - private static void EditorStartupPreservesNativeFallback() - { - Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); - FileFor("service.lfx", "service:project"); - Check(LicenseManager.LoadFile(true, true) && Native.CurrentId == "native-installation-fallback" && Native.LoadCalls == 0, "Search without a floating file replaced the native fallback"); - var floating = FileFor("Assets/floating.lfx"); - Check(Connect(floating) && Return(), "Unable to temporarily return a seat"); - AssemblyReloadEvents.Reload(); - Set(typeof(LicenseManager), "s_pendingFloatingSessionState", 0); - // Native initialization on reload finds a valid non-floating fallback again. - Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); - Check(LicenseManager.LoadFile(allowFloating: LicenseManager.AutomaticFloatingCheckoutEnabled, preferFloating: true), "Disabled checkout discarded the native fallback"); - Check(!LicenseManager.HasFloatingSession && Native.OpenCalls == 1 && Native.CurrentId == "native-installation-fallback", "Native fallback caused a returned seat to be reacquired"); - } - private static void EditorStartupFloatingFailureUsesNativeState() - { - Native.CurrentType = 0; Native.CurrentId = "native-installation-fallback"; Native.Valid = true; Capture(); - var floating = FileFor("Assets/floating.lfx"); - Native.OpenResult = false; Native.BeforeOpen = Native.clear; // Native open clears the previous license before connecting. - Check(!LicenseManager.LoadFile(true, true), "Failed startup checkout borrowed fallback validity"); - Check(Native.OpenCalls == 1 && !LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense, "Failed checkout left a stale valid license"); - Check(LicenseManager.LastOperationError.Contains(Native.Failure) && File.Exists(floating) && File.Exists(floating + ".meta"), "Failed startup checkout lost its error or license file"); - var window = Window(); - for (int i = 0; i < 3; ++i) { Call(window, "OnGUI"); Call(window, "StartUpdateLicenseInformation"); Wait(window); } - Check(Native.OpenCalls == 1, "Scanning after startup failure retried checkout"); - } - private static void DisabledLoadingSkipsFallback() - { - FileFor("fallback.lfx", "fallback"); - Check(!LicenseManager.LoadFile(allowFloating: false) && Native.OpenCalls == 0, "Disabled fallback checked out"); - Check(LicenseManager.LoadFile() && Native.OpenCalls == 1, "Default fallback changed"); - } - private static void DeactivationRejectsFloatingAndUnknown() - { - Native.CurrentType = 0; Native.Valid = true; Native.CurrentId = "service"; - foreach (var path in new[] { FileFor("floating.lfx"), FileFor("unknown.lfx", "invalid") }) - Check(!LicenseManager.DeactivateAndDelete(path) && File.Exists(path) && File.Exists(path + ".meta"), "Unsafe file deactivation/deletion"); - Check(Native.LoadCalls == 0 && Native.DeactivateCalls == 0 && Native.CurrentType == 0, "Guard loaded/deactivated another license"); - Native.CurrentType = 1; Capture(); Check(!LicenseManager.DeactivateLoaded() && Native.DeactivateCalls == 0, "Floating session deactivated"); - } - private static void ActiveDeletionReturnsFirst() - { - var path = FileFor("floating.lfx"); Connect(path); var window = Window(); - TestUI.ConfirmDialog = false; Call(window, "DeleteLicense", Data(path)); - Check(Native.CloseCalls == 0 && File.Exists(path), "Cancel changed session"); - TestUI.ConfirmDialog = true; - Native.BeforeClose = () => Check(File.Exists(path) && File.Exists(path + ".meta"), "File removed before return"); - Call(window, "DeleteLicense", Data(path)); Wait(window); - Check(!File.Exists(path) && !File.Exists(path + ".meta") && Native.CloseCalls == 1 && Native.DeactivateCalls == 0, "Return/delete failed"); - Check(TestUI.Dialogs.All(d => !d.ToLowerInvariant().Contains("deactivat")) && TestUI.Dialogs.Last().Contains("Return Seat and Delete"), "Floating dialog offered deactivation"); - } - private static void FailedDeletionReturnKeepsFiles() - { - var path = FileFor("floating.lfx"); Connect(path); var window = Window(); - Native.CloseResult = false; TestUI.ConfirmDialog = true; - Call(window, "DeleteLicense", Data(path)); Wait(window); - Check(File.Exists(path) && File.Exists(path + ".meta") && ((string)Get(window, "m_operationError")).Contains(Native.Failure), "Failed return deleted file or lost error"); - Call(window, "StartUpdateLicenseInformation"); Wait(window); - Check(!string.IsNullOrEmpty((string)Get(window, "m_operationError")), "Rescan erased return failure"); - } - private static void InactiveDeletionUsesPaths() - { - foreach (var id in new[] { "shared", "" }) { - var active = FileFor("a" + id + ".lfx", "floating:" + id); - var inactive = FileFor("b" + id + ".lfx", "floating:" + id); - Connect(active); var window = Window(); TestUI.ConfirmDialog = true; - var returns = Native.CloseCalls; - Call(window, "DeleteLicense", Data(inactive)); Wait(window); - Check(!File.Exists(inactive) && Native.CloseCalls == returns && LicenseManager.HasFloatingSession, "Duplicate/empty ID returned another file's seat"); - Check(!LicenseManager.DeleteFile(active), "Raw deletion removed active file"); - Return(); - } - } - private static void UnknownSourceRequiresReturn() - { - var path = FileFor("floating.lfx", "floating:"); - Native.CurrentType = 1; Native.Valid = true; Native.CurrentId = ""; Capture(); - var window = Window(); Call(window, "OnGUI"); - Check(LicenseManager.ActiveFloatingLicenseFilename == null, "Guessed external session source"); - Check(TestUI.Buttons.Any(b => b.Text == "Return Seat" && b.Enabled), "Unknown source has no Return Seat"); - Check(TestUI.MiscButtons.Where(b => b.Icon == MiscIcon.EntryRemove).All(b => !b.Enabled), "Unknown source permits floating deletion"); - Call(window, "DeleteLicense", Data(path)); Check(File.Exists(path) && TestUI.Dialogs.Count == 0, "Unknown source deletion prompted or deleted"); - Call(window, "ReturnSeat", new object[] { null }); Wait(window); - Check(!LicenseManager.HasFloatingSession && File.Exists(path), "Unknown source return failed"); - } - private static void ControlsFollowSelectedMetadata() - { - var floating = FileFor("floating.lfx"); var service = FileFor("service.lfx", "service:service"); var unknown = FileFor("unknown.lfx", "invalid"); - var window = Window(); - foreach (var path in new[] { floating, service, unknown }) { - TestUI.Reset(); Call(window, "LicenseDataGUI", Data(path)); - Check(TestUI.MiscButtons.Any(b => b.Tooltip.Contains("deactivate")) == (path == service), "Wrong deactivation tooltip"); - Check(TestUI.Buttons.Any(b => b.Text == "Connect") == (path == floating), "Wrong Connect control"); - Check(TestUI.MiscButtons.Any(b => b.Icon == MiscIcon.Update) == (path != floating), "Floating refresh control exposed"); - } - Connect(floating); Capture(); TestUI.Reset(); Call(window, "OnGUI"); - Check(TestUI.Buttons.Where(b => b.Text == "Connect" || b.Text == "Activate").All(b => !b.Enabled), "Held seat permits replacement"); - Check(TestUI.MiscButtons.Where(b => b.Icon == MiscIcon.Update).All(b => !b.Enabled), "Held seat permits refresh"); - } - private static void PlayModeDisablesChanges() - { - var path = FileFor("floating.lfx"); Connect(path); var window = Window(); - EditorApplication.isPlayingOrWillChangePlaymode = true; TestUI.Reset(); Call(window, "OnGUI"); - Check(TestUI.Buttons.All(b => !b.Enabled) && TestUI.MiscButtons.All(b => !b.Enabled), "Play mode enables license changes"); - } - private static void ImportsInspectBeforeConnect() - { - var path = FileFor("source.lfx"); var window = Window(); - TestUI.ImportFile = path; TestUI.ConfirmDialog = true; - try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } - TestUI.ImportFile = null; Wait(window); - Check(File.Exists("Assets/source.lfx") && Native.OpenCalls == 0 && Native.LoadCalls == 0, "Floating import checked out a seat"); - Check(((string)Get(window, "m_importMessage")).Contains("Connect"), "Import gave no Connect guidance"); - TestUI.ImportFile = FileFor("unidentified.lfx", "fallback"); - try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } - TestUI.ImportFile = null; Wait(window); - Check(File.Exists("Assets/unidentified.lfx") && Native.OpenCalls == 0 && Native.LoadCalls == 0, "Unidentified import used checkout fallback"); - } - private static void NonFloatingWorkflows() - { - var service = FileFor("service.lfx", "service:service"); - Check(LicenseManager.LoadFile(service), "Service load failed"); - Check(LicenseManager.Refresh(service) && Native.RefreshCalls == 1, "Service refresh failed"); - Check(LicenseManager.Activate(123, "synthetic", "Assets") && Native.ActivateCalls == 1, "Activation failed"); - Check(LicenseManager.DeactivateAndDelete(service) && Native.DeactivateCalls == 1, "Service deactivation failed"); - var imported = FileFor("import.lfx", "service:imported"); var window = Window(); - TestUI.ImportFile = imported; TestUI.ConfirmDialog = true; - try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } - TestUI.ImportFile = null; Wait(window); Check(Native.CurrentId == "imported", "Service import failed to load"); - } - private static void RuntimeGenerationWhileFloating() - { - var path = FileFor("floating.lfx"); Connect(path); - File.WriteAllText("application.exe", "synthetic reference"); - Check(LicenseManager.GenerateEncryptedRuntime(42, "synthetic", Directory.GetCurrentDirectory(), "application.exe") && File.Exists("agx.rtlfx"), "Runtime generation gated on floating editor license"); - Check(LicenseManager.HasFloatingSession && Native.OpenCalls == 1 && Native.CloseCalls == 0, "Runtime generation changed editor seat"); - } - private static void ClosingWindowDuringReturn() - { - var path = FileFor("floating.lfx"); Connect(path); var window = Window(); - using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); - Native.BeforeClose = () => { entered.Set(); Check(release.Wait(5000), "Return wait timed out"); }; - Call(window, "ReturnSeat", path); - try { Check(entered.Wait(5000), "Return did not start"); Call(window, "OnDisable"); } - finally { release.Set(); } - LicenseManager.AwaitTasks(); EditorApplication.Tick(); - Check(!LicenseManager.AutomaticFloatingCheckoutEnabled && !LicenseManager.HasFloatingSession, "Closing window lost return state"); - Check(File.Exists(path) && File.Exists(path + ".meta"), "Closed window deleted files"); - } - private static void ExternalSessionChangesAreObserved() - { - var path = FileFor("floating.lfx"); var window = Window(); - Native.CurrentType = 1; Native.Valid = true; - Call(window, "OnEditorUpdate"); Call(window, "OnGUI"); - Check(TestUI.Buttons.Any(b => b.Text == "Return Seat" && b.Enabled) && LicenseManager.ActiveFloatingLicenseFilename == null, "External session not observed"); - Native.clear(); Call(window, "OnEditorUpdate"); - Check(!LicenseWarnings.CurrentLicense.IsFloating, "External return not observed"); - Connect(path); Native.clear(); - Check(LicenseManager.ActiveFloatingLicenseFilename == null, "Ended session kept source filename"); - Native.CurrentType = 1; Capture(); - Check(LicenseManager.ActiveFloatingLicenseFilename == null, "New external session inherited old source"); - } - private static void HeldSeatRejectsReplacementOperations() - { - var floating = FileFor("floating.lfx"); var service = FileFor("service.lfx", "service:service"); - Check(!LicenseManager.Refresh(floating) && Native.RefreshCalls == 0 && Native.OpenCalls == 0, "Floating file refreshed/connected via refresh"); - Connect(floating); - Check(!LicenseManager.Activate(1, "synthetic", "Assets") && Native.ActivateCalls == 0, "Activation replaced seat"); - Check(!LicenseManager.Refresh(service) && Native.RefreshCalls == 0, "Refresh replaced seat"); - Check(!LicenseManager.Load("service:service") && Native.LoadCalls == 0, "Content load replaced seat"); - Check(!LicenseManager.CreateOfflineLicense("synthetic", "offline.lfx", false) && !File.Exists("offline.lfx"), "Offline activation replaced seat"); - var window = Window(); TestUI.ImportFile = service; TestUI.ConfirmDialog = true; - try { Call(window, "ActivateLicenseGUI"); } catch (UnityEngine.ExitGUIException) { } - TestUI.ImportFile = null; Wait(window); - Check(File.Exists("Assets/service.lfx") && Native.LoadCalls == 0, "Import replaced seat"); - Check(LicenseManager.HasFloatingSession && Native.CloseCalls == 0, "Replacement attempt returned seat"); - } - private static void ReloadWaitsForPendingReturn() - { - var path = FileFor("floating.lfx"); Connect(path); EditorApplication.Tick(); - using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); - Native.BeforeClose = () => { entered.Set(); Check(release.Wait(5000), "Pending return timed out"); }; - LicenseManager.ReturnFloatingAsync(null); - Check(entered.Wait(5000) && LicenseManager.IsReturning, "Return busy state missing"); - var releaseTask = Task.Run(() => { Thread.Sleep(25); release.Set(); }); - AssemblyReloadEvents.Reload(); - Check(!LicenseManager.IsBusy && !LicenseManager.HasFloatingSession && SessionState.GetBool("AGXUnity.FloatingLicenseReturned", false), "Reload failed to await and persist return"); - releaseTask.Wait(); - } - - private static void RejectedLoadsDoNotBorrowExistingValidity() - { - Native.CurrentType = 0; Native.CurrentId = "old-license"; Native.Valid = true; Capture(); - Native.LoadResult = false; - foreach (var content in new[] { "service:rejected", "Key { invalid legacy }", "invalid-obfuscated" }) { - Check(!LicenseManager.Load(content), "Rejected content reported success using the old license"); - var file = FileFor("rejected" + (content.StartsWith("") ? ".lfx" : ".lic"), content); - Check(!LicenseManager.LoadFile(file) && File.ReadAllText(file) == content, "Rejected file reported success or changed"); - Check(LicenseManager.LicenseInfo.IsValid && NativeHandler.Instance.HasValidLicense && Native.CurrentId == "old-license", "Rejected load misrepresented or cleared the surviving license"); - } - } - - private static void FailedRuntimeActivationPreservesRequest() - { - Native.CurrentType = 0; Native.CurrentId = "old-license"; Native.Valid = true; Capture(); - Native.RuntimeActivationResult = false; - var request = FileFor("activation.rtlfx", "RT=synthetic-rejected"); - Check(!LicenseManager.ActivateEncryptedRuntime(request, "Assets"), "Rejected runtime activation borrowed old license validity"); - Check(File.ReadAllText(request) == "RT=synthetic-rejected" && File.Exists(request + ".meta") && !File.Exists("Assets/agx.lfx"), "Rejected runtime activation lost the request or created a license"); - Check(Native.CurrentId == "old-license" && NativeHandler.Instance.HasValidLicense, "Rejected runtime activation changed the surviving license"); - } - - private static void FailedRuntimeActivationSkipsRefreshAndFloatingFallback() - { - Native.RuntimeActivationResult = false; Native.IsRefreshed = true; - Native.Failure = "Cannot activate a floating license"; - var request = FileFor("activation.rtlfx", "RT=synthetic-rejected"); - Check(!LicenseManager.ActivateEncryptedRuntime(request, "Assets"), "Failed activation used another operation's success"); - Check(File.ReadAllText(request) == "RT=synthetic-rejected" && Native.OpenCalls == 0, "Failed runtime activation rewrote the request or tried floating checkout"); - } - - private static void SuccessfulLoadsAndRuntimeActivation() - { - foreach (var content in new[] { "service:valid", "Key { synthetic legacy }", "synthetic-obfuscated" }) - Check(LicenseManager.Load(content) && LicenseManager.LicenseInfo.IsValid && NativeHandler.Instance.HasValidLicense, "Successful load failed"); - var request = FileFor("activation.rtlfx", "RT=synthetic-valid"); - Check(LicenseManager.ActivateEncryptedRuntime(request, "Assets") && File.Exists("Assets/agx.lfx") && !File.Exists(request), "Successful runtime activation did not replace its request with a license"); - } - - private static void AcceptedLoadRequiresValidLicense() - { - Native.ValidAfterLoad = false; - Check(!LicenseManager.Load("service:invalid") && !LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense, "Native acceptance was treated as a valid license"); - } - - private static void LoadExceptionsRefreshValidity() - { - Native.CurrentType = 0; Native.CurrentId = "old-license"; Native.Valid = true; Capture(); - Native.BeforeLoad = () => { Native.clear(); throw new IOException("synthetic load exception after native state change"); }; - Check(!LicenseManager.Load("service:rejected"), "Load exception reported success"); - Check(!NativeHandler.Instance.HasValidLicense && !LicenseManager.LicenseInfo.IsValid, "Load exception left stale managed validity"); - } - - private static void SearchPreservesValidNativeLicense() - { - Native.CurrentType = 0; Native.CurrentId = "script-loaded-license"; Native.Valid = true; Capture(); - Native.LoadResult = false; - Check(LicenseManager.LoadFile(), "Search rejected a valid native license with no files"); - Check(Native.isValid() && Native.CurrentId == "script-loaded-license" && NativeHandler.Instance.HasValidLicense, "Search cleared a script-loaded license and restored only its metadata"); - FileFor("invalid.lfx", "service:rejected"); - Check(LicenseManager.LoadFile() && Native.LoadCalls == 0 && Native.isValid(), "Search replaced an already valid native license"); - } - - private static void FailedSearchUsesNativeState() - { - Native.CurrentType = 0; Native.CurrentId = "expired-old-license"; Native.Status = "Old expiration error"; Capture(); - Native.LoadResult = false; Native.Failure = "Current file rejected"; - FileFor("invalid.lfx", "service:rejected"); - Check(!LicenseManager.LoadFile(), "Failed search reported success"); - Check(!LicenseManager.LicenseInfo.IsValid && !NativeHandler.Instance.HasValidLicense && LicenseManager.LicenseInfo.Status == Native.Failure, "Failed search restored previous license information instead of native state"); - } - - private static void EmptySearchUsesNativeState() - { - Native.CurrentType = 0; Native.CurrentId = "expired-old-license"; Native.Status = "Old expiration error"; Capture(); - Check(!LicenseManager.LoadFile(), "Empty search reported success"); - Check(!LicenseManager.LicenseInfo.IsParsed && LicenseManager.LicenseInfo.Status == Native.Status && !NativeHandler.Instance.HasValidLicense, "Empty search restored stale metadata"); - } - - private static void AssetImportWorkersCannotAccessLicensing() - { - var floating = FileFor("floating.lfx"); - var runtime = FileFor("runtime.rtlfx", "RT=synthetic"); - AssetDatabase.WorkerProcess = true; - Set(typeof(LicenseManager), "s_isAssetImportWorkerProcess", null); - agx.Runtime.InstanceCalls = 0; - try { - Check(!LicenseManager.CanAccessRuntime && !LicenseManager.HasFloatingSession, "Asset worker can access native session state"); - Check(!LicenseInfo.Create().IsParsed && !LicenseManager.LicenseInfo.IsParsed && !LicenseManager.UpdateLicenseInformation().IsParsed, "Asset worker obtained license information"); - Check(!LicenseManager.QueryInfo(floating).IsParsed, "Asset worker queried a license"); - Check(!LicenseManager.LoadFile() && !LicenseManager.LoadFile(false) && !LicenseManager.LoadFile(true, true) && !LicenseManager.LoadFile(floating) && !LicenseManager.Load("service:synthetic"), "Asset worker loaded a license"); - var previousTask = Get(typeof(LicenseManager), "s_operationTask"); - var rejected = 0; - Action reject = success => { Check(!success, "Asset worker operation succeeded"); ++rejected; }; - LicenseManager.ConnectFloatingAsync(floating, reject); - LicenseManager.ReturnFloatingAsync(reject); - LicenseManager.ActivateAsync(1, "synthetic", "Assets", reject); - LicenseManager.RefreshAsync(floating, reject); - Check(rejected == 4 && !LicenseManager.IsBusy && ReferenceEquals(previousTask, Get(typeof(LicenseManager), "s_operationTask")), "Asset worker scheduled a licensing task or missed a callback"); - Check(!LicenseManager.Activate(1, "synthetic", "Assets") && !LicenseManager.Refresh(floating), "Asset worker activated or refreshed synchronously"); - Check(!LicenseManager.ActivateEncryptedRuntime("Assets") && !LicenseManager.ActivateEncryptedRuntime(runtime, "Assets"), "Asset worker activated a runtime license"); - Check(!LicenseManager.GenerateEncryptedRuntime(1, "synthetic", Directory.GetCurrentDirectory(), floating), "Asset worker generated runtime activation data"); - Check(!LicenseManager.GenerateOfflineActivation(1, "synthetic", "offline-request.txt") && !LicenseManager.CreateOfflineLicense("synthetic", "offline.lfx"), "Asset worker used offline licensing"); - Check(!LicenseManager.DeactivateLoaded() && !LicenseManager.DeactivateAndDelete(floating) && !LicenseManager.DeleteFile(floating), "Asset worker removed a license"); - LicenseManager.Reset(); - Check(!LicenseManager.ReturnFloating("Asset worker shutdown"), "Asset worker returned a session"); - Check(File.Exists(floating) && File.Exists(floating + ".meta") && File.Exists(runtime) && !File.Exists("Assets/agx.lfx"), "Asset worker modified licensing files"); - Check(agx.Runtime.InstanceCalls == 0, "Asset worker reached the native runtime"); - } - finally { - AssetDatabase.WorkerProcess = false; - Set(typeof(LicenseManager), "s_isAssetImportWorkerProcess", null); - } - } -} From ce9f21e39884e54963e92b22d93ca25e178c70bd Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Mon, 14 Sep 2026 13:45:45 +0200 Subject: [PATCH 05/11] Remove double warnings --- AGXUnity/LicenseManager.cs | 275 +++++++++++------- Editor/AGXUnityEditor/LicenseWarnings.cs | 2 +- .../GenerateRuntimeLicenseActivationWindow.cs | 38 ++- .../Windows/LicenseManagerWindow.cs | 110 ++++--- 4 files changed, 281 insertions(+), 144 deletions(-) diff --git a/AGXUnity/LicenseManager.cs b/AGXUnity/LicenseManager.cs index e244bf42..b97999d8 100644 --- a/AGXUnity/LicenseManager.cs +++ b/AGXUnity/LicenseManager.cs @@ -1,4 +1,4 @@ -using AGXUnity.Utils; +using AGXUnity.Utils; using System; using System.Collections.Generic; using System.IO; @@ -170,6 +170,52 @@ public static string ActiveFloatingLicenseFilename /// public static string LastOperationError { get; private set; } + /// + /// Route licensing feedback to the caller's UI instead of the Console. + /// Dispose on the calling thread after starting the operation. Tasks started + /// inside the scope retain this setting; unrelated callers keep their logging. + /// Failure details remain available through LastOperationError. + /// + public static IDisposable SuppressConsoleLogging() => new ConsoleLoggingScope(); + + private static readonly AsyncLocal s_consoleLoggingSuppression = new AsyncLocal(); + + private sealed class ConsoleLoggingScope : IDisposable + { + private readonly int m_previous = s_consoleLoggingSuppression.Value; + private bool m_disposed; + + public ConsoleLoggingScope() { s_consoleLoggingSuppression.Value = m_previous + 1; } + + public void Dispose() + { + if ( m_disposed ) + return; + s_consoleLoggingSuppression.Value = m_previous; + m_disposed = true; + } + } + + private static void Log( string message ) + { + if ( s_consoleLoggingSuppression.Value == 0 ) Debug.Log( message ); + } + + private static void LogWarning( string message ) + { + if ( s_consoleLoggingSuppression.Value == 0 ) Debug.LogWarning( message ); + } + + private static void LogError( string message ) + { + if ( s_consoleLoggingSuppression.Value == 0 ) Debug.LogError( message ); + } + + private static void LogException( System.Exception error ) + { + if ( s_consoleLoggingSuppression.Value == 0 ) Debug.LogException( error ); + } + /// /// Manual return disables automatic checkout for the remainder of this editor /// session, until an explicit Connect succeeds. Standalone defaults are unchanged. @@ -261,13 +307,13 @@ public static LicenseInfo QueryInfo( string filename ) return info; if ( !File.Exists( filename ) ) { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to query license info for license {filename} - file doesn't exist." ); + LogWarning( $"AGXUnity.LicenseManager: Unable to query license info for license {filename} - file doesn't exist." ); return info; } var licenseType = GetLicenseType( filename ); if ( licenseType == LicenseInfo.LicenseType.Unknown ) { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to query license info for license {filename} - unknown file extension." ); + LogWarning( $"AGXUnity.LicenseManager: Unable to query license info for license {filename} - unknown file extension." ); return info; } @@ -339,61 +385,45 @@ public static bool GenerateEncryptedRuntime( int runtimeLicenseId, { if ( !CanAccessRuntime ) return false; - - if ( !Directory.Exists( applicationRootDirectory ) ) { - Debug.LogError( "AGXUnity.LicenseManager: Unable to generate encrypted runtime license - " + - $"application root directory \"{applicationRootDirectory}\" doesn't exist." ); - return false; - } - - if ( !Path.IsPathRooted( applicationRootDirectory ) ) { - Debug.LogError( "AGXUnity.LicenseManager: Unable to generate encrypted runtime license - " + - $"application root directory \"{applicationRootDirectory}\" isn't rooted." ); - return false; - } - - var absolutePathToReferenceFile = $"{applicationRootDirectory}/{referenceApplicationFile}".Replace( '\\', '/' ); - if ( !File.Exists( absolutePathToReferenceFile ) ) { - Debug.LogError( "AGXUnity.LicenseManager: Unable to generate encrypted runtime license - " + - $"reference file \"{referenceApplicationFile}\" doesn't exist relative to \"{applicationRootDirectory}\"." ); - return false; - } + if ( IsBusy ) + return RecordOperationError( "Wait for the current license operation before generating a runtime activation file." ); + LastOperationError = null; + if ( runtimeLicenseId <= 0 || string.IsNullOrWhiteSpace( runtimeLicensePassword ) ) + return RecordOperationError( "Enter a positive runtime license ID and an activation code." ); try { + if ( !Directory.Exists( applicationRootDirectory ) || !Path.IsPathRooted( applicationRootDirectory ) ) + return RecordOperationError( $"The build directory must be an existing absolute path: {applicationRootDirectory}" ); + var buildRoot = Path.GetFullPath( applicationRootDirectory ).TrimEnd( '/', '\\' ) + Path.DirectorySeparatorChar; + var reference = Path.GetFullPath( Path.Combine( buildRoot, referenceApplicationFile ) ); + if ( Path.IsPathRooted( referenceApplicationFile ) || + !reference.StartsWith( buildRoot, Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal ) ) + return RecordOperationError( "The reference file must be inside the selected build directory." ); + if ( !File.Exists( reference ) ) + return RecordOperationError( $"The reference file does not exist: {reference}" ); + + string encrypted; agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).pushbackPath( applicationRootDirectory ); - var encrypted = Runtime.encryptRuntimeActivation( runtimeLicenseId, - runtimeLicensePassword, - referenceApplicationFile ); - agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).removeFilePath( applicationRootDirectory ); - - if ( !string.IsNullOrEmpty( encrypted ) ) { - // Fall-back directory is application root but we'll try to find - // the AGX Dynamics binaries and place it there if applicationRootDirectory - // is part of the resources of AGX Dynamics. - var licenseTargetDirectory = Directory.GetFiles( applicationRootDirectory, - "agxPhysics.dll", - SearchOption.AllDirectories ).Select( file => new FileInfo( file ).Directory.FullName ).FirstOrDefault() ?? - applicationRootDirectory; - - var encryptedFilename = $"{licenseTargetDirectory}/agx{s_runtimeActivationExtension}".Replace( '\\', '/' ); - - File.WriteAllText( encryptedFilename, encrypted ); - - onSuccess?.Invoke( encryptedFilename ); - - return true; + try { + encrypted = Runtime.encryptRuntimeActivation( runtimeLicenseId, runtimeLicensePassword, referenceApplicationFile ); + if ( string.IsNullOrEmpty( encrypted ) ) + return RecordOperationError( GetNativeOperationError( "Runtime activation file generation" ) ); + } + finally { + agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).removeFilePath( applicationRootDirectory ); } - Debug.LogError( "AGXUnity.LicenseManager: Unable to generate encrypted runtime license - " + - $"encryption failed with status: {Runtime.getStatus()}" ); + var licenseTargetDirectory = Directory.GetFiles( applicationRootDirectory, "agxPhysics.dll", SearchOption.AllDirectories ) + .Select( file => new FileInfo( file ).Directory.FullName ).FirstOrDefault() ?? applicationRootDirectory; + var encryptedFilename = $"{licenseTargetDirectory}/agx{s_runtimeActivationExtension}".Replace( '\\', '/' ); + File.WriteAllText( encryptedFilename, encrypted ); + onSuccess?.Invoke( encryptedFilename ); + return true; } catch ( System.Exception e ) { - Debug.LogError( "AGXUnity.LicenseManager: Exception occurred during generate of encrypted runtime " + - $"for application root \"{applicationRootDirectory}\"." ); - Debug.LogException( e ); + LogException( e ); + return RecordOperationError( $"Unable to generate or save the runtime activation file for \"{applicationRootDirectory}\". {e.Message}" ); } - - return false; } /// @@ -441,14 +471,21 @@ public static bool ActivateEncryptedRuntime( string filename, string targetDirec Directory.CreateDirectory( targetDirectory ); } catch ( System.Exception e ) { - Debug.LogError( $"AGXUnity.LicenseManager: Unable to create given target directory - \"{targetDirectory}\"." ); - Debug.LogException( e ); + RecordOperationError( $"Unable to create runtime license directory \"{targetDirectory}\". {e.Message}" ); + LogException( e ); return false; } var success = LoadFile( filename, $"{targetDirectory}/agx{GetLicenseExtension( LicenseInfo.LicenseType.Service )}" ); - if ( success ) - File.Delete( filename ); + if ( success ) { + try { + File.Delete( filename ); + } + catch ( System.Exception e ) { + RecordOperationError( $"Runtime activation succeeded, but the activation request \"{filename}\" could not be removed. {e.Message}" ); + LogException( e ); + } + } return success; } @@ -475,21 +512,27 @@ public static bool GenerateOfflineActivation( int licenseId, var success = false; try { + if ( IsBusy ) + throw new AGXUnity.Exception( "Wait for the current license operation before generating an offline request." ); + LastOperationError = null; + if ( licenseId <= 0 || string.IsNullOrWhiteSpace( licensePassword ) ) + throw new AGXUnity.Exception( "Enter a positive license ID and an activation password." ); var activationText = Runtime.generateOfflineActivationRequest( licenseId, licensePassword ); - if ( !string.IsNullOrEmpty( Runtime.getStatus() ) ) - throw new AGXUnity.Exception( Runtime.getStatus() ); + if ( string.IsNullOrEmpty( activationText ) || !string.IsNullOrEmpty( Runtime.getStatus() ) ) + throw new AGXUnity.Exception( GetNativeOperationError( "Offline activation request" ) ); File.WriteAllText( outputFilename, activationText ); success = File.Exists( outputFilename ); } catch ( System.Exception e ) { + LastOperationError = $"Unable to generate or save the offline activation request. {e.Message}"; if ( throwOnError ) throw; success = false; - Debug.LogError( e.Message ); + LogError( e.Message ); } return success; @@ -519,6 +562,7 @@ public static bool CreateOfflineLicense( string webResponseFilenameOrContent, try { if ( IsBusy ) throw new AGXUnity.Exception( "The license manager is busy." ); + LastOperationError = null; RequireNoFloatingSession(); if ( Path.GetExtension( licenseFilename ) != GetLicenseExtension( LicenseInfo.LicenseType.Service ) ) licenseFilename += GetLicenseExtension( LicenseInfo.LicenseType.Service ); @@ -527,19 +571,24 @@ public static bool CreateOfflineLicense( string webResponseFilenameOrContent, webResponseFilenameOrContent = File.ReadAllText( webResponseFilenameOrContent ); if ( !Runtime.processOfflineActivationRequest( webResponseFilenameOrContent ) ) - throw new AGXUnity.Exception( Runtime.getStatus() ); + throw new AGXUnity.Exception( GetNativeOperationError( "Offline activation response" ) ); File.WriteAllText( licenseFilename, Runtime.readEncryptedLicense() ); success = File.Exists( licenseFilename ); } catch ( System.Exception e ) { + LastOperationError = $"Unable to process or save the offline license. {e.Message}"; if ( throwOnerror ) throw; success = false; - Debug.LogError( e.Message ); + LogError( e.Message ); + } + finally { + if ( !IsBusy ) + UpdateInformationAfterOperation(); } return success; @@ -571,6 +620,10 @@ public static void ActivateAsync( int licenseId, { StartOperation( LicenseOperation.Activate, () => { RequireNoFloatingSession(); + if ( licenseId <= 0 || string.IsNullOrWhiteSpace( licensePassword ) ) + throw new AGXUnity.Exception( "Enter a positive license ID and an activation password." ); + if ( !Directory.Exists( targetDirectory ) ) + throw new AGXUnity.Exception( $"The license output directory does not exist: {targetDirectory}" ); var licenseFilename = IO.Environment.FindUniqueFilename( $"{targetDirectory}/agx{GetLicenseExtension( LicenseInfo.LicenseType.Service )}" ); return Runtime.activateAgxLicense( licenseId, licensePassword, licenseFilename ); }, onDone ); @@ -678,14 +731,12 @@ public static bool DeactivateAndDelete( string filename ) return false; if ( !File.Exists( filename ) ) { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate and delete license {filename} - file doesn't exist." ); - return false; + return RecordOperationError( $"Unable to deactivate and delete \"{filename}\": the file does not exist." ); } var licenseType = GetLicenseType( filename ); if ( licenseType == LicenseInfo.LicenseType.Unknown ) { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate and delete license {filename} - unknown file extension." ); - return false; + return RecordOperationError( $"Unable to deactivate \"{filename}\": unknown file extension. The file was preserved." ); } // Inspect the file before loading it: a failed checkout must never turn @@ -693,13 +744,12 @@ public static bool DeactivateAndDelete( string filename ) try { var info = QueryInfo( filename ); if ( info.IsFloating || !info.IsParsed || HasFloatingSession ) { - Debug.LogWarning( "AGXUnity.LicenseManager: Cannot deactivate an unidentified or floating license, or replace a held floating seat." ); - return false; + return RecordOperationError( "Cannot deactivate an unidentified or floating license, or replace a held floating seat. The file was preserved." ); } } catch ( System.Exception e ) { - Debug.LogException( e ); - return false; + LogException( e ); + return RecordOperationError( $"Unable to inspect \"{filename}\" before deactivation. The file was preserved. {e.Message}" ); } if ( licenseType == LicenseInfo.LicenseType.Legacy ) { @@ -710,14 +760,13 @@ public static bool DeactivateAndDelete( string filename ) // If we're not able to load the license we cannot deactivate it because // we don't know if we're deactivating the given file or some other // license loaded. - if ( LoadFile( filename, $"Deactivating license: \"{filename}\"." ) ) - DeactivateLoaded(); - else { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate the license {filename} - the license has to be valid.\n" + - LicenseInfo.Status ); - } - - return DeleteFile( filename ); + if ( !LoadFile( filename, $"Deactivating license: \"{filename}\"." ) ) + return RecordOperationError( $"Unable to load \"{filename}\" for deactivation. The file was preserved.\n\n{LastOperationError}" ); + if ( !DeactivateLoaded() ) + return RecordOperationError( $"The license file was preserved because deactivation failed.\n\n{LastOperationError}" ); + if ( !DeleteFile( filename ) ) + return RecordOperationError( $"The license was deactivated, but local file cleanup failed.\n\n{LastOperationError}" ); + return true; } /// @@ -729,21 +778,26 @@ public static bool DeactivateLoaded() if ( !CanAccessRuntime ) return false; if ( IsBusy || HasFloatingSession || UpdateLicenseInformation().Type != LicenseInfo.LicenseType.Service ) { - Debug.LogWarning( "AGXUnity.LicenseManager: Only an identified non-floating service license can be deactivated." ); - return false; + return RecordOperationError( "Only an identified non-floating service license can be deactivated, with no other license operation in progress." ); } var success = false; + LastOperationError = null; try { success = Runtime.deactivateAgxLicense(); if ( success ) Reset(); else - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate loaded license - {Runtime.getStatus()}" ); + RecordOperationError( GetNativeOperationError( "Deactivation" ) ); } catch ( System.Exception e ) { - Debug.LogException( e ); + success = false; + RecordOperationError( $"Deactivation failed. {e.Message}" ); + LogException( e ); + } + finally { + UpdateInformationAfterOperation(); } return success; } @@ -888,13 +942,13 @@ public static bool DeleteFile( string filename ) try { if ( !CanAccessRuntime || IsBusy ) return false; + LastOperationError = null; if ( HasFloatingSession && QueryInfo( filename ).IsFloating ) { var active = ActiveFloatingLicenseFilename; if ( string.IsNullOrEmpty( active ) || string.Equals( Path.GetFullPath( filename ).Replace( '\\', '/' ), active, Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal ) ) { - Debug.LogWarning( "AGXUnity.LicenseManager: Return the current floating seat before deleting this file." ); - return false; + return RecordOperationError( "Return the current floating seat before deleting this file." ); } } File.Delete( filename ); @@ -906,8 +960,8 @@ public static bool DeleteFile( string filename ) #endif } catch ( System.Exception e ) { - Debug.LogException( e ); - return false; + LogException( e ); + return RecordOperationError( $"Unable to delete \"{filename}\" and its metadata. {e.Message}" ); } return !File.Exists( filename ); @@ -923,6 +977,8 @@ private static bool LoadFile( string filename, string context, bool allowFloatin { if ( !CanAccessRuntime ) return false; + if ( !IsBusy ) + LastOperationError = null; if ( IsBusy || HasFloatingSession ) { IssueLoadWarning( "Return the floating seat and wait for any pending operation before loading a license.", context ); return false; @@ -946,12 +1002,13 @@ private static bool LoadFile( string filename, string context, bool allowFloatin return false; } catch ( System.Exception e ) { - IssueLoadWarning( $"Caught exception reading text from file: {filename}.", context ); - Debug.LogException( e ); + IssueLoadWarning( $"Unable to read license file \"{filename}\". {e.Message}", context ); + LogException( e ); return false; } var loadSuccess = Load( text, context ); + var loadError = LastOperationError; // Runtime activation owns its output file. Its request must survive a // failure without being rewritten or used for a floating checkout. @@ -973,11 +1030,14 @@ private static bool LoadFile( string filename, string context, bool allowFloatin LoadInfo( $"Successfully updated license file {filename}.", context ); } else if ( isRefreshed ) { - Debug.LogError( $"AGXUnity.LicenseManager: Unable to write updated license information to {filename} - write access is required." ); + RecordOperationError( ( string.IsNullOrEmpty( loadError ) ? string.Empty : loadError + "\n\n" ) + + $"Updated license information could not be saved to \"{filename}\". Write access is required." ); } } catch ( System.Exception e ) { - Debug.LogException( e ); + RecordOperationError( ( string.IsNullOrEmpty( loadError ) ? string.Empty : loadError + "\n\n" ) + + $"Unable to save refreshed license information to \"{filename}\". {e.Message}" ); + LogException( e ); } // Compatibility fallback for native versions that cannot query the file. @@ -1000,6 +1060,8 @@ private static bool Load( string licenseContent, string context ) { if ( !CanAccessRuntime ) return false; + if ( !IsBusy ) + LastOperationError = null; if ( IsBusy || HasFloatingSession ) { IssueLoadWarning( "Return the floating seat and wait for any pending operation before loading a license.", context ); return false; @@ -1011,7 +1073,7 @@ private static bool Load( string licenseContent, string context ) var licenseContentType = FindLicenseContentType( licenseContent ); if ( licenseContentType == LicenseContentType.Unknown ) { - IssueLoadWarning( $"Unknown license content: \"{licenseContent}\".", context ); + IssueLoadWarning( "The license content could not be identified.", context ); return false; } @@ -1052,10 +1114,12 @@ private static bool Load( string licenseContent, string context ) LoadInfo( $"Loading obfuscated legacy license successful: {success && Runtime.isValid()}.", context ); } + if ( !success || !Runtime.isValid() ) + RecordOperationError( GetNativeOperationError( licenseContentType == LicenseContentType.EncryptedRuntimeService ? "Runtime activation" : "License loading" ) ); } catch ( System.Exception e ) { - IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); - Debug.LogException( e ); + IssueLoadWarning( $"License loading failed. {e.Message}", context ); + LogException( e ); success = false; } finally { @@ -1078,7 +1142,7 @@ private static bool LoadFloating( string file, string context ) catch ( System.Exception e ) { LastOperationError = e.Message; IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); - Debug.LogException( e ); + LogException( e ); } finally { UpdateInformationAfterOperation(); @@ -1099,7 +1163,7 @@ public static bool ReturnFloating( string context ) catch ( System.Exception e ) { LastOperationError = e.Message; IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); - Debug.LogException( e ); + LogException( e ); } finally { UpdateInformationAfterOperation(); @@ -1155,6 +1219,8 @@ private static void StartOperation( LicenseOperation operation, Func execu LastOperationError = null; try { success = execute(); + if ( success && ( operation == LicenseOperation.Activate || operation == LicenseOperation.Refresh ) ) + success = Runtime.isValid(); if ( !success ) LastOperationError = GetNativeOperationError( operation ); #if UNITY_EDITOR @@ -1164,20 +1230,20 @@ private static void StartOperation( LicenseOperation operation, Func execu } catch ( System.Exception e ) { LastOperationError = $"{operation} failed. {e.Message}"; - Debug.LogException( e ); + LogException( e ); } finally { UpdateInformationAfterOperation(); } if ( !success ) - Debug.LogWarning( $"AGXUnity.LicenseManager: {LastOperationError}" ); + LogWarning( $"AGXUnity.LicenseManager: {LastOperationError}" ); onDone?.Invoke( success ); } ); s_operationTask.Start( TaskScheduler.Default ); return; } } - Debug.LogWarning( "AGXUnity.LicenseManager: Another license operation is in progress." ); + LogWarning( "AGXUnity.LicenseManager: Another license operation is in progress." ); onDone?.Invoke( false ); } @@ -1189,11 +1255,16 @@ private static void UpdateInformationAfterOperation() catch ( System.Exception e ) { // Deliver the native result even if shutdown races with information // refresh. ReturnFloating is also called by NativeHandler's finalizer. - Debug.LogException( e ); + LogException( e ); } } private static string GetNativeOperationError( LicenseOperation operation ) + { + return GetNativeOperationError( operation.ToString() ); + } + + private static string GetNativeOperationError( string operation ) { var status = Runtime.getStatus(); var extended = Runtime.getExtendedStatus(); @@ -1237,13 +1308,21 @@ private static void ApplyFloatingSessionState() private static void LoadInfo( string info, string context ) { if ( !Application.isEditor ) - Debug.Log( $"AGXUnity.LicenseManager: {info} (Context: {context})" ); + Log( $"AGXUnity.LicenseManager: {info} (Context: {context})" ); } private static void IssueLoadWarning( string warning, string context ) { + LastOperationError = warning; if ( !Application.isEditor ) - Debug.LogWarning( $"AGXUnity.LicenseManager: {warning} (Context: {context})" ); + LogWarning( $"AGXUnity.LicenseManager: {warning} (Context: {context})" ); + } + + private static bool RecordOperationError( string error ) + { + LastOperationError = error; + LogWarning( $"AGXUnity.LicenseManager: {error}" ); + return false; } diff --git a/Editor/AGXUnityEditor/LicenseWarnings.cs b/Editor/AGXUnityEditor/LicenseWarnings.cs index 6fe33ef9..78b626bb 100644 --- a/Editor/AGXUnityEditor/LicenseWarnings.cs +++ b/Editor/AGXUnityEditor/LicenseWarnings.cs @@ -44,7 +44,7 @@ internal static string GetActivationError( LicenseInfo info ) return "License activation failed.\n\n" + ( !info.IsValid && !string.IsNullOrWhiteSpace( info.Status ) ? info.Status.Trim() : - "Check the Console for details, then verify your license ID, activation password, and connection before retrying." ); + "Verify your license ID, activation password, and connection before retrying." ); } internal static void ScheduleStartupWarning() diff --git a/Editor/AGXUnityEditor/Windows/GenerateRuntimeLicenseActivationWindow.cs b/Editor/AGXUnityEditor/Windows/GenerateRuntimeLicenseActivationWindow.cs index d3c59379..2fd3a900 100644 --- a/Editor/AGXUnityEditor/Windows/GenerateRuntimeLicenseActivationWindow.cs +++ b/Editor/AGXUnityEditor/Windows/GenerateRuntimeLicenseActivationWindow.cs @@ -1,4 +1,4 @@ -using AGXUnity.Utils; +using AGXUnity.Utils; using System.IO; using System.Linq; using UnityEditor; @@ -26,7 +26,7 @@ public string BuildDirectory set { if ( string.IsNullOrEmpty( value ) ) { - Debug.LogWarning( "Invalid build directory - directory is null or empty." ); + m_generationError = "Invalid build directory - directory is null or empty."; return; } @@ -34,7 +34,7 @@ public string BuildDirectory value = $"{Directory.GetCurrentDirectory()}/{value}"; if ( !Directory.Exists( value ) ) { - Debug.LogWarning( $"Invalid build directory - directory \"{value}\" doesn't exist." ); + m_generationError = $"Invalid build directory - directory \"{value}\" doesn't exist."; return; } @@ -55,8 +55,8 @@ public string ReferenceFileInBuild if ( tmp.StartsWith( BuildDirectory ) ) tmp = tmp.MakeRelative( BuildDirectory, false ); else if ( Path.IsPathRooted( tmp ) ) { - Debug.LogWarning( $"Reference file \"{tmp}\" doesn't seems to be in the build directory \"{BuildDirectory}\". " + - "Make sure the build directory is correct before reference file is set." ); + m_generationError = $"Reference file \"{tmp}\" is not in the build directory \"{BuildDirectory}\". " + + "Check the build directory before selecting the reference file."; return; } @@ -77,6 +77,8 @@ public void Initialize( string buildDirectory, string referenceFile ) private void OnGUI() { + using var feedback = AGXUnity.LicenseManager.SuppressConsoleLogging(); + m_scroll = EditorGUILayout.BeginScrollView( m_scroll ); using ( GUI.AlignBlock.Center ) GUILayout.Box( IconManager.GetAGXUnityLogo(), GUI.Skin.customStyles[ 3 ], @@ -104,7 +106,7 @@ private void OnGUI() "Build directory", newBuildDirectory => { if ( !Directory.Exists( newBuildDirectory ) ) { - Debug.LogWarning( $"Ignoring given build path {newBuildDirectory} doesn't exist." ); + m_generationError = $"The build directory does not exist: {newBuildDirectory}"; return; } BuildDirectory = newBuildDirectory; @@ -121,15 +123,19 @@ private void OnGUI() m_idPassword.Id = EditorGUILayout.TextField( GUI.MakeLabel( "Runtime License Id" ), m_idPassword.Id, InspectorEditor.Skin.TextField ); - if ( m_idPassword.Id.Any( c => !char.IsDigit( c ) ) ) - m_idPassword.Id = new string( m_idPassword.Id.Where( c => char.IsDigit( c ) ).ToArray() ); m_idPassword.Password = EditorGUILayout.PasswordField( GUI.MakeLabel( "Runtime Activation Code" ), m_idPassword.Password ); var generateToolTip = string.Empty; + if ( !string.IsNullOrEmpty( m_idPassword.Id ) && + ( !int.TryParse( m_idPassword.Id, out var enteredId ) || enteredId <= 0 ) ) + EditorGUILayout.HelpBox( "Enter a license ID between 1 and 2147483647.", MessageType.Error, true ); + if ( !string.IsNullOrEmpty( m_generationError ) ) + EditorGUILayout.HelpBox( m_generationError, MessageType.Error, true ); using ( new GUI.EnabledBlock( ValidateGenerate( ref generateToolTip ) ) ) { GUILayout.Space( 3 ); if ( GUILayout.Button( GUI.MakeLabel( "Generate", false, generateToolTip ) ) ) { + m_generationError = null; var generatedFilename = string.Empty; if ( AGXUnity.LicenseManager.GenerateEncryptedRuntime( System.Convert.ToInt32( m_idPassword.Id ), m_idPassword.Password, @@ -141,18 +147,24 @@ private void OnGUI() "Ok" ); Close(); } + else + m_generationError = AGXUnity.LicenseManager.LastOperationError ?? "Runtime activation file generation failed. Check the license details and that the build directory is writable."; } } + EditorGUILayout.EndScrollView(); } private bool ValidateGenerate( ref string toolTip ) { - var idGiven = !string.IsNullOrEmpty( m_idPassword.Id ) && m_idPassword.Id.Length > 1; - var passwordGiven = !string.IsNullOrEmpty( m_idPassword.Password ) && m_idPassword.Password.Length > 1; + var idGiven = int.TryParse( m_idPassword.Id, out var id ) && id > 0; + var passwordGiven = !string.IsNullOrWhiteSpace( m_idPassword.Password ); var directoryValid = Directory.Exists( BuildDirectory ); var fileExist = File.Exists( ReferenceFileInBuildFull ); + var canGenerate = !AGXUnity.LicenseManager.IsBusy && !EditorApplication.isPlayingOrWillChangePlaymode; + if ( !canGenerate ) + toolTip += "Wait for license operations to finish and exit play mode before generating.\n"; if ( !idGiven ) - toolTip += "Missing license id.\n"; + toolTip += "Enter a license ID between 1 and 2147483647.\n"; if ( !passwordGiven ) toolTip += "Missing license password.\n"; if ( !directoryValid ) @@ -165,7 +177,7 @@ private bool ValidateGenerate( ref string toolTip ) else if ( string.IsNullOrEmpty( toolTip ) ) toolTip = "Generate encrypted runtime license."; - return idGiven && + return canGenerate && idGiven && passwordGiven && directoryValid && fileExist; @@ -174,5 +186,7 @@ private bool ValidateGenerate( ref string toolTip ) private LicenseManagerWindow.IdPassword m_idPassword = LicenseManagerWindow.IdPassword.Empty(); private string m_buildDirectory = string.Empty; private string m_referenceFileInBuild = string.Empty; + private string m_generationError; + private Vector2 m_scroll; } } diff --git a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs index 6e28b2fb..2772a482 100644 --- a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs +++ b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs @@ -69,6 +69,7 @@ private void OnFocus() private void OnGUI() { + using var feedback = AGXUnity.LicenseManager.SuppressConsoleLogging(); ValidateLicenseDirectory(); if ( m_clearFocus ) { UnityEngine.GUI.FocusControl( "" ); @@ -88,6 +89,14 @@ private void OnGUI() m_scroll = EditorGUILayout.BeginScrollView( m_scroll ); + using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !IsBusy ) ) { + if ( GUILayout.Button( GUI.MakeLabel( "Rescan License Files", false, + "Search the project again for license files added, removed, or changed while this window was open." ), + InspectorEditor.Skin.Button ) ) + StartUpdateLicenseInformation(); + } + GUILayout.Space( 6 ); + if ( IsUpdatingLicenseInformation ) ShowNotification( GUI.MakeLabel( "Reading..." ) ); else if ( IsBusy ) @@ -184,12 +193,23 @@ private void ActivateLicenseGUI() "Yes", "Cancel" ) ) { try { + m_operationError = null; + m_importMessage = null; File.Copy( sourceLicense, targetLicense, false ); var info = AGXUnity.LicenseManager.QueryInfo( targetLicense ); m_importMessage = info.IsFloating ? "Floating license imported. Select Connect to request a seat." : - !info.IsParsed ? "License copied, but its type could not be identified. Check the file and the Console for details." : null; - if ( info.IsParsed && !info.IsFloating && !AGXUnity.LicenseManager.HasFloatingSession && AGXUnity.LicenseManager.LoadFile( targetLicense ) ) - m_activationError = null; + null; + if ( !info.IsParsed ) + m_operationError = $"License copied to \"{targetLicense}\", but its type could not be identified. Check that this is a complete license file."; + if ( info.IsParsed && !info.IsFloating && !AGXUnity.LicenseManager.HasFloatingSession ) { + if ( AGXUnity.LicenseManager.LoadFile( targetLicense ) ) + m_activationError = null; + else + m_operationError = $"License copied to \"{targetLicense}\", but loading failed.\n\n" + + ( AGXUnity.LicenseManager.LastOperationError ?? "AGX did not accept the license file." ); + if ( m_operationError == null ) + m_operationError = AGXUnity.LicenseManager.LastOperationError; + } else if ( !info.IsFloating && AGXUnity.LicenseManager.HasFloatingSession ) m_importMessage = "License copied. Return the current floating seat before loading this license."; LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); @@ -200,7 +220,9 @@ private void ActivateLicenseGUI() throw; } catch ( System.Exception e ) { - Debug.LogException( e ); + m_operationError = $"Unable to import \"{sourceLicense}\". {e.Message}"; + LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); + StartUpdateLicenseInformation(); } } } @@ -210,8 +232,6 @@ private void ActivateLicenseGUI() m_licenseActivateData.Id = EditorGUILayout.TextField( GUI.MakeLabel( "License Id" ), m_licenseActivateData.Id, InspectorEditor.Skin.TextField ); - if ( m_licenseActivateData.Id.Any( c => !char.IsDigit( c ) ) ) - m_licenseActivateData.Id = new string( m_licenseActivateData.Id.Where( c => char.IsDigit( c ) ).ToArray() ); m_licenseActivateData.Password = EditorGUILayout.PasswordField( GUI.MakeLabel( "Activation Password" ), m_licenseActivateData.Password ); @@ -225,11 +245,11 @@ private void ActivateLicenseGUI() newDirectory = "Assets"; if ( !Directory.Exists( newDirectory ) ) { - Debug.LogWarning( $"Invalid license directory: {newDirectory} - directory doesn't exist." ); + m_operationError = $"Invalid license directory: {newDirectory} - directory doesn't exist."; return; } else if ( !IO.Utils.IsValidProjectFolder( newDirectory ) ) { - Debug.LogWarning( $"Invalid license directory: {newDirectory} - directory has to be in the project." ); + m_operationError = $"Invalid license directory: {newDirectory} - directory has to be in the project."; return; } LicenseDirectory = newDirectory; @@ -269,17 +289,15 @@ private void ActivateLicense( int licenseId ) m_licenseActivateData.Password, LicenseDirectory, success => { + var error = success ? null : AGXUnity.LicenseManager.LastOperationError; var info = AGXUnity.LicenseInfo.Create(); - if ( !success ) - Debug.LogError( "License Error: ".Color( Color.red ) + LicenseWarnings.GetActivationError( info ) ); - completion.TrySetResult( new LicenseOperationResult { Success = success, LicenseInfo = info } ); + completion.TrySetResult( new LicenseOperationResult { Success = success, LicenseInfo = info, Error = error } ); } ); } catch ( System.Exception e ) { - Debug.LogException( e ); completion.TrySetResult( new LicenseOperationResult { LicenseInfo = AGXUnity.LicenseInfo.Create(), - Error = LicenseWarnings.GetActivationError( new AGXUnity.LicenseInfo() ) + Error = $"License activation failed. {e.Message}" } ); } } @@ -327,6 +345,8 @@ private void LicenseDataGUI( LicenseData data ) licenseFileButtons.ToArray() ); InspectorGUI.SelectableTextField( GUI.MakeLabel( "License type" ), floating ? "Floating" : data.LicenseInfo.TypeDescription ); + if ( !string.IsNullOrEmpty( data.ReadError ) ) + EditorGUILayout.HelpBox( data.ReadError, MessageType.Error, true ); if ( floating ) { using ( new GUI.EnabledBlock( canChange && !LicenseWarnings.CurrentLicense.IsFloating ) ) { @@ -379,7 +399,14 @@ private void DeleteLicense( LicenseData data ) var filename = Path.GetFileName( data.Filename ); m_operationError = null; // Query again at the point of action in case the file changed after scanning. - var info = AGXUnity.LicenseManager.QueryInfo( data.Filename ); + AGXUnity.LicenseInfo info; + try { + info = AGXUnity.LicenseManager.QueryInfo( data.Filename ); + } + catch ( System.Exception e ) { + m_operationError = $"Unable to inspect \"{filename}\" before deletion. The file was preserved. {e.Message}"; + return; + } if ( info.IsFloating && AGXUnity.LicenseManager.HasFloatingSession ) { if ( string.IsNullOrEmpty( AGXUnity.LicenseManager.ActiveFloatingLicenseFilename ) ) { m_operationError = "Return the current floating seat before deleting floating files. Its source file is unknown."; @@ -401,13 +428,13 @@ private void DeleteLicense( LicenseData data ) if ( choice == 1 ) return; if ( !( choice == 0 ? AGXUnity.LicenseManager.DeactivateAndDelete( data.Filename ) : AGXUnity.LicenseManager.DeleteFile( data.Filename ) ) ) - m_operationError = $"Unable to remove \"{filename}\". Check the Console for details."; + m_operationError = AGXUnity.LicenseManager.LastOperationError ?? $"Unable to remove \"{filename}\". Check file permissions and your connection before retrying."; } else { if ( !EditorUtility.DisplayDialog( $"Delete \"{filename}\"?", "Delete this license file from the project?", "Delete", "Cancel" ) ) return; if ( !AGXUnity.LicenseManager.DeleteFile( data.Filename ) ) - m_operationError = $"Unable to delete \"{filename}\". Check the Console for details."; + m_operationError = AGXUnity.LicenseManager.LastOperationError ?? $"Unable to delete \"{filename}\". Check that the file is writable and is not in use."; } LicenseWarnings.Capture( AGXUnity.LicenseManager.UpdateLicenseInformation() ); StartUpdateLicenseInformation(); @@ -433,7 +460,7 @@ private void BeginFloatingOperation( LicenseOperation operation, System.Action completion.TrySetResult( new LicenseOperationResult { Success = success, - Error = success ? null : AGXUnity.LicenseManager.LastOperationError ?? $"{operation} failed. Check the Console for details.", + Error = success ? null : AGXUnity.LicenseManager.LastOperationError ?? $"{operation} failed. Check the server connection and wait for other license operations to finish before retrying.", LicenseInfo = AGXUnity.LicenseInfo.Create() } ) ); } @@ -462,6 +489,7 @@ private void StartUpdateLicenseInformation() private void OnEditorUpdate() { + using var feedback = AGXUnity.LicenseManager.SuppressConsoleLogging(); // License operations invoke callbacks from worker threads. // Wait until the native task has finished before reading files or UI state. if ( AGXUnity.LicenseManager.IsBusy ) @@ -487,7 +515,7 @@ private void OnEditorUpdate() m_importMessage = null; if ( m_operation == LicenseOperation.Return && !string.IsNullOrEmpty( m_deleteAfterReturn ) && !AGXUnity.LicenseManager.DeleteFile( m_deleteAfterReturn ) ) - m_operationError = "The seat was returned, but the license file could not be deleted. Check the Console for details."; + m_operationError = "The seat was returned, but local file cleanup failed.\n\n" + AGXUnity.LicenseManager.LastOperationError; } m_deleteAfterReturn = null; } @@ -496,7 +524,12 @@ private void OnEditorUpdate() else if ( !result.Success ) m_operationError = result.Error; if ( m_operation == LicenseOperation.Refresh && !string.IsNullOrEmpty( m_restoreAfterRefresh ) ) { - AGXUnity.LicenseManager.LoadFile( m_restoreAfterRefresh ); + var restored = AGXUnity.LicenseManager.LoadFile( m_restoreAfterRefresh ); + if ( !restored || !string.IsNullOrEmpty( AGXUnity.LicenseManager.LastOperationError ) ) + m_operationError = ( string.IsNullOrEmpty( m_operationError ) ? string.Empty : m_operationError + "\n\n" ) + + ( restored ? "The previous license was loaded, but saving its updated file failed.\n\n" : + $"Unable to restore the previously loaded license \"{m_restoreAfterRefresh}\".\n\n" ) + + AGXUnity.LicenseManager.LastOperationError; LicenseWarnings.Capture( AGXUnity.LicenseManager.UpdateLicenseInformation() ); m_restoreAfterRefresh = null; } @@ -510,12 +543,11 @@ private void OnEditorUpdate() return; if ( m_updateLicenseInfoTask != null && m_updateLicenseInfoTask.IsCompleted ) { - var licenseData = m_updateLicenseInfoTask.Result; + var scan = m_updateLicenseInfoTask.Result; m_updateLicenseInfoTask = null; - if ( licenseData != null ) - m_licenseData = licenseData; - else - m_licenseReadError = "Unable to read license information. Check the Console for details."; + if ( scan.Data != null ) + m_licenseData = scan.Data; + m_licenseReadError = scan.Error; RemoveNotification(); Repaint(); } @@ -530,8 +562,6 @@ private void OnEditorUpdate() m_checkLicenseValidity = false; var info = AGXUnity.LicenseManager.UpdateLicenseInformation(); LicenseWarnings.Capture( info ); - if ( info.IsValid ) - m_activationError = null; Repaint(); } @@ -541,14 +571,22 @@ private void OnEditorUpdate() try { var licenseData = new List(); foreach ( var licenseFile in AGXUnity.LicenseManager.FindLicenseFiles() ) { - var info = AGXUnity.LicenseManager.QueryInfo( licenseFile ); - licenseData.Add( new LicenseData { Filename = licenseFile, LicenseInfo = info } ); + try { + var info = AGXUnity.LicenseManager.QueryInfo( licenseFile ); + licenseData.Add( new LicenseData { + Filename = licenseFile, + LicenseInfo = info, + ReadError = info.IsParsed ? null : $"Unable to identify \"{licenseFile}\". Check that the file exists and contains a complete license." + } ); + } + catch ( System.Exception e ) { + licenseData.Add( new LicenseData { Filename = licenseFile, ReadError = $"Unable to read \"{licenseFile}\". {e.Message}" } ); + } } - return licenseData; + return new LicenseScanResult { Data = licenseData }; } catch ( System.Exception e ) { - Debug.LogException( e ); - return null; + return new LicenseScanResult { Error = $"Unable to read license information. {e.Message}" }; } } ); } @@ -573,7 +611,6 @@ private void RefreshLicense( LicenseData licenseData ) } ); } catch ( System.Exception e ) { - Debug.LogException( e ); completion.TrySetResult( new LicenseOperationResult { LicenseInfo = AGXUnity.LicenseInfo.Create(), Error = e.Message } ); } } @@ -589,6 +626,7 @@ private struct LicenseData { public string Filename; public AGXUnity.LicenseInfo LicenseInfo; + public string ReadError; } private struct LicenseOperationResult @@ -598,13 +636,19 @@ private struct LicenseOperationResult public string Error; } + private struct LicenseScanResult + { + public List Data; + public string Error; + } + private enum LicenseOperation { None, Activate, Refresh, Connect, Return } private IdPassword m_licenseActivateData = IdPassword.Empty(); private Vector2 m_scroll = Vector2.zero; [System.NonSerialized] private List m_licenseData = new List(); - private Task> m_updateLicenseInfoTask = null; + private Task m_updateLicenseInfoTask = null; private Task m_licenseOperationTask = null; private bool m_updateLicenseInformationRequested = false; private LicenseOperation m_operation = LicenseOperation.None; From 3a992df4270f9562842cef00579d41b70db71531 Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Mon, 14 Sep 2026 16:30:55 +0200 Subject: [PATCH 06/11] Formatting --- .../AGXUnityEditor/Windows/LicenseManagerWindow.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs index 2772a482..c2f55ea2 100644 --- a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs +++ b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs @@ -295,7 +295,8 @@ private void ActivateLicense( int licenseId ) } ); } catch ( System.Exception e ) { - completion.TrySetResult( new LicenseOperationResult { + completion.TrySetResult( new LicenseOperationResult + { LicenseInfo = AGXUnity.LicenseInfo.Create(), Error = $"License activation failed. {e.Message}" } ); @@ -458,7 +459,8 @@ private void BeginFloatingOperation( LicenseOperation operation, System.Action(); m_licenseOperationTask = completion.Task; try { - start( success => completion.TrySetResult( new LicenseOperationResult { + start( success => completion.TrySetResult( new LicenseOperationResult + { Success = success, Error = success ? null : AGXUnity.LicenseManager.LastOperationError ?? $"{operation} failed. Check the server connection and wait for other license operations to finish before retrying.", LicenseInfo = AGXUnity.LicenseInfo.Create() @@ -573,7 +575,8 @@ private void OnEditorUpdate() foreach ( var licenseFile in AGXUnity.LicenseManager.FindLicenseFiles() ) { try { var info = AGXUnity.LicenseManager.QueryInfo( licenseFile ); - licenseData.Add( new LicenseData { + licenseData.Add( new LicenseData + { Filename = licenseFile, LicenseInfo = info, ReadError = info.IsParsed ? null : $"Unable to identify \"{licenseFile}\". Check that the file exists and contains a complete license." @@ -603,7 +606,8 @@ private void RefreshLicense( LicenseData licenseData ) try { AGXUnity.LicenseManager.RefreshAsync( licenseData.Filename, success => { - completion.TrySetResult( new LicenseOperationResult { + completion.TrySetResult( new LicenseOperationResult + { Success = success, Error = success ? null : AGXUnity.LicenseManager.LastOperationError, LicenseInfo = AGXUnity.LicenseInfo.Create() From 2275944519188e9ee54c8ad4a0e7f02810bc64e3 Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Wed, 16 Sep 2026 10:47:45 +0200 Subject: [PATCH 07/11] Added safety check for floating license --- AGXUnity/LicenseInfo.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGXUnity/LicenseInfo.cs b/AGXUnity/LicenseInfo.cs index 7ca8e1d3..12ec0997 100644 --- a/AGXUnity/LicenseInfo.cs +++ b/AGXUnity/LicenseInfo.cs @@ -129,7 +129,8 @@ public static LicenseInfo Create() if ( info.IsFloating || agx.Runtime.instance().hasKey( "InstallationID" ) ) { info.Type = LicenseType.Service; - info.UniqueId = agx.Runtime.instance().readValue( "InstallationID" ); + if(agx.Runtime.instance().hasKey( "InstallationID" )) + info.UniqueId = agx.Runtime.instance().readValue( "InstallationID" ); } else if ( agx.Runtime.instance().hasKey( "License" ) ) { info.Type = LicenseType.Legacy; @@ -155,7 +156,7 @@ public static LicenseInfo FromNative( agx.LicenseInfo native ) info.IsValid = native.licenseType != -1; LicenseInfo.ParseDate( ref info, native.endDate ); - info.Type = native.licenseType == -1 ? LicenseType.Unknown : LicenseType.Service; + info.Type = info.IsValid ? LicenseType.Unknown : LicenseType.Service; info.TypeDescription = "Unknown"; var subscriptionType = native.product.Split("-"); if ( subscriptionType.Length == 2 ) From 7eab5d3a901323bc9f3cfe60d2e133af79fd6a85 Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Wed, 16 Sep 2026 13:57:46 +0200 Subject: [PATCH 08/11] fixed inverted check --- AGXUnity/LicenseInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGXUnity/LicenseInfo.cs b/AGXUnity/LicenseInfo.cs index 12ec0997..2cc1dd28 100644 --- a/AGXUnity/LicenseInfo.cs +++ b/AGXUnity/LicenseInfo.cs @@ -156,7 +156,7 @@ public static LicenseInfo FromNative( agx.LicenseInfo native ) info.IsValid = native.licenseType != -1; LicenseInfo.ParseDate( ref info, native.endDate ); - info.Type = info.IsValid ? LicenseType.Unknown : LicenseType.Service; + info.Type = info.IsValid ? LicenseType.Service : LicenseType.Unknown; info.TypeDescription = "Unknown"; var subscriptionType = native.product.Split("-"); if ( subscriptionType.Length == 2 ) From d57d955b8f36406b5c31a474547d00a9de498c2a Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Wed, 16 Sep 2026 14:06:12 +0200 Subject: [PATCH 09/11] Ensure rescanning is done in edit time --- Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs index c2f55ea2..169fb738 100644 --- a/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs +++ b/Editor/AGXUnityEditor/Windows/LicenseManagerWindow.cs @@ -89,7 +89,7 @@ private void OnGUI() m_scroll = EditorGUILayout.BeginScrollView( m_scroll ); - using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !IsBusy ) ) { + using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !IsBusy && !EditorApplication.isPlayingOrWillChangePlaymode ) ) { if ( GUILayout.Button( GUI.MakeLabel( "Rescan License Files", false, "Search the project again for license files added, removed, or changed while this window was open." ), InspectorEditor.Skin.Button ) ) From cc9068e3af2e877e9aeabf8fc521352a6d724ac8 Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Wed, 16 Sep 2026 14:08:36 +0200 Subject: [PATCH 10/11] Compunded errors for LoadFile --- AGXUnity/LicenseManager.cs | 89 ++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/AGXUnity/LicenseManager.cs b/AGXUnity/LicenseManager.cs index b97999d8..efb40046 100644 --- a/AGXUnity/LicenseManager.cs +++ b/AGXUnity/LicenseManager.cs @@ -1,4 +1,4 @@ -using AGXUnity.Utils; +using AGXUnity.Utils; using System; using System.Collections.Generic; using System.IO; @@ -166,7 +166,8 @@ public static string ActiveFloatingLicenseFilename } /// - /// Failure details captured before querying license information changes native status. + /// Diagnostics from the last accepted operation, captured before querying license + /// information changes native status. Successful loads may retain cleanup warnings. /// public static string LastOperationError { get; private set; } @@ -268,6 +269,8 @@ public static bool LoadFile( bool allowFloating, bool preferFloating ) if ( !CanAccessRuntime || IsBusy ) return false; + LastOperationError = null; + // Scripts or native initialization may have already loaded a license. // Preserve the actual native license instead of clearing it and later // restoring only a snapshot of its information. @@ -278,6 +281,7 @@ public static bool LoadFile( bool allowFloating, bool preferFloating ) if ( !currentLicense.IsValid ) Reset(); + var errors = new List(); var licenseFiles = FindLicenseFiles(); foreach ( var licenseFile in licenseFiles ) { var file = licenseFile.PrettyPath(); @@ -286,12 +290,16 @@ public static bool LoadFile( bool allowFloating, bool preferFloating ) allowFloating, floatingOnly: currentLicense.IsValid ) ) return true; + if ( !string.IsNullOrWhiteSpace( LastOperationError ) ) + errors.Add( $"{file}:\n{LastOperationError}" ); } UpdateLicenseInformation(); // With no floating candidate, retain the native fallback. A failed // checkout may clear it; always use its actual validity after the search. - return currentLicense.IsValid && LicenseInfo.IsValid; + var success = currentLicense.IsValid && LicenseInfo.IsValid; + LastOperationError = success ? null : CombineOperationErrors( errors.ToArray() ); + return success; } public static LicenseInfo QueryInfo( string filename ) @@ -435,7 +443,7 @@ public static bool GenerateEncryptedRuntime( int runtimeLicenseId, /// True if successfully activated, otherwise false. public static bool ActivateEncryptedRuntime( string targetDirectory ) { - if ( !CanAccessRuntime ) + if ( !CanAccessRuntime || IsBusy ) return false; var filename = FindRuntimeActivationFiles().FirstOrDefault(); if ( string.IsNullOrEmpty( filename ) ) { @@ -458,7 +466,7 @@ public static bool ActivateEncryptedRuntime( string targetDirectory ) /// True if successfully activated and written, otherwise false. public static bool ActivateEncryptedRuntime( string filename, string targetDirectory ) { - if ( !CanAccessRuntime ) + if ( !CanAccessRuntime || IsBusy ) return false; if ( string.IsNullOrEmpty( filename ) || !File.Exists( filename ) ) { IssueLoadWarning( "Unable to activate runtime license, filename not given or doesn't exist.", @@ -508,12 +516,17 @@ public static bool GenerateOfflineActivation( int licenseId, { if ( !CanAccessRuntime ) return false; + if ( IsBusy ) { + const string error = "Wait for the current license operation before generating an offline request."; + if ( throwOnError ) + throw new AGXUnity.Exception( error ); + LogWarning( error ); + return false; + } var success = false; try { - if ( IsBusy ) - throw new AGXUnity.Exception( "Wait for the current license operation before generating an offline request." ); LastOperationError = null; if ( licenseId <= 0 || string.IsNullOrWhiteSpace( licensePassword ) ) throw new AGXUnity.Exception( "Enter a positive license ID and an activation password." ); @@ -556,12 +569,17 @@ public static bool CreateOfflineLicense( string webResponseFilenameOrContent, { if ( !CanAccessRuntime ) return false; + if ( IsBusy ) { + const string error = "The license manager is busy."; + if ( throwOnerror ) + throw new AGXUnity.Exception( error ); + LogWarning( error ); + return false; + } var success = false; try { - if ( IsBusy ) - throw new AGXUnity.Exception( "The license manager is busy." ); LastOperationError = null; RequireNoFloatingSession(); if ( Path.GetExtension( licenseFilename ) != GetLicenseExtension( LicenseInfo.LicenseType.Service ) ) @@ -1009,6 +1027,7 @@ private static bool LoadFile( string filename, string context, bool allowFloatin var loadSuccess = Load( text, context ); var loadError = LastOperationError; + string saveError = null; // Runtime activation owns its output file. Its request must survive a // failure without being rewritten or used for a floating checkout. @@ -1022,21 +1041,21 @@ private static bool LoadFile( string filename, string context, bool allowFloatin var isRefreshedAndCanWrite = isRefreshed && IO.Environment.CanWriteToExisting( filename ); if ( isRefreshedAndCanWrite ) { - LoadInfo( $"The license has been refreshed - rewriting license file {filename}.", context ); + IssueLoadInfo( $"The license has been refreshed - rewriting license file {filename}.", context ); using ( var str = new StreamWriter( filename, false ) ) str.WriteLine( Runtime.readEncryptedLicense() ); - LoadInfo( $"Successfully updated license file {filename}.", context ); + IssueLoadInfo( $"Successfully updated license file {filename}.", context ); } else if ( isRefreshed ) { - RecordOperationError( ( string.IsNullOrEmpty( loadError ) ? string.Empty : loadError + "\n\n" ) + - $"Updated license information could not be saved to \"{filename}\". Write access is required." ); + saveError = $"Updated license information could not be saved to \"{filename}\". Write access is required."; + RecordOperationError( CombineOperationErrors( loadError, saveError ) ); } } catch ( System.Exception e ) { - RecordOperationError( ( string.IsNullOrEmpty( loadError ) ? string.Empty : loadError + "\n\n" ) + - $"Unable to save refreshed license information to \"{filename}\". {e.Message}" ); + saveError = $"Unable to save refreshed license information to \"{filename}\". {e.Message}"; + RecordOperationError( CombineOperationErrors( loadError, saveError ) ); LogException( e ); } @@ -1045,6 +1064,10 @@ private static bool LoadFile( string filename, string context, bool allowFloatin if ( allowFloating && !loadSuccess && ( LicenseInfo.Status?.IndexOf( "floating", StringComparison.OrdinalIgnoreCase ) ?? -1 ) >= 0 ) { loadSuccess = LoadFloating( filename, context ); + // A successful fallback resolves the original load failure, but it + // cannot resolve a failure to save refreshed information to disk. + LastOperationError = loadSuccess ? saveError : + CombineOperationErrors( loadError, saveError, LastOperationError ); } return loadSuccess; @@ -1083,7 +1106,7 @@ private static bool Load( string licenseContent, string context ) // Service type. if ( licenseContentType == LicenseContentType.Service ) { success = Runtime.loadLicenseString( licenseContent ); - LoadInfo( $"Loading service license successful: {success && Runtime.isValid()}.", + IssueLoadInfo( $"Loading service license successful: {success && Runtime.isValid()}.", context ); } // Runtime license activation. @@ -1099,19 +1122,19 @@ private static bool Load( string licenseContent, string context ) agxIO.Environment.instance().getFilePath( agxIO.Environment.Type.RESOURCE_PATH ).removeFilePath( cwd ); } - LoadInfo( $"Activating encrypted runtime license \"{context}\" successful: {success && Runtime.isValid()}.", + IssueLoadInfo( $"Activating encrypted runtime license \"{context}\" successful: {success && Runtime.isValid()}.", context ); } // Legacy type. else if ( licenseContentType == LicenseContentType.Legacy ) { success = Runtime.unlock( licenseContent ); - LoadInfo( $"Loading legacy license successful: {success && Runtime.isValid()}.", + IssueLoadInfo( $"Loading legacy license successful: {success && Runtime.isValid()}.", context ); } // Assume obfuscated legacy. else { success = Runtime.verifyAndUnlock( licenseContent ); - LoadInfo( $"Loading obfuscated legacy license successful: {success && Runtime.isValid()}.", + IssueLoadInfo( $"Loading obfuscated legacy license successful: {success && Runtime.isValid()}.", context ); } if ( !success || !Runtime.isValid() ) @@ -1137,11 +1160,10 @@ private static bool LoadFloating( string file, string context ) try { success = OpenFloatingSession( file ); LastOperationError = success ? null : GetNativeOperationError( LicenseOperation.Connect ); - LoadInfo( $"Loading floating license successful: {success}.", context ); + IssueLoadInfo( $"Loading floating license successful: {success}.", context ); } catch ( System.Exception e ) { - LastOperationError = e.Message; - IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); + IssueLoadWarning( $"Floating license checkout failed. {e.Message}", context ); LogException( e ); } finally { @@ -1158,11 +1180,10 @@ public static bool ReturnFloating( string context ) try { success = CloseFloatingSession(); LastOperationError = success ? null : GetNativeOperationError( LicenseOperation.Return ); - LoadInfo( $"Returning floating license successful: {success}.", context ); + IssueLoadInfo( $"Returning floating license successful: {success}.", context ); } catch ( System.Exception e ) { - LastOperationError = e.Message; - IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); + IssueLoadWarning( $"Returning the floating license failed. {e.Message}", context ); LogException( e ); } finally { @@ -1305,7 +1326,7 @@ private static void ApplyFloatingSessionState() private static int s_pendingFloatingSessionState; #endif - private static void LoadInfo( string info, string context ) + private static void IssueLoadInfo( string info, string context ) { if ( !Application.isEditor ) Log( $"AGXUnity.LicenseManager: {info} (Context: {context})" ); @@ -1313,18 +1334,30 @@ private static void LoadInfo( string info, string context ) private static void IssueLoadWarning( string warning, string context ) { - LastOperationError = warning; + // A rejected synchronous request must not replace the asynchronous + // operation's result. These helpers are not used by StartOperation. + if ( !IsBusy ) + LastOperationError = $"{warning} (Context: {context})"; if ( !Application.isEditor ) LogWarning( $"AGXUnity.LicenseManager: {warning} (Context: {context})" ); } private static bool RecordOperationError( string error ) { - LastOperationError = error; + if ( !IsBusy ) + LastOperationError = error; LogWarning( $"AGXUnity.LicenseManager: {error}" ); return false; } + // Combine diagnostics at operation boundaries, not in the logging helpers: + // callers may wrap an earlier failure, or recover it through a fallback. + private static string CombineOperationErrors( params string[] errors ) + { + var message = string.Join( "\n\n", errors.Where( error => !string.IsNullOrWhiteSpace( error ) ).Distinct() ); + return message.Length == 0 ? null : message; + } + private static string[] s_licenseExtensions = new string[] { null, ".lfx", ".lic" }; private static string s_runtimeActivationExtension = ".rtlfx"; From 0f6ffdf2dac4c1cb82f580bde1d8f58642bab3a2 Mon Sep 17 00:00:00 2001 From: Rasmus Lyxell Date: Wed, 16 Sep 2026 14:11:14 +0200 Subject: [PATCH 11/11] Formatting --- AGXUnity/LicenseInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGXUnity/LicenseInfo.cs b/AGXUnity/LicenseInfo.cs index 2cc1dd28..6adf00be 100644 --- a/AGXUnity/LicenseInfo.cs +++ b/AGXUnity/LicenseInfo.cs @@ -129,7 +129,7 @@ public static LicenseInfo Create() if ( info.IsFloating || agx.Runtime.instance().hasKey( "InstallationID" ) ) { info.Type = LicenseType.Service; - if(agx.Runtime.instance().hasKey( "InstallationID" )) + if ( agx.Runtime.instance().hasKey( "InstallationID" ) ) info.UniqueId = agx.Runtime.instance().readValue( "InstallationID" ); } else if ( agx.Runtime.instance().hasKey( "License" ) ) {