using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using CheapLoc; using Dalamud.Configuration; using Dalamud.Configuration.Internal; using Dalamud.Game; using Dalamud.Game.Gui; using Dalamud.Game.Gui.Dtr; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.Internal; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Networking.Http; using Dalamud.Plugin.Internal.Exceptions; using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using Dalamud.Utility.Timing; using Newtonsoft.Json; namespace Dalamud.Plugin.Internal; /// /// Class responsible for loading and unloading plugins. /// NOTE: ALL plugin exposed services are marked as dependencies for PluginManager in Service{T}. /// [ServiceManager.EarlyLoadedService] #pragma warning disable SA1015 // DalamudTextureWrap registers textures to dispose with IM [InherentDependency] // LocalPlugin uses ServiceContainer to create scopes [InherentDependency] #pragma warning restore SA1015 internal partial class PluginManager : IDisposable, IServiceType { /// /// The current Dalamud API level, used to handle breaking changes. Only plugins with this level will be loaded. /// As of Dalamud 9.x, this always matches the major version number of Dalamud. /// public static int DalamudApiLevel => Assembly.GetExecutingAssembly().GetName().Version!.Major; /// /// Default time to wait between plugin unload and plugin assembly unload. /// public const int PluginWaitBeforeFreeDefault = 1000; // upped from 500ms, seems more stable private static readonly ModuleLog Log = new("PLUGINM"); private readonly object pluginListLock = new(); private readonly DirectoryInfo pluginDirectory; private readonly DirectoryInfo devPluginDirectory; private readonly BannedPlugin[]? bannedPlugins; private readonly DalamudLinkPayload openInstallerWindowPluginChangelogsLink; [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); [ServiceManager.ServiceDependency] private readonly DalamudStartInfo startInfo = Service.Get(); [ServiceManager.ServiceDependency] private readonly HappyHttpClient happyHttpClient = Service.Get(); [ServiceManager.ServiceConstructor] private PluginManager() { this.pluginDirectory = new DirectoryInfo(this.startInfo.PluginDirectory!); if (!this.pluginDirectory.Exists) this.pluginDirectory.Create(); this.SafeMode = EnvironmentConfiguration.DalamudNoPlugins || this.configuration.PluginSafeMode || this.startInfo.NoLoadPlugins; try { var appdata = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var safeModeFile = Path.Combine(appdata, "XIVLauncher", ".dalamud_safemode"); if (File.Exists(safeModeFile)) { this.SafeMode = true; File.Delete(safeModeFile); } } catch (Exception ex) { Log.Error(ex, "Couldn't check safe mode file"); } if (this.SafeMode) { this.configuration.PluginSafeMode = false; this.configuration.QueueSave(); } this.PluginConfigs = new PluginConfigurations(Path.Combine(Path.GetDirectoryName(this.startInfo.ConfigurationPath) ?? string.Empty, "pluginConfigs")); var bannedPluginsJson = File.ReadAllText(Path.Combine(this.startInfo.AssetDirectory!, "UIRes", "bannedplugin.json")); this.bannedPlugins = JsonConvert.DeserializeObject(bannedPluginsJson); if (this.bannedPlugins == null) { throw new InvalidDataException("Couldn't deserialize banned plugins manifest."); } this.openInstallerWindowPluginChangelogsLink = Service.Get().AddChatLinkHandler("Dalamud", 1003, (_, _) => { Service.GetNullable()?.OpenPluginInstallerPluginChangelogs(); }); this.configuration.PluginTestingOptIns ??= new List(); this.ApplyPatches(); } /// /// An event that fires when the installed plugins have changed. /// public event Action? OnInstalledPluginsChanged; /// /// An event that fires when the available plugins have changed. /// public event Action? OnAvailablePluginsChanged; /// /// Gets a list of all loaded plugins. /// public ImmutableList InstalledPlugins { get; private set; } = ImmutableList.Create(); /// /// Gets a list of all available plugins. /// public ImmutableList AvailablePlugins { get; private set; } = ImmutableList.Create(); /// /// Gets a list of all plugins with an available update. /// public ImmutableList UpdatablePlugins { get; private set; } = ImmutableList.Create(); /// /// Gets a list of all plugin repositories. The main repo should always be first. /// public List Repos { get; private set; } = new(); /// /// Gets a value indicating whether plugins are not still loading from boot. /// public bool PluginsReady { get; private set; } /// /// 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; } /// /// Gets or sets a value indicating whether plugins of all API levels will be loaded. /// public bool LoadAllApiLevels { get; set; } /// /// Gets or sets a value indicating whether banned plugins will be loaded. /// public bool LoadBannedPlugins { get; set; } /// /// 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; } /// /// Check if a manifest even has an available testing version. /// /// The manifest to test. /// Whether or not a testing version is available. public static bool HasTestingVersion(PluginManifest manifest) { var av = manifest.AssemblyVersion; var tv = manifest.TestingAssemblyVersion; var hasTv = tv != null; if (hasTv) { return tv > av; } return false; } /// /// 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 void PrintUpdatedPlugins(List? updateMetadata, string header) { var chatGui = Service.Get(); if (updateMetadata is { Count: > 0 }) { chatGui.PrintChat(new XivChatEntry { Message = new SeString(new List() { new TextPayload(header), new TextPayload(" ["), new UIForegroundPayload(500), this.openInstallerWindowPluginChangelogsLink, new TextPayload(Loc.Localize("DalamudInstallerPluginChangelogHelp", "Open plugin changelogs")), RawPayload.LinkTerminator, new UIForegroundPayload(0), new TextPayload("]"), }), }); foreach (var metadata in updateMetadata) { if (metadata.WasUpdated) { chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); } else { chatGui.PrintChat(new XivChatEntry { Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version), Type = XivChatType.Urgent, }); } } } } /// /// For a given manifest, determine if the user opted into testing this plugin. /// /// Manifest to check. /// A value indicating whether testing should be used. public bool HasTestingOptIn(PluginManifest manifest) { return this.configuration.PluginTestingOptIns!.Any(x => x.InternalName == manifest.InternalName); } /// /// 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 bool UseTesting(PluginManifest manifest) { if (!this.configuration.DoPluginTest) return false; if (!this.HasTestingOptIn(manifest)) return false; if (manifest.IsTestingExclusive) return true; return HasTestingVersion(manifest); } /// public void Dispose() { var disposablePlugins = this.InstalledPlugins.Where(plugin => plugin.State is PluginState.Loaded or PluginState.LoadError).ToArray(); if (disposablePlugins.Any()) { // Unload them first, just in case some of plugin codes are still running via callbacks initiated externally. foreach (var plugin in disposablePlugins.Where(plugin => !plugin.Manifest.CanUnloadAsync)) { try { plugin.UnloadAsync(true, false).Wait(); } catch (Exception ex) { Log.Error(ex, $"Error unloading {plugin.Name}"); } } Task.WaitAll(disposablePlugins .Where(plugin => plugin.Manifest.CanUnloadAsync) .Select(plugin => Task.Run(async () => { try { await plugin.UnloadAsync(true, false); } catch (Exception ex) { Log.Error(ex, $"Error unloading {plugin.Name}"); } })).ToArray()); // Just in case plugins still have tasks running that they didn't cancel when they should have, // give them some time to complete it. Thread.Sleep(this.configuration.PluginWaitBeforeFree ?? PluginWaitBeforeFreeDefault); // Now that we've waited enough, dispose the whole plugin. // Since plugins should have been unloaded above, this should be done quickly. foreach (var plugin in disposablePlugins) plugin.ExplicitDisposeIgnoreExceptions($"Error disposing {plugin.Name}", Log); } 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 repos = new List() { PluginRepository.MainRepo }; repos.AddRange(this.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. /// /// The task. public async Task LoadAllPlugins() { var pluginDefs = new List(); var devPluginDefs = new List(); if (!this.pluginDirectory.Exists) this.pluginDirectory.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()) { var versionsDefs = new List(); foreach (var versionDir in pluginDir.GetDirectories()) { try { 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); if (manifest.IsTestingExclusive && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != manifest.InternalName)) this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(manifest.InternalName)); versionsDefs.Add(new PluginDef(dllFile, manifest, false)); } catch (Exception ex) { Log.Error(ex, "Could not load manifest for installed at {Directory}", versionDir.FullName); } } this.configuration.QueueSave(); try { pluginDefs.Add(versionsDefs.OrderByDescending(x => x.Manifest!.EffectiveVersion).First()); } catch (Exception ex) { Log.Error(ex, "Couldn't choose best version for plugin: {Name}", pluginDir.Name); } } // devPlugins are more freeform. Look for any dll and hope to get lucky. var devDllFiles = new List(); foreach (var setting in this.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) { try { // 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; if (manifest != null && manifest.InternalName.IsNullOrEmpty()) { Log.Error("InternalName for dll at {Path} was null", manifestFile.FullName); continue; } devPluginDefs.Add(new PluginDef(dllFile, manifest, true)); } catch (Exception ex) { Log.Error(ex, "Could not load manifest for dev at {Directory}", dllFile.FullName); } } // 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); async Task LoadPluginOnBoot(string logPrefix, PluginDef pluginDef, CancellationToken token) { token.ThrowIfCancellationRequested(); using (Timings.Start($"{pluginDef.DllFile.Name}: {logPrefix}Boot")) { try { await this.LoadPluginAsync( pluginDef.DllFile, pluginDef.Manifest, PluginLoadReason.Boot, pluginDef.IsDev, isBoot: true); } catch (InvalidPluginException) { // Not a plugin } catch (Exception ex) { Log.Error(ex, "{0}: During boot plugin load, an unexpected error occurred", logPrefix); } } } async Task LoadPluginsSync(string logPrefix, IEnumerable pluginDefsList, CancellationToken token) { Log.Information($"============= LoadPluginsSync({logPrefix}) START ============="); foreach (var pluginDef in pluginDefsList) await LoadPluginOnBoot(logPrefix, pluginDef, token).ConfigureAwait(false); Log.Information($"============= LoadPluginsSync({logPrefix}) END ============="); } async Task LoadPluginsAsync(string logPrefix, IEnumerable pluginDefsList, CancellationToken token) { Log.Information($"============= LoadPluginsAsync({logPrefix}) START ============="); await Task.WhenAll( pluginDefsList .Select(pluginDef => Task.Run( Timings.AttachTimingHandle( () => LoadPluginOnBoot(logPrefix, pluginDef, token)), token)) .ToArray()).ConfigureAwait(false); Log.Information($"============= LoadPluginsAsync({logPrefix}) END ============="); } var syncPlugins = pluginDefs.Where(def => def.Manifest?.LoadSync == true).ToList(); var asyncPlugins = pluginDefs.Where(def => def.Manifest?.LoadSync != true).ToList(); var loadTasks = new List(); var tokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(5)); // Load plugins that can be loaded anytime await LoadPluginsSync( "AnytimeSync", syncPlugins.Where(def => def.Manifest?.LoadRequiredState == 2), tokenSource.Token); loadTasks.Add(LoadPluginsAsync( "AnytimeAsync", asyncPlugins.Where(def => def.Manifest?.LoadRequiredState == 2), tokenSource.Token)); // Pass the rest of plugin loading to another thread(task) _ = Task.Run( async () => { // Load plugins that want to be loaded during Framework.Tick var framework = await Service.GetAsync().ConfigureAwait(false); await framework.RunOnTick( () => LoadPluginsSync( "FrameworkTickSync", syncPlugins.Where(def => def.Manifest?.LoadRequiredState == 1), tokenSource.Token), cancellationToken: tokenSource.Token).ConfigureAwait(false); loadTasks.Add(LoadPluginsAsync( "FrameworkTickAsync", asyncPlugins.Where(def => def.Manifest?.LoadRequiredState == 1), tokenSource.Token)); // Load plugins that want to be loaded during Framework.Tick, when drawing facilities are available _ = await Service.GetAsync().ConfigureAwait(false); await framework.RunOnTick( () => LoadPluginsSync( "DrawAvailableSync", syncPlugins.Where(def => def.Manifest?.LoadRequiredState is 0 or null), tokenSource.Token), cancellationToken: tokenSource.Token); loadTasks.Add(LoadPluginsAsync( "DrawAvailableAsync", asyncPlugins.Where(def => def.Manifest?.LoadRequiredState is 0 or null), tokenSource.Token)); // Save signatures when all plugins are done loading, successful or not. try { await Task.WhenAll(loadTasks).ConfigureAwait(false); Log.Information("Loaded plugins on boot"); } catch (Exception e) { Log.Error(e, "Failed to load at least one plugin"); } var sigScanner = await Service.GetAsync().ConfigureAwait(false); this.PluginsReady = true; this.NotifyInstalledPluginsChanged(); sigScanner.Save(); }, tokenSource.Token); } /// /// 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) { Log.Information("Now reloading all PluginMasters..."); Debug.Assert(!this.Repos.First().IsThirdParty, "First repository should be main repository"); await this.Repos.First().ReloadPluginMasterAsync(); // Load official repo first await Task.WhenAll(this.Repos.Skip(1).Select(repo => repo.ReloadPluginMasterAsync())); Log.Information("PluginMasters reloaded, now refiltering..."); 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() { // devPlugins are more freeform. Look for any dll and hope to get lucky. var devDllFiles = new List(); foreach (var setting in this.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 lock (this.pluginListLock) { 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.LoadPluginAsync(dllFile, manifest, PluginLoadReason.Installer, isDev: true, doNotLoad: true) .Wait(); 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})"); // Ensure that we have a testing opt-in for this plugin if we are installing a testing version if (useTesting && this.configuration.PluginTestingOptIns!.All(x => x.InternalName != repoManifest.InternalName)) { // TODO: this isn't safe this.configuration.PluginTestingOptIns.Add(new PluginTestingOptIn(repoManifest.InternalName)); this.configuration.QueueSave(); } var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; var response = await this.happyHttpClient.SharedHttpClient.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 (manifest.InternalName != repoManifest.InternalName) { Directory.Delete(outputDir.FullName, true); throw new Exception( $"Distributed internal name does not match repo internal name: {manifest.InternalName} - {repoManifest.InternalName}"); } if (useTesting) { manifest.Testing = true; } // Document the url the plugin was installed from manifest.InstalledFromUrl = repoManifest.SourceRepo.IsThirdParty ? repoManifest.SourceRepo.PluginMasterUrl : LocalPluginManifest.FlagMainRepo; manifest.Save(manifestFile); Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); var plugin = await this.LoadPluginAsync(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 async Task LoadPluginAsync(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 (manifest != null && manifest.InternalName == null) { Log.Error("{FileName}: Your manifest has no internal name set! Can't load this.", dllFile.FullName); throw new Exception("No internal name"); } 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.IsOrphaned) { await plugin.LoadAsync(reason); } else { Log.Verbose($"{name} not loaded, disabled:{plugin.IsDisabled} orphaned:{plugin.IsOrphaned}"); } } catch (InvalidPluginException) { PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); 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}"); // NOTE(goat): This can't work - plugins don't "unload" if they fail to load. // 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 if (plugin.IsOrphaned) { // Orphaned plugins get added, so that users aren't confused. Log.Information(ex, $"Plugin was orphaned, adding anyways: {dllFile.Name}"); } else if (isBoot) { // During boot load, plugins always get added to the list so they can be fiddled with in the UI Log.Information(ex, $"Regular plugin failed to load, adding anyways: {dllFile.Name}"); // NOTE(goat): This can't work - plugins don't "unload" if they fail to load. // plugin.Disable(); // Disable here, otherwise you can't enable+load later } else if (!plugin.CheckPolicy()) { // During boot load, plugins always get added to the list so they can be fiddled with in the UI Log.Information(ex, $"Plugin not loaded due to policy, adding anyways: {dllFile.Name}"); // NOTE(goat): This can't work - plugins don't "unload" if they fail to load. // plugin.Disable(); // Disable here, otherwise you can't enable+load later } else { PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); throw; } } } lock (this.pluginListLock) { this.InstalledPlugins = this.InstalledPlugins.Add(plugin); } return plugin; } /// /// Remove a plugin. /// /// Plugin to remove. public void RemovePlugin(LocalPlugin plugin) { if (plugin.State != PluginState.Unloaded && plugin.HasEverStartedLoad) throw new InvalidPluginOperationException($"Unable to remove {plugin.Name}, not unloaded and had loaded before"); lock (this.pluginListLock) { this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); } PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); this.NotifyInstalledPluginsChanged(); this.NotifyAvailablePluginsChanged(); } /// /// Cleanup disabled plugins. Does not target devPlugins. /// public void CleanupPlugins() { 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 { for (var i = 0; i < versionDirs.Length; i++) { var versionDir = versionDirs[i]; try { if (i != 0) { Log.Information($"Old version: cleaning up {versionDir.FullName}"); versionDir.Delete(true); continue; } 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; } if (manifestFile.Length == 0) { Log.Information($"Manifest empty: cleaning up {versionDir.FullName}"); versionDir.Delete(true); continue; } var manifest = LocalPluginManifest.Load(manifestFile); if (manifest.ScheduledForDeletion) { Log.Information($"Scheduled deletion: 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. /// /// Ignore disabled plugins. /// Perform a dry run, don't install anything. /// If this action was performed as part of an auto-update. /// Success or failure and a list of updated plugin metadata. public async Task> UpdatePluginsAsync(bool ignoreDisabled, bool dryRun, bool autoUpdate = 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; if (plugin.InstalledPlugin.Manifest.Disabled && ignoreDisabled) continue; if (plugin.InstalledPlugin.Manifest.ScheduledForDeletion) continue; var result = await this.UpdateSinglePluginAsync(plugin, false, dryRun); if (result != null) updatedList.Add(result); } this.NotifyInstalledPluginsChanged(); this.NotifyPluginsForStateChange( autoUpdate ? PluginListInvalidationKind.AutoUpdate : PluginListInvalidationKind.Update, updatedList.Select(x => x.InternalName)); 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, HasChangelog = !metadata.UpdateManifest.Changelog.IsNullOrWhitespace(), }; if (!dryRun) { // Unload if loaded if (plugin.State is PluginState.Loaded or PluginState.LoadError or PluginState.DependencyResolutionFailed) { try { await plugin.UnloadAsync(); } catch (Exception ex) { Log.Error(ex, "Error during unload (update)"); updateStatus.WasUpdated = false; return updateStatus; } } if (plugin.IsDev) { try { plugin.DllFile.Delete(); lock (this.pluginListLock) { this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); } } catch (Exception ex) { Log.Error(ex, "Error during delete (update)"); updateStatus.WasUpdated = false; return updateStatus; } } else { try { if (!plugin.IsDisabled) plugin.Disable(); lock (this.pluginListLock) { this.InstalledPlugins = this.InstalledPlugins.Remove(plugin); } } catch (Exception ex) { Log.Error(ex, "Error during disable (update)"); updateStatus.WasUpdated = false; return updateStatus; } } // 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 { await this.InstallPluginAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update); } catch (Exception ex) { Log.Error(ex, "Error during install (update)"); updateStatus.WasUpdated = false; return updateStatus; } } if (notify && updateStatus.WasUpdated) this.NotifyInstalledPluginsChanged(); return updateStatus; } /// /// Delete the plugin configuration, unload/reload it if loaded. /// /// The plugin. /// Throws if the plugin is still loading/unloading. /// The task. public async Task DeleteConfigurationAsync(LocalPlugin plugin) { if (plugin.State is PluginState.Loading or PluginState.Unloading) throw new Exception("Cannot delete configuration for a loading/unloading plugin"); var isReloading = plugin.IsLoaded; if (isReloading) await plugin.UnloadAsync(); for (var waitUntil = Environment.TickCount64 + 1000; Environment.TickCount64 < waitUntil;) { try { this.PluginConfigs.Delete(plugin.Name); break; } catch (IOException) { await Task.Delay(100); } } if (isReloading) { // Let's indicate "installer" here since this is supposed to be a fresh install await plugin.LoadAsync(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) { // Testing exclusive if (manifest.IsTestingExclusive && !this.configuration.DoPluginTest) { Log.Verbose($"Testing exclusivity: {manifest.InternalName} - {manifest.AssemblyVersion} - {manifest.TestingAssemblyVersion}"); return false; } // Applicable version if (manifest.ApplicableVersion < this.startInfo.GameVersion) { Log.Verbose($"Game version: {manifest.InternalName} - {manifest.AssemblyVersion} - {manifest.TestingAssemblyVersion}"); return false; } // API level - we keep the API before this in the installer to show as "outdated" if (manifest.DalamudApiLevel < DalamudApiLevel - 1 && !this.LoadAllApiLevels) { Log.Verbose($"API Level: {manifest.InternalName} - {manifest.AssemblyVersion} - {manifest.TestingAssemblyVersion}"); return false; } // Banned if (this.IsManifestBanned(manifest)) { Log.Verbose($"Banned: {manifest.InternalName} - {manifest.AssemblyVersion} - {manifest.TestingAssemblyVersion}"); return false; } return true; } /// /// 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) { Debug.Assert(this.bannedPlugins != null, "this.bannedPlugins != null"); if (this.LoadBannedPlugins) return false; var config = Service.Get(); var versionToCheck = manifest.AssemblyVersion; if (config.DoPluginTest && manifest.TestingAssemblyVersion > manifest.AssemblyVersion) { versionToCheck = manifest.TestingAssemblyVersion; } return this.bannedPlugins.Any(ban => (ban.Name == manifest.InternalName || ban.Name == Hash.GetStringSha256Hash(manifest.InternalName)) && ban.AssemblyVersion >= versionToCheck); } /// /// 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) { Debug.Assert(this.bannedPlugins != null, "this.bannedPlugins != null"); return this.bannedPlugins.LastOrDefault(ban => ban.Name == manifest.InternalName).Reason; } /// /// Get the plugin that called this method by walking the provided stack trace, /// or null, if it cannot be determined. /// At the time, this is naive and shouldn't be used for security-critical checks. /// /// The trace to walk. /// The calling plugin, or null. public LocalPlugin? FindCallingPlugin(StackTrace trace) { foreach (var frame in trace.GetFrames()) { var declaringType = frame.GetMethod()?.DeclaringType; if (declaringType == null) continue; lock (this.pluginListLock) { foreach (var plugin in this.InstalledPlugins) { if (plugin.AssemblyName != null && plugin.AssemblyName.FullName == declaringType.Assembly.GetName().FullName) return plugin; } } } return null; } /// /// Get the plugin that called this method by walking the stack, /// or null, if it cannot be determined. /// At the time, this is naive and shouldn't be used for security-critical checks. /// /// The calling plugin, or null. public LocalPlugin? FindCallingPlugin() => this.FindCallingPlugin(new StackTrace()); private void DetectAvailablePluginUpdates() { var updatablePlugins = new List(); foreach (var plugin in this.InstalledPlugins) { var installedVersion = plugin.IsTesting ? plugin.Manifest.TestingAssemblyVersion : plugin.Manifest.AssemblyVersion; var updates = this.AvailablePlugins .Where(remoteManifest => plugin.Manifest.InternalName == remoteManifest.InternalName) .Where(remoteManifest => plugin.Manifest.InstalledFromUrl == remoteManifest.SourceRepo.PluginMasterUrl || !remoteManifest.SourceRepo.IsThirdParty) .Where(remoteManifest => remoteManifest.DalamudApiLevel == DalamudApiLevel) .Select(remoteManifest => { var useTesting = this.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) { var update = updates.Aggregate((t1, t2) => t1.candidateVersion > t2.candidateVersion ? t1 : t2); updatablePlugins.Add(new AvailablePluginUpdate(plugin, update.remoteManifest, update.useTesting)); } } this.UpdatablePlugins = updatablePlugins.ToImmutableList(); } private void NotifyAvailablePluginsChanged() { this.DetectAvailablePluginUpdates(); this.OnAvailablePluginsChanged?.InvokeSafely(); } private void NotifyInstalledPluginsChanged() { this.DetectAvailablePluginUpdates(); this.OnInstalledPluginsChanged?.InvokeSafely(); } private void NotifyPluginsForStateChange(PluginListInvalidationKind kind, IEnumerable affectedInternalNames) { foreach (var installedPlugin in this.InstalledPlugins) { if (!installedPlugin.IsLoaded || installedPlugin.DalamudInterface == null) continue; installedPlugin.DalamudInterface.NotifyActivePluginsChanged( kind, // ReSharper disable once PossibleMultipleEnumeration affectedInternalNames.Contains(installedPlugin.Manifest.InternalName)); } } 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 ConcurrentDictionary 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); } }