using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; 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.Interface.Internal.Windows.PluginInstaller; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Networking.Http; using Dalamud.Plugin.Internal.Exceptions; using Dalamud.Plugin.Internal.Profiles; using Dalamud.Plugin.Internal.Types; using Dalamud.Plugin.Internal.Types.Manifest; using Dalamud.Plugin.Ipc.Internal; using Dalamud.Support; 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.BlockingEarlyLoadedService] #pragma warning disable SA1015 // DalamudTextureWrap registers textures to dispose with IM [InherentDependency] // LocalPlugin uses ServiceContainer to create scopes [InherentDependency] // DalamudPluginInterface hands out a reference to this, so we have to keep it around // TODO api9: make it a service [InherentDependency] #pragma warning restore SA1015 internal partial class PluginManager : IDisposable, IServiceType { /// /// 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 BannedPlugin[]? bannedPlugins; private readonly List installedPluginsList = new(); private readonly List availablePluginsList = new(); private readonly List updatablePluginsList = new(); private readonly DalamudLinkPayload openInstallerWindowPluginChangelogsLink; [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); [ServiceManager.ServiceDependency] private readonly Dalamud dalamud = Service.Get(); [ServiceManager.ServiceDependency] private readonly ProfileManager profileManager = Service.Get(); [ServiceManager.ServiceDependency] private readonly HappyHttpClient happyHttpClient = Service.Get(); [ServiceManager.ServiceDependency] private readonly ChatGui chatGui = Service.Get(); static PluginManager() { DalamudApiLevel = typeof(PluginManager).Assembly.GetName().Version!.Major; } [ServiceManager.ServiceConstructor] private PluginManager( ServiceManager.RegisterStartupBlockerDelegate registerStartupBlocker, ServiceManager.RegisterUnloadAfterDelegate registerUnloadAfter) { this.pluginDirectory = new DirectoryInfo(this.dalamud.StartInfo.PluginDirectory!); if (!this.pluginDirectory.Exists) this.pluginDirectory.Create(); this.SafeMode = EnvironmentConfiguration.DalamudNoPlugins || this.configuration.PluginSafeMode || this.dalamud.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.dalamud.StartInfo.ConfigurationPath) ?? string.Empty, "pluginConfigs")); var bannedPluginsJson = File.ReadAllText(Path.Combine(this.dalamud.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 = this.chatGui.AddChatLinkHandler("Dalamud", 1003, (_, _) => { Service.GetNullable()?.OpenPluginInstallerTo(PluginInstallerWindow.PluginInstallerOpenKind.Changelogs); }); this.configuration.PluginTestingOptIns ??= new(); this.MainRepo = PluginRepository.CreateMainRepo(this.happyHttpClient); this.ApplyPatches(); registerStartupBlocker( Task.Run(this.LoadAndStartLoadSyncPlugins), "Waiting for plugins that asked to be loaded before the game."); registerUnloadAfter( ResolvePossiblePluginDependencyServices(), "See the attached comment for the called function."); } /// /// 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 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 { get; private set; } /// /// Gets a copy of the list of all loaded plugins. /// public IEnumerable InstalledPlugins { get { lock (this.pluginListLock) { return this.installedPluginsList.ToList(); } } } /// /// Gets a copy of the list of all available plugins. /// public IEnumerable AvailablePlugins { get { lock (this.pluginListLock) { return this.availablePluginsList.ToList(); } } } /// /// Gets a copy of the list of all plugins with an available update. /// public IEnumerable UpdatablePlugins { get { lock (this.pluginListLock) { return this.updatablePluginsList.ToList(); } } } /// /// Gets the main repository. /// public PluginRepository MainRepo { get; } /// /// 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 { get; private set; } /// /// 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(IPluginManifest manifest) { var av = manifest.AssemblyVersion; var tv = manifest.TestingAssemblyVersion; var hasTv = tv != null; if (hasTv) { return tv > av; } return false; } /// /// Get a disposable that will lock plugin lists while it is not disposed. /// You must NEVER use this in async code. /// /// The aforementioned disposable. public IDisposable GetSyncScope() => new ScopedSyncRoot(this.pluginListLock); /// /// 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) { if (updateMetadata is { Count: > 0 }) { this.chatGui.Print(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.Status == PluginUpdateStatus.StatusKind.Success) { this.chatGui.Print(Locs.DalamudPluginUpdateSuccessful(metadata.Name, metadata.Version)); } else { this.chatGui.Print(new XivChatEntry { Message = Locs.DalamudPluginUpdateFailed(metadata.Name, metadata.Version, PluginUpdateStatus.LocalizeUpdateStatusKind(metadata.Status)), 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(IPluginManifest 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(IPluginManifest 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.installedPluginsList.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 { this.MainRepo }; repos.AddRange(this.configuration.ThirdRepoList .Where(repo => repo.IsEnabled) .Select(repo => new PluginRepository(this.happyHttpClient, 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 == null) { Log.Error("Manifest for plugin at {Path} was null", dllFile.FullName); continue; } 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.MaxBy(x => x.Manifest!.EffectiveVersion)); } 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 now required for devPlugins var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); if (!manifestFile.Exists) { Log.Information("DLL at {DllPath} has no manifest, this is no longer valid", dllFile.FullName); continue; } var manifest = LocalPluginManifest.Load(manifestFile); if (manifest == null) { Log.Information("Could not deserialize manifest for DLL at {DllPath}", dllFile.FullName); continue; } 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.NotifyinstalledPluginsListChanged(); sigScanner.Save(); try { this.ParanoiaValidatePluginsAndProfiles(); } catch (Exception ex) { Log.Error(ex, "Plugin and profile validation failed!"); } }, 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..."); this.ReposReady = false; try { 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); } catch (Exception ex) { Log.Error(ex, "Could not reload plugin repositories"); } finally { this.ReposReady = true; } } /// /// 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) { lock (this.pluginListLock) { this.availablePluginsList.Clear(); this.availablePluginsList.AddRange(this.Repos .SelectMany(repo => repo.PluginMaster) .Where(this.IsManifestEligible) .Where(IsManifestVisible)); 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; Log.Verbose("Scanning dev plugins at {Path}", setting.Path); 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.installedPluginsList.Any(lp => lp.DllFile.FullName == dllFile.FullName)) continue; } // Manifests are now required for devPlugins var manifestFile = LocalPluginManifest.GetManifestFile(dllFile); if (!manifestFile.Exists) { Log.Information("DLL at {DllPath} has no manifest, this is no longer valid", dllFile.FullName); continue; } var manifest = LocalPluginManifest.Load(manifestFile); if (manifest == null) { Log.Information("Could not deserialize manifest for DLL at {DllPath}", dllFile.FullName); continue; } 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.NotifyinstalledPluginsListChanged(); } /// /// 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. /// WorkingPluginId this plugin should inherit. /// A representing the asynchronous operation. public async Task InstallPluginAsync( RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, Guid? inheritedWorkingPluginId = null) { var stream = await this.DownloadPluginAsync(repoManifest, useTesting); return await this.InstallPluginInternalAsync(repoManifest, useTesting, reason, stream, inheritedWorkingPluginId); } /// /// 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.installedPluginsList.Remove(plugin); } PluginLocations.Remove(plugin.AssemblyName?.FullName ?? string.Empty, out _); this.NotifyinstalledPluginsListChanged(); 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 updateTasks = new List>(); // Prevent collection was modified errors lock (this.pluginListLock) { foreach (var plugin in this.updatablePluginsList) { // Can't update that! if (plugin.InstalledPlugin.IsDev) continue; if (!plugin.InstalledPlugin.IsWantedByAnyProfile && ignoreDisabled) continue; if (plugin.InstalledPlugin.Manifest.ScheduledForDeletion) continue; updateTasks.Add(this.UpdateSinglePluginAsync(plugin, false, dryRun)); } } var updatedList = await Task.WhenAll(updateTasks); this.NotifyinstalledPluginsListChanged(); this.NotifyPluginsForStateChange( autoUpdate ? PluginListInvalidationKind.AutoUpdate : PluginListInvalidationKind.Update, updatedList.Select(x => x.InternalName)); Log.Information("Plugin update OK. {updateCount} plugins updated.", updatedList.Length); 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 workingPluginId = metadata.InstalledPlugin.Manifest.WorkingPluginId; if (workingPluginId == Guid.Empty) throw new Exception("Existing plugin had no WorkingPluginId"); var updateStatus = new PluginUpdateStatus { InternalName = plugin.Manifest.InternalName, Name = plugin.Manifest.Name, Version = (metadata.UseTesting ? metadata.UpdateManifest.TestingAssemblyVersion : metadata.UpdateManifest.AssemblyVersion)!, Status = PluginUpdateStatus.StatusKind.Success, HasChangelog = !metadata.UpdateManifest.Changelog.IsNullOrWhitespace(), }; if (!dryRun) { // Download the update before unloading Stream updateStream; try { updateStream = await this.DownloadPluginAsync(metadata.UpdateManifest, metadata.UseTesting); } catch (Exception ex) { Log.Error(ex, "Error during download (update)"); updateStatus.Status = PluginUpdateStatus.StatusKind.FailedDownload; return updateStatus; } // 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.Status = PluginUpdateStatus.StatusKind.FailedUnload; return updateStatus; } } if (plugin.IsDev) { throw new Exception("We should never update a dev plugin"); } try { // TODO: Why were we ever doing this? We should never be loading the old version in the first place /* if (!plugin.IsDisabled) plugin.Disable(); */ lock (this.pluginListLock) { this.installedPluginsList.Remove(plugin); } } catch (Exception ex) { Log.Error(ex, "Error during remove from plugin list (update)"); updateStatus.Status = PluginUpdateStatus.StatusKind.FailedUnload; 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.InstallPluginInternalAsync(metadata.UpdateManifest, metadata.UseTesting, PluginLoadReason.Update, updateStream, workingPluginId); } catch (Exception ex) { Log.Error(ex, "Error during install (update)"); updateStatus.Status = PluginUpdateStatus.StatusKind.FailedLoad; return updateStatus; } } if (notify && updateStatus.Status == PluginUpdateStatus.StatusKind.Success) this.NotifyinstalledPluginsListChanged(); 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.dalamud.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.installedPluginsList) { 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()); /// /// Resolves the services that a plugin may have a dependency on.
/// This is required, as the lifetime of a plugin cannot be longer than PluginManager, /// and we want to ensure that dependency services to be kept alive at least until all the plugins, and thus /// PluginManager to be gone. ///
/// The dependency services. private static IEnumerable ResolvePossiblePluginDependencyServices() { foreach (var serviceType in ServiceManager.GetConcreteServiceTypes()) { if (serviceType == typeof(PluginManager)) continue; // Scoped plugin services lifetime is tied to their scopes. They go away when LocalPlugin goes away. // Nonetheless, their direct dependencies must be considered. if (serviceType.GetServiceKind() == ServiceManager.ServiceKind.ScopedService) { var typeAsServiceT = ServiceHelpers.GetAsService(serviceType); var dependencies = ServiceHelpers.GetDependencies(typeAsServiceT, false); ServiceManager.Log.Verbose("Found dependencies of scoped plugin service {Type} ({Cnt})", serviceType.FullName!, dependencies!.Count); foreach (var scopedDep in dependencies) { if (scopedDep == typeof(PluginManager)) throw new Exception("Scoped plugin services cannot depend on PluginManager."); ServiceManager.Log.Verbose("PluginManager MUST depend on {Type} via {BaseType}", scopedDep.FullName!, serviceType.FullName!); yield return scopedDep; } continue; } var pluginInterfaceAttribute = serviceType.GetCustomAttribute(true); if (pluginInterfaceAttribute == null) continue; ServiceManager.Log.Verbose("PluginManager MUST depend on {Type}", serviceType.FullName!); yield return serviceType; } } /// /// Check if there are any inconsistencies with our plugins, their IDs, and our profiles. /// private void ParanoiaValidatePluginsAndProfiles() { var seenIds = new List(); foreach (var installedPlugin in this.InstalledPlugins) { if (installedPlugin.Manifest.WorkingPluginId == Guid.Empty) throw new Exception($"{(installedPlugin is LocalDevPlugin ? "DevPlugin" : "Plugin")} '{installedPlugin.Manifest.InternalName}' has an empty WorkingPluginId."); if (seenIds.Contains(installedPlugin.Manifest.WorkingPluginId)) { throw new Exception( $"{(installedPlugin is LocalDevPlugin ? "DevPlugin" : "Plugin")} '{installedPlugin.Manifest.InternalName}' has a duplicate WorkingPluginId '{installedPlugin.Manifest.WorkingPluginId}'"); } seenIds.Add(installedPlugin.Manifest.WorkingPluginId); } this.profileManager.ParanoiaValidateProfiles(); } private async Task DownloadPluginAsync(RemotePluginManifest repoManifest, bool useTesting) { var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl) { Headers = { Accept = { new MediaTypeWithQualityHeaderValue("application/zip"), }, }, }; var response = await this.happyHttpClient.SharedHttpClient.SendAsync(request); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStreamAsync(); } /// /// 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. /// Stream of the ZIP archive containing the plugin that is about to be installed. /// WorkingPluginId this plugin should inherit. /// A representing the asynchronous operation. private async Task InstallPluginInternalAsync(RemotePluginManifest repoManifest, bool useTesting, PluginLoadReason reason, Stream zipStream, Guid? inheritedWorkingPluginId = null) { var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; Log.Debug($"Installing plugin {repoManifest.Name} (testing={useTesting}, version={version}, reason={reason})"); // If this plugin is in the default profile for whatever reason, delete the state // If it was in multiple profiles and is still, the user uninstalled it and chose to keep it in there, // or the user removed the plugin manually in which case we don't care if (reason == PluginLoadReason.Installer) { try { // We don't need to apply, it doesn't matter await this.profileManager.DefaultProfile.RemoveByInternalNameAsync(repoManifest.InternalName, false); } catch (ProfileOperationException) { // ignored } } else { // If we are doing anything other than a fresh install, not having a workingPluginId is an error that must be fixed Debug.Assert(inheritedWorkingPluginId != null, "inheritedWorkingPluginId != null"); } // 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 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}"); using (var archive = new ZipArchive(zipStream)) { 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. Util.WriteAllTextSafe(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 == null) throw new Exception("Plugin had no valid manifest"); 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 (manifest.WorkingPluginId != Guid.Empty) throw new Exception("Plugin shall not specify a WorkingPluginId"); manifest.WorkingPluginId = inheritedWorkingPluginId ?? Guid.NewGuid(); if (useTesting) { manifest.Testing = true; } // Document the url the plugin was installed from manifest.InstalledFromUrl = repoManifest.SourceRepo.IsThirdParty ? repoManifest.SourceRepo.PluginMasterUrl : SpecialPluginSource.MainRepo; manifest.Save(manifestFile, "installation"); Log.Information($"Installed plugin {manifest.Name} (testing={useTesting})"); var plugin = await this.LoadPluginAsync(dllFile, manifest, reason); this.NotifyinstalledPluginsListChanged(); 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. private 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 || manifest.Name == null)) { Log.Error("{FileName}: Your manifest has no internal name or name set! Can't load this.", dllFile.FullName); throw new Exception("No internal name"); } if (isDev) { Log.Information($"Loading dev plugin {name}"); plugin = new LocalDevPlugin(dllFile, manifest); } else { Log.Information($"Loading plugin {name}"); plugin = new LocalPlugin(dllFile, manifest); } // Perform a migration from InternalName to GUIDs. The plugin should definitely have a GUID here. // This will also happen if you are installing a plugin with the installer, and that's intended! // It means that, if you have a profile which has unsatisfied plugins, installing a matching plugin will // enter it into the profiles it can match. if (plugin.Manifest.WorkingPluginId == Guid.Empty) throw new Exception("Plugin should have a WorkingPluginId at this point"); this.profileManager.MigrateProfilesToGuidsForPlugin(plugin.Manifest.InternalName, plugin.Manifest.WorkingPluginId); var wantedByAnyProfile = false; // Now, if this is a devPlugin, figure out if we want to load it if (isDev) { var devPlugin = (LocalDevPlugin)plugin; loadPlugin &= !isBoot; var wantsInDefaultProfile = this.profileManager.DefaultProfile.WantsPlugin(plugin.Manifest.WorkingPluginId); if (wantsInDefaultProfile == null) { // We don't know about this plugin, so we don't want to do anything here. // The code below will take care of it and add it with the default value. Log.Verbose("DevPlugin {Name} not wanted in default plugin", plugin.Manifest.InternalName); // Check if any profile wants this plugin. We need to do this here, since we want to allow loading a dev plugin if a non-default profile wants it active. // Note that this will not add the plugin to the default profile. That's done below in any other case. wantedByAnyProfile = await this.profileManager.GetWantStateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); // If it is wanted by any other profile, we do want to load it. if (wantedByAnyProfile) loadPlugin = true; } else if (wantsInDefaultProfile == false && devPlugin.StartOnBoot) { // We didn't want this plugin, and StartOnBoot is on. That means we don't want it and it should stay off until manually enabled. Log.Verbose("DevPlugin {Name} disabled and StartOnBoot => disable", plugin.Manifest.InternalName); await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); loadPlugin = false; } else if (wantsInDefaultProfile == true && devPlugin.StartOnBoot) { // We wanted this plugin, and StartOnBoot is on. That means we actually do want it. Log.Verbose("DevPlugin {Name} enabled and StartOnBoot => enable", plugin.Manifest.InternalName); await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, true, false); loadPlugin = !doNotLoad; } else if (wantsInDefaultProfile == true && !devPlugin.StartOnBoot) { // We wanted this plugin, but StartOnBoot is off. This means we don't want it anymore. Log.Verbose("DevPlugin {Name} enabled and !StartOnBoot => disable", plugin.Manifest.InternalName); await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); loadPlugin = false; } else if (wantsInDefaultProfile == false && !devPlugin.StartOnBoot) { // We didn't want this plugin, and StartOnBoot is off. We don't want it. Log.Verbose("DevPlugin {Name} disabled and !StartOnBoot => disable", plugin.Manifest.InternalName); await this.profileManager.DefaultProfile.AddOrUpdateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, false, false); loadPlugin = false; } plugin = devPlugin; } #pragma warning disable CS0618 var defaultState = manifest?.Disabled != true && loadPlugin; #pragma warning restore CS0618 // Plugins that aren't in any profile will be added to the default profile with this call. // We are skipping a double-lookup for dev plugins that are wanted by non-default profiles, as noted above. wantedByAnyProfile = wantedByAnyProfile || await this.profileManager.GetWantStateAsync(plugin.Manifest.WorkingPluginId, plugin.Manifest.InternalName, defaultState); Log.Information("{Name} defaultState: {State} wantedByAnyProfile: {WantedByAny} loadPlugin: {LoadPlugin}", plugin.Manifest.InternalName, defaultState, wantedByAnyProfile, loadPlugin); if (loadPlugin) { try { if (wantedByAnyProfile && !plugin.IsOrphaned) { await plugin.LoadAsync(reason); } else { Log.Verbose($"{name} not loaded, wantToLoad:{wantedByAnyProfile} 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; } } } if (plugin == null) throw new Exception("Plugin was null when adding to list"); lock (this.pluginListLock) { this.installedPluginsList.Add(plugin); } return plugin; } private void DetectAvailablePluginUpdates() { Log.Debug("Starting plugin update check..."); lock (this.pluginListLock) { this.updatablePluginsList.Clear(); foreach (var plugin in this.installedPluginsList) { 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); this.updatablePluginsList.Add(new AvailablePluginUpdate(plugin, update.remoteManifest, update.useTesting)); } } } Log.Debug("Update check found {updateCount} available updates.", this.updatablePluginsList.Count); } private void NotifyAvailablePluginsChanged() { this.DetectAvailablePluginUpdates(); this.OnAvailablePluginsChanged?.InvokeSafely(); } private void NotifyinstalledPluginsListChanged() { this.DetectAvailablePluginUpdates(); this.OnInstalledPluginsChanged?.InvokeSafely(); } private void NotifyPluginsForStateChange(PluginListInvalidationKind kind, IEnumerable affectedInternalNames) { foreach (var installedPlugin in this.installedPluginsList) { if (!installedPlugin.IsLoaded || installedPlugin.DalamudInterface == null) continue; installedPlugin.DalamudInterface.NotifyActivePluginsChanged( kind, // ReSharper disable once PossibleMultipleEnumeration affectedInternalNames.Contains(installedPlugin.Manifest.InternalName)); } } private void LoadAndStartLoadSyncPlugins() { try { using (Timings.Start("PM Load Plugin Repos")) { _ = this.SetPluginReposFromConfigAsync(false); this.OnInstalledPluginsChanged += () => Task.Run(Troubleshooting.LogTroubleshooting); Log.Information("[T3] PM repos OK!"); } using (Timings.Start("PM Cleanup Plugins")) { this.CleanupPlugins(); Log.Information("[T3] PMC OK!"); } using (Timings.Start("PM Load Sync Plugins")) { this.LoadAllPlugins().Wait(); Log.Information("[T3] PML OK!"); } _ = Task.Run(Troubleshooting.LogTroubleshooting); } catch (Exception ex) { Log.Error(ex, "Plugin load failed"); } } 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, string why) => Loc.Localize("DalamudPluginUpdateFailed", " 》 {0} update to v{1} failed ({2}).").Format(name, version, why); } } /// /// 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); } }