From 8b7f9b58bf1bef946792e384d3559b3f196c09a7 Mon Sep 17 00:00:00 2001 From: kalilistic <35899782+kalilistic@users.noreply.github.com> Date: Thu, 12 May 2022 04:34:45 -0400 Subject: [PATCH] refactor: fix plugin internal style errors (#830) --- .editorconfig | 2 + Dalamud.sln.DotSettings | 4 + .../PluginInstaller/PluginChangelogEntry.cs | 1 + Dalamud/Plugin/Internal/LocalDevPlugin.cs | 164 -- Dalamud/Plugin/Internal/LocalPlugin.cs | 481 ---- Dalamud/Plugin/Internal/PluginManager.cs | 1985 ++++++++--------- Dalamud/Plugin/Internal/PluginRepository.cs | 120 - .../Internal/Types/AvailablePluginUpdate.cs | 59 +- .../Plugin/Internal/Types/LocalDevPlugin.cs | 162 ++ Dalamud/Plugin/Internal/Types/LocalPlugin.cs | 501 +++++ .../Internal/Types/LocalPluginManifest.cs | 144 +- .../Plugin/Internal/Types/PluginManifest.cs | 274 ++- .../Plugin/Internal/Types/PluginRepository.cs | 118 + .../Internal/Types/PluginRepositoryState.cs | 41 +- Dalamud/Plugin/Internal/Types/PluginState.cs | 49 +- .../Internal/Types/PluginUpdateStatus.cs | 41 +- .../Internal/Types/RemotePluginManifest.cs | 25 +- 17 files changed, 2076 insertions(+), 2095 deletions(-) delete mode 100644 Dalamud/Plugin/Internal/LocalDevPlugin.cs delete mode 100644 Dalamud/Plugin/Internal/LocalPlugin.cs delete mode 100644 Dalamud/Plugin/Internal/PluginRepository.cs create mode 100644 Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs create mode 100644 Dalamud/Plugin/Internal/Types/LocalPlugin.cs create mode 100644 Dalamud/Plugin/Internal/Types/PluginRepository.cs diff --git a/.editorconfig b/.editorconfig index 109c9f406..1e4724862 100644 --- a/.editorconfig +++ b/.editorconfig @@ -123,10 +123,12 @@ resharper_foreach_can_be_partly_converted_to_query_using_another_get_enumerator_ resharper_invert_if_highlighting = none resharper_loop_can_be_converted_to_query_highlighting = none resharper_method_has_async_overload_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = none resharper_redundant_base_qualifier_highlighting = none resharper_suggest_var_or_type_built_in_types_highlighting = hint resharper_suggest_var_or_type_elsewhere_highlighting = hint resharper_suggest_var_or_type_simple_types_highlighting = hint +resharper_unused_auto_property_accessor_global_highlighting = none csharp_style_deconstructed_variable_declaration=true:silent [*.{appxmanifest,asax,ascx,aspx,axaml,axml,build,c,c++,cc,cginc,compute,config,cp,cpp,cs,cshtml,csproj,css,cu,cuh,cxx,dbml,discomap,dtd,h,hh,hlsl,hlsli,hlslinc,hpp,htm,html,hxx,inc,inl,ino,ipp,js,json,jsproj,jsx,lsproj,master,mpp,mq4,mq5,mqh,njsproj,nuspec,paml,proj,props,proto,razor,resjson,resw,resx,skin,StyleCop,targets,tasks,tpp,ts,tsx,usf,ush,vb,vbproj,xaml,xamlx,xml,xoml,xsd}] diff --git a/Dalamud.sln.DotSettings b/Dalamud.sln.DotSettings index 690f1a7ac..188e70c2b 100644 --- a/Dalamud.sln.DotSettings +++ b/Dalamud.sln.DotSettings @@ -50,11 +50,15 @@ True True True + True True True + True True True True + True + True True True True diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs index ce2353ec8..3e955e389 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginChangelogEntry.cs @@ -1,6 +1,7 @@ using System; using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; namespace Dalamud.Interface.Internal.Windows.PluginInstaller diff --git a/Dalamud/Plugin/Internal/LocalDevPlugin.cs b/Dalamud/Plugin/Internal/LocalDevPlugin.cs deleted file mode 100644 index 27989531c..000000000 --- a/Dalamud/Plugin/Internal/LocalDevPlugin.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -using Dalamud.Configuration.Internal; -using Dalamud.Interface.Internal.Notifications; -using Dalamud.Logging.Internal; -using Dalamud.Plugin.Internal.Types; - -namespace Dalamud.Plugin.Internal -{ - /// - /// This class represents a dev plugin and all facets of its lifecycle. - /// The DLL on disk, dependencies, loaded assembly, etc. - /// - internal class LocalDevPlugin : LocalPlugin, IDisposable - { - private static readonly ModuleLog Log = new("PLUGIN"); - - // Ref to Dalamud.Configuration.DevPluginSettings - private readonly DevPluginSettings devSettings; - - private FileSystemWatcher? fileWatcher; - private CancellationTokenSource fileWatcherTokenSource = new(); - private int reloadCounter; - - /// - /// Initializes a new instance of the class. - /// - /// Path to the DLL file. - /// The plugin manifest. - public LocalDevPlugin(FileInfo dllFile, LocalPluginManifest? manifest) - : base(dllFile, manifest) - { - var configuration = Service.Get(); - - if (!configuration.DevPluginSettings.TryGetValue(dllFile.FullName, out this.devSettings)) - { - configuration.DevPluginSettings[dllFile.FullName] = this.devSettings = new DevPluginSettings(); - configuration.Save(); - } - - if (this.AutomaticReload) - { - this.EnableReloading(); - } - } - - /// - /// Gets or sets a value indicating whether this dev plugin should start on boot. - /// - public bool StartOnBoot - { - get => this.devSettings.StartOnBoot; - set => this.devSettings.StartOnBoot = value; - } - - /// - /// Gets or sets a value indicating whether this dev plugin should reload on change. - /// - public bool AutomaticReload - { - get => this.devSettings.AutomaticReloading; - set - { - this.devSettings.AutomaticReloading = value; - - if (this.devSettings.AutomaticReloading) - { - this.EnableReloading(); - } - else - { - this.DisableReloading(); - } - } - } - - /// - public new void Dispose() - { - if (this.fileWatcher != null) - { - this.fileWatcher.Changed -= this.OnFileChanged; - this.fileWatcherTokenSource.Cancel(); - this.fileWatcher.Dispose(); - } - - base.Dispose(); - } - - /// - /// Configure this plugin for automatic reloading and enable it. - /// - public void EnableReloading() - { - if (this.fileWatcher == null) - { - this.fileWatcherTokenSource = new(); - this.fileWatcher = new FileSystemWatcher(this.DllFile.DirectoryName); - this.fileWatcher.Changed += this.OnFileChanged; - this.fileWatcher.Filter = this.DllFile.Name; - this.fileWatcher.NotifyFilter = NotifyFilters.LastWrite; - this.fileWatcher.EnableRaisingEvents = true; - } - } - - /// - /// Disable automatic reloading for this plugin. - /// - public void DisableReloading() - { - if (this.fileWatcher != null) - { - this.fileWatcherTokenSource.Cancel(); - this.fileWatcher.Changed -= this.OnFileChanged; - this.fileWatcher.Dispose(); - this.fileWatcher = null; - } - } - - private void OnFileChanged(object sender, FileSystemEventArgs args) - { - var current = Interlocked.Increment(ref this.reloadCounter); - - Task.Delay(500).ContinueWith( - _ => - { - if (this.fileWatcherTokenSource.IsCancellationRequested) - { - Log.Debug($"Skipping reload of {this.Name}, file watcher was cancelled."); - return; - } - - if (current != this.reloadCounter) - { - Log.Debug($"Skipping reload of {this.Name}, file has changed again."); - return; - } - - if (this.State != PluginState.Loaded) - { - Log.Debug($"Skipping reload of {this.Name}, state ({this.State}) is not {PluginState.Loaded}."); - return; - } - - var notificationManager = Service.Get(); - - try - { - this.Reload(); - notificationManager.AddNotification($"The DevPlugin '{this.Name} was reloaded successfully.", "Plugin reloaded!", NotificationType.Success); - } - catch (Exception ex) - { - Log.Error(ex, "DevPlugin reload failed."); - notificationManager.AddNotification($"The DevPlugin '{this.Name} could not be reloaded.", "Plugin reload failed!", NotificationType.Error); - } - }, - this.fileWatcherTokenSource.Token); - } - } -} diff --git a/Dalamud/Plugin/Internal/LocalPlugin.cs b/Dalamud/Plugin/Internal/LocalPlugin.cs deleted file mode 100644 index ba6297103..000000000 --- a/Dalamud/Plugin/Internal/LocalPlugin.cs +++ /dev/null @@ -1,481 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Reflection; - -using Dalamud.Configuration.Internal; -using Dalamud.Game; -using Dalamud.Game.Gui.Dtr; -using Dalamud.IoC.Internal; -using Dalamud.Logging.Internal; -using Dalamud.Plugin.Internal.Exceptions; -using Dalamud.Plugin.Internal.Loader; -using Dalamud.Plugin.Internal.Types; -using Dalamud.Utility; -using Dalamud.Utility.Signatures; - -namespace Dalamud.Plugin.Internal -{ - /// - /// This class represents a plugin and all facets of its lifecycle. - /// The DLL on disk, dependencies, loaded assembly, etc. - /// - internal class LocalPlugin : IDisposable - { - private static readonly ModuleLog Log = new("LOCALPLUGIN"); - - private readonly FileInfo manifestFile; - private readonly FileInfo disabledFile; - private readonly FileInfo testingFile; - - private PluginLoader? loader; - private Assembly? pluginAssembly; - private Type? pluginType; - private IDalamudPlugin? instance; - - /// - /// Initializes a new instance of the class. - /// - /// Path to the DLL file. - /// The plugin manifest. - public LocalPlugin(FileInfo dllFile, LocalPluginManifest? manifest) - { - if (dllFile.Name == "FFXIVClientStructs.Generators.dll") - { - // Could this be done another way? Sure. It is an extremely common source - // of errors in the log through, and should never be loaded as a plugin. - Log.Error($"Not a plugin: {dllFile.FullName}"); - throw new InvalidPluginException(dllFile); - } - - this.DllFile = dllFile; - this.State = PluginState.Unloaded; - - this.loader = PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, this.SetupLoaderConfig); - - try - { - this.pluginAssembly = this.loader.LoadDefaultAssembly(); - } - catch (Exception ex) - { - this.pluginAssembly = null; - this.pluginType = null; - this.loader.Dispose(); - - Log.Error(ex, $"Not a plugin: {this.DllFile.FullName}"); - throw new InvalidPluginException(this.DllFile); - } - - try - { - this.pluginType = this.pluginAssembly.GetTypes().FirstOrDefault(type => type.IsAssignableTo(typeof(IDalamudPlugin))); - } - catch (ReflectionTypeLoadException ex) - { - Log.Error(ex, $"Could not load one or more types when searching for IDalamudPlugin: {this.DllFile.FullName}"); - // Something blew up when parsing types, but we still want to look for IDalamudPlugin. Let Load() handle the error. - this.pluginType = ex.Types.FirstOrDefault(type => type.IsAssignableTo(typeof(IDalamudPlugin))); - } - - if (this.pluginType == default) - { - this.pluginAssembly = null; - this.pluginType = null; - this.loader.Dispose(); - - Log.Error($"Nothing inherits from IDalamudPlugin: {this.DllFile.FullName}"); - throw new InvalidPluginException(this.DllFile); - } - - var assemblyVersion = this.pluginAssembly.GetName().Version; - - // Although it is conditionally used here, we need to set the initial value regardless. - this.manifestFile = LocalPluginManifest.GetManifestFile(this.DllFile); - - // If the parameter manifest was null - if (manifest == null) - { - this.Manifest = new LocalPluginManifest() - { - Author = "developer", - Name = Path.GetFileNameWithoutExtension(this.DllFile.Name), - InternalName = Path.GetFileNameWithoutExtension(this.DllFile.Name), - AssemblyVersion = assemblyVersion, - Description = string.Empty, - ApplicableVersion = GameVersion.Any, - DalamudApiLevel = PluginManager.DalamudApiLevel, - IsHide = false, - }; - - // Save the manifest to disk so there won't be any problems later. - // We'll update the name property after it can be retrieved from the instance. - this.Manifest.Save(this.manifestFile); - } - else - { - this.Manifest = manifest; - } - - // This converts from the ".disabled" file feature to the manifest instead. - this.disabledFile = LocalPluginManifest.GetDisabledFile(this.DllFile); - if (this.disabledFile.Exists) - { - this.Manifest.Disabled = true; - this.disabledFile.Delete(); - } - - // This converts from the ".testing" file feature to the manifest instead. - this.testingFile = LocalPluginManifest.GetTestingFile(this.DllFile); - if (this.testingFile.Exists) - { - this.Manifest.Testing = true; - this.testingFile.Delete(); - } - - var pluginManager = Service.Get(); - this.IsBanned = pluginManager.IsManifestBanned(this.Manifest); - this.BanReason = pluginManager.GetBanReason(this.Manifest); - - this.SaveManifest(); - } - - /// - /// Gets the associated with this plugin. - /// - public DalamudPluginInterface? DalamudInterface { get; private set; } - - /// - /// Gets the path to the plugin DLL. - /// - public FileInfo DllFile { get; } - - /// - /// Gets the plugin manifest, if one exists. - /// - public LocalPluginManifest Manifest { get; private set; } - - /// - /// Gets or sets the current state of the plugin. - /// - public PluginState State { get; protected set; } - - /// - /// Gets the AssemblyName plugin, populated during . - /// - /// Plugin type. - public AssemblyName? AssemblyName { get; private set; } - - /// - /// Gets the plugin name, directly from the plugin or if it is not loaded from the manifest. - /// - public string Name => this.instance?.Name ?? this.Manifest.Name; - - /// - /// Gets an optional reason, if the plugin is banned. - /// - public string BanReason { get; } - - /// - /// Gets a value indicating whether the plugin is loaded and running. - /// - public bool IsLoaded => this.State == PluginState.Loaded; - - /// - /// Gets a value indicating whether the plugin is disabled. - /// - public bool IsDisabled => this.Manifest.Disabled; - - /// - /// Gets a value indicating whether this plugin's API level is out of date. - /// - public bool IsOutdated => this.Manifest.DalamudApiLevel < PluginManager.DalamudApiLevel; - - /// - /// Gets a value indicating whether the plugin is for testing use only. - /// - public bool IsTesting => this.Manifest.IsTestingExclusive || this.Manifest.Testing; - - /// - /// Gets a value indicating whether this plugin has been banned. - /// - public bool IsBanned { get; } - - /// - /// Gets a value indicating whether this plugin is dev plugin. - /// - public bool IsDev => this is LocalDevPlugin; - - /// - public void Dispose() - { - this.instance?.Dispose(); - this.instance = null; - - this.DalamudInterface?.ExplicitDispose(); - this.DalamudInterface = null; - - this.pluginType = null; - this.pluginAssembly = null; - - this.loader?.Dispose(); - } - - /// - /// Load this plugin. - /// - /// The reason why this plugin is being loaded. - /// Load while reloading. - public void Load(PluginLoadReason reason, bool reloading = false) - { - var startInfo = Service.Get(); - var configuration = Service.Get(); - var pluginManager = Service.Get(); - - // Allowed: Unloaded - switch (this.State) - { - case PluginState.InProgress: - throw new InvalidPluginOperationException($"Unable to load {this.Name}, already working"); - case PluginState.Loaded: - throw new InvalidPluginOperationException($"Unable to load {this.Name}, already loaded"); - case PluginState.LoadError: - throw new InvalidPluginOperationException($"Unable to load {this.Name}, load previously faulted, unload first"); - case PluginState.UnloadError: - throw new InvalidPluginOperationException($"Unable to load {this.Name}, unload previously faulted, restart Dalamud"); - } - - if (pluginManager.IsManifestBanned(this.Manifest)) - throw new BannedPluginException($"Unable to load {this.Name}, banned"); - - if (this.Manifest.ApplicableVersion < startInfo.GameVersion) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, no applicable version"); - - if (this.Manifest.DalamudApiLevel < PluginManager.DalamudApiLevel && !configuration.LoadAllApiLevels) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, incompatible API level"); - - if (this.Manifest.Disabled) - throw new InvalidPluginOperationException($"Unable to load {this.Name}, disabled"); - - this.State = PluginState.InProgress; - Log.Information($"Loading {this.DllFile.Name}"); - - if (this.DllFile.DirectoryName != null && File.Exists(Path.Combine(this.DllFile.DirectoryName, "Dalamud.dll"))) - { - Log.Error("==== IMPORTANT MESSAGE TO {0}, THE DEVELOPER OF {1} ====", this.Manifest.Author!, this.Manifest.InternalName); - Log.Error("YOU ARE INCLUDING DALAMUD DEPENDENCIES IN YOUR BUILDS!!!"); - Log.Error("You may not be able to load your plugin. \"False\" needs to be set in your csproj."); - Log.Error("If you are using ILMerge, do not merge anything other than your direct dependencies."); - Log.Error("Do not merge FFXIVClientStructs.Generators.dll."); - Log.Error("Please refer to https://github.com/goatcorp/Dalamud/discussions/603 for more information."); - } - - try - { - this.loader ??= PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, this.SetupLoaderConfig); - - if (reloading || this.IsDev) - { - if (this.IsDev) - { - // If a dev plugin is set to not autoload on boot, but we want to reload it at the arbitrary load - // time, we need to essentially "Unload" the plugin, but we can't call plugin.Unload because of the - // load state checks. Null any references to the assembly and types, then proceed with regular reload - // operations. - this.pluginAssembly = null; - this.pluginType = null; - } - - this.loader.Reload(); - - if (this.IsDev) - { - // Reload the manifest in-case there were changes here too. - var manifestFile = LocalPluginManifest.GetManifestFile(this.DllFile); - if (manifestFile.Exists) - { - this.Manifest = LocalPluginManifest.Load(manifestFile); - } - } - } - - // Load the assembly - this.pluginAssembly ??= this.loader.LoadDefaultAssembly(); - - this.AssemblyName = this.pluginAssembly.GetName(); - - // Find the plugin interface implementation. It is guaranteed to exist after checking in the ctor. - this.pluginType ??= this.pluginAssembly.GetTypes().First(type => type.IsAssignableTo(typeof(IDalamudPlugin))); - - // Check for any loaded plugins with the same assembly name - var assemblyName = this.pluginAssembly.GetName().Name; - foreach (var otherPlugin in pluginManager.InstalledPlugins) - { - // During hot-reloading, this plugin will be in the plugin list, and the instance will have been disposed - if (otherPlugin == this || otherPlugin.instance == null) - continue; - - var otherPluginAssemblyName = otherPlugin.instance.GetType().Assembly.GetName().Name; - if (otherPluginAssemblyName == assemblyName) - { - this.State = PluginState.Unloaded; - Log.Debug($"Duplicate assembly: {this.Name}"); - - throw new DuplicatePluginException(assemblyName); - } - } - - // Update the location for the Location and CodeBase patches - PluginManager.PluginLocations[this.pluginType.Assembly.FullName] = new(this.DllFile); - - this.DalamudInterface = new DalamudPluginInterface(this.pluginAssembly.GetName().Name!, this.DllFile, reason, this.IsDev); - - var ioc = Service.Get(); - this.instance = ioc.Create(this.pluginType, this.DalamudInterface) as IDalamudPlugin; - if (this.instance == null) - { - this.State = PluginState.LoadError; - this.DalamudInterface.ExplicitDispose(); - Log.Error($"Error while loading {this.Name}, failed to bind and call the plugin constructor"); - return; - } - - SignatureHelper.Initialise(this.instance); - - // In-case the manifest name was a placeholder. Can occur when no manifest was included. - if (this.instance.Name != this.Manifest.Name) - { - this.Manifest.Name = this.instance.Name; - this.Manifest.Save(this.manifestFile); - } - - this.State = PluginState.Loaded; - Log.Information($"Finished loading {this.DllFile.Name}"); - } - catch (Exception ex) - { - this.State = PluginState.LoadError; - Log.Error(ex, $"Error while loading {this.Name}"); - - throw; - } - } - - /// - /// Unload this plugin. This is the same as dispose, but without the "disposed" connotations. This object should stay - /// in the plugin list until it has been actually disposed. - /// - /// Unload while reloading. - public void Unload(bool reloading = false) - { - // Allowed: Loaded, LoadError(we are cleaning this up while we're at it) - switch (this.State) - { - case PluginState.InProgress: - throw new InvalidPluginOperationException($"Unable to unload {this.Name}, already working"); - case PluginState.Unloaded: - throw new InvalidPluginOperationException($"Unable to unload {this.Name}, already unloaded"); - case PluginState.UnloadError: - throw new InvalidPluginOperationException($"Unable to unload {this.Name}, unload previously faulted, restart Dalamud"); - } - - try - { - this.State = PluginState.InProgress; - Log.Information($"Unloading {this.DllFile.Name}"); - - this.instance?.Dispose(); - this.instance = null; - - this.DalamudInterface?.ExplicitDispose(); - this.DalamudInterface = null; - - this.pluginType = null; - this.pluginAssembly = null; - - if (!reloading) - { - this.loader?.Dispose(); - this.loader = null; - } - - this.State = PluginState.Unloaded; - Log.Information($"Finished unloading {this.DllFile.Name}"); - } - catch (Exception ex) - { - this.State = PluginState.UnloadError; - Log.Error(ex, $"Error while unloading {this.Name}"); - - throw; - } - } - - /// - /// Reload this plugin. - /// - public void Reload() - { - this.Unload(true); - - // We need to handle removed DTR nodes here, as otherwise, plugins will not be able to re-add their bar entries after updates. - var dtr = Service.Get(); - dtr.HandleRemovedNodes(); - - this.Load(PluginLoadReason.Reload, true); - } - - /// - /// Revert a disable. Must be unloaded first, does not load. - /// - public void Enable() - { - // Allowed: Unloaded, UnloadError - switch (this.State) - { - case PluginState.InProgress: - case PluginState.Loaded: - case PluginState.LoadError: - throw new InvalidPluginOperationException($"Unable to enable {this.Name}, still loaded"); - } - - if (!this.Manifest.Disabled) - throw new InvalidPluginOperationException($"Unable to enable {this.Name}, not disabled"); - - this.Manifest.Disabled = false; - this.SaveManifest(); - } - - /// - /// Disable this plugin, must be unloaded first. - /// - public void Disable() - { - // Allowed: Unloaded, UnloadError - switch (this.State) - { - case PluginState.InProgress: - case PluginState.Loaded: - case PluginState.LoadError: - throw new InvalidPluginOperationException($"Unable to disable {this.Name}, still loaded"); - } - - if (this.Manifest.Disabled) - throw new InvalidPluginOperationException($"Unable to disable {this.Name}, already disabled"); - - this.Manifest.Disabled = true; - this.SaveManifest(); - } - - private void SetupLoaderConfig(LoaderConfig config) - { - config.IsUnloadable = true; - config.LoadInMemory = true; - config.PreferSharedTypes = false; - config.SharedAssemblies.Add(typeof(Lumina.GameData).Assembly.GetName()); - config.SharedAssemblies.Add(typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName()); - } - - private void SaveManifest() => this.Manifest.Save(this.manifestFile); - } -} diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index f98e122f5..25ec9a11c 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -21,441 +21,303 @@ using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using Newtonsoft.Json; -namespace Dalamud.Plugin.Internal +namespace Dalamud.Plugin.Internal; + +/// +/// Class responsible for loading and unloading plugins. +/// +internal partial class PluginManager : IDisposable { /// - /// Class responsible for loading and unloading plugins. + /// The current Dalamud API level, used to handle breaking changes. Only plugins with this level will be loaded. /// - internal partial class PluginManager : IDisposable + public const int DalamudApiLevel = 6; + + private static readonly ModuleLog Log = new("PLUGINM"); + + private readonly DirectoryInfo pluginDirectory; + private readonly DirectoryInfo devPluginDirectory; + private readonly BannedPlugin[] bannedPlugins; + + /// + /// Initializes a new instance of the class. + /// + public PluginManager() { - /// - /// The current Dalamud API level, used to handle breaking changes. Only plugins with this level will be loaded. - /// - public const int DalamudApiLevel = 6; + var startInfo = Service.Get(); + var configuration = Service.Get(); - private static readonly ModuleLog Log = new("PLUGINM"); + this.pluginDirectory = new DirectoryInfo(startInfo.PluginDirectory); + this.devPluginDirectory = new DirectoryInfo(startInfo.DefaultPluginDirectory); - private readonly DirectoryInfo pluginDirectory; - private readonly DirectoryInfo devPluginDirectory; - private readonly BannedPlugin[] bannedPlugins; + if (!this.pluginDirectory.Exists) + this.pluginDirectory.Create(); - /// - /// Initializes a new instance of the class. - /// - public PluginManager() + if (!this.devPluginDirectory.Exists) + this.devPluginDirectory.Create(); + + this.SafeMode = EnvironmentConfiguration.DalamudNoPlugins || configuration.PluginSafeMode; + if (this.SafeMode) { - var startInfo = Service.Get(); - var configuration = Service.Get(); - - this.pluginDirectory = new DirectoryInfo(startInfo.PluginDirectory); - this.devPluginDirectory = new DirectoryInfo(startInfo.DefaultPluginDirectory); - - if (!this.pluginDirectory.Exists) - this.pluginDirectory.Create(); - - if (!this.devPluginDirectory.Exists) - this.devPluginDirectory.Create(); - - this.SafeMode = EnvironmentConfiguration.DalamudNoPlugins || configuration.PluginSafeMode; - if (this.SafeMode) - { - configuration.PluginSafeMode = false; - configuration.Save(); - } - - this.PluginConfigs = new PluginConfigurations(Path.Combine(Path.GetDirectoryName(startInfo.ConfigurationPath) ?? string.Empty, "pluginConfigs")); - - var bannedPluginsJson = File.ReadAllText(Path.Combine(startInfo.AssetDirectory, "UIRes", "bannedplugin.json")); - this.bannedPlugins = JsonConvert.DeserializeObject(bannedPluginsJson) ?? Array.Empty(); - - this.ApplyPatches(); + configuration.PluginSafeMode = false; + configuration.Save(); } - /// - /// An event that fires when the installed plugins have changed. - /// - public event Action? OnInstalledPluginsChanged; + this.PluginConfigs = new PluginConfigurations(Path.Combine(Path.GetDirectoryName(startInfo.ConfigurationPath) ?? string.Empty, "pluginConfigs")); - /// - /// An event that fires when the available plugins have changed. - /// - public event Action? OnAvailablePluginsChanged; + var bannedPluginsJson = File.ReadAllText(Path.Combine(startInfo.AssetDirectory, "UIRes", "bannedplugin.json")); + this.bannedPlugins = JsonConvert.DeserializeObject(bannedPluginsJson) ?? Array.Empty(); - /// - /// Gets a list of all loaded plugins. - /// - public ImmutableList InstalledPlugins { get; private set; } = ImmutableList.Create(); + this.ApplyPatches(); + } - /// - /// Gets a list of all available plugins. - /// - public ImmutableList AvailablePlugins { get; private set; } = ImmutableList.Create(); + /// + /// An event that fires when the installed plugins have changed. + /// + public event Action? OnInstalledPluginsChanged; - /// - /// Gets a list of all plugins with an available update. - /// - public ImmutableList UpdatablePlugins { get; private set; } = ImmutableList.Create(); + /// + /// An event that fires when the available plugins have changed. + /// + public event Action? OnAvailablePluginsChanged; - /// - /// Gets a list of all plugin repositories. The main repo should always be first. - /// - public List Repos { get; private set; } = new(); + /// + /// Gets a list of all loaded plugins. + /// + public ImmutableList InstalledPlugins { get; private set; } = ImmutableList.Create(); - /// - /// Gets a value indicating whether plugins are not still loading from boot. - /// - public bool PluginsReady { get; private set; } + /// + /// Gets a list of all available plugins. + /// + public ImmutableList AvailablePlugins { get; private set; } = ImmutableList.Create(); - /// - /// Gets a value indicating whether all added repos are not in progress. - /// - public bool ReposReady => this.Repos.All(repo => repo.State != PluginRepositoryState.InProgress); + /// + /// Gets a list of all plugins with an available update. + /// + public ImmutableList UpdatablePlugins { get; private set; } = ImmutableList.Create(); - /// - /// Gets a value indicating whether the plugin manager started in safe mode. - /// - public bool SafeMode { get; init; } + /// + /// Gets a list of all plugin repositories. The main repo should always be first. + /// + public List Repos { get; private set; } = new(); - /// - /// Gets the object used when initializing plugins. - /// - public PluginConfigurations PluginConfigs { get; } + /// + /// Gets a value indicating whether plugins are not still loading from boot. + /// + public bool PluginsReady { get; private set; } - /// - /// Print to chat any plugin updates and whether they were successful. - /// - /// The list of updated plugin metadata. - /// The header text to send to chat prior to any update info. - public static void PrintUpdatedPlugins(List? updateMetadata, string header) + /// + /// Gets a value indicating whether all added repos are not in progress. + /// + public bool ReposReady => this.Repos.All(repo => repo.State != PluginRepositoryState.InProgress); + + /// + /// Gets a value indicating whether the plugin manager started in safe mode. + /// + public bool SafeMode { get; init; } + + /// + /// Gets the object used when initializing plugins. + /// + public PluginConfigurations PluginConfigs { get; } + + /// + /// Print to chat any plugin updates and whether they were successful. + /// + /// The list of updated plugin metadata. + /// The header text to send to chat prior to any update info. + public static void PrintUpdatedPlugins(List? updateMetadata, string header) + { + var chatGui = Service.Get(); + + if (updateMetadata is { Count: > 0 }) { - var chatGui = Service.Get(); + chatGui.Print(header); - if (updateMetadata is { Count: > 0 }) + foreach (var metadata in updateMetadata) { - chatGui.Print(header); - - foreach (var metadata in updateMetadata) + if (metadata.WasUpdated) { - if (metadata.WasUpdated) + chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); + } + else + { + chatGui.PrintChat(new XivChatEntry { - chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); - } - else - { - chatGui.PrintChat(new XivChatEntry - { - Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version), - Type = XivChatType.Urgent, - }); - } + Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version), + Type = XivChatType.Urgent, + }); } } } + } - /// - /// For a given manifest, determine if the testing version should be used over the normal version. - /// The higher of the two versions is calculated after checking other settings. - /// - /// Manifest to check. - /// A value indicating whether testing should be used. - public static bool UseTesting(PluginManifest manifest) - { - var configuration = Service.Get(); - - if (!configuration.DoPluginTest) - return false; - - if (manifest.IsTestingExclusive) - return true; - - var av = manifest.AssemblyVersion; - var tv = manifest.TestingAssemblyVersion; - var hasTv = tv != null; - - if (hasTv) - { - return tv > av; - } + /// + /// For a given manifest, determine if the testing version should be used over the normal version. + /// The higher of the two versions is calculated after checking other settings. + /// + /// Manifest to check. + /// A value indicating whether testing should be used. + public static bool UseTesting(PluginManifest manifest) + { + var configuration = Service.Get(); + if (!configuration.DoPluginTest) return false; + + if (manifest.IsTestingExclusive) + return true; + + var av = manifest.AssemblyVersion; + var tv = manifest.TestingAssemblyVersion; + var hasTv = tv != null; + + if (hasTv) + { + return tv > av; } - /// - /// Gets a value indicating whether the given repo manifest should be visible to the user. - /// - /// Repo manifest. - /// If the manifest is visible. - public static bool IsManifestVisible(RemotePluginManifest manifest) + return false; + } + + /// + /// Gets a value indicating whether the given repo manifest should be visible to the user. + /// + /// Repo manifest. + /// If the manifest is visible. + public static bool IsManifestVisible(RemotePluginManifest manifest) + { + var configuration = Service.Get(); + + // Hidden by user + if (configuration.HiddenPluginInternalName.Contains(manifest.InternalName)) + return false; + + // Hidden by manifest + return !manifest.IsHide; + } + + /// + public void Dispose() + { + foreach (var plugin in this.InstalledPlugins) { - var configuration = Service.Get(); - - // Hidden by user - if (configuration.HiddenPluginInternalName.Contains(manifest.InternalName)) - return false; - - // Hidden by manifest - return !manifest.IsHide; + try + { + plugin.Dispose(); + } + catch (Exception ex) + { + Log.Error(ex, $"Error disposing {plugin.Name}"); + } } - /// - public void Dispose() + this.assemblyLocationMonoHook?.Dispose(); + this.assemblyCodeBaseMonoHook?.Dispose(); + } + + /// + /// Set the list of repositories to use and downloads their contents. + /// Should be called when the Settings window has been updated or at instantiation. + /// + /// Whether the available plugins changed event should be sent after. + /// A representing the asynchronous operation. + public async Task SetPluginReposFromConfigAsync(bool notify) + { + var configuration = Service.Get(); + + var repos = new List() { PluginRepository.MainRepo }; + repos.AddRange(configuration.ThirdRepoList + .Where(repo => repo.IsEnabled) + .Select(repo => new PluginRepository(repo.Url, repo.IsEnabled))); + + this.Repos = repos; + await this.ReloadPluginMastersAsync(notify); + } + + /// + /// Load all plugins, sorted by priority. Any plugins with no explicit definition file or a negative priority + /// are loaded asynchronously. + /// + /// + /// This should only be called during Dalamud startup. + /// + public void LoadAllPlugins() + { + if (this.SafeMode) { - foreach (var plugin in this.InstalledPlugins) + Log.Information("PluginSafeMode was enabled, not loading any plugins."); + return; + } + + var configuration = Service.Get(); + + var pluginDefs = new List(); + var devPluginDefs = new List(); + + if (!this.pluginDirectory.Exists) + this.pluginDirectory.Create(); + + if (!this.devPluginDirectory.Exists) + this.devPluginDirectory.Create(); + + // Add installed plugins. These are expected to be in a specific format so we can look for exactly that. + foreach (var pluginDir in this.pluginDirectory.GetDirectories()) + { + foreach (var versionDir in pluginDir.GetDirectories()) + { + var dllFile = new FileInfo(Path.Combine(versionDir.FullName, $"{pluginDir.Name}.dll")); + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + + if (!manifestFile.Exists) + continue; + + var manifest = LocalPluginManifest.Load(manifestFile); + + pluginDefs.Add(new PluginDef(dllFile, manifest, false)); + } + } + + // devPlugins are more freeform. Look for any dll and hope to get lucky. + var devDllFiles = this.devPluginDirectory.GetFiles("*.dll", SearchOption.AllDirectories).ToList(); + + foreach (var setting in configuration.DevPluginLoadLocations) + { + if (!setting.IsEnabled) + continue; + + if (Directory.Exists(setting.Path)) + { + devDllFiles.AddRange(new DirectoryInfo(setting.Path).GetFiles("*.dll", SearchOption.AllDirectories)); + } + else if (File.Exists(setting.Path)) + { + devDllFiles.Add(new FileInfo(setting.Path)); + } + } + + foreach (var dllFile in devDllFiles) + { + // Manifests are not required for devPlugins. the Plugin type will handle any null manifests. + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + var manifest = manifestFile.Exists ? LocalPluginManifest.Load(manifestFile) : null; + devPluginDefs.Add(new PluginDef(dllFile, manifest, true)); + } + + // Sort for load order - unloaded definitions have default priority of 0 + pluginDefs.Sort(PluginDef.Sorter); + devPluginDefs.Sort(PluginDef.Sorter); + + // Dev plugins should load first. + pluginDefs.InsertRange(0, devPluginDefs); + + void LoadPlugins(IEnumerable pluginDefsList) + { + foreach (var pluginDef in pluginDefsList) { try { - plugin.Dispose(); - } - catch (Exception ex) - { - Log.Error(ex, $"Error disposing {plugin.Name}"); - } - } - - this.assemblyLocationMonoHook?.Dispose(); - this.assemblyCodeBaseMonoHook?.Dispose(); - } - - /// - /// Set the list of repositories to use and downloads their contents. - /// Should be called when the Settings window has been updated or at instantiation. - /// - /// Whether the available plugins changed event should be sent after. - /// A representing the asynchronous operation. - public async Task SetPluginReposFromConfigAsync(bool notify) - { - var configuration = Service.Get(); - - var repos = new List() { PluginRepository.MainRepo }; - repos.AddRange(configuration.ThirdRepoList - .Where(repo => repo.IsEnabled) - .Select(repo => new PluginRepository(repo.Url, repo.IsEnabled))); - - this.Repos = repos; - await this.ReloadPluginMastersAsync(notify); - } - - /// - /// Load all plugins, sorted by priority. Any plugins with no explicit definition file or a negative priority - /// are loaded asynchronously. - /// - /// - /// This should only be called during Dalamud startup. - /// - public void LoadAllPlugins() - { - if (this.SafeMode) - { - Log.Information("PluginSafeMode was enabled, not loading any plugins."); - return; - } - - var configuration = Service.Get(); - - var pluginDefs = new List(); - var devPluginDefs = new List(); - - if (!this.pluginDirectory.Exists) - this.pluginDirectory.Create(); - - if (!this.devPluginDirectory.Exists) - this.devPluginDirectory.Create(); - - // Add installed plugins. These are expected to be in a specific format so we can look for exactly that. - foreach (var pluginDir in this.pluginDirectory.GetDirectories()) - { - foreach (var versionDir in pluginDir.GetDirectories()) - { - var dllFile = new FileInfo(Path.Combine(versionDir.FullName, $"{pluginDir.Name}.dll")); - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - - if (!manifestFile.Exists) - continue; - - var manifest = LocalPluginManifest.Load(manifestFile); - - pluginDefs.Add(new PluginDef(dllFile, manifest, false)); - } - } - - // devPlugins are more freeform. Look for any dll and hope to get lucky. - var devDllFiles = this.devPluginDirectory.GetFiles("*.dll", SearchOption.AllDirectories).ToList(); - - foreach (var setting in configuration.DevPluginLoadLocations) - { - if (!setting.IsEnabled) - continue; - - if (Directory.Exists(setting.Path)) - { - devDllFiles.AddRange(new DirectoryInfo(setting.Path).GetFiles("*.dll", SearchOption.AllDirectories)); - } - else if (File.Exists(setting.Path)) - { - devDllFiles.Add(new FileInfo(setting.Path)); - } - } - - foreach (var dllFile in devDllFiles) - { - // Manifests are not required for devPlugins. the Plugin type will handle any null manifests. - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - var manifest = manifestFile.Exists ? LocalPluginManifest.Load(manifestFile) : null; - devPluginDefs.Add(new PluginDef(dllFile, manifest, true)); - } - - // Sort for load order - unloaded definitions have default priority of 0 - pluginDefs.Sort(PluginDef.Sorter); - devPluginDefs.Sort(PluginDef.Sorter); - - // Dev plugins should load first. - pluginDefs.InsertRange(0, devPluginDefs); - - void LoadPlugins(IEnumerable pluginDefsList) - { - foreach (var pluginDef in pluginDefsList) - { - try - { - this.LoadPlugin(pluginDef.DllFile, pluginDef.Manifest, PluginLoadReason.Boot, pluginDef.IsDev, isBoot: true); - } - catch (InvalidPluginException) - { - // Not a plugin - } - catch (Exception ex) - { - Log.Error(ex, "During boot plugin load, an unexpected error occurred"); - } - } - } - - // Load sync plugins - var syncPlugins = pluginDefs.Where(def => def.Manifest?.LoadPriority > 0); - LoadPlugins(syncPlugins); - - var asyncPlugins = pluginDefs.Where(def => def.Manifest == null || def.Manifest.LoadPriority <= 0); - Task.Run(() => LoadPlugins(asyncPlugins)) - .ContinueWith(_ => - { - this.PluginsReady = true; - this.NotifyInstalledPluginsChanged(); - }); - } - - /// - /// Reload all loaded plugins. - /// - public void ReloadAllPlugins() - { - var aggregate = new List(); - - foreach (var plugin in this.InstalledPlugins) - { - if (plugin.IsLoaded) - { - try - { - plugin.Reload(); - } - catch (Exception ex) - { - Log.Error(ex, "Error during reload all"); - - aggregate.Add(ex); - } - } - } - - if (aggregate.Any()) - { - throw new AggregateException(aggregate); - } - } - - /// - /// Reload the PluginMaster for each repo, filter, and event that the list has updated. - /// - /// Whether to notify that available plugins have changed afterwards. - /// A representing the asynchronous operation. - public async Task ReloadPluginMastersAsync(bool notify = true) - { - await Task.WhenAll(this.Repos.Select(repo => repo.ReloadPluginMasterAsync())); - - this.RefilterPluginMasters(notify); - } - - /// - /// Apply visibility and eligibility filters to the available plugins, then event that the list has updated. - /// - /// Whether to notify that available plugins have changed afterwards. - public void RefilterPluginMasters(bool notify = true) - { - this.AvailablePlugins = this.Repos - .SelectMany(repo => repo.PluginMaster) - .Where(this.IsManifestEligible) - .Where(IsManifestVisible) - .ToImmutableList(); - - if (notify) - { - this.NotifyAvailablePluginsChanged(); - } - } - - /// - /// Scan the devPlugins folder for new DLL files that are not already loaded into the manager. They are not loaded, - /// only shown as disabled in the installed plugins window. This is a modified version of LoadAllPlugins that works - /// a little differently. - /// - public void ScanDevPlugins() - { - if (this.SafeMode) - { - Log.Information("PluginSafeMode was enabled, not scanning any dev plugins."); - return; - } - - var configuration = Service.Get(); - - if (!this.devPluginDirectory.Exists) - this.devPluginDirectory.Create(); - - // devPlugins are more freeform. Look for any dll and hope to get lucky. - var devDllFiles = this.devPluginDirectory.GetFiles("*.dll", SearchOption.AllDirectories).ToList(); - - foreach (var setting in configuration.DevPluginLoadLocations) - { - if (!setting.IsEnabled) - continue; - - if (Directory.Exists(setting.Path)) - { - devDllFiles.AddRange(new DirectoryInfo(setting.Path).GetFiles("*.dll", SearchOption.AllDirectories)); - } - else if (File.Exists(setting.Path)) - { - devDllFiles.Add(new FileInfo(setting.Path)); - } - } - - var listChanged = false; - - foreach (var dllFile in devDllFiles) - { - // This file is already known to us - if (this.InstalledPlugins.Any(lp => lp.DllFile.FullName == dllFile.FullName)) - continue; - - // Manifests are not required for devPlugins. the Plugin type will handle any null manifests. - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - var manifest = manifestFile.Exists ? LocalPluginManifest.Load(manifestFile) : null; - - try - { - // Add them to the list and let the user decide, nothing is auto-loaded. - this.LoadPlugin(dllFile, manifest, PluginLoadReason.Installer, isDev: true, doNotLoad: true); - listChanged = true; + this.LoadPlugin(pluginDef.DllFile, pluginDef.Manifest, PluginLoadReason.Boot, pluginDef.IsDev, isBoot: true); } catch (InvalidPluginException) { @@ -463,675 +325,812 @@ namespace Dalamud.Plugin.Internal } catch (Exception ex) { - Log.Error(ex, $"During devPlugin scan, an unexpected error occurred"); + Log.Error(ex, "During boot plugin load, an unexpected error occurred"); } } + } - if (listChanged) + // Load sync plugins + var syncPlugins = pluginDefs.Where(def => def.Manifest?.LoadPriority > 0); + LoadPlugins(syncPlugins); + + var asyncPlugins = pluginDefs.Where(def => def.Manifest == null || def.Manifest.LoadPriority <= 0); + Task.Run(() => LoadPlugins(asyncPlugins)) + .ContinueWith(_ => + { + this.PluginsReady = true; this.NotifyInstalledPluginsChanged(); - } + }); + } - /// - /// Install a plugin from a repository and load it. - /// - /// The plugin definition. - /// If the testing version should be used. - /// The reason this plugin was loaded. - /// A representing the asynchronous operation. - public async Task InstallPluginAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason) + /// + /// Reload all loaded plugins. + /// + public void ReloadAllPlugins() + { + var aggregate = new List(); + + foreach (var plugin in this.InstalledPlugins) { - Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting})"); - - var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; - var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; - - var response = await Util.HttpClient.GetAsync(downloadUrl); - response.EnsureSuccessStatusCode(); - - var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); - - try - { - if (outputDir.Exists) - outputDir.Delete(true); - - outputDir.Create(); - } - catch - { - // ignored, since the plugin may be loaded already - } - - Log.Debug($"Extracting to {outputDir}"); - // This throws an error, even with overwrite=false - // ZipFile.ExtractToDirectory(tempZip.FullName, outputDir.FullName, false); - using (var archive = new ZipArchive(await response.Content.ReadAsStreamAsync())) - { - foreach (var zipFile in archive.Entries) - { - var outputFile = new FileInfo(Path.GetFullPath(Path.Combine(outputDir.FullName, zipFile.FullName))); - - if (!outputFile.FullName.StartsWith(outputDir.FullName, StringComparison.OrdinalIgnoreCase)) - { - throw new IOException("Trying to extract file outside of destination directory. See this link for more info: https://snyk.io/research/zip-slip-vulnerability"); - } - - if (outputFile.Directory == null) - { - throw new IOException("Output directory invalid."); - } - - if (zipFile.Name.IsNullOrEmpty()) - { - // Assuming Empty for Directory - Log.Verbose($"ZipFile name is null or empty, treating as a directory: {outputFile.Directory.FullName}"); - Directory.CreateDirectory(outputFile.Directory.FullName); - continue; - } - - // Ensure directory is created - Directory.CreateDirectory(outputFile.Directory.FullName); - - try - { - zipFile.ExtractToFile(outputFile.FullName, true); - } - catch (Exception ex) - { - if (outputFile.Extension.EndsWith("dll")) - { - throw new IOException($"Could not overwrite {zipFile.Name}: {ex.Message}"); - } - - Log.Error($"Could not overwrite {zipFile.Name}: {ex.Message}"); - } - } - } - - var dllFile = LocalPluginManifest.GetPluginFile(outputDir, repoManifest); - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - - // We need to save the repoManifest due to how the repo fills in some fields that authors are not expected to use. - File.WriteAllText(manifestFile.FullName, JsonConvert.SerializeObject(repoManifest, Formatting.Indented)); - - // Reload as a local manifest, add some attributes, and save again. - var manifest = LocalPluginManifest.Load(manifestFile); - - if (useTesting) - { - manifest.Testing = true; - } - - if (repoManifest.SourceRepo.IsThirdParty) - { - // Only document the url if it came from a third party repo. - manifest.InstalledFromUrl = repoManifest.SourceRepo.PluginMasterUrl; - } - - manifest.Save(manifestFile); - - Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); - - var plugin = this.LoadPlugin(dllFile, manifest, reason); - - this.NotifyInstalledPluginsChanged(); - return plugin; - } - - /// - /// Load a plugin. - /// - /// The associated with the main assembly of this plugin. - /// The already loaded definition, if available. - /// The reason this plugin was loaded. - /// If this plugin should support development features. - /// If this plugin is being loaded at boot. - /// Don't load the plugin, just don't do it. - /// The loaded plugin. - public LocalPlugin LoadPlugin(FileInfo dllFile, LocalPluginManifest? manifest, PluginLoadReason reason, bool isDev = false, bool isBoot = false, bool doNotLoad = false) - { - var name = manifest?.Name ?? dllFile.Name; - var loadPlugin = !doNotLoad; - - LocalPlugin plugin; - - if (isDev) - { - Log.Information($"Loading dev plugin {name}"); - var devPlugin = new LocalDevPlugin(dllFile, manifest); - loadPlugin &= !isBoot || devPlugin.StartOnBoot; - - // If we're not loading it, make sure it's disabled - if (!loadPlugin && !devPlugin.IsDisabled) - devPlugin.Disable(); - - plugin = devPlugin; - } - else - { - Log.Information($"Loading plugin {name}"); - plugin = new LocalPlugin(dllFile, manifest); - } - - if (loadPlugin) + if (plugin.IsLoaded) { try { - if (plugin.IsDisabled) - plugin.Enable(); - - plugin.Load(reason); + plugin.Reload(); } - catch (InvalidPluginException) + catch (Exception ex) + { + Log.Error(ex, "Error during reload all"); + + aggregate.Add(ex); + } + } + } + + if (aggregate.Any()) + { + throw new AggregateException(aggregate); + } + } + + /// + /// Reload the PluginMaster for each repo, filter, and event that the list has updated. + /// + /// Whether to notify that available plugins have changed afterwards. + /// A representing the asynchronous operation. + public async Task ReloadPluginMastersAsync(bool notify = true) + { + await Task.WhenAll(this.Repos.Select(repo => repo.ReloadPluginMasterAsync())); + + this.RefilterPluginMasters(notify); + } + + /// + /// Apply visibility and eligibility filters to the available plugins, then event that the list has updated. + /// + /// Whether to notify that available plugins have changed afterwards. + public void RefilterPluginMasters(bool notify = true) + { + this.AvailablePlugins = this.Repos + .SelectMany(repo => repo.PluginMaster) + .Where(this.IsManifestEligible) + .Where(IsManifestVisible) + .ToImmutableList(); + + if (notify) + { + this.NotifyAvailablePluginsChanged(); + } + } + + /// + /// Scan the devPlugins folder for new DLL files that are not already loaded into the manager. They are not loaded, + /// only shown as disabled in the installed plugins window. This is a modified version of LoadAllPlugins that works + /// a little differently. + /// + public void ScanDevPlugins() + { + if (this.SafeMode) + { + Log.Information("PluginSafeMode was enabled, not scanning any dev plugins."); + return; + } + + var configuration = Service.Get(); + + if (!this.devPluginDirectory.Exists) + this.devPluginDirectory.Create(); + + // devPlugins are more freeform. Look for any dll and hope to get lucky. + var devDllFiles = this.devPluginDirectory.GetFiles("*.dll", SearchOption.AllDirectories).ToList(); + + foreach (var setting in configuration.DevPluginLoadLocations) + { + if (!setting.IsEnabled) + continue; + + if (Directory.Exists(setting.Path)) + { + devDllFiles.AddRange(new DirectoryInfo(setting.Path).GetFiles("*.dll", SearchOption.AllDirectories)); + } + else if (File.Exists(setting.Path)) + { + devDllFiles.Add(new FileInfo(setting.Path)); + } + } + + var listChanged = false; + + foreach (var dllFile in devDllFiles) + { + // This file is already known to us + if (this.InstalledPlugins.Any(lp => lp.DllFile.FullName == dllFile.FullName)) + continue; + + // Manifests are not required for devPlugins. the Plugin type will handle any null manifests. + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + var manifest = manifestFile.Exists ? LocalPluginManifest.Load(manifestFile) : null; + + try + { + // Add them to the list and let the user decide, nothing is auto-loaded. + this.LoadPlugin(dllFile, manifest, PluginLoadReason.Installer, isDev: true, doNotLoad: true); + listChanged = true; + } + catch (InvalidPluginException) + { + // Not a plugin + } + catch (Exception ex) + { + Log.Error(ex, $"During devPlugin scan, an unexpected error occurred"); + } + } + + if (listChanged) + this.NotifyInstalledPluginsChanged(); + } + + /// + /// Install a plugin from a repository and load it. + /// + /// The plugin definition. + /// If the testing version should be used. + /// The reason this plugin was loaded. + /// A representing the asynchronous operation. + public async Task InstallPluginAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason) + { + Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting})"); + + var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; + var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; + + var response = await Util.HttpClient.GetAsync(downloadUrl); + response.EnsureSuccessStatusCode(); + + var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); + + try + { + if (outputDir.Exists) + outputDir.Delete(true); + + outputDir.Create(); + } + catch + { + // ignored, since the plugin may be loaded already + } + + Log.Debug($"Extracting to {outputDir}"); + // This throws an error, even with overwrite=false + // ZipFile.ExtractToDirectory(tempZip.FullName, outputDir.FullName, false); + using (var archive = new ZipArchive(await response.Content.ReadAsStreamAsync())) + { + foreach (var zipFile in archive.Entries) + { + var outputFile = new FileInfo(Path.GetFullPath(Path.Combine(outputDir.FullName, zipFile.FullName))); + + if (!outputFile.FullName.StartsWith(outputDir.FullName, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("Trying to extract file outside of destination directory. See this link for more info: https://snyk.io/research/zip-slip-vulnerability"); + } + + if (outputFile.Directory == null) + { + throw new IOException("Output directory invalid."); + } + + if (zipFile.Name.IsNullOrEmpty()) + { + // Assuming Empty for Directory + Log.Verbose($"ZipFile name is null or empty, treating as a directory: {outputFile.Directory.FullName}"); + Directory.CreateDirectory(outputFile.Directory.FullName); + continue; + } + + // Ensure directory is created + Directory.CreateDirectory(outputFile.Directory.FullName); + + try + { + zipFile.ExtractToFile(outputFile.FullName, true); + } + catch (Exception ex) + { + if (outputFile.Extension.EndsWith("dll")) + { + throw new IOException($"Could not overwrite {zipFile.Name}: {ex.Message}"); + } + + Log.Error($"Could not overwrite {zipFile.Name}: {ex.Message}"); + } + } + } + + var dllFile = LocalPluginManifest.GetPluginFile(outputDir, repoManifest); + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + + // We need to save the repoManifest due to how the repo fills in some fields that authors are not expected to use. + File.WriteAllText(manifestFile.FullName, JsonConvert.SerializeObject(repoManifest, Formatting.Indented)); + + // Reload as a local manifest, add some attributes, and save again. + var manifest = LocalPluginManifest.Load(manifestFile); + + if (useTesting) + { + manifest.Testing = true; + } + + if (repoManifest.SourceRepo.IsThirdParty) + { + // Only document the url if it came from a third party repo. + manifest.InstalledFromUrl = repoManifest.SourceRepo.PluginMasterUrl; + } + + manifest.Save(manifestFile); + + Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); + + var plugin = this.LoadPlugin(dllFile, manifest, reason); + + this.NotifyInstalledPluginsChanged(); + return plugin; + } + + /// + /// Load a plugin. + /// + /// The associated with the main assembly of this plugin. + /// The already loaded definition, if available. + /// The reason this plugin was loaded. + /// If this plugin should support development features. + /// If this plugin is being loaded at boot. + /// Don't load the plugin, just don't do it. + /// The loaded plugin. + public LocalPlugin LoadPlugin(FileInfo dllFile, LocalPluginManifest? manifest, PluginLoadReason reason, bool isDev = false, bool isBoot = false, bool doNotLoad = false) + { + var name = manifest?.Name ?? dllFile.Name; + var loadPlugin = !doNotLoad; + + LocalPlugin plugin; + + if (isDev) + { + Log.Information($"Loading dev plugin {name}"); + var devPlugin = new LocalDevPlugin(dllFile, manifest); + loadPlugin &= !isBoot || devPlugin.StartOnBoot; + + // If we're not loading it, make sure it's disabled + if (!loadPlugin && !devPlugin.IsDisabled) + devPlugin.Disable(); + + plugin = devPlugin; + } + else + { + Log.Information($"Loading plugin {name}"); + plugin = new LocalPlugin(dllFile, manifest); + } + + if (loadPlugin) + { + try + { + if (plugin.IsDisabled) + plugin.Enable(); + + plugin.Load(reason); + } + catch (InvalidPluginException) + { + PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty); + throw; + } + catch (BannedPluginException) + { + // Out of date plugins get added so they can be updated. + Log.Information($"Plugin was banned, adding anyways: {dllFile.Name}"); + } + catch (Exception ex) + { + if (plugin.IsDev) + { + // Dev plugins always get added to the list so they can be fiddled with in the UI + Log.Information(ex, $"Dev plugin failed to load, adding anyways: {dllFile.Name}"); + plugin.Disable(); // Disable here, otherwise you can't enable+load later + } + else if (plugin.IsOutdated) + { + // Out of date plugins get added so they can be updated. + Log.Information(ex, $"Plugin was outdated, adding anyways: {dllFile.Name}"); + } + else { PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty); throw; } - catch (BannedPluginException) - { - // Out of date plugins get added so they can be updated. - Log.Information($"Plugin was banned, adding anyways: {dllFile.Name}"); - } - catch (Exception ex) - { - if (plugin.IsDev) - { - // Dev plugins always get added to the list so they can be fiddled with in the UI - Log.Information(ex, $"Dev plugin failed to load, adding anyways: {dllFile.Name}"); - plugin.Disable(); // Disable here, otherwise you can't enable+load later - } - else if (plugin.IsOutdated) - { - // Out of date plugins get added so they can be updated. - Log.Information(ex, $"Plugin was outdated, adding anyways: {dllFile.Name}"); - } - else - { - PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty); - throw; - } - } - } - - this.InstalledPlugins = this.InstalledPlugins.Add(plugin); - return plugin; - } - - /// - /// Remove a plugin. - /// - /// Plugin to remove. - public void RemovePlugin(LocalPlugin plugin) - { - if (plugin.State != PluginState.Unloaded) - throw new InvalidPluginOperationException($"Unable to remove {plugin.Name}, not unloaded"); - - this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); - PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty); - - this.NotifyInstalledPluginsChanged(); - this.NotifyAvailablePluginsChanged(); - } - - /// - /// Cleanup disabled plugins. Does not target devPlugins. - /// - public void CleanupPlugins() - { - var configuration = Service.Get(); - var startInfo = Service.Get(); - - foreach (var pluginDir in this.pluginDirectory.GetDirectories()) - { - try - { - var versionDirs = pluginDir.GetDirectories(); - - versionDirs = versionDirs - .OrderByDescending(dir => - { - var isVersion = Version.TryParse(dir.Name, out var version); - - if (!isVersion) - { - Log.Debug($"Not a version, cleaning up {dir.FullName}"); - dir.Delete(true); - } - - return version; - }) - .ToArray(); - - if (versionDirs.Length == 0) - { - Log.Information($"No versions: cleaning up {pluginDir.FullName}"); - pluginDir.Delete(true); - } - else - { - foreach (var versionDir in versionDirs) - { - try - { - var dllFile = new FileInfo(Path.Combine(versionDir.FullName, $"{pluginDir.Name}.dll")); - if (!dllFile.Exists) - { - Log.Information($"Missing dll: cleaning up {versionDir.FullName}"); - versionDir.Delete(true); - continue; - } - - var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); - if (!manifestFile.Exists) - { - Log.Information($"Missing manifest: cleaning up {versionDir.FullName}"); - versionDir.Delete(true); - continue; - } - - var manifest = LocalPluginManifest.Load(manifestFile); - if (manifest.Disabled) - { - Log.Information($"Disabled: cleaning up {versionDir.FullName}"); - versionDir.Delete(true); - continue; - } - - if (manifest.DalamudApiLevel < DalamudApiLevel - 1 && !configuration.LoadAllApiLevels) - { - Log.Information($"Lower API: cleaning up {versionDir.FullName}"); - versionDir.Delete(true); - continue; - } - - if (manifest.ApplicableVersion < startInfo.GameVersion) - { - Log.Information($"Inapplicable version: cleaning up {versionDir.FullName}"); - versionDir.Delete(true); - } - } - catch (Exception ex) - { - Log.Error(ex, $"Could not clean up {versionDir.FullName}"); - } - } - } - } - catch (Exception ex) - { - Log.Error(ex, $"Could not clean up {pluginDir.FullName}"); - } } } - /// - /// Update all non-dev plugins. - /// - /// Perform a dry run, don't install anything. - /// Success or failure and a list of updated plugin metadata. - public async Task> UpdatePluginsAsync(bool dryRun = false) + this.InstalledPlugins = this.InstalledPlugins.Add(plugin); + return plugin; + } + + /// + /// Remove a plugin. + /// + /// Plugin to remove. + public void RemovePlugin(LocalPlugin plugin) + { + if (plugin.State != PluginState.Unloaded) + throw new InvalidPluginOperationException($"Unable to remove {plugin.Name}, not unloaded"); + + this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); + PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty); + + this.NotifyInstalledPluginsChanged(); + this.NotifyAvailablePluginsChanged(); + } + + /// + /// Cleanup disabled plugins. Does not target devPlugins. + /// + public void CleanupPlugins() + { + var configuration = Service.Get(); + var startInfo = Service.Get(); + + foreach (var pluginDir in this.pluginDirectory.GetDirectories()) { - Log.Information("Starting plugin update"); - - var updatedList = new List(); - - // Prevent collection was modified errors - foreach (var plugin in this.UpdatablePlugins) + try { - // Can't update that! - if (plugin.InstalledPlugin.IsDev) - continue; + var versionDirs = pluginDir.GetDirectories(); - var result = await this.UpdateSinglePluginAsync(plugin, false, dryRun); - if (result != null) - updatedList.Add(result); - } + versionDirs = versionDirs + .OrderByDescending(dir => + { + var isVersion = Version.TryParse(dir.Name, out var version); - this.NotifyInstalledPluginsChanged(); + if (!isVersion) + { + Log.Debug($"Not a version, cleaning up {dir.FullName}"); + dir.Delete(true); + } - Log.Information("Plugin update OK."); + return version; + }) + .ToArray(); - return updatedList; - } - - /// - /// Update a single plugin, provided a valid . - /// - /// The available plugin update. - /// Whether to notify that installed plugins have changed afterwards. - /// Whether or not to actually perform the update, or just indicate success. - /// The status of the update. - public async Task UpdateSinglePluginAsync(AvailablePluginUpdate metadata, bool notify, bool dryRun) - { - var plugin = metadata.InstalledPlugin; - - var updateStatus = new PluginUpdateStatus - { - InternalName = plugin.Manifest.InternalName, - Name = plugin.Manifest.Name, - Version = (metadata.UseTesting - ? metadata.UpdateManifest.TestingAssemblyVersion - : metadata.UpdateManifest.AssemblyVersion)!, - WasUpdated = true, - }; - - if (!dryRun) - { - // Unload if loaded - if (plugin.State == PluginState.Loaded || plugin.State == PluginState.LoadError) + if (versionDirs.Length == 0) { - try - { - plugin.Unload(); - } - catch (Exception ex) - { - Log.Error(ex, "Error during unload (update)"); - updateStatus.WasUpdated = false; - return updateStatus; - } - } - - if (plugin.IsDev) - { - try - { - plugin.DllFile.Delete(); - this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); - } - catch (Exception ex) - { - Log.Error(ex, "Error during delete (update)"); - updateStatus.WasUpdated = false; - return updateStatus; - } + Log.Information($"No versions: cleaning up {pluginDir.FullName}"); + pluginDir.Delete(true); } else { - try + foreach (var versionDir in versionDirs) { - plugin.Disable(); - this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); - } - catch (Exception ex) - { - Log.Error(ex, "Error during disable (update)"); - updateStatus.WasUpdated = false; - return updateStatus; + try + { + var dllFile = new FileInfo(Path.Combine(versionDir.FullName, $"{pluginDir.Name}.dll")); + if (!dllFile.Exists) + { + Log.Information($"Missing dll: cleaning up {versionDir.FullName}"); + versionDir.Delete(true); + continue; + } + + var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); + if (!manifestFile.Exists) + { + Log.Information($"Missing manifest: cleaning up {versionDir.FullName}"); + versionDir.Delete(true); + continue; + } + + var manifest = LocalPluginManifest.Load(manifestFile); + if (manifest.Disabled) + { + Log.Information($"Disabled: cleaning up {versionDir.FullName}"); + versionDir.Delete(true); + continue; + } + + if (manifest.DalamudApiLevel < DalamudApiLevel - 1 && !configuration.LoadAllApiLevels) + { + Log.Information($"Lower API: cleaning up {versionDir.FullName}"); + versionDir.Delete(true); + continue; + } + + if (manifest.ApplicableVersion < startInfo.GameVersion) + { + Log.Information($"Inapplicable version: cleaning up {versionDir.FullName}"); + versionDir.Delete(true); + } + } + catch (Exception ex) + { + Log.Error(ex, $"Could not clean up {versionDir.FullName}"); + } } } + } + catch (Exception ex) + { + Log.Error(ex, $"Could not clean up {pluginDir.FullName}"); + } + } + } - // We need to handle removed DTR nodes here, as otherwise, plugins will not be able to re-add their bar entries after updates. - var dtr = Service.Get(); - dtr.HandleRemovedNodes(); + /// + /// Update all non-dev plugins. + /// + /// Perform a dry run, don't install anything. + /// Success or failure and a list of updated plugin metadata. + public async Task> UpdatePluginsAsync(bool dryRun = false) + { + Log.Information("Starting plugin update"); + var updatedList = new List(); + + // Prevent collection was modified errors + foreach (var plugin in this.UpdatablePlugins) + { + // Can't update that! + if (plugin.InstalledPlugin.IsDev) + continue; + + var result = await this.UpdateSinglePluginAsync(plugin, false, dryRun); + if (result != null) + updatedList.Add(result); + } + + this.NotifyInstalledPluginsChanged(); + + Log.Information("Plugin update OK."); + + return updatedList; + } + + /// + /// Update a single plugin, provided a valid . + /// + /// The available plugin update. + /// Whether to notify that installed plugins have changed afterwards. + /// Whether or not to actually perform the update, or just indicate success. + /// The status of the update. + public async Task UpdateSinglePluginAsync(AvailablePluginUpdate metadata, bool notify, bool dryRun) + { + var plugin = metadata.InstalledPlugin; + + var updateStatus = new PluginUpdateStatus + { + InternalName = plugin.Manifest.InternalName, + Name = plugin.Manifest.Name, + Version = (metadata.UseTesting + ? metadata.UpdateManifest.TestingAssemblyVersion + : metadata.UpdateManifest.AssemblyVersion)!, + WasUpdated = true, + }; + + if (!dryRun) + { + // Unload if loaded + if (plugin.State == PluginState.Loaded || plugin.State == PluginState.LoadError) + { try { - await this.InstallPluginAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update); + plugin.Unload(); } catch (Exception ex) { - Log.Error(ex, "Error during install (update)"); + Log.Error(ex, "Error during unload (update)"); updateStatus.WasUpdated = false; return updateStatus; } } - if (notify && updateStatus.WasUpdated) - this.NotifyInstalledPluginsChanged(); - - return updateStatus; - } - - /// - /// Unload the plugin, delete its configuration, and reload it. - /// - /// The plugin. - /// Throws if the plugin is still loading/unloading. - public void DeleteConfiguration(LocalPlugin plugin) - { - if (plugin.State == PluginState.InProgress) - throw new Exception("Cannot delete configuration for a loading/unloading plugin"); - - if (plugin.IsLoaded) - plugin.Unload(); - - // Let's wait so any handles on files in plugin configurations can be closed - Thread.Sleep(500); - - this.PluginConfigs.Delete(plugin.Name); - - Thread.Sleep(500); - - // Let's indicate "installer" here since this is supposed to be a fresh install - plugin.Load(PluginLoadReason.Installer); - } - - /// - /// Gets a value indicating whether the given manifest is eligible for ANYTHING. These are hard - /// checks that should not allow installation or loading. - /// - /// Plugin manifest. - /// If the manifest is eligible. - public bool IsManifestEligible(PluginManifest manifest) - { - var configuration = Service.Get(); - var startInfo = Service.Get(); - - // Testing exclusive - if (manifest.IsTestingExclusive && !configuration.DoPluginTest) - return false; - - // Applicable version - if (manifest.ApplicableVersion < startInfo.GameVersion) - return false; - - // API level - if (manifest.DalamudApiLevel < DalamudApiLevel && !configuration.LoadAllApiLevels) - return false; - - // Banned - return !this.IsManifestBanned(manifest); - } - - /// - /// Determine if a plugin has been banned by inspecting the manifest. - /// - /// Manifest to inspect. - /// A value indicating whether the plugin/manifest has been banned. - public bool IsManifestBanned(PluginManifest manifest) - { - var configuration = Service.Get(); - return !configuration.LoadBannedPlugins && this.bannedPlugins.Any(ban => (ban.Name == manifest.InternalName || ban.Name == Hash.GetStringSha256Hash(manifest.InternalName)) - && ban.AssemblyVersion >= manifest.AssemblyVersion); - } - - /// - /// Get the reason of a banned plugin by inspecting the manifest. - /// - /// Manifest to inspect. - /// The reason of the ban, if any. - public string GetBanReason(PluginManifest manifest) - { - return this.bannedPlugins.LastOrDefault(ban => ban.Name == manifest.InternalName).Reason; - } - - private void DetectAvailablePluginUpdates() - { - var updatablePlugins = new List(); - - foreach (var plugin in this.InstalledPlugins) + if (plugin.IsDev) { - var installedVersion = plugin.IsTesting - ? plugin.Manifest.TestingAssemblyVersion - : plugin.Manifest.AssemblyVersion; - - var updates = this.AvailablePlugins - .Where(remoteManifest => plugin.Manifest.InternalName == remoteManifest.InternalName) - .Select(remoteManifest => - { - var useTesting = UseTesting(remoteManifest); - var candidateVersion = useTesting - ? remoteManifest.TestingAssemblyVersion - : remoteManifest.AssemblyVersion; - var isUpdate = candidateVersion > installedVersion; - - return (isUpdate, useTesting, candidateVersion, remoteManifest); - }) - .Where(tpl => tpl.isUpdate) - .ToList(); - - if (updates.Count > 0) + try { - var update = updates.Aggregate((t1, t2) => t1.candidateVersion > t2.candidateVersion ? t1 : t2); - updatablePlugins.Add(new AvailablePluginUpdate(plugin, update.remoteManifest, update.useTesting)); + plugin.DllFile.Delete(); + this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); + } + catch (Exception ex) + { + Log.Error(ex, "Error during delete (update)"); + updateStatus.WasUpdated = false; + return updateStatus; + } + } + else + { + try + { + plugin.Disable(); + this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); + } + catch (Exception ex) + { + Log.Error(ex, "Error during disable (update)"); + updateStatus.WasUpdated = false; + return updateStatus; } } - this.UpdatablePlugins = updatablePlugins.ToImmutableList(); - } - - private void NotifyAvailablePluginsChanged() - { - this.DetectAvailablePluginUpdates(); + // We need to handle removed DTR nodes here, as otherwise, plugins will not be able to re-add their bar entries after updates. + var dtr = Service.Get(); + dtr.HandleRemovedNodes(); try { - this.OnAvailablePluginsChanged?.Invoke(); + await this.InstallPluginAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update); } catch (Exception ex) { - Log.Error(ex, $"Error notifying {nameof(this.OnAvailablePluginsChanged)}"); + Log.Error(ex, "Error during install (update)"); + updateStatus.WasUpdated = false; + return updateStatus; } } - private void NotifyInstalledPluginsChanged() - { - this.DetectAvailablePluginUpdates(); + if (notify && updateStatus.WasUpdated) + this.NotifyInstalledPluginsChanged(); - try - { - this.OnInstalledPluginsChanged?.Invoke(); - } - catch (Exception ex) - { - Log.Error(ex, $"Error notifying {nameof(this.OnInstalledPluginsChanged)}"); - } - } - - private static class Locs - { - public static string DalamudPluginUpdateSuccessful(string name, Version version) => Loc.Localize("DalamudPluginUpdateSuccessful", " 》 {0} updated to v{1}.").Format(name, version); - - public static string DalamudPluginUpdateFailed(string name, Version version) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed.").Format(name, version); - } + return updateStatus; } /// - /// Class responsible for loading and unloading plugins. - /// This contains the assembly patching functionality to resolve assembly locations. + /// Unload the plugin, delete its configuration, and reload it. /// - internal partial class PluginManager + /// The plugin. + /// Throws if the plugin is still loading/unloading. + public void DeleteConfiguration(LocalPlugin plugin) { - /// - /// A mapping of plugin assembly name to patch data. Used to fill in missing data due to loading - /// plugins via byte[]. - /// - internal static readonly Dictionary PluginLocations = new(); + if (plugin.State == PluginState.InProgress) + throw new Exception("Cannot delete configuration for a loading/unloading plugin"); - private MonoMod.RuntimeDetour.Hook? assemblyLocationMonoHook; - private MonoMod.RuntimeDetour.Hook? assemblyCodeBaseMonoHook; + if (plugin.IsLoaded) + plugin.Unload(); - /// - /// Patch method for internal class RuntimeAssembly.Location, also known as Assembly.Location. - /// This patch facilitates resolving the assembly location for plugins that are loaded via byte[]. - /// It should never be called manually. - /// - /// A delegate that acts as the original method. - /// The equivalent of `this`. - /// The plugin location, or the result from the original method. - private static string AssemblyLocationPatch(Func orig, Assembly self) + // Let's wait so any handles on files in plugin configurations can be closed + Thread.Sleep(500); + + this.PluginConfigs.Delete(plugin.Name); + + Thread.Sleep(500); + + // Let's indicate "installer" here since this is supposed to be a fresh install + plugin.Load(PluginLoadReason.Installer); + } + + /// + /// Gets a value indicating whether the given manifest is eligible for ANYTHING. These are hard + /// checks that should not allow installation or loading. + /// + /// Plugin manifest. + /// If the manifest is eligible. + public bool IsManifestEligible(PluginManifest manifest) + { + var configuration = Service.Get(); + var startInfo = Service.Get(); + + // Testing exclusive + if (manifest.IsTestingExclusive && !configuration.DoPluginTest) + return false; + + // Applicable version + if (manifest.ApplicableVersion < startInfo.GameVersion) + return false; + + // API level + if (manifest.DalamudApiLevel < DalamudApiLevel && !configuration.LoadAllApiLevels) + return false; + + // Banned + return !this.IsManifestBanned(manifest); + } + + /// + /// Determine if a plugin has been banned by inspecting the manifest. + /// + /// Manifest to inspect. + /// A value indicating whether the plugin/manifest has been banned. + public bool IsManifestBanned(PluginManifest manifest) + { + var configuration = Service.Get(); + return !configuration.LoadBannedPlugins && this.bannedPlugins.Any(ban => (ban.Name == manifest.InternalName || ban.Name == Hash.GetStringSha256Hash(manifest.InternalName)) + && ban.AssemblyVersion >= manifest.AssemblyVersion); + } + + /// + /// Get the reason of a banned plugin by inspecting the manifest. + /// + /// Manifest to inspect. + /// The reason of the ban, if any. + public string GetBanReason(PluginManifest manifest) + { + return this.bannedPlugins.LastOrDefault(ban => ban.Name == manifest.InternalName).Reason; + } + + private void DetectAvailablePluginUpdates() + { + var updatablePlugins = new List(); + + foreach (var plugin in this.InstalledPlugins) { - var result = orig(self); + var installedVersion = plugin.IsTesting + ? plugin.Manifest.TestingAssemblyVersion + : plugin.Manifest.AssemblyVersion; - if (string.IsNullOrEmpty(result)) + var updates = this.AvailablePlugins + .Where(remoteManifest => plugin.Manifest.InternalName == remoteManifest.InternalName) + .Select(remoteManifest => + { + var useTesting = UseTesting(remoteManifest); + var candidateVersion = useTesting + ? remoteManifest.TestingAssemblyVersion + : remoteManifest.AssemblyVersion; + var isUpdate = candidateVersion > installedVersion; + + return (isUpdate, useTesting, candidateVersion, remoteManifest); + }) + .Where(tpl => tpl.isUpdate) + .ToList(); + + if (updates.Count > 0) { - foreach (var assemblyName in GetStackFrameAssemblyNames()) - { - if (PluginLocations.TryGetValue(assemblyName, out var data)) - { - result = data.Location; - break; - } - } - } - - result ??= string.Empty; - - Log.Verbose($"Assembly.Location // {self.FullName} // {result}"); - return result; - } - - /// - /// Patch method for internal class RuntimeAssembly.CodeBase, also known as Assembly.CodeBase. - /// This patch facilitates resolving the assembly location for plugins that are loaded via byte[]. - /// It should never be called manually. - /// - /// A delegate that acts as the original method. - /// The equivalent of `this`. - /// The plugin code base, or the result from the original method. - private static string AssemblyCodeBasePatch(Func orig, Assembly self) - { - var result = orig(self); - - if (string.IsNullOrEmpty(result)) - { - foreach (var assemblyName in GetStackFrameAssemblyNames()) - { - if (PluginLocations.TryGetValue(assemblyName, out var data)) - { - result = data.CodeBase; - break; - } - } - } - - result ??= string.Empty; - - Log.Verbose($"Assembly.CodeBase // {self.FullName} // {result}"); - return result; - } - - private static IEnumerable GetStackFrameAssemblyNames() - { - var stackTrace = new StackTrace(); - var stackFrames = stackTrace.GetFrames(); - - foreach (var stackFrame in stackFrames) - { - var methodBase = stackFrame.GetMethod(); - if (methodBase == null) - continue; - - yield return methodBase.Module.Assembly.FullName!; + var update = updates.Aggregate((t1, t2) => t1.candidateVersion > t2.candidateVersion ? t1 : t2); + updatablePlugins.Add(new AvailablePluginUpdate(plugin, update.remoteManifest, update.useTesting)); } } - private void ApplyPatches() + this.UpdatablePlugins = updatablePlugins.ToImmutableList(); + } + + private void NotifyAvailablePluginsChanged() + { + this.DetectAvailablePluginUpdates(); + + try { - var targetType = typeof(PluginManager).Assembly.GetType(); - - var locationTarget = targetType.GetProperty(nameof(Assembly.Location))!.GetGetMethod(); - var locationPatch = typeof(PluginManager).GetMethod(nameof(AssemblyLocationPatch), BindingFlags.NonPublic | BindingFlags.Static); - this.assemblyLocationMonoHook = new MonoMod.RuntimeDetour.Hook(locationTarget, locationPatch); - - #pragma warning disable CS0618 - #pragma warning disable SYSLIB0012 - var codebaseTarget = targetType.GetProperty(nameof(Assembly.CodeBase))?.GetGetMethod(); - #pragma warning restore SYSLIB0012 - #pragma warning restore CS0618 - var codebasePatch = typeof(PluginManager).GetMethod(nameof(AssemblyCodeBasePatch), BindingFlags.NonPublic | BindingFlags.Static); - this.assemblyCodeBaseMonoHook = new MonoMod.RuntimeDetour.Hook(codebaseTarget, codebasePatch); + this.OnAvailablePluginsChanged?.Invoke(); + } + catch (Exception ex) + { + Log.Error(ex, $"Error notifying {nameof(this.OnAvailablePluginsChanged)}"); } } + + private void NotifyInstalledPluginsChanged() + { + this.DetectAvailablePluginUpdates(); + + try + { + this.OnInstalledPluginsChanged?.Invoke(); + } + catch (Exception ex) + { + Log.Error(ex, $"Error notifying {nameof(this.OnInstalledPluginsChanged)}"); + } + } + + private static class Locs + { + public static string DalamudPluginUpdateSuccessful(string name, Version version) => Loc.Localize("DalamudPluginUpdateSuccessful", " 》 {0} updated to v{1}.").Format(name, version); + + public static string DalamudPluginUpdateFailed(string name, Version version) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed.").Format(name, version); + } +} + +/// +/// Class responsible for loading and unloading plugins. +/// This contains the assembly patching functionality to resolve assembly locations. +/// +internal partial class PluginManager +{ + /// + /// A mapping of plugin assembly name to patch data. Used to fill in missing data due to loading + /// plugins via byte[]. + /// + internal static readonly Dictionary PluginLocations = new(); + + private MonoMod.RuntimeDetour.Hook? assemblyLocationMonoHook; + private MonoMod.RuntimeDetour.Hook? assemblyCodeBaseMonoHook; + + /// + /// Patch method for internal class RuntimeAssembly.Location, also known as Assembly.Location. + /// This patch facilitates resolving the assembly location for plugins that are loaded via byte[]. + /// It should never be called manually. + /// + /// A delegate that acts as the original method. + /// The equivalent of `this`. + /// The plugin location, or the result from the original method. + private static string AssemblyLocationPatch(Func orig, Assembly self) + { + var result = orig(self); + + if (string.IsNullOrEmpty(result)) + { + foreach (var assemblyName in GetStackFrameAssemblyNames()) + { + if (PluginLocations.TryGetValue(assemblyName, out var data)) + { + result = data.Location; + break; + } + } + } + + result ??= string.Empty; + + Log.Verbose($"Assembly.Location // {self.FullName} // {result}"); + return result; + } + + /// + /// Patch method for internal class RuntimeAssembly.CodeBase, also known as Assembly.CodeBase. + /// This patch facilitates resolving the assembly location for plugins that are loaded via byte[]. + /// It should never be called manually. + /// + /// A delegate that acts as the original method. + /// The equivalent of `this`. + /// The plugin code base, or the result from the original method. + private static string AssemblyCodeBasePatch(Func orig, Assembly self) + { + var result = orig(self); + + if (string.IsNullOrEmpty(result)) + { + foreach (var assemblyName in GetStackFrameAssemblyNames()) + { + if (PluginLocations.TryGetValue(assemblyName, out var data)) + { + result = data.CodeBase; + break; + } + } + } + + result ??= string.Empty; + + Log.Verbose($"Assembly.CodeBase // {self.FullName} // {result}"); + return result; + } + + private static IEnumerable GetStackFrameAssemblyNames() + { + var stackTrace = new StackTrace(); + var stackFrames = stackTrace.GetFrames(); + + foreach (var stackFrame in stackFrames) + { + var methodBase = stackFrame.GetMethod(); + if (methodBase == null) + continue; + + yield return methodBase.Module.Assembly.FullName!; + } + } + + private void ApplyPatches() + { + var targetType = typeof(PluginManager).Assembly.GetType(); + + var locationTarget = targetType.GetProperty(nameof(Assembly.Location))!.GetGetMethod(); + var locationPatch = typeof(PluginManager).GetMethod(nameof(AssemblyLocationPatch), BindingFlags.NonPublic | BindingFlags.Static); + this.assemblyLocationMonoHook = new MonoMod.RuntimeDetour.Hook(locationTarget, locationPatch); + +#pragma warning disable CS0618 +#pragma warning disable SYSLIB0012 + var codebaseTarget = targetType.GetProperty(nameof(Assembly.CodeBase))?.GetGetMethod(); +#pragma warning restore SYSLIB0012 +#pragma warning restore CS0618 + var codebasePatch = typeof(PluginManager).GetMethod(nameof(AssemblyCodeBasePatch), BindingFlags.NonPublic | BindingFlags.Static); + this.assemblyCodeBaseMonoHook = new MonoMod.RuntimeDetour.Hook(codebaseTarget, codebasePatch); + } } diff --git a/Dalamud/Plugin/Internal/PluginRepository.cs b/Dalamud/Plugin/Internal/PluginRepository.cs deleted file mode 100644 index c00755ec2..000000000 --- a/Dalamud/Plugin/Internal/PluginRepository.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; - -using Dalamud.Logging.Internal; -using Dalamud.Plugin.Internal.Types; -using Newtonsoft.Json; - -namespace Dalamud.Plugin.Internal -{ - /// - /// This class represents a single plugin repository. - /// - internal class PluginRepository - { - private const string DalamudPluginsMasterUrl = "https://kamori.goats.dev/Plugin/PluginMaster"; - - private static readonly ModuleLog Log = new("PLUGINR"); - - private static readonly HttpClient HttpClient = new() - { - DefaultRequestHeaders = - { - CacheControl = new CacheControlHeaderValue - { - NoCache = true, - }, - }, - }; - - /// - /// Initializes a new instance of the class. - /// - /// The plugin master URL. - /// Whether the plugin repo is enabled. - public PluginRepository(string pluginMasterUrl, bool isEnabled) - { - this.PluginMasterUrl = pluginMasterUrl; - this.IsThirdParty = pluginMasterUrl != DalamudPluginsMasterUrl; - this.IsEnabled = isEnabled; - } - - /// - /// Gets a new instance of the class for the main repo. - /// - public static PluginRepository MainRepo => new(DalamudPluginsMasterUrl, true); - - /// - /// Gets the pluginmaster.json URL. - /// - public string PluginMasterUrl { get; } - - /// - /// Gets a value indicating whether this plugin repository is from a third party. - /// - public bool IsThirdParty { get; } - - /// - /// Gets a value indicating whether this repo is enabled. - /// - public bool IsEnabled { get; } - - /// - /// Gets the plugin master list of available plugins. - /// - public ReadOnlyCollection? PluginMaster { get; private set; } - - /// - /// Gets the initialization state of the plugin repository. - /// - public PluginRepositoryState State { get; private set; } - - /// - /// Reload the plugin master asynchronously in a task. - /// - /// The new state. - public async Task ReloadPluginMasterAsync() - { - this.State = PluginRepositoryState.InProgress; - this.PluginMaster = new List().AsReadOnly(); - - try - { - Log.Information($"Fetching repo: {this.PluginMasterUrl}"); - - using var response = await HttpClient.GetAsync(this.PluginMasterUrl); - response.EnsureSuccessStatusCode(); - - var data = await response.Content.ReadAsStringAsync(); - var pluginMaster = JsonConvert.DeserializeObject>(data); - - if (pluginMaster == null) - { - throw new Exception("Deserialized PluginMaster was null."); - } - - pluginMaster.Sort((pm1, pm2) => pm1.Name.CompareTo(pm2.Name)); - - // Set the source for each remote manifest. Allows for checking if is 3rd party. - foreach (var manifest in pluginMaster) - { - manifest.SourceRepo = this; - } - - this.PluginMaster = pluginMaster.AsReadOnly(); - - Log.Debug($"Successfully fetched repo: {this.PluginMasterUrl}"); - this.State = PluginRepositoryState.Success; - } - catch (Exception ex) - { - Log.Error(ex, $"PluginMaster failed: {this.PluginMasterUrl}"); - this.State = PluginRepositoryState.Fail; - } - } - } -} diff --git a/Dalamud/Plugin/Internal/Types/AvailablePluginUpdate.cs b/Dalamud/Plugin/Internal/Types/AvailablePluginUpdate.cs index 32dde337c..13523a379 100644 --- a/Dalamud/Plugin/Internal/Types/AvailablePluginUpdate.cs +++ b/Dalamud/Plugin/Internal/Types/AvailablePluginUpdate.cs @@ -1,36 +1,35 @@ -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Information about an available plugin update. +/// +internal record AvailablePluginUpdate { /// - /// Information about an available plugin update. + /// Initializes a new instance of the class. /// - internal record AvailablePluginUpdate + /// The installed plugin to update. + /// The manifest to use for the update. + /// If the testing version should be used for the update. + public AvailablePluginUpdate(LocalPlugin installedPlugin, RemotePluginManifest updateManifest, bool useTesting) { - /// - /// Initializes a new instance of the class. - /// - /// The installed plugin to update. - /// The manifest to use for the update. - /// If the testing version should be used for the update. - public AvailablePluginUpdate(LocalPlugin installedPlugin, RemotePluginManifest updateManifest, bool useTesting) - { - this.InstalledPlugin = installedPlugin; - this.UpdateManifest = updateManifest; - this.UseTesting = useTesting; - } - - /// - /// Gets the currently installed plugin. - /// - public LocalPlugin InstalledPlugin { get; init; } - - /// - /// Gets the available update manifest. - /// - public RemotePluginManifest UpdateManifest { get; init; } - - /// - /// Gets a value indicating whether the update should use the testing URL. - /// - public bool UseTesting { get; init; } + this.InstalledPlugin = installedPlugin; + this.UpdateManifest = updateManifest; + this.UseTesting = useTesting; } + + /// + /// Gets the currently installed plugin. + /// + public LocalPlugin InstalledPlugin { get; init; } + + /// + /// Gets the available update manifest. + /// + public RemotePluginManifest UpdateManifest { get; init; } + + /// + /// Gets a value indicating whether the update should use the testing URL. + /// + public bool UseTesting { get; init; } } diff --git a/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs new file mode 100644 index 000000000..eb0877227 --- /dev/null +++ b/Dalamud/Plugin/Internal/Types/LocalDevPlugin.cs @@ -0,0 +1,162 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.Configuration.Internal; +using Dalamud.Interface.Internal.Notifications; +using Dalamud.Logging.Internal; + +namespace Dalamud.Plugin.Internal.Types; + +/// +/// This class represents a dev plugin and all facets of its lifecycle. +/// The DLL on disk, dependencies, loaded assembly, etc. +/// +internal class LocalDevPlugin : LocalPlugin, IDisposable +{ + private static readonly ModuleLog Log = new("PLUGIN"); + + // Ref to Dalamud.Configuration.DevPluginSettings + private readonly DevPluginSettings devSettings; + + private FileSystemWatcher? fileWatcher; + private CancellationTokenSource fileWatcherTokenSource = new(); + private int reloadCounter; + + /// + /// Initializes a new instance of the class. + /// + /// Path to the DLL file. + /// The plugin manifest. + public LocalDevPlugin(FileInfo dllFile, LocalPluginManifest? manifest) + : base(dllFile, manifest) + { + var configuration = Service.Get(); + + if (!configuration.DevPluginSettings.TryGetValue(dllFile.FullName, out this.devSettings)) + { + configuration.DevPluginSettings[dllFile.FullName] = this.devSettings = new DevPluginSettings(); + configuration.Save(); + } + + if (this.AutomaticReload) + { + this.EnableReloading(); + } + } + + /// + /// Gets or sets a value indicating whether this dev plugin should start on boot. + /// + public bool StartOnBoot + { + get => this.devSettings.StartOnBoot; + set => this.devSettings.StartOnBoot = value; + } + + /// + /// Gets or sets a value indicating whether this dev plugin should reload on change. + /// + public bool AutomaticReload + { + get => this.devSettings.AutomaticReloading; + set + { + this.devSettings.AutomaticReloading = value; + + if (this.devSettings.AutomaticReloading) + { + this.EnableReloading(); + } + else + { + this.DisableReloading(); + } + } + } + + /// + public new void Dispose() + { + if (this.fileWatcher != null) + { + this.fileWatcher.Changed -= this.OnFileChanged; + this.fileWatcherTokenSource.Cancel(); + this.fileWatcher.Dispose(); + } + + base.Dispose(); + } + + /// + /// Configure this plugin for automatic reloading and enable it. + /// + public void EnableReloading() + { + if (this.fileWatcher == null && this.DllFile.DirectoryName != null) + { + this.fileWatcherTokenSource = new CancellationTokenSource(); + this.fileWatcher = new FileSystemWatcher(this.DllFile.DirectoryName); + this.fileWatcher.Changed += this.OnFileChanged; + this.fileWatcher.Filter = this.DllFile.Name; + this.fileWatcher.NotifyFilter = NotifyFilters.LastWrite; + this.fileWatcher.EnableRaisingEvents = true; + } + } + + /// + /// Disable automatic reloading for this plugin. + /// + public void DisableReloading() + { + if (this.fileWatcher != null) + { + this.fileWatcherTokenSource.Cancel(); + this.fileWatcher.Changed -= this.OnFileChanged; + this.fileWatcher.Dispose(); + this.fileWatcher = null; + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs args) + { + var current = Interlocked.Increment(ref this.reloadCounter); + + Task.Delay(500).ContinueWith( + _ => + { + if (this.fileWatcherTokenSource.IsCancellationRequested) + { + Log.Debug($"Skipping reload of {this.Name}, file watcher was cancelled."); + return; + } + + if (current != this.reloadCounter) + { + Log.Debug($"Skipping reload of {this.Name}, file has changed again."); + return; + } + + if (this.State != PluginState.Loaded) + { + Log.Debug($"Skipping reload of {this.Name}, state ({this.State}) is not {PluginState.Loaded}."); + return; + } + + var notificationManager = Service.Get(); + + try + { + this.Reload(); + notificationManager.AddNotification($"The DevPlugin '{this.Name} was reloaded successfully.", "Plugin reloaded!", NotificationType.Success); + } + catch (Exception ex) + { + Log.Error(ex, "DevPlugin reload failed."); + notificationManager.AddNotification($"The DevPlugin '{this.Name} could not be reloaded.", "Plugin reload failed!", NotificationType.Error); + } + }, + this.fileWatcherTokenSource.Token); + } +} diff --git a/Dalamud/Plugin/Internal/Types/LocalPlugin.cs b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs new file mode 100644 index 000000000..1ac413ca1 --- /dev/null +++ b/Dalamud/Plugin/Internal/Types/LocalPlugin.cs @@ -0,0 +1,501 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; + +using Dalamud.Configuration.Internal; +using Dalamud.Game; +using Dalamud.Game.Gui.Dtr; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Plugin.Internal.Exceptions; +using Dalamud.Plugin.Internal.Loader; +using Dalamud.Utility; +using Dalamud.Utility.Signatures; + +namespace Dalamud.Plugin.Internal.Types; + +/// +/// This class represents a plugin and all facets of its lifecycle. +/// The DLL on disk, dependencies, loaded assembly, etc. +/// +internal class LocalPlugin : IDisposable +{ + private static readonly ModuleLog Log = new("LOCALPLUGIN"); + + private readonly FileInfo manifestFile; + private readonly FileInfo disabledFile; + private readonly FileInfo testingFile; + + private PluginLoader? loader; + private Assembly? pluginAssembly; + private Type? pluginType; + private IDalamudPlugin? instance; + + /// + /// Initializes a new instance of the class. + /// + /// Path to the DLL file. + /// The plugin manifest. + public LocalPlugin(FileInfo dllFile, LocalPluginManifest? manifest) + { + if (dllFile.Name == "FFXIVClientStructs.Generators.dll") + { + // Could this be done another way? Sure. It is an extremely common source + // of errors in the log through, and should never be loaded as a plugin. + Log.Error($"Not a plugin: {dllFile.FullName}"); + throw new InvalidPluginException(dllFile); + } + + this.DllFile = dllFile; + this.State = PluginState.Unloaded; + + this.loader = PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, SetupLoaderConfig); + + try + { + this.pluginAssembly = this.loader.LoadDefaultAssembly(); + } + catch (Exception ex) + { + this.pluginAssembly = null; + this.pluginType = null; + this.loader.Dispose(); + + Log.Error(ex, $"Not a plugin: {this.DllFile.FullName}"); + throw new InvalidPluginException(this.DllFile); + } + + try + { + this.pluginType = this.pluginAssembly.GetTypes().FirstOrDefault(type => type.IsAssignableTo(typeof(IDalamudPlugin))); + } + catch (ReflectionTypeLoadException ex) + { + Log.Error(ex, $"Could not load one or more types when searching for IDalamudPlugin: {this.DllFile.FullName}"); + // Something blew up when parsing types, but we still want to look for IDalamudPlugin. Let Load() handle the error. + this.pluginType = ex.Types.FirstOrDefault(type => type != null && type.IsAssignableTo(typeof(IDalamudPlugin))); + } + + if (this.pluginType == default) + { + this.pluginAssembly = null; + this.pluginType = null; + this.loader.Dispose(); + + Log.Error($"Nothing inherits from IDalamudPlugin: {this.DllFile.FullName}"); + throw new InvalidPluginException(this.DllFile); + } + + var assemblyVersion = this.pluginAssembly.GetName().Version; + + // Although it is conditionally used here, we need to set the initial value regardless. + this.manifestFile = LocalPluginManifest.GetManifestFile(this.DllFile); + + // If the parameter manifest was null + if (manifest == null) + { + this.Manifest = new LocalPluginManifest() + { + Author = "developer", + Name = Path.GetFileNameWithoutExtension(this.DllFile.Name), + InternalName = Path.GetFileNameWithoutExtension(this.DllFile.Name), + AssemblyVersion = assemblyVersion ?? new Version("1.0.0.0"), + Description = string.Empty, + ApplicableVersion = GameVersion.Any, + DalamudApiLevel = PluginManager.DalamudApiLevel, + IsHide = false, + }; + + // Save the manifest to disk so there won't be any problems later. + // We'll update the name property after it can be retrieved from the instance. + this.Manifest.Save(this.manifestFile); + } + else + { + this.Manifest = manifest; + } + + // This converts from the ".disabled" file feature to the manifest instead. + this.disabledFile = LocalPluginManifest.GetDisabledFile(this.DllFile); + if (this.disabledFile.Exists) + { + this.Manifest.Disabled = true; + this.disabledFile.Delete(); + } + + // This converts from the ".testing" file feature to the manifest instead. + this.testingFile = LocalPluginManifest.GetTestingFile(this.DllFile); + if (this.testingFile.Exists) + { + this.Manifest.Testing = true; + this.testingFile.Delete(); + } + + var pluginManager = Service.Get(); + this.IsBanned = pluginManager.IsManifestBanned(this.Manifest); + this.BanReason = pluginManager.GetBanReason(this.Manifest); + + this.SaveManifest(); + } + + /// + /// Gets the associated with this plugin. + /// + public DalamudPluginInterface? DalamudInterface { get; private set; } + + /// + /// Gets the path to the plugin DLL. + /// + public FileInfo DllFile { get; } + + /// + /// Gets the plugin manifest, if one exists. + /// + public LocalPluginManifest Manifest { get; private set; } + + /// + /// Gets or sets the current state of the plugin. + /// + public PluginState State { get; protected set; } + + /// + /// Gets the AssemblyName plugin, populated during . + /// + /// Plugin type. + public AssemblyName? AssemblyName { get; private set; } + + /// + /// Gets the plugin name, directly from the plugin or if it is not loaded from the manifest. + /// + public string Name => this.instance?.Name ?? this.Manifest.Name; + + /// + /// Gets an optional reason, if the plugin is banned. + /// + public string BanReason { get; } + + /// + /// Gets a value indicating whether the plugin is loaded and running. + /// + public bool IsLoaded => this.State == PluginState.Loaded; + + /// + /// Gets a value indicating whether the plugin is disabled. + /// + public bool IsDisabled => this.Manifest.Disabled; + + /// + /// Gets a value indicating whether this plugin's API level is out of date. + /// + public bool IsOutdated => this.Manifest.DalamudApiLevel < PluginManager.DalamudApiLevel; + + /// + /// Gets a value indicating whether the plugin is for testing use only. + /// + public bool IsTesting => this.Manifest.IsTestingExclusive || this.Manifest.Testing; + + /// + /// Gets a value indicating whether this plugin has been banned. + /// + public bool IsBanned { get; } + + /// + /// Gets a value indicating whether this plugin is dev plugin. + /// + public bool IsDev => this is LocalDevPlugin; + + /// + public void Dispose() + { + this.instance?.Dispose(); + this.instance = null; + + this.DalamudInterface?.ExplicitDispose(); + this.DalamudInterface = null; + + this.pluginType = null; + this.pluginAssembly = null; + + this.loader?.Dispose(); + } + + /// + /// Load this plugin. + /// + /// The reason why this plugin is being loaded. + /// Load while reloading. + public void Load(PluginLoadReason reason, bool reloading = false) + { + var startInfo = Service.Get(); + var configuration = Service.Get(); + var pluginManager = Service.Get(); + + // Allowed: Unloaded + switch (this.State) + { + case PluginState.InProgress: + throw new InvalidPluginOperationException($"Unable to load {this.Name}, already working"); + case PluginState.Loaded: + throw new InvalidPluginOperationException($"Unable to load {this.Name}, already loaded"); + case PluginState.LoadError: + throw new InvalidPluginOperationException($"Unable to load {this.Name}, load previously faulted, unload first"); + case PluginState.UnloadError: + throw new InvalidPluginOperationException($"Unable to load {this.Name}, unload previously faulted, restart Dalamud"); + case PluginState.Unloaded: + break; + default: + throw new ArgumentOutOfRangeException(this.State.ToString()); + } + + if (pluginManager.IsManifestBanned(this.Manifest)) + throw new BannedPluginException($"Unable to load {this.Name}, banned"); + + if (this.Manifest.ApplicableVersion < startInfo.GameVersion) + throw new InvalidPluginOperationException($"Unable to load {this.Name}, no applicable version"); + + if (this.Manifest.DalamudApiLevel < PluginManager.DalamudApiLevel && !configuration.LoadAllApiLevels) + throw new InvalidPluginOperationException($"Unable to load {this.Name}, incompatible API level"); + + if (this.Manifest.Disabled) + throw new InvalidPluginOperationException($"Unable to load {this.Name}, disabled"); + + this.State = PluginState.InProgress; + Log.Information($"Loading {this.DllFile.Name}"); + + if (this.DllFile.DirectoryName != null && File.Exists(Path.Combine(this.DllFile.DirectoryName, "Dalamud.dll"))) + { + Log.Error("==== IMPORTANT MESSAGE TO {0}, THE DEVELOPER OF {1} ====", this.Manifest.Author!, this.Manifest.InternalName); + Log.Error("YOU ARE INCLUDING DALAMUD DEPENDENCIES IN YOUR BUILDS!!!"); + Log.Error("You may not be able to load your plugin. \"False\" needs to be set in your csproj."); + Log.Error("If you are using ILMerge, do not merge anything other than your direct dependencies."); + Log.Error("Do not merge FFXIVClientStructs.Generators.dll."); + Log.Error("Please refer to https://github.com/goatcorp/Dalamud/discussions/603 for more information."); + } + + try + { + this.loader ??= PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, SetupLoaderConfig); + + if (reloading || this.IsDev) + { + if (this.IsDev) + { + // If a dev plugin is set to not autoload on boot, but we want to reload it at the arbitrary load + // time, we need to essentially "Unload" the plugin, but we can't call plugin.Unload because of the + // load state checks. Null any references to the assembly and types, then proceed with regular reload + // operations. + this.pluginAssembly = null; + this.pluginType = null; + } + + this.loader.Reload(); + + if (this.IsDev) + { + // Reload the manifest in-case there were changes here too. + var manifestDevFile = LocalPluginManifest.GetManifestFile(this.DllFile); + if (manifestDevFile.Exists) + { + this.Manifest = LocalPluginManifest.Load(manifestDevFile); + } + } + } + + // Load the assembly + this.pluginAssembly ??= this.loader.LoadDefaultAssembly(); + + this.AssemblyName = this.pluginAssembly.GetName(); + + // Find the plugin interface implementation. It is guaranteed to exist after checking in the ctor. + this.pluginType ??= this.pluginAssembly.GetTypes().First(type => type.IsAssignableTo(typeof(IDalamudPlugin))); + + // Check for any loaded plugins with the same assembly name + var assemblyName = this.pluginAssembly.GetName().Name; + foreach (var otherPlugin in pluginManager.InstalledPlugins) + { + // During hot-reloading, this plugin will be in the plugin list, and the instance will have been disposed + if (otherPlugin == this || otherPlugin.instance == null) + continue; + + var otherPluginAssemblyName = otherPlugin.instance.GetType().Assembly.GetName().Name; + if (otherPluginAssemblyName == assemblyName && otherPluginAssemblyName != null) + { + this.State = PluginState.Unloaded; + Log.Debug($"Duplicate assembly: {this.Name}"); + + throw new DuplicatePluginException(assemblyName); + } + } + + // Update the location for the Location and CodeBase patches + PluginManager.PluginLocations[this.pluginType.Assembly.FullName] = new PluginPatchData(this.DllFile); + + this.DalamudInterface = new DalamudPluginInterface(this.pluginAssembly.GetName().Name!, this.DllFile, reason, this.IsDev); + + var ioc = Service.Get(); + this.instance = ioc.Create(this.pluginType, this.DalamudInterface) as IDalamudPlugin; + if (this.instance == null) + { + this.State = PluginState.LoadError; + this.DalamudInterface.ExplicitDispose(); + Log.Error($"Error while loading {this.Name}, failed to bind and call the plugin constructor"); + return; + } + + SignatureHelper.Initialise(this.instance); + + // In-case the manifest name was a placeholder. Can occur when no manifest was included. + if (this.instance.Name != this.Manifest.Name) + { + this.Manifest.Name = this.instance.Name; + this.Manifest.Save(this.manifestFile); + } + + this.State = PluginState.Loaded; + Log.Information($"Finished loading {this.DllFile.Name}"); + } + catch (Exception ex) + { + this.State = PluginState.LoadError; + Log.Error(ex, $"Error while loading {this.Name}"); + + throw; + } + } + + /// + /// Unload this plugin. This is the same as dispose, but without the "disposed" connotations. This object should stay + /// in the plugin list until it has been actually disposed. + /// + /// Unload while reloading. + public void Unload(bool reloading = false) + { + // Allowed: Loaded, LoadError(we are cleaning this up while we're at it) + switch (this.State) + { + case PluginState.InProgress: + throw new InvalidPluginOperationException($"Unable to unload {this.Name}, already working"); + case PluginState.Unloaded: + throw new InvalidPluginOperationException($"Unable to unload {this.Name}, already unloaded"); + case PluginState.UnloadError: + throw new InvalidPluginOperationException($"Unable to unload {this.Name}, unload previously faulted, restart Dalamud"); + case PluginState.Loaded: + break; + case PluginState.LoadError: + break; + default: + throw new ArgumentOutOfRangeException(this.State.ToString()); + } + + try + { + this.State = PluginState.InProgress; + Log.Information($"Unloading {this.DllFile.Name}"); + + this.instance?.Dispose(); + this.instance = null; + + this.DalamudInterface?.ExplicitDispose(); + this.DalamudInterface = null; + + this.pluginType = null; + this.pluginAssembly = null; + + if (!reloading) + { + this.loader?.Dispose(); + this.loader = null; + } + + this.State = PluginState.Unloaded; + Log.Information($"Finished unloading {this.DllFile.Name}"); + } + catch (Exception ex) + { + this.State = PluginState.UnloadError; + Log.Error(ex, $"Error while unloading {this.Name}"); + + throw; + } + } + + /// + /// Reload this plugin. + /// + public void Reload() + { + this.Unload(true); + + // We need to handle removed DTR nodes here, as otherwise, plugins will not be able to re-add their bar entries after updates. + var dtr = Service.Get(); + dtr.HandleRemovedNodes(); + + this.Load(PluginLoadReason.Reload, true); + } + + /// + /// Revert a disable. Must be unloaded first, does not load. + /// + public void Enable() + { + // Allowed: Unloaded, UnloadError + switch (this.State) + { + case PluginState.InProgress: + case PluginState.Loaded: + case PluginState.LoadError: + throw new InvalidPluginOperationException($"Unable to enable {this.Name}, still loaded"); + case PluginState.Unloaded: + break; + case PluginState.UnloadError: + break; + default: + throw new ArgumentOutOfRangeException(this.State.ToString()); + } + + if (!this.Manifest.Disabled) + throw new InvalidPluginOperationException($"Unable to enable {this.Name}, not disabled"); + + this.Manifest.Disabled = false; + this.SaveManifest(); + } + + /// + /// Disable this plugin, must be unloaded first. + /// + public void Disable() + { + // Allowed: Unloaded, UnloadError + switch (this.State) + { + case PluginState.InProgress: + case PluginState.Loaded: + case PluginState.LoadError: + throw new InvalidPluginOperationException($"Unable to disable {this.Name}, still loaded"); + case PluginState.Unloaded: + break; + case PluginState.UnloadError: + break; + default: + throw new ArgumentOutOfRangeException(this.State.ToString()); + } + + if (this.Manifest.Disabled) + throw new InvalidPluginOperationException($"Unable to disable {this.Name}, already disabled"); + + this.Manifest.Disabled = true; + this.SaveManifest(); + } + + private static void SetupLoaderConfig(LoaderConfig config) + { + config.IsUnloadable = true; + config.LoadInMemory = true; + config.PreferSharedTypes = false; + config.SharedAssemblies.Add(typeof(Lumina.GameData).Assembly.GetName()); + config.SharedAssemblies.Add(typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName()); + } + + private void SaveManifest() => this.Manifest.Save(this.manifestFile); +} diff --git a/Dalamud/Plugin/Internal/Types/LocalPluginManifest.cs b/Dalamud/Plugin/Internal/Types/LocalPluginManifest.cs index a68418b2f..261f28b0e 100644 --- a/Dalamud/Plugin/Internal/Types/LocalPluginManifest.cs +++ b/Dalamud/Plugin/Internal/Types/LocalPluginManifest.cs @@ -1,99 +1,79 @@ using System.IO; -using Dalamud.Utility; using Newtonsoft.Json; -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Information about a plugin, packaged in a json file with the DLL. This variant includes additional information such as +/// if the plugin is disabled and if it was installed from a testing URL. This is designed for use with manifests on disk. +/// +internal record LocalPluginManifest : PluginManifest { /// - /// Information about a plugin, packaged in a json file with the DLL. This variant includes additional information such as - /// if the plugin is disabled and if it was installed from a testing URL. This is designed for use with manifests on disk. + /// Gets or sets a value indicating whether the plugin is disabled and should not be loaded. + /// This value supersedes the ".disabled" file functionality and should not be included in the plugin master. /// - internal record LocalPluginManifest : PluginManifest - { - /// - /// Gets or sets a value indicating whether the plugin is disabled and should not be loaded. - /// This value supercedes the ".disabled" file functionality and should not be included in the plugin master. - /// - public bool Disabled { get; set; } = false; + public bool Disabled { get; set; } - /// - /// Gets or sets a value indicating whether the plugin should only be loaded when testing is enabled. - /// This value supercedes the ".testing" file functionality and should not be included in the plugin master. - /// - public bool Testing { get; set; } = false; + /// + /// Gets or sets a value indicating whether the plugin should only be loaded when testing is enabled. + /// This value supersedes the ".testing" file functionality and should not be included in the plugin master. + /// + public bool Testing { get; set; } - /// - /// Gets or sets the 3rd party repo URL that this plugin was installed from. Used to display where the plugin was - /// sourced from on the installed plugin view. This should not be included in the plugin master. This value is null - /// when installed from the main repo. - /// - public string InstalledFromUrl { get; set; } + /// + /// Gets or sets the 3rd party repo URL that this plugin was installed from. Used to display where the plugin was + /// sourced from on the installed plugin view. This should not be included in the plugin master. This value is null + /// when installed from the main repo. + /// + public string InstalledFromUrl { get; set; } = string.Empty; - /// - /// Gets a value indicating whether this manifest is associated with a plugin that was installed from a third party - /// repo. Unless the manifest has been manually modified, this is determined by the InstalledFromUrl being null. - /// - public bool IsThirdParty => !string.IsNullOrEmpty(this.InstalledFromUrl); + /// + /// Gets a value indicating whether this manifest is associated with a plugin that was installed from a third party + /// repo. Unless the manifest has been manually modified, this is determined by the InstalledFromUrl being null. + /// + public bool IsThirdParty => !string.IsNullOrEmpty(this.InstalledFromUrl); - /// - /// Save a plugin manifest to file. - /// - /// Path to save at. - public void Save(FileInfo manifestFile) => File.WriteAllText(manifestFile.FullName, JsonConvert.SerializeObject(this, Formatting.Indented)); + /// + /// Save a plugin manifest to file. + /// + /// Path to save at. + public void Save(FileInfo manifestFile) => File.WriteAllText(manifestFile.FullName, JsonConvert.SerializeObject(this, Formatting.Indented)); - /// - /// Loads a plugin manifest from file. - /// - /// Path to the manifest. - /// A object. - public static LocalPluginManifest Load(FileInfo manifestFile) => JsonConvert.DeserializeObject(File.ReadAllText(manifestFile.FullName)); + /// + /// Loads a plugin manifest from file. + /// + /// Path to the manifest. + /// A object. + public static LocalPluginManifest Load(FileInfo manifestFile) => JsonConvert.DeserializeObject(File.ReadAllText(manifestFile.FullName))!; - /// - /// A standardized way to get the plugin DLL name that should accompany a manifest file. May not exist. - /// - /// Manifest directory. - /// The manifest. - /// The file. - public static FileInfo GetPluginFile(DirectoryInfo dir, PluginManifest manifest) => new(Path.Combine(dir.FullName, $"{manifest.InternalName}.dll")); + /// + /// A standardized way to get the plugin DLL name that should accompany a manifest file. May not exist. + /// + /// Manifest directory. + /// The manifest. + /// The file. + public static FileInfo GetPluginFile(DirectoryInfo dir, PluginManifest manifest) => new(Path.Combine(dir.FullName, $"{manifest.InternalName}.dll")); - /// - /// A standardized way to get the manifest file that should accompany a plugin DLL. May not exist. - /// - /// The plugin DLL. - /// The file. - public static FileInfo GetManifestFile(FileInfo dllFile) => new(Path.Combine(dllFile.DirectoryName, Path.GetFileNameWithoutExtension(dllFile.Name) + ".json")); + /// + /// A standardized way to get the manifest file that should accompany a plugin DLL. May not exist. + /// + /// The plugin DLL. + /// The file. + public static FileInfo GetManifestFile(FileInfo dllFile) => new(Path.Combine(dllFile.DirectoryName!, Path.GetFileNameWithoutExtension(dllFile.Name) + ".json")); - /// - /// A standardized way to get the obsolete .disabled file that should accompany a plugin DLL. May not exist. - /// - /// The plugin DLL. - /// The .disabled file. - public static FileInfo GetDisabledFile(FileInfo dllFile) => new(Path.Combine(dllFile.DirectoryName, ".disabled")); + /// + /// A standardized way to get the obsolete .disabled file that should accompany a plugin DLL. May not exist. + /// + /// The plugin DLL. + /// The .disabled file. + public static FileInfo GetDisabledFile(FileInfo dllFile) => new(Path.Combine(dllFile.DirectoryName!, ".disabled")); - /// - /// A standardized way to get the obsolete .testing file that should accompany a plugin DLL. May not exist. - /// - /// The plugin DLL. - /// The .testing file. - public static FileInfo GetTestingFile(FileInfo dllFile) => new(Path.Combine(dllFile.DirectoryName, ".testing")); - - /// - /// Check if this manifest is valid. - /// - /// Whether or not this manifest is valid. - public bool CheckSanity() - { - if (this.InternalName.IsNullOrEmpty()) - return false; - - if (this.Name.IsNullOrEmpty()) - return false; - - if (this.DalamudApiLevel == 0) - return false; - - return true; - } - } + /// + /// A standardized way to get the obsolete .testing file that should accompany a plugin DLL. May not exist. + /// + /// The plugin DLL. + /// The .testing file. + public static FileInfo GetTestingFile(FileInfo dllFile) => new(Path.Combine(dllFile.DirectoryName!, ".testing")); } diff --git a/Dalamud/Plugin/Internal/Types/PluginManifest.cs b/Dalamud/Plugin/Internal/Types/PluginManifest.cs index 9d28a1b5c..422c1b0c8 100644 --- a/Dalamud/Plugin/Internal/Types/PluginManifest.cs +++ b/Dalamud/Plugin/Internal/Types/PluginManifest.cs @@ -4,177 +4,159 @@ using System.Collections.Generic; using Dalamud.Game; using Newtonsoft.Json; -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Information about a plugin, packaged in a json file with the DLL. +/// +internal record PluginManifest { /// - /// Information about a plugin, packaged in a json file with the DLL. + /// Gets the author/s of the plugin. /// - internal record PluginManifest - { - /// - /// Gets the author/s of the plugin. - /// - [JsonProperty] - public string? Author { get; init; } + [JsonProperty] + public string? Author { get; init; } - /// - /// Gets or sets the public name of the plugin. - /// - [JsonProperty] - public string Name { get; set; } + /// + /// Gets or sets the public name of the plugin. + /// + [JsonProperty] + public string Name { get; set; } = null!; - /// - /// Gets a punchline of the plugins functions. - /// - [JsonProperty] - public string? Punchline { get; init; } + /// + /// Gets a punchline of the plugins functions. + /// + [JsonProperty] + public string? Punchline { get; init; } - /// - /// Gets a description of the plugins functions. - /// - [JsonProperty] - public string? Description { get; init; } + /// + /// Gets a description of the plugins functions. + /// + [JsonProperty] + public string? Description { get; init; } - /// - /// Gets a changelog. - /// - [JsonProperty] - public string? Changelog { get; init; } + /// + /// Gets a changelog. + /// + [JsonProperty] + public string? Changelog { get; init; } - /// - /// Gets a list of tags defined on the plugin. - /// - [JsonProperty] - public List? Tags { get; init; } + /// + /// Gets a list of tags defined on the plugin. + /// + [JsonProperty] + public List? Tags { get; init; } - /// - /// Gets a list of category tags defined on the plugin. - /// - [JsonProperty] - public List? CategoryTags { get; init; } + /// + /// Gets a list of category tags defined on the plugin. + /// + [JsonProperty] + public List? CategoryTags { get; init; } - /// - /// Gets a value indicating whether or not the plugin is hidden in the plugin installer. - /// This value comes from the plugin master and is in addition to the list of hidden names kept by Dalamud. - /// - [JsonProperty] - public bool IsHide { get; init; } + /// + /// Gets a value indicating whether or not the plugin is hidden in the plugin installer. + /// This value comes from the plugin master and is in addition to the list of hidden names kept by Dalamud. + /// + [JsonProperty] + public bool IsHide { get; init; } - /// - /// Gets the internal name of the plugin, which should match the assembly name of the plugin. - /// - [JsonProperty] - public string InternalName { get; init; } + /// + /// Gets the internal name of the plugin, which should match the assembly name of the plugin. + /// + [JsonProperty] + public string InternalName { get; init; } = null!; - /// - /// Gets the current assembly version of the plugin. - /// - [JsonProperty] - public Version AssemblyVersion { get; init; } + /// + /// Gets the current assembly version of the plugin. + /// + [JsonProperty] + public Version AssemblyVersion { get; init; } = null!; - /// - /// Gets the current testing assembly version of the plugin. - /// - [JsonProperty] - public Version? TestingAssemblyVersion { get; init; } + /// + /// Gets the current testing assembly version of the plugin. + /// + [JsonProperty] + public Version? TestingAssemblyVersion { get; init; } - /// - /// Gets a value indicating whether the is not null. - /// - [JsonIgnore] - public bool HasAssemblyVersion => this.AssemblyVersion != null; + /// + /// Gets a value indicating whether the plugin is only available for testing. + /// + [JsonProperty] + public bool IsTestingExclusive { get; init; } - /// - /// Gets a value indicating whether the is not null. - /// - [JsonIgnore] - public bool HasTestingAssemblyVersion => this.TestingAssemblyVersion != null; + /// + /// Gets an URL to the website or source code of the plugin. + /// + [JsonProperty] + public string? RepoUrl { get; init; } - /// - /// Gets a value indicating whether the plugin is only available for testing. - /// - [JsonProperty] - public bool IsTestingExclusive { get; init; } + /// + /// Gets the version of the game this plugin works with. + /// + [JsonProperty] + [JsonConverter(typeof(GameVersionConverter))] + public GameVersion? ApplicableVersion { get; init; } = GameVersion.Any; - /// - /// Gets an URL to the website or source code of the plugin. - /// - [JsonProperty] - public string? RepoUrl { get; init; } + /// + /// Gets the API level of this plugin. For the current API level, please see + /// for the currently used API level. + /// + [JsonProperty] + public int DalamudApiLevel { get; init; } = PluginManager.DalamudApiLevel; - /// - /// Gets the version of the game this plugin works with. - /// - [JsonProperty] - [JsonConverter(typeof(GameVersionConverter))] - public GameVersion? ApplicableVersion { get; init; } = GameVersion.Any; + /// + /// Gets the number of downloads this plugin has. + /// + [JsonProperty] + public long DownloadCount { get; init; } - /// - /// Gets the API level of this plugin. For the current API level, please see - /// for the currently used API level. - /// - [JsonProperty] - public int DalamudApiLevel { get; init; } = PluginManager.DalamudApiLevel; + /// + /// Gets the last time this plugin was updated. + /// + [JsonProperty] + public long LastUpdate { get; init; } - /// - /// Gets the number of downloads this plugin has. - /// - [JsonProperty] - public long DownloadCount { get; init; } + /// + /// Gets the download link used to install the plugin. + /// + [JsonProperty] + public string DownloadLinkInstall { get; init; } = null!; - /// - /// Gets the last time this plugin was updated. - /// - [JsonProperty] - public long LastUpdate { get; init; } + /// + /// Gets the download link used to update the plugin. + /// + [JsonProperty] + public string DownloadLinkUpdate { get; init; } = null!; - /// - /// Gets the download link used to install the plugin. - /// - [JsonProperty] - public string DownloadLinkInstall { get; init; } + /// + /// Gets the download link used to get testing versions of the plugin. + /// + [JsonProperty] + public string DownloadLinkTesting { get; init; } = null!; - /// - /// Gets the download link used to update the plugin. - /// - [JsonProperty] - public string DownloadLinkUpdate { get; init; } + /// + /// Gets the load priority for this plugin. Higher values means higher priority. 0 is default priority. + /// + [JsonProperty] + public int LoadPriority { get; init; } - /// - /// Gets the download link used to get testing versions of the plugin. - /// - [JsonProperty] - public string DownloadLinkTesting { get; init; } + /// + /// Gets a list of screenshot image URLs to show in the plugin installer. + /// + public List? ImageUrls { get; init; } - /// - /// Gets the load priority for this plugin. Higher values means higher priority. 0 is default priority. - /// - [JsonProperty] - public int LoadPriority { get; init; } + /// + /// Gets an URL for the plugin's icon. + /// + public string? IconUrl { get; init; } - /// - /// Gets a list of screenshot image URLs to show in the plugin installer. - /// - public List? ImageUrls { get; init; } + /// + /// Gets a value indicating whether this plugin accepts feedback. + /// + public bool AcceptsFeedback { get; init; } = true; - /// - /// Gets an URL for the plugin's icon. - /// - public string? IconUrl { get; init; } - - /// - /// Gets a value indicating whether this plugin accepts feedback. - /// - public bool AcceptsFeedback { get; init; } = true; - - /// - /// Gets a message that is shown to users when sending feedback. - /// - public string? FeedbackMessage { get; init; } - - /// - /// Gets a value indicating the webhook URL feedback is sent to. - /// - public string? FeedbackWebhook { get; init; } - } + /// + /// Gets a message that is shown to users when sending feedback. + /// + public string? FeedbackMessage { get; init; } } diff --git a/Dalamud/Plugin/Internal/Types/PluginRepository.cs b/Dalamud/Plugin/Internal/Types/PluginRepository.cs new file mode 100644 index 000000000..63ea5c5d4 --- /dev/null +++ b/Dalamud/Plugin/Internal/Types/PluginRepository.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +using Dalamud.Logging.Internal; +using Newtonsoft.Json; + +namespace Dalamud.Plugin.Internal.Types; + +/// +/// This class represents a single plugin repository. +/// +internal class PluginRepository +{ + private const string DalamudPluginsMasterUrl = "https://kamori.goats.dev/Plugin/PluginMaster"; + + private static readonly ModuleLog Log = new("PLUGINR"); + + private static readonly HttpClient HttpClient = new() + { + DefaultRequestHeaders = + { + CacheControl = new CacheControlHeaderValue + { + NoCache = true, + }, + }, + }; + + /// + /// Initializes a new instance of the class. + /// + /// The plugin master URL. + /// Whether the plugin repo is enabled. + public PluginRepository(string pluginMasterUrl, bool isEnabled) + { + this.PluginMasterUrl = pluginMasterUrl; + this.IsThirdParty = pluginMasterUrl != DalamudPluginsMasterUrl; + this.IsEnabled = isEnabled; + } + + /// + /// Gets a new instance of the class for the main repo. + /// + public static PluginRepository MainRepo => new(DalamudPluginsMasterUrl, true); + + /// + /// Gets the pluginmaster.json URL. + /// + public string PluginMasterUrl { get; } + + /// + /// Gets a value indicating whether this plugin repository is from a third party. + /// + public bool IsThirdParty { get; } + + /// + /// Gets a value indicating whether this repo is enabled. + /// + public bool IsEnabled { get; } + + /// + /// Gets the plugin master list of available plugins. + /// + public ReadOnlyCollection? PluginMaster { get; private set; } + + /// + /// Gets the initialization state of the plugin repository. + /// + public PluginRepositoryState State { get; private set; } + + /// + /// Reload the plugin master asynchronously in a task. + /// + /// The new state. + public async Task ReloadPluginMasterAsync() + { + this.State = PluginRepositoryState.InProgress; + this.PluginMaster = new List().AsReadOnly(); + + try + { + Log.Information($"Fetching repo: {this.PluginMasterUrl}"); + + using var response = await HttpClient.GetAsync(this.PluginMasterUrl); + response.EnsureSuccessStatusCode(); + + var data = await response.Content.ReadAsStringAsync(); + var pluginMaster = JsonConvert.DeserializeObject>(data); + + if (pluginMaster == null) + { + throw new Exception("Deserialized PluginMaster was null."); + } + + pluginMaster.Sort((pm1, pm2) => string.Compare(pm1.Name, pm2.Name, StringComparison.Ordinal)); + + // Set the source for each remote manifest. Allows for checking if is 3rd party. + foreach (var manifest in pluginMaster) + { + manifest.SourceRepo = this; + } + + this.PluginMaster = pluginMaster.AsReadOnly(); + + Log.Debug($"Successfully fetched repo: {this.PluginMasterUrl}"); + this.State = PluginRepositoryState.Success; + } + catch (Exception ex) + { + Log.Error(ex, $"PluginMaster failed: {this.PluginMasterUrl}"); + this.State = PluginRepositoryState.Fail; + } + } +} diff --git a/Dalamud/Plugin/Internal/Types/PluginRepositoryState.cs b/Dalamud/Plugin/Internal/Types/PluginRepositoryState.cs index 46aa2c351..2909ff981 100644 --- a/Dalamud/Plugin/Internal/Types/PluginRepositoryState.cs +++ b/Dalamud/Plugin/Internal/Types/PluginRepositoryState.cs @@ -1,28 +1,27 @@ -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Values representing plugin repository state. +/// +internal enum PluginRepositoryState { /// - /// Values representing plugin repository state. + /// State is unknown. /// - internal enum PluginRepositoryState - { - /// - /// State is unknown. - /// - Unknown, + Unknown, - /// - /// Currently loading. - /// - InProgress, + /// + /// Currently loading. + /// + InProgress, - /// - /// Load was successful. - /// - Success, + /// + /// Load was successful. + /// + Success, - /// - /// Load failed. - /// - Fail, - } + /// + /// Load failed. + /// + Fail, } diff --git a/Dalamud/Plugin/Internal/Types/PluginState.cs b/Dalamud/Plugin/Internal/Types/PluginState.cs index f32543b39..da5fcf977 100644 --- a/Dalamud/Plugin/Internal/Types/PluginState.cs +++ b/Dalamud/Plugin/Internal/Types/PluginState.cs @@ -1,33 +1,32 @@ -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Values representing plugin load state. +/// +internal enum PluginState { /// - /// Values representing plugin load state. + /// Plugin is defined, but unloaded. /// - internal enum PluginState - { - /// - /// Plugin is defined, but unloaded. - /// - Unloaded, + Unloaded, - /// - /// Plugin has thrown an error during unload. - /// - UnloadError, + /// + /// Plugin has thrown an error during unload. + /// + UnloadError, - /// - /// Currently loading. - /// - InProgress, + /// + /// Currently loading. + /// + InProgress, - /// - /// Load is successful. - /// - Loaded, + /// + /// Load is successful. + /// + Loaded, - /// - /// Plugin has thrown an error during loading. - /// - LoadError, - } + /// + /// Plugin has thrown an error during loading. + /// + LoadError, } diff --git a/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs b/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs index f0394b9b7..02eba7ea7 100644 --- a/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs +++ b/Dalamud/Plugin/Internal/Types/PluginUpdateStatus.cs @@ -1,30 +1,29 @@ using System; -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Plugin update status. +/// +internal class PluginUpdateStatus { /// - /// Plugin update status. + /// Gets the plugin internal name. /// - internal class PluginUpdateStatus - { - /// - /// Gets or sets the plugin internal name. - /// - public string InternalName { get; set; } + public string InternalName { get; init; } = null!; - /// - /// Gets or sets the plugin name. - /// - public string Name { get; set; } + /// + /// Gets the plugin name. + /// + public string Name { get; init; } = null!; - /// - /// Gets or sets the plugin version. - /// - public Version Version { get; set; } + /// + /// Gets the plugin version. + /// + public Version Version { get; init; } = null!; - /// - /// Gets or sets a value indicating whether the plugin was updated. - /// - public bool WasUpdated { get; set; } - } + /// + /// Gets or sets a value indicating whether the plugin was updated. + /// + public bool WasUpdated { get; set; } } diff --git a/Dalamud/Plugin/Internal/Types/RemotePluginManifest.cs b/Dalamud/Plugin/Internal/Types/RemotePluginManifest.cs index cbb989159..09084d569 100644 --- a/Dalamud/Plugin/Internal/Types/RemotePluginManifest.cs +++ b/Dalamud/Plugin/Internal/Types/RemotePluginManifest.cs @@ -1,18 +1,19 @@ +using JetBrains.Annotations; using Newtonsoft.Json; -namespace Dalamud.Plugin.Internal.Types +namespace Dalamud.Plugin.Internal.Types; + +/// +/// Information about a plugin, packaged in a json file with the DLL. This variant includes additional information such as +/// if the plugin is disabled and if it was installed from a testing URL. This is designed for use with manifests on disk. +/// +[UsedImplicitly] +internal record RemotePluginManifest : PluginManifest { /// - /// Information about a plugin, packaged in a json file with the DLL. This variant includes additional information such as - /// if the plugin is disabled and if it was installed from a testing URL. This is designed for use with manifests on disk. + /// Gets or sets the plugin repository this manifest came from. Used in reporting which third party repo a manifest + /// may have come from in the plugins available view. This functionality should not be included in the plugin master. /// - internal record RemotePluginManifest : PluginManifest - { - /// - /// Gets or sets the plugin repository this manifest came from. Used in reporting which third party repo a manifest - /// may have come from in the plugins available view. This functionality should not be included in the plugin master. - /// - [JsonIgnore] - public PluginRepository SourceRepo { get; set; } = null; - } + [JsonIgnore] + public PluginRepository SourceRepo { get; set; } = null!; }