diff --git a/AGXUnity/LicenseInfo.cs b/AGXUnity/LicenseInfo.cs index 8b9bcbb0..6adf00be 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,9 +127,10 @@ 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" ); + if ( agx.Runtime.instance().hasKey( "InstallationID" ) ) + info.UniqueId = agx.Runtime.instance().readValue( "InstallationID" ); } else if ( agx.Runtime.instance().hasKey( "License" ) ) { info.Type = LicenseType.Legacy; @@ -158,7 +156,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 = info.IsValid ? LicenseType.Service : LicenseType.Unknown; + 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..efb40046 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,55 +126,180 @@ 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; + } + } /// - /// True if the activation task is running, otherwise false. + /// Diagnostics from the last accepted operation, captured before querying license + /// information changes native status. Successful loads may retain cleanup warnings. /// - public static bool IsBusy => IsActivating || IsRefreshing; + public static string LastOperationError { get; private set; } /// - /// 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. + /// 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. + /// + 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 + } + } + + /// + /// 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 ) + { + 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; - // 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(); + 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. + var currentLicense = UpdateLicenseInformation(); + if ( HasFloatingSession || ( currentLicense.IsValid && !( allowFloating && preferFloating ) ) ) + return currentLicense.IsValid; - Reset(); + if ( !currentLicense.IsValid ) + Reset(); + var errors = new List(); var licenseFiles = FindLicenseFiles(); 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, + floatingOnly: currentLicense.IsValid ) ) return true; + if ( !string.IsNullOrWhiteSpace( LastOperationError ) ) + errors.Add( $"{file}:\n{LastOperationError}" ); } - // 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; - } - - return false; + UpdateLicenseInformation(); + // With no floating candidate, retain the native fallback. A failed + // checkout may clear it; always use its actual validity after the search. + var success = currentLicense.IsValid && LicenseInfo.IsValid; + LastOperationError = success ? null : CombineOperationErrors( errors.ToArray() ); + return success; } public static LicenseInfo QueryInfo( string filename ) @@ -185,13 +315,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; } @@ -263,61 +393,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; } /// @@ -329,6 +443,8 @@ public static bool GenerateEncryptedRuntime( int runtimeLicenseId, /// True if successfully activated, otherwise false. public static bool ActivateEncryptedRuntime( string targetDirectory ) { + if ( !CanAccessRuntime || IsBusy ) + return false; var filename = FindRuntimeActivationFiles().FirstOrDefault(); if ( string.IsNullOrEmpty( filename ) ) { IssueLoadWarning( "Unable to activate runtime license, license file not found.", @@ -350,6 +466,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 || IsBusy ) + 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}\"" ); @@ -361,14 +479,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; } @@ -391,25 +516,36 @@ 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 { + 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; @@ -433,10 +569,19 @@ 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 { + LastOperationError = null; + RequireNoFloatingSession(); if ( Path.GetExtension( licenseFilename ) != GetLicenseExtension( LicenseInfo.LicenseType.Service ) ) licenseFilename += GetLicenseExtension( LicenseInfo.LicenseType.Service ); @@ -444,19 +589,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; @@ -486,32 +636,15 @@ 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(); + 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 ); } /// @@ -527,8 +660,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 +680,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 +699,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,15 +745,29 @@ public static bool Refresh( string filename ) /// True if the license were successfully deactivated and deleted. public static bool DeactivateAndDelete( string filename ) { - if ( !File.Exists( filename ) ) { - Debug.LogWarning( $"AGXUnity.LicenseManager: Unable to deactivate and delete license {filename} - file doesn't exist." ); + if ( !CanAccessRuntime || IsBusy ) return false; + + if ( !File.Exists( filename ) ) { + 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 + // into deactivation of a different, currently loaded license. + try { + var info = QueryInfo( filename ); + if ( info.IsFloating || !info.IsParsed || HasFloatingSession ) { + return RecordOperationError( "Cannot deactivate an unidentified or floating license, or replace a held floating seat. The file was preserved." ); + } + } + catch ( System.Exception e ) { + LogException( e ); + return RecordOperationError( $"Unable to inspect \"{filename}\" before deactivation. The file was preserved. {e.Message}" ); } if ( licenseType == LicenseInfo.LicenseType.Legacy ) { @@ -622,14 +778,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; } /// @@ -640,18 +795,27 @@ public static bool DeactivateLoaded() { if ( !CanAccessRuntime ) return false; + if ( IsBusy || HasFloatingSession || UpdateLicenseInformation().Type != LicenseInfo.LicenseType.Service ) { + 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 ) { + success = false; + RecordOperationError( $"Deactivation failed. {e.Message}" ); + LogException( e ); } - catch ( Exception e ) { - Debug.LogException( e ); + finally { + UpdateInformationAfterOperation(); } return success; } @@ -661,13 +825,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 +915,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 +958,17 @@ public static LicenseContentType FindLicenseContentType( string licenseContent ) 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 ) ) { + return RecordOperationError( "Return the current floating seat before deleting this file." ); + } + } File.Delete( filename ); #if UNITY_EDITOR if ( File.Exists( $"{filename}.meta" ) ) { @@ -802,8 +977,9 @@ public static bool DeleteFile( string filename ) } #endif } - catch ( Exception e ) { - Debug.LogException( e ); + catch ( System.Exception e ) { + LogException( e ); + return RecordOperationError( $"Unable to delete \"{filename}\" and its metadata. {e.Message}" ); } return !File.Exists( filename ); @@ -815,11 +991,16 @@ 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, bool floatingOnly = false ) { 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; + } if ( string.IsNullOrEmpty( filename ) ) { IssueLoadWarning( "Filename is null or empty.", context ); return false; @@ -833,14 +1014,25 @@ 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 ); + if ( floatingOnly ) + return false; } - catch ( Exception e ) { - IssueLoadWarning( $"Caught exception reading text from file: {filename}.", context ); - Debug.LogException( e ); + catch ( System.Exception e ) { + IssueLoadWarning( $"Unable to read license file \"{filename}\". {e.Message}", context ); + LogException( e ); return false; } 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. + 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'. @@ -849,25 +1041,33 @@ private static bool LoadFile( string filename, string context ) 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 ) { - Debug.LogError( $"AGXUnity.LicenseManager: Unable to write updated license information 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 ( Exception e ) { - Debug.LogException( e ); + catch ( System.Exception e ) { + saveError = $"Unable to save refreshed license information to \"{filename}\". {e.Message}"; + RecordOperationError( CombineOperationErrors( loadError, saveError ) ); + 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 ); + // 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; @@ -883,7 +1083,12 @@ 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; + } if ( string.IsNullOrEmpty( licenseContent ) ) { IssueLoadWarning( "License content is null or empty.", context ); return false; @@ -891,17 +1096,17 @@ 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; - - } + 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 ); + IssueLoadInfo( $"Loading service license successful: {success && Runtime.isValid()}.", context ); } // Runtime license activation. @@ -910,92 +1115,256 @@ 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 ); + } + + IssueLoadInfo( $"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 ); + IssueLoadInfo( $"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 ); + IssueLoadInfo( $"Loading obfuscated legacy license successful: {success && Runtime.isValid()}.", context ); } + if ( !success || !Runtime.isValid() ) + RecordOperationError( GetNativeOperationError( licenseContentType == LicenseContentType.EncryptedRuntimeService ? "Runtime activation" : "License loading" ) ); } - catch ( Exception e ) { - IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); - Debug.LogException( e ); - return false; + catch ( System.Exception e ) { + IssueLoadWarning( $"License loading failed. {e.Message}", context ); + LogException( e ); + 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 ); + IssueLoadInfo( $"Loading floating license successful: {success}.", context ); + } + catch ( System.Exception e ) { + IssueLoadWarning( $"Floating license checkout failed. {e.Message}", context ); + LogException( e ); + } + finally { + UpdateInformationAfterOperation(); } - catch ( Exception e ) { - IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); - Debug.LogException( e ); + return success; + } + + public static bool ReturnFloating( string context ) + { + if ( !CanAccessRuntime || IsBusy ) return false; + var success = false; + try { + success = CloseFloatingSession(); + LastOperationError = success ? null : GetNativeOperationError( LicenseOperation.Return ); + IssueLoadInfo( $"Returning floating license successful: {success}.", context ); } + catch ( System.Exception e ) { + IssueLoadWarning( $"Returning the floating license failed. {e.Message}", context ); + LogException( e ); + } + finally { + UpdateInformationAfterOperation(); + } + return success; + } - UpdateLicenseInformation(); + private static bool OpenFloatingSession( string filename ) + { + var normalized = Path.GetFullPath( filename ).Replace( '\\', '/' ); + var success = Runtime.openNetworkSession( normalized ); + if ( success ) + s_activeFloatingLicenseFilename = normalized; + return success; + } - return LicenseInfo.IsValid; + private static bool CloseFloatingSession() + { + var success = Runtime.closeNetworkSession(); + if ( success ) + s_activeFloatingLicenseFilename = null; + return success; } - public static bool ReturnFloating( string context ) + private static void RequireNoFloatingSession() { - if ( !CanAccessRuntime ) - return false; + 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 && ( operation == LicenseOperation.Activate || operation == LicenseOperation.Refresh ) ) + success = Runtime.isValid(); + 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}"; + LogException( e ); + } + finally { + UpdateInformationAfterOperation(); + } + if ( !success ) + LogWarning( $"AGXUnity.LicenseManager: {LastOperationError}" ); + onDone?.Invoke( success ); + } ); + s_operationTask.Start( TaskScheduler.Default ); + return; + } + } + LogWarning( "AGXUnity.LicenseManager: Another license operation is in progress." ); + onDone?.Invoke( false ); + } - bool success; + private static void UpdateInformationAfterOperation() + { try { - success = Runtime.closeNetworkSession(); - LoadInfo( $"Returning floating license successful: {success}.", context ); + UpdateLicenseInformation(); } - catch ( Exception e ) { - IssueLoadWarning( "Caught exception calling AGX Dynamics.", context ); - Debug.LogException( e ); - return false; + catch ( System.Exception e ) { + // Deliver the native result even if shutdown races with information + // refresh. ReturnFloating is also called by NativeHandler's finalizer. + LogException( e ); } + } - return success; + private static string GetNativeOperationError( LicenseOperation operation ) + { + return GetNativeOperationError( operation.ToString() ); } - private static void LoadInfo( string info, string context ) + private static string GetNativeOperationError( string 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 IssueLoadInfo( 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 ) { + // 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 ) - Debug.LogWarning( $"AGXUnity.LicenseManager: {warning} (Context: {context})" ); + LogWarning( $"AGXUnity.LicenseManager: {warning} (Context: {context})" ); + } + + private static bool RecordOperationError( string 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"; 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 new file mode 100644 index 00000000..78b626bb --- /dev/null +++ b/Editor/AGXUnityEditor/LicenseWarnings.cs @@ -0,0 +1,95 @@ +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 || !LicenseManager.AutomaticFloatingCheckoutEnabled ) + 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() : + "Verify your license ID, activation password, and connection before retrying." ); + } + + internal static void ScheduleStartupWarning() + { + if ( Application.isBatchMode || CurrentLicense.IsValid || !LicenseManager.AutomaticFloatingCheckoutEnabled || + SessionState.GetBool( s_startupWarningShownKey, false ) ) + return; + + EditorApplication.update -= ShowStartupWarning; + EditorApplication.update += ShowStartupWarning; + } + + private static void ShowStartupWarning() + { + if ( Application.isBatchMode || !LicenseManager.AutomaticFloatingCheckoutEnabled || + 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 16d37599..c4fd44d8 100644 --- a/Editor/AGXUnityEditor/Manager.cs +++ b/Editor/AGXUnityEditor/Manager.cs @@ -788,12 +788,19 @@ 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(); + // 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 ) { + LicenseWarnings.Capture( AGXUnity.LicenseInfo.Create() ); + LicenseWarnings.ScheduleStartupWarning(); + } } catch ( Exception ) { return EnvironmentState.Uninitialized; 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 f535571d..169fb738 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,25 @@ 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() { + using var feedback = AGXUnity.LicenseManager.SuppressConsoleLogging(); ValidateLicenseDirectory(); + if ( m_clearFocus ) { + UnityEngine.GUI.FocusControl( "" ); + m_clearFocus = false; + } using ( GUI.AlignBlock.Center ) GUILayout.Box( IconManager.GetAGXUnityLogo(), @@ -73,19 +89,50 @@ private void OnGUI() m_scroll = EditorGUILayout.BeginScrollView( m_scroll ); + 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 ) ) + StartUpdateLicenseInformation(); + } + GUILayout.Space( 6 ); + if ( IsUpdatingLicenseInformation ) ShowNotification( GUI.MakeLabel( "Reading..." ) ); - else if ( AGXUnity.LicenseManager.IsBusy ) - ShowNotification( GUI.MakeLabel( AGXUnity.LicenseManager.IsActivating ? - "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 ); + else if ( IsBusy ) + 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 && 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 ); } - using ( new GUI.EnabledBlock( !IsUpdatingLicenseInformation && !AGXUnity.LicenseManager.IsBusy ) ) { + 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, "Temporarily release the floating seat until you select Connect or restart the editor." ), 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 ); @@ -118,7 +165,7 @@ private void OnGUI() EditorGUILayout.EndScrollView(); - if ( AGXUnity.LicenseManager.IsBusy || IsUpdatingLicenseInformation ) + if ( IsBusy || IsUpdatingLicenseInformation ) Repaint(); } @@ -132,8 +179,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", ".", @@ -146,7 +193,26 @@ 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." : + 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() ); StartUpdateLicenseInformation(); GUIUtility.ExitGUI(); } @@ -154,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(); } } } @@ -164,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 ); @@ -179,49 +245,71 @@ 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; } ); - 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 && + !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() ), - 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_operationError = null; + m_operation = LicenseOperation.Activate; + var completion = new TaskCompletionSource(); + m_licenseOperationTask = completion.Task; + try { + AGXUnity.LicenseManager.ActivateAsync( licenseId, + m_licenseActivateData.Password, + LicenseDirectory, + success => { + var error = success ? null : AGXUnity.LicenseManager.LastOperationError; + var info = AGXUnity.LicenseInfo.Create(); + completion.TrySetResult( new LicenseOperationResult { Success = success, LicenseInfo = info, Error = error } ); + } ); + } + catch ( System.Exception e ) { + completion.TrySetResult( new LicenseOperationResult + { + LicenseInfo = AGXUnity.LicenseInfo.Create(), + Error = $"License activation failed. {e.Message}" + } ); + } + } + private void LicenseDataGUI( LicenseData data ) { var highlight = m_licenseData.Count > 1 && !IsUpdatingLicenseInformation && - !AGXUnity.LicenseManager.IsBusy && - data.LicenseInfo.UniqueId == AGXUnity.LicenseManager.LicenseInfo.UniqueId; + !IsBusy && + ( 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 @@ -233,51 +321,42 @@ 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 ) - 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 ( !string.IsNullOrEmpty( data.ReadError ) ) + EditorGUILayout.HelpBox( data.ReadError, MessageType.Error, true ); + + 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. Floating licenses connect automatically when the editor starts." ), + InspectorEditor.Skin.Button ) ) + ConnectLicense( data.Filename ); + } + } InspectorGUI.Separator( 1, 6 ); @@ -298,6 +377,100 @@ 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. + 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."; + 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 = 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 = AGXUnity.LicenseManager.LastOperationError ?? $"Unable to delete \"{filename}\". Check that the file is writable and is not in use."; + } + 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 server connection and wait for other license operations to finish before retrying.", + 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", @@ -312,60 +485,137 @@ private void ValidateLicenseDirectory() private void StartUpdateLicenseInformation() { - if ( IsUpdatingLicenseInformation ) + m_updateLicenseInformationRequested = true; + m_licenseReadError = null; + } + + 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 ) 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_operation == LicenseOperation.Activate ) { + if ( result.Success ) { + m_licenseActivateData = IdPassword.Empty(); + m_activationError = null; + } + else + 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 local file cleanup failed.\n\n" + AGXUnity.LicenseManager.LastOperationError; + } + m_deleteAfterReturn = null; + } + else if ( result.Success && result.LicenseInfo.IsValid ) + m_activationError = null; + else if ( !result.Success ) + m_operationError = result.Error; + if ( m_operation == LicenseOperation.Refresh && !string.IsNullOrEmpty( 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; + } + m_operation = LicenseOperation.None; + 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 scan = m_updateLicenseInfoTask.Result; m_updateLicenseInfoTask = null; + if ( scan.Data != null ) + m_licenseData = scan.Data; + m_licenseReadError = scan.Error; + RemoveNotification(); + Repaint(); } - if ( m_updateLicenseInfoTask == null ) - EditorApplication.update -= OnUpdateLicenseInformation; - } + if ( m_updateLicenseInfoTask != null ) + return; - 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 ); - - UpdateLicenseInfo( licenseData.Filename, AGXUnity.LicenseManager.LicenseInfo ); - if ( prevLicense.Filename != licenseData.Filename ) - AGXUnity.LicenseManager.LoadFile( prevLicense.Filename ); - - StartUpdateLicenseInformation(); - } ); + 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.LicenseManager.UpdateLicenseInformation(); + LicenseWarnings.Capture( info ); + Repaint(); + } + + if ( m_updateLicenseInformationRequested ) { + m_updateLicenseInformationRequested = false; + m_updateLicenseInfoTask = Task.Run( () => { + try { + var licenseData = new List(); + foreach ( var licenseFile in AGXUnity.LicenseManager.FindLicenseFiles() ) { + 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 new LicenseScanResult { Data = licenseData }; + } + catch ( System.Exception e ) { + return new LicenseScanResult { Error = $"Unable to read license information. {e.Message}" }; + } + } ); + } } - 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 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 => { + completion.TrySetResult( new LicenseOperationResult + { + Success = success, + Error = success ? null : AGXUnity.LicenseManager.LastOperationError, + LicenseInfo = AGXUnity.LicenseInfo.Create() + } ); + } ); + } + catch ( System.Exception e ) { + completion.TrySetResult( new LicenseOperationResult { LicenseInfo = AGXUnity.LicenseInfo.Create(), Error = e.Message } ); } } @@ -380,13 +630,40 @@ private struct LicenseData { public string Filename; public AGXUnity.LicenseInfo LicenseInfo; + public string ReadError; + } + + private struct LicenseOperationResult + { + public bool Success; + public AGXUnity.LicenseInfo LicenseInfo; + 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; + 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; + private string m_licenseReadError = null; [System.NonSerialized] private GUIStyle m_activeLicenseStyle = null;