using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Artemis.Core.DeviceProviders; using Artemis.Core.DryIoc; using Artemis.Storage.Entities.General; using Artemis.Storage.Entities.Plugins; using Artemis.Storage.Entities.Surface; using Artemis.Storage.Repositories.Interfaces; using DryIoc; using McMaster.NETCore.Plugins; using RGB.NET.Core; using Serilog; namespace Artemis.Core.Services; /// /// Provides access to plugin loading and unloading /// internal class PluginManagementService : IPluginManagementService { private readonly IDeviceRepository _deviceRepository; private readonly IContainer _container; private readonly ILogger _logger; private readonly IPluginRepository _pluginRepository; private readonly List _plugins; private readonly IQueuedActionRepository _queuedActionRepository; private FileSystemWatcher? _hotReloadWatcher; private bool _disposed; private bool _isElevated; public PluginManagementService(IContainer container, ILogger logger, IPluginRepository pluginRepository, IDeviceRepository deviceRepository, IQueuedActionRepository queuedActionRepository) { _container = container; _logger = logger; _pluginRepository = pluginRepository; _deviceRepository = deviceRepository; _queuedActionRepository = queuedActionRepository; _plugins = new List(); ProcessPluginDeletionQueue(); StartHotReload(); } private void CopyBuiltInPlugin(ZipArchive zipArchive, string targetDirectory) { ZipArchiveEntry metaDataFileEntry = zipArchive.Entries.First(e => e.Name == "plugin.json"); DirectoryInfo pluginDirectory = new(Path.Combine(Constants.PluginsFolder, targetDirectory)); bool createLockFile = File.Exists(Path.Combine(pluginDirectory.FullName, "artemis.lock")); // Remove the old directory if it exists if (Directory.Exists(pluginDirectory.FullName)) pluginDirectory.Delete(true); // Extract everything in the same archive directory to the unique plugin directory Utilities.CreateAccessibleDirectory(pluginDirectory.FullName); string metaDataDirectory = metaDataFileEntry.FullName.Replace(metaDataFileEntry.Name, ""); foreach (ZipArchiveEntry zipArchiveEntry in zipArchive.Entries) { if (zipArchiveEntry.FullName.StartsWith(metaDataDirectory) && !zipArchiveEntry.FullName.EndsWith("/")) { string target = Path.Combine(pluginDirectory.FullName, zipArchiveEntry.FullName.Remove(0, metaDataDirectory.Length)); // Create folders Utilities.CreateAccessibleDirectory(Path.GetDirectoryName(target)!); // Extract files zipArchiveEntry.ExtractToFile(target); } } if (createLockFile) File.Create(Path.Combine(pluginDirectory.FullName, "artemis.lock")).Close(); } public List AdditionalPluginDirectories { get; } = new(); public bool LoadingPlugins { get; private set; } #region Built in plugins public void CopyBuiltInPlugins() { OnCopyingBuildInPlugins(); DirectoryInfo pluginDirectory = new(Constants.PluginsFolder); if (Directory.Exists(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.Modules.Overlay-29e3ff97"))) Directory.Delete(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.Modules.Overlay-29e3ff97"), true); if (Directory.Exists(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.DataModelExpansions.TestData-ab41d601"))) Directory.Delete(Path.Combine(pluginDirectory.FullName, "Artemis.Plugins.DataModelExpansions.TestData-ab41d601"), true); // Iterate built-in plugins DirectoryInfo builtInPluginDirectory = new(Path.Combine(Constants.ApplicationFolder, "Plugins")); if (!builtInPluginDirectory.Exists) { _logger.Warning("No built-in plugins found at {pluginDir}, skipping CopyBuiltInPlugins", builtInPluginDirectory.FullName); return; } foreach (FileInfo zipFile in builtInPluginDirectory.EnumerateFiles("*.zip")) { try { ExtractBuiltInPlugin(zipFile, pluginDirectory); } catch (Exception e) { _logger.Error(e, "Failed to copy built-in plugin from {ZipFile}", zipFile.FullName); } } } private void ExtractBuiltInPlugin(FileInfo zipFile, DirectoryInfo pluginDirectory) { // Find the metadata file in the zip using ZipArchive archive = ZipFile.OpenRead(zipFile.FullName); ZipArchiveEntry? metaDataFileEntry = archive.Entries.FirstOrDefault(e => e.Name == "plugin.json"); if (metaDataFileEntry == null) throw new ArtemisPluginException("Couldn't find a plugin.json in " + zipFile.FullName); using StreamReader reader = new(metaDataFileEntry.Open()); PluginInfo builtInPluginInfo = CoreJson.Deserialize(reader.ReadToEnd())!; string preferred = builtInPluginInfo.PreferredPluginDirectory; // Find the matching plugin in the plugin folder DirectoryInfo? match = pluginDirectory.EnumerateDirectories().FirstOrDefault(d => d.Name == preferred); if (match == null) { CopyBuiltInPlugin(archive, preferred); } else { string metadataFile = Path.Combine(match.FullName, "plugin.json"); if (!File.Exists(metadataFile)) { _logger.Debug("Copying missing built-in plugin {builtInPluginInfo}", builtInPluginInfo); CopyBuiltInPlugin(archive, preferred); } else if (metaDataFileEntry.LastWriteTime > File.GetLastWriteTime(metadataFile)) { try { _logger.Debug("Copying updated built-in plugin {builtInPluginInfo}", builtInPluginInfo); CopyBuiltInPlugin(archive, preferred); } catch (Exception e) { throw new ArtemisPluginException($"Failed to install built-in plugin: {e.Message}", e); } } } } #endregion public List GetAllPlugins() { lock (_plugins) { return new List(_plugins); } } public List GetFeaturesOfType() where T : PluginFeature { lock (_plugins) { return _plugins.Where(p => p.IsEnabled) .SelectMany(p => p.Features.Where(f => f.Instance != null && f.Instance.IsEnabled && f.Instance is T)) .Select(f => f.Instance) .Cast() .ToList(); } } public Plugin? GetPluginByAssembly(Assembly? assembly) { if (assembly == null) return null; lock (_plugins) { return _plugins.FirstOrDefault(p => p.Assembly == assembly); } } // TODO: move to a more appropriate service public DeviceProvider GetDeviceProviderByDevice(IRGBDevice rgbDevice) { return GetFeaturesOfType().First(d => d.RgbDeviceProvider.Devices.Contains(rgbDevice)); } public Plugin? GetCallingPlugin() { return GetPluginFromStackTrace(new StackTrace()); } public Plugin? GetPluginFromException(Exception exception) { if (exception is ArtemisPluginException pluginException && pluginException.Plugin != null) return pluginException.Plugin; return GetPluginFromStackTrace(new StackTrace(exception)); } private Plugin? GetPluginFromStackTrace(StackTrace stackTrace) { StackFrame[] stackFrames = stackTrace.GetFrames(); // get method calls (frames) foreach (StackFrame stackFrame in stackFrames) { Assembly? assembly = stackFrame.GetMethod()?.DeclaringType?.Assembly; Plugin? plugin = GetPluginByAssembly(assembly); if (plugin != null) return plugin; } return null; } private Plugin? GetPluginByDirectory(DirectoryInfo directory) { lock (_plugins) { return _plugins.FirstOrDefault(p => p.Directory.FullName == directory.FullName); } } public void Dispose() { // Disposal happens manually before container disposal but the container doesn't know that so a 2nd call will be made if (_disposed) return; _disposed = true; UnloadPlugins(); } #region Plugins public void LoadPlugins(bool isElevated) { if (Constants.StartupArguments.Contains("--no-plugins")) { _logger.Warning("Artemis launched with --no-plugins, skipping the loading of plugins"); return; } bool ignorePluginLock = Constants.StartupArguments.Contains("--ignore-plugin-lock"); bool stayElevated = Constants.StartupArguments.Contains("--force-elevation"); bool droppedAdmin = Constants.StartupArguments.Contains("--dropped-admin"); if (LoadingPlugins) throw new ArtemisCoreException("Cannot load plugins while a previous load hasn't been completed yet."); _isElevated = isElevated; LoadingPlugins = true; // Unload all currently loaded plugins first UnloadPlugins(); // Load the plugin assemblies into the plugin context DirectoryInfo pluginDirectory = new(Constants.PluginsFolder); foreach (DirectoryInfo subDirectory in pluginDirectory.EnumerateDirectories()) { try { LoadPlugin(subDirectory); } catch (Exception e) { _logger.Warning(new ArtemisPluginException($"Failed to load plugin at {subDirectory}", e), "Plugin exception"); } } foreach (DirectoryInfo directory in AdditionalPluginDirectories) { try { LoadPlugin(directory); } catch (Exception e) { _logger.Warning(new ArtemisPluginException($"Failed to load plugin at {directory}", e), "Plugin exception"); } } // ReSharper disable InconsistentlySynchronizedField - It's read-only, idc _logger.Debug("Loaded {count} plugin(s)", _plugins.Count); bool adminRequired = _plugins.Any(p => p.Info.RequiresAdmin && p.Entity.IsEnabled && p.HasEnabledFeatures()); if (!isElevated && adminRequired) { _logger.Information("Restarting because one or more plugins requires elevation"); // No need for a delay this early on, nothing that needs graceful shutdown is happening yet Utilities.Restart(true, TimeSpan.Zero); return; } if (isElevated && !adminRequired && !stayElevated) { if (droppedAdmin) { _logger.Information("No plugin requires elevation but dropping admin failed before, ignoring"); } else { // No need for a delay this early on, nothing that needs graceful shutdown is happening yet _logger.Information("Restarting because no plugin requires elevation and --force-elevation was not supplied"); Utilities.Restart(false, TimeSpan.Zero, "--dropped-admin"); return; } } foreach (Plugin plugin in _plugins.Where(p => p.Info.IsCompatible && p.Entity.IsEnabled)) { try { EnablePlugin(plugin, false, ignorePluginLock); } catch (ArtemisPluginPrerequisiteException) { _logger.Warning("Skipped enabling plugin {plugin} because not all prerequisites are met", plugin); } } _logger.Debug("Enabled {count} plugin(s)", _plugins.Count(p => p.IsEnabled)); // ReSharper restore InconsistentlySynchronizedField LoadingPlugins = false; } public void UnloadPlugins() { // Unload all plugins // ReSharper disable InconsistentlySynchronizedField - UnloadPlugin will lock it when it has to while (_plugins.Count > 0) UnloadPlugin(_plugins[0]); // ReSharper restore InconsistentlySynchronizedField lock (_plugins) { _plugins.Clear(); } } public Plugin? LoadPlugin(DirectoryInfo directory) { _logger.Verbose("Loading plugin from {directory}", directory.FullName); // Load the metadata string metadataFile = Path.Combine(directory.FullName, "plugin.json"); if (!File.Exists(metadataFile)) _logger.Warning(new ArtemisPluginException("Couldn't find the plugins metadata file at " + metadataFile), "Plugin exception"); // PluginInfo contains the ID which we need to move on PluginInfo pluginInfo = CoreJson.Deserialize(File.ReadAllText(metadataFile))!; if (!pluginInfo.IsCompatible) return null; if (pluginInfo.Guid == Constants.CorePluginInfo.Guid) throw new ArtemisPluginException($"Plugin {pluginInfo} cannot use reserved GUID {pluginInfo.Guid}"); lock (_plugins) { // Ensure the plugin is not already loaded if (_plugins.Any(p => p.Guid == pluginInfo.Guid)) throw new ArtemisCoreException($"Cannot load plugin {pluginInfo} because it is using a GUID already used by another plugin"); } // Load the entity and fall back on creating a new one Plugin plugin = new(pluginInfo, directory, _pluginRepository.GetPluginByGuid(pluginInfo.Guid)); OnPluginLoading(new PluginEventArgs(plugin)); // Locate the main assembly entry string? mainFile = plugin.ResolveRelativePath(plugin.Info.Main); if (!File.Exists(mainFile)) throw new ArtemisPluginException(plugin, "Couldn't find the plugins main entry at " + mainFile); FileInfo[] fileInfos = directory.GetFiles(); if (!fileInfos.Any(f => string.Equals(f.Name, plugin.Info.Main, StringComparison.InvariantCulture))) throw new ArtemisPluginException(plugin, "Plugin main entry casing mismatch at " + plugin.Info.Main); // Load the plugin, all types implementing Plugin and register them with DI plugin.PluginLoader = PluginLoader.CreateFromAssemblyFile(mainFile, configure => { configure.IsUnloadable = true; configure.LoadInMemory = true; configure.PreferSharedTypes = true; // Resolving failed, try a loaded assembly but ignoring the version configure.DefaultContext.Resolving += (context, assemblyName) => context.Assemblies.FirstOrDefault(a => a.GetName().Name == assemblyName.Name); }); try { plugin.Assembly = plugin.PluginLoader.LoadDefaultAssembly(); } catch (Exception e) { throw new ArtemisPluginException(plugin, "Failed to load the plugins assembly", e); } // Get the Plugin feature from the main assembly and if there is only one, instantiate it List featureTypes; try { featureTypes = plugin.Assembly.GetTypes().Where(t => typeof(PluginFeature).IsAssignableFrom(t) && !t.IsAbstract).ToList(); } catch (ReflectionTypeLoadException e) { throw new ArtemisPluginException( plugin, "Failed to initialize the plugin assembly", // ReSharper disable once RedundantEnumerableCastCall - Casting from nullable to non-nullable here new AggregateException(e.LoaderExceptions.Where(le => le != null).Cast().ToArray()) ); } foreach (Type featureType in featureTypes) { // Load the enabled state and if not found, default to true PluginFeatureEntity featureEntity = plugin.Entity.Features.FirstOrDefault(i => i.Type == featureType.FullName) ?? new PluginFeatureEntity {Type = featureType.FullName!}; PluginFeatureInfo feature = new(plugin, featureType, featureEntity, (PluginFeatureAttribute?) Attribute.GetCustomAttribute(featureType, typeof(PluginFeatureAttribute))); // If the plugin only has a single feature, it should always be enabled if (featureTypes.Count == 1) feature.AlwaysEnabled = true; plugin.AddFeature(feature); } if (!featureTypes.Any()) _logger.Warning("Plugin {plugin} contains no features", plugin); // It is appropriate to call this now that we have the features of this plugin plugin.AutoEnableIfNew(); List bootstrappers = plugin.Assembly.GetTypes().Where(t => typeof(PluginBootstrapper).IsAssignableFrom(t)).ToList(); if (bootstrappers.Count > 1) _logger.Warning("{Plugin} has more than one bootstrapper, only initializing {FullName}", plugin, bootstrappers.First().FullName); if (bootstrappers.Any()) { plugin.Bootstrapper = (PluginBootstrapper?) Activator.CreateInstance(bootstrappers.First()); plugin.Bootstrapper?.InternalOnPluginLoaded(plugin); } lock (_plugins) { _plugins.Add(plugin); } OnPluginLoaded(new PluginEventArgs(plugin)); return plugin; } public void EnablePlugin(Plugin plugin, bool saveState, bool ignorePluginLock) { if (!plugin.Info.IsCompatible) throw new ArtemisPluginException(plugin, $"This plugin only supports the following operating system(s): {plugin.Info.Platforms}"); if (plugin.Assembly == null) throw new ArtemisPluginException(plugin, "Cannot enable a plugin that hasn't successfully been loaded"); if (plugin.Info.RequiresAdmin && plugin.HasEnabledFeatures() && !_isElevated) { if (!saveState) throw new ArtemisCoreException("Cannot enable a plugin that requires elevation without saving it's state."); plugin.Entity.IsEnabled = true; SavePlugin(plugin); _logger.Information("Restarting because a newly enabled plugin requires elevation"); Utilities.Restart(true, TimeSpan.FromMilliseconds(500)); return; } if (!plugin.Info.ArePrerequisitesMet()) throw new ArtemisPluginPrerequisiteException(plugin.Info, "Cannot enable a plugin whose prerequisites aren't all met"); // Create a child container for the plugin, be a bit more forgiving about concrete types plugin.Container = _container.CreateChild(newRules: _container.Rules.WithConcreteTypeDynamicRegistrations()); try { plugin.Container.RegisterPlugin(plugin); } catch (Exception e) { _logger.Error(e, "Failed to register plugin services for plugin {plugin}, skipping enabling", plugin); return; } OnPluginEnabling(new PluginEventArgs(plugin)); plugin.SetEnabled(true); // Create instances of each feature // Construction should be simple and not contain any logic so failure at this point means the entire plugin fails foreach (PluginFeatureInfo featureInfo in plugin.Features) { try { plugin.Container.Register(featureInfo.FeatureType, reuse: Reuse.Singleton); PluginFeature instance = (PluginFeature) plugin.Container.Resolve(featureInfo.FeatureType); // Get the PluginFeature attribute which contains extra info on the feature featureInfo.Instance = instance; instance.Info = featureInfo; instance.Plugin = plugin; instance.Profiler = plugin.GetProfiler("Feature - " + featureInfo.Name); instance.Entity = featureInfo.Entity; } catch (Exception e) { _logger.Warning(new ArtemisPluginException(plugin, "Failed to instantiate feature", e), "Failed to instantiate feature from plugin {plugin}", plugin); featureInfo.LoadException = e; } } // Activate features after they are all loaded foreach (PluginFeatureInfo pluginFeature in plugin.Features.Where(f => f.Instance != null && (f.EnabledInStorage || f.AlwaysEnabled))) { try { EnablePluginFeature(pluginFeature.Instance!, false, !ignorePluginLock); } catch (Exception) { if (pluginFeature.AlwaysEnabled) DisablePlugin(plugin, false); throw; } } if (saveState) { plugin.Entity.IsEnabled = plugin.IsEnabled; SavePlugin(plugin); } OnPluginEnabled(new PluginEventArgs(plugin)); } public void UnloadPlugin(Plugin plugin) { try { DisablePlugin(plugin, false); } catch (Exception e) { _logger.Warning(new ArtemisPluginException(plugin, "Exception during DisablePlugin call for UnloadPlugin", e), "Failed to unload plugin"); } finally { OnPluginDisabled(new PluginEventArgs(plugin)); } plugin.Dispose(); lock (_plugins) { _plugins.Remove(plugin); OnPluginUnloaded(new PluginEventArgs(plugin)); } } public void DisablePlugin(Plugin plugin, bool saveState) { if (!plugin.IsEnabled) return; foreach (PluginFeatureInfo pluginFeatureInfo in plugin.Features) { if (pluginFeatureInfo.Instance != null && pluginFeatureInfo.Instance.IsEnabled) DisablePluginFeature(pluginFeatureInfo.Instance, false); } plugin.SetEnabled(false); plugin.Container?.Dispose(); plugin.Container = null; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); if (saveState) { plugin.Entity.IsEnabled = plugin.IsEnabled; SavePlugin(plugin); } OnPluginDisabled(new PluginEventArgs(plugin)); } public Plugin? ImportPlugin(string fileName) { DirectoryInfo pluginDirectory = new(Constants.PluginsFolder); // Find the metadata file in the zip using ZipArchive archive = ZipFile.OpenRead(fileName); ZipArchiveEntry? metaDataFileEntry = archive.Entries.FirstOrDefault(e => e.Name == "plugin.json"); if (metaDataFileEntry == null) throw new ArtemisPluginException("Couldn't find a plugin.json in " + fileName); using StreamReader reader = new(metaDataFileEntry.Open()); PluginInfo pluginInfo = CoreJson.Deserialize(reader.ReadToEnd())!; if (!pluginInfo.Main.EndsWith(".dll")) throw new ArtemisPluginException("Main entry in plugin.json must point to a .dll file"); Plugin? existing = _plugins.FirstOrDefault(p => p.Guid == pluginInfo.Guid); if (existing != null) try { RemovePlugin(existing, false); } catch (Exception e) { throw new ArtemisPluginException("A plugin with the same GUID is already loaded, failed to remove old version", e); } string targetDirectory = pluginInfo.PreferredPluginDirectory; if (Directory.Exists(Path.Combine(pluginDirectory.FullName, targetDirectory))) Directory.Delete(Path.Combine(pluginDirectory.FullName, targetDirectory), true); // Extract everything in the same archive directory to the unique plugin directory DirectoryInfo directoryInfo = new(Path.Combine(pluginDirectory.FullName, targetDirectory)); Utilities.CreateAccessibleDirectory(directoryInfo.FullName); string metaDataDirectory = metaDataFileEntry.FullName.Replace(metaDataFileEntry.Name, ""); foreach (ZipArchiveEntry zipArchiveEntry in archive.Entries) { if (zipArchiveEntry.FullName.StartsWith(metaDataDirectory) && !zipArchiveEntry.FullName.EndsWith("/")) { string target = Path.Combine(directoryInfo.FullName, zipArchiveEntry.FullName.Remove(0, metaDataDirectory.Length)); // Create folders Utilities.CreateAccessibleDirectory(Path.GetDirectoryName(target)!); // Extract files zipArchiveEntry.ExtractToFile(target); } } // Load the newly extracted plugin and return the result return LoadPlugin(directoryInfo); } public void RemovePlugin(Plugin plugin, bool removeSettings) { DirectoryInfo directory = plugin.Directory; lock (_plugins) { if (_plugins.Contains(plugin)) UnloadPlugin(plugin); } directory.Delete(true); if (removeSettings) RemovePluginSettings(plugin); } public void RemovePluginSettings(Plugin plugin) { if (plugin.IsEnabled) throw new ArtemisCoreException("Cannot remove the settings of an enabled plugin"); _pluginRepository.RemoveSettings(plugin.Guid); foreach (DeviceEntity deviceEntity in _deviceRepository.GetAll().Where(e => e.DeviceProvider == plugin.Guid.ToString())) _deviceRepository.Remove(deviceEntity); plugin.Settings?.ClearSettings(); } #endregion #region Features public void EnablePluginFeature(PluginFeature pluginFeature, bool saveState, bool isAutoEnable) { _logger.Verbose("Enabling plugin feature {feature} - {plugin}", pluginFeature, pluginFeature.Plugin); OnPluginFeatureEnabling(new PluginFeatureEventArgs(pluginFeature)); if (pluginFeature.Plugin.Info.RequiresAdmin && !_isElevated) { if (!saveState) { OnPluginFeatureEnableFailed(new PluginFeatureEventArgs(pluginFeature)); if (isAutoEnable) _logger.Warning("Skipped auto-enabling plugin feature {feature} - {plugin} because it requires elevation but state isn't being saved", pluginFeature, pluginFeature.Plugin); else throw new ArtemisCoreException("Cannot enable a feature that requires elevation without saving it's state."); } pluginFeature.Entity.IsEnabled = true; pluginFeature.Plugin.Entity.IsEnabled = true; SavePlugin(pluginFeature.Plugin); _logger.Information("Restarting because a newly enabled feature requires elevation"); Utilities.Restart(true, TimeSpan.FromMilliseconds(500)); return; } if (!pluginFeature.Info.ArePrerequisitesMet()) { OnPluginFeatureEnableFailed(new PluginFeatureEventArgs(pluginFeature)); throw new ArtemisPluginPrerequisiteException(pluginFeature.Info, "Cannot enable a plugin feature whose prerequisites aren't all met"); } try { pluginFeature.SetEnabled(true, isAutoEnable); if (saveState) pluginFeature.Entity.IsEnabled = true; } catch (Exception e) { if (isAutoEnable) { // Schedule a retry based on the amount of attempts if (pluginFeature.AutoEnableAttempts < 4 && pluginFeature.Plugin.IsEnabled) { TimeSpan retryDelay = TimeSpan.FromSeconds(pluginFeature.AutoEnableAttempts * 10); _logger.Warning( e, "Plugin feature '{feature} - {plugin}' failed to enable during attempt ({attempt}/3), scheduling a retry in {retryDelay}.", pluginFeature, pluginFeature.Plugin, pluginFeature.AutoEnableAttempts, retryDelay ); Task.Run(async () => { await Task.Delay(retryDelay); if (!pluginFeature.IsEnabled && !_disposed) EnablePluginFeature(pluginFeature, saveState, true); }); } else { _logger.Warning(e, "Plugin feature '{feature} - {plugin}' failed to enable after 3 attempts, giving up.", pluginFeature, pluginFeature.Plugin); } } else { _logger.Warning(e, "Plugin feature '{feature} - {plugin}' failed to enable.", pluginFeature, pluginFeature.Plugin); throw; } } finally { // On an auto-enable, ensure PluginInfo.Enabled is true even if enable failed, that way a failure on auto-enable does // not affect the user's settings if (saveState) { if (isAutoEnable) pluginFeature.Entity.IsEnabled = true; SavePlugin(pluginFeature.Plugin); } if (pluginFeature.IsEnabled) { _logger.Verbose("Successfully enabled plugin feature {feature} - {plugin}", pluginFeature, pluginFeature.Plugin); OnPluginFeatureEnabled(new PluginFeatureEventArgs(pluginFeature)); } else { OnPluginFeatureEnableFailed(new PluginFeatureEventArgs(pluginFeature)); } } } public void DisablePluginFeature(PluginFeature pluginFeature, bool saveState) { try { _logger.Verbose("Disabling plugin feature {feature} - {plugin}", pluginFeature, pluginFeature.Plugin); pluginFeature.SetEnabled(false); } finally { if (saveState) { pluginFeature.Entity.IsEnabled = false; SavePlugin(pluginFeature.Plugin); } if (!pluginFeature.IsEnabled) { _logger.Verbose("Successfully disabled plugin feature {feature} - {plugin}", pluginFeature, pluginFeature.Plugin); OnPluginFeatureDisabled(new PluginFeatureEventArgs(pluginFeature)); } } } #endregion #region Queued actions public void QueuePluginDeletion(Plugin plugin) { _queuedActionRepository.Add(new QueuedActionEntity { Type = "DeletePlugin", CreatedAt = DateTimeOffset.Now, Parameters = new Dictionary { {"pluginGuid", plugin.Guid.ToString()}, {"plugin", plugin.ToString()}, {"directory", plugin.Directory.FullName} } }); } public void DequeuePluginDeletion(Plugin plugin) { QueuedActionEntity? queuedActionEntity = _queuedActionRepository.GetByType("DeletePlugin").FirstOrDefault(q => q.Parameters["pluginGuid"].Equals(plugin.Guid.ToString())); if (queuedActionEntity != null) _queuedActionRepository.Remove(queuedActionEntity); } private void ProcessPluginDeletionQueue() { foreach (QueuedActionEntity queuedActionEntity in _queuedActionRepository.GetByType("DeletePlugin")) { string? directory = queuedActionEntity.Parameters["directory"].ToString(); try { if (Directory.Exists(directory)) { _logger.Information("Queued plugin deletion - deleting folder - {plugin}", queuedActionEntity.Parameters["plugin"]); Directory.Delete(directory!, true); } else { _logger.Information("Queued plugin deletion - folder already deleted - {plugin}", queuedActionEntity.Parameters["plugin"]); } _queuedActionRepository.Remove(queuedActionEntity); } catch (Exception e) { _logger.Warning(e, "Queued plugin deletion failed - {plugin}", queuedActionEntity.Parameters["plugin"]); } } } #endregion #region Storage private void SavePlugin(Plugin plugin) { foreach (PluginFeatureInfo featureInfo in plugin.Features.Where(i => i.Instance != null)) { if (plugin.Entity.Features.All(i => i.Type != featureInfo.FeatureType.FullName)) plugin.Entity.Features.Add(featureInfo.Instance!.Entity); } _pluginRepository.SavePlugin(plugin.Entity); } #endregion #region Events public event EventHandler? CopyingBuildInPlugins; public event EventHandler? PluginLoading; public event EventHandler? PluginLoaded; public event EventHandler? PluginUnloaded; public event EventHandler? PluginEnabling; public event EventHandler? PluginEnabled; public event EventHandler? PluginDisabled; public event EventHandler? PluginFeatureEnabling; public event EventHandler? PluginFeatureEnabled; public event EventHandler? PluginFeatureDisabled; public event EventHandler? PluginFeatureEnableFailed; protected virtual void OnCopyingBuildInPlugins() { CopyingBuildInPlugins?.Invoke(this, EventArgs.Empty); } protected virtual void OnPluginLoading(PluginEventArgs e) { PluginLoading?.Invoke(this, e); } protected virtual void OnPluginLoaded(PluginEventArgs e) { PluginLoaded?.Invoke(this, e); } protected virtual void OnPluginUnloaded(PluginEventArgs e) { PluginUnloaded?.Invoke(this, e); } protected virtual void OnPluginEnabling(PluginEventArgs e) { PluginEnabling?.Invoke(this, e); } protected virtual void OnPluginEnabled(PluginEventArgs e) { PluginEnabled?.Invoke(this, e); } protected virtual void OnPluginDisabled(PluginEventArgs e) { PluginDisabled?.Invoke(this, e); } protected virtual void OnPluginFeatureEnabling(PluginFeatureEventArgs e) { PluginFeatureEnabling?.Invoke(this, e); } protected virtual void OnPluginFeatureEnabled(PluginFeatureEventArgs e) { PluginFeatureEnabled?.Invoke(this, e); } protected virtual void OnPluginFeatureDisabled(PluginFeatureEventArgs e) { PluginFeatureDisabled?.Invoke(this, e); } protected virtual void OnPluginFeatureEnableFailed(PluginFeatureEventArgs e) { PluginFeatureEnableFailed?.Invoke(this, e); } #endregion #region Hot Reload private void StartHotReload() { // Watch for changes in the plugin directory, "plugin.json". // If this file is changed, reload the plugin. _hotReloadWatcher = new FileSystemWatcher(Constants.PluginsFolder, "plugin.json"); _hotReloadWatcher.NotifyFilter = NotifyFilters.CreationTime | NotifyFilters.FileName; _hotReloadWatcher.Created += FileSystemWatcherOnCreated; _hotReloadWatcher.Error += FileSystemWatcherOnError; _hotReloadWatcher.IncludeSubdirectories = true; _hotReloadWatcher.EnableRaisingEvents = true; } private void FileSystemWatcherOnError(object sender, ErrorEventArgs e) { _logger.Error(e.GetException(), "File system watcher error"); } private void FileSystemWatcherOnCreated(object sender, FileSystemEventArgs e) { string? pluginPath = Path.GetDirectoryName(e.FullPath); if (pluginPath == null) { _logger.Warning("Plugin change detected, but could not get plugin directory. {fullPath}", e.FullPath); return; } DirectoryInfo pluginDirectory = new(pluginPath); Plugin? plugin = GetPluginByDirectory(pluginDirectory); if (plugin == null) { _logger.Warning("Plugin change detected, but could not find plugin. {fullPath}", e.FullPath); return; } if (!plugin.Info.HotReloadSupported) { _logger.Information("Plugin change detected, but hot reload not supported. {pluginName}", plugin.Info.Name); return; } _logger.Information("Plugin change detected, reloading. {pluginName}", plugin.Info.Name); bool wasEnabled = plugin.IsEnabled; UnloadPlugin(plugin); Thread.Sleep(500); Plugin? loadedPlugin = LoadPlugin(pluginDirectory); if (loadedPlugin == null) return; if (wasEnabled) EnablePlugin(loadedPlugin, true, false); _logger.Information("Plugin reloaded. {fullPath}", e.FullPath); } #endregion } /// /// Represents a type of plugin management action /// public enum PluginManagementAction { /// /// A plugin management action that removes a plugin /// Delete // /// // /// A plugin management action that updates a plugin // /// // Update }