using System; using System.IO; using System.Threading.Tasks; using Artemis.Storage.Entities.Plugins; namespace Artemis.Core { /// /// Represents an feature of a certain type provided by a plugin /// public abstract class PluginFeature : CorePropertyChanged, IDisposable { private bool _isEnabled; /// /// Gets the plugin feature info related to this feature /// public PluginFeatureInfo Info { get; internal set; } = null!; // Will be set right after construction /// /// Gets the plugin that provides this feature /// public Plugin Plugin { get; internal set; } = null!; // Will be set right after construction /// /// Gets the profiler that can be used to take profiling measurements /// public Profiler Profiler { get; internal set; } = null!; // Will be set right after construction /// /// Gets whether the plugin is enabled /// public bool IsEnabled { get => _isEnabled; internal set => SetAndNotify(ref _isEnabled, value); } internal int AutoEnableAttempts { get; set; } /// /// Gets the identifier of this plugin feature /// public string Id => $"{GetType().FullName}-{Plugin.Guid.ToString().Substring(0, 8)}"; // Not as unique as a GUID but good enough and stays readable internal PluginFeatureEntity Entity { get; set; } = null!; // Will be set right after construction /// /// Called when the feature is activated /// public abstract void Enable(); /// /// Called when the feature is deactivated or when Artemis shuts down /// public abstract void Disable(); /// /// Occurs when the feature is enabled /// public event EventHandler? Enabled; /// /// Occurs when the feature is disabled /// public event EventHandler? Disabled; /// /// Releases the unmanaged resources used by the plugin feature and optionally releases the managed resources. /// /// /// to release both managed and unmanaged resources; /// to release only unmanaged resources. /// protected virtual void Dispose(bool disposing) { if (disposing) InternalDisable(); } /// /// Triggers the Enabled event /// protected virtual void OnEnabled() { Enabled?.Invoke(this, EventArgs.Empty); } /// /// Triggers the Disabled event /// protected virtual void OnDisabled() { Disabled?.Invoke(this, EventArgs.Empty); } internal void StartUpdateMeasure() { Profiler.StartMeasurement("Update"); } internal void StopUpdateMeasure() { Profiler.StopMeasurement("Update"); } internal void StartRenderMeasure() { Profiler.StartMeasurement("Render"); } internal void StopRenderMeasure() { Profiler.StopMeasurement("Render"); } internal void SetEnabled(bool enable, bool isAutoEnable = false) { if (enable == IsEnabled) return; if (Plugin == null) throw new ArtemisCoreException("Cannot enable a plugin feature that is not associated with a plugin"); lock (Plugin) { if (!Plugin.IsEnabled) throw new ArtemisCoreException("Cannot enable a plugin feature of a disabled plugin"); if (!enable) { // Even if disable failed, still leave it in a disabled state to avoid more issues InternalDisable(); IsEnabled = false; OnDisabled(); return; } try { if (isAutoEnable) AutoEnableAttempts++; if (isAutoEnable && GetLockFileCreated()) { // Don't wrap existing lock exceptions, simply rethrow them if (Info.LoadException is ArtemisPluginLockException) throw Info.LoadException; throw new ArtemisPluginLockException(Info.LoadException); } CreateLockFile(); IsEnabled = true; // Allow up to 15 seconds for plugins to activate. // This means plugins that need more time should do their long running tasks in a background thread, which is intentional // This would've been a perfect match for Thread.Abort but that didn't make it into .NET Core Task enableTask = Task.Run(InternalEnable); if (!enableTask.Wait(TimeSpan.FromSeconds(15))) throw new ArtemisPluginException(Plugin, "Plugin load timeout"); Info.LoadException = null; AutoEnableAttempts = 0; OnEnabled(); } // If enable failed, put it back in a disabled state catch (Exception e) { IsEnabled = false; Info.LoadException = e; throw; } finally { // Clean up the lock file unless the failure was due to the lock file // After all, we failed but not miserably :) if (Info.LoadException is not ArtemisPluginLockException) DeleteLockFile(); } } } internal virtual void InternalEnable() { Enable(); } internal virtual void InternalDisable() { if (IsEnabled) Disable(); } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } #region Loading internal void CreateLockFile() { if (Plugin == null) throw new ArtemisCoreException("Cannot lock a plugin feature that is not associated with a plugin"); File.Create(Plugin.ResolveRelativePath($"{GetType().FullName}.lock")).Close(); } internal void DeleteLockFile() { if (Plugin == null) throw new ArtemisCoreException("Cannot lock a plugin feature that is not associated with a plugin"); if (GetLockFileCreated()) File.Delete(Plugin.ResolveRelativePath($"{GetType().FullName}.lock")); } internal bool GetLockFileCreated() { if (Plugin == null) throw new ArtemisCoreException("Cannot lock a plugin feature that is not associated with a plugin"); return File.Exists(Plugin.ResolveRelativePath($"{GetType().FullName}.lock")); } #endregion #region Timed updates /// /// Registers a timed update that whenever the plugin is enabled calls the provided at the /// provided /// /// /// The interval at which the update should occur /// /// The action to call every time the interval has passed. The delta time parameter represents the /// time passed since the last update in seconds /// /// An optional name used in exceptions and profiling /// The resulting plugin update registration which can be used to stop the update public TimedUpdateRegistration AddTimedUpdate(TimeSpan interval, Action action, string? name = null) { if (action == null) throw new ArgumentNullException(nameof(action)); return new TimedUpdateRegistration(this, interval, action, name); } /// /// Registers a timed update that whenever the plugin is enabled calls the provided at the /// provided /// /// /// The interval at which the update should occur /// /// The async action to call every time the interval has passed. The delta time parameter /// represents the time passed since the last update in seconds /// /// An optional name used in exceptions and profiling /// The resulting plugin update registration public TimedUpdateRegistration AddTimedUpdate(TimeSpan interval, Func asyncAction, string? name = null) { if (asyncAction == null) throw new ArgumentNullException(nameof(asyncAction)); return new TimedUpdateRegistration(this, interval, asyncAction, name); } #endregion } }