using System; using System.IO; using System.Linq; using System.Reflection; using Dalamud.Configuration.Internal; using Dalamud.Game; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal.Exceptions; using Dalamud.Plugin.Internal.Loader; using Dalamud.Plugin.Internal.Types; 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?.Dispose(); 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.Dispose(); Log.Error($"Error while loading {this.Name}, failed to bind and call the plugin constructor"); return; } // 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?.Dispose(); 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); 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); } }