diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 34268798d..331a489a6 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -51,8 +51,11 @@ - IDE0002;IDE0003;IDE0044;IDE1006;CA1822;CS1591;CS1701;CS1702 + IDE0002;IDE0003;IDE1006;IDE0044;CA1822;CS1591;CS1701;CS1702 + + + @@ -65,9 +68,8 @@ - - + diff --git a/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs b/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs new file mode 100644 index 000000000..a4b75f7d3 --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/AssemblyLoadContextBuilder.cs @@ -0,0 +1,316 @@ +// Copyright (c) Nate McMaster, Dalamud contributors. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; + +using Dalamud.Plugin.Internal.Loader.LibraryModel; + +namespace Dalamud.Plugin.Internal.Loader +{ + /// + /// A builder for creating an instance of . + /// + internal class AssemblyLoadContextBuilder + { + private readonly List additionalProbingPaths = new(); + private readonly List resourceProbingPaths = new(); + private readonly List resourceProbingSubpaths = new(); + private readonly Dictionary managedLibraries = new(StringComparer.Ordinal); + private readonly Dictionary nativeLibraries = new(StringComparer.Ordinal); + private readonly HashSet privateAssemblies = new(StringComparer.Ordinal); + private readonly HashSet defaultAssemblies = new(StringComparer.Ordinal); + private AssemblyLoadContext defaultLoadContext = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default; + private string? mainAssemblyPath; + private bool preferDefaultLoadContext; + + private bool isCollectible; + private bool loadInMemory; + private bool shadowCopyNativeLibraries; + + /// + /// Creates an assembly load context using settings specified on the builder. + /// + /// A new ManagedLoadContext. + public AssemblyLoadContext Build() + { + var resourceProbingPaths = new List(this.resourceProbingPaths); + foreach (var additionalPath in this.additionalProbingPaths) + { + foreach (var subPath in this.resourceProbingSubpaths) + { + resourceProbingPaths.Add(Path.Combine(additionalPath, subPath)); + } + } + + if (this.mainAssemblyPath == null) + throw new InvalidOperationException($"Missing required property. You must call '{nameof(this.SetMainAssemblyPath)}' to configure the default assembly."); + + return new ManagedLoadContext( + this.mainAssemblyPath, + this.managedLibraries, + this.nativeLibraries, + this.privateAssemblies, + this.defaultAssemblies, + this.additionalProbingPaths, + resourceProbingPaths, + this.defaultLoadContext, + this.preferDefaultLoadContext, + this.isCollectible, + this.loadInMemory, + this.shadowCopyNativeLibraries); + } + + /// + /// Set the file path to the main assembly for the context. This is used as the starting point for loading + /// other assemblies. The directory that contains it is also known as the 'app local' directory. + /// + /// The file path. Must not be null or empty. Must be an absolute path. + /// The builder. + public AssemblyLoadContextBuilder SetMainAssemblyPath(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Argument must not be null or empty.", nameof(path)); + + if (!Path.IsPathRooted(path)) + throw new ArgumentException("Argument must be a full path.", nameof(path)); + + this.mainAssemblyPath = path; + + return this; + } + + /// + /// Replaces the default used by the . + /// Use this feature if the of the is not the Runtime's default load context. + /// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != . + /// + /// The context to set. + /// The builder. + public AssemblyLoadContextBuilder SetDefaultContext(AssemblyLoadContext context) + { + this.defaultLoadContext = context ?? throw new ArgumentException($"Bad Argument: AssemblyLoadContext in {nameof(AssemblyLoadContextBuilder)}.{nameof(this.SetDefaultContext)} is null."); + + return this; + } + + /// + /// Instructs the load context to prefer a private version of this assembly, even if that version is + /// different from the version used by the host application. + /// Use this when you do not need to exchange types created from within the load context with other contexts + /// or the default app context. + /// + /// This may mean the types loaded from + /// this assembly will not match the types from an assembly with the same name, but different version, + /// in the host application. + /// + /// + /// For example, if the host application has a type named Foo from assembly Banana, Version=1.0.0.0 + /// and the load context prefers a private version of Banan, Version=2.0.0.0, when comparing two objects, + /// one created by the host (Foo1) and one created from within the load context (Foo2), they will not have the same + /// type. Foo1.GetType() != Foo2.GetType(). + /// + /// + /// The name of the assembly. + /// The builder. + public AssemblyLoadContextBuilder PreferLoadContextAssembly(AssemblyName assemblyName) + { + if (assemblyName.Name != null) + this.privateAssemblies.Add(assemblyName.Name); + + return this; + } + + /// + /// Instructs the load context to first attempt to load assemblies by this name from the default app context, even + /// if other assemblies in this load context express a dependency on a higher or lower version. + /// Use this when you need to exchange types created from within the load context with other contexts + /// or the default app context. + /// + /// The name of the assembly. + /// The builder. + public AssemblyLoadContextBuilder PreferDefaultLoadContextAssembly(AssemblyName assemblyName) + { + var names = new Queue(new[] { assemblyName }); + + while (names.TryDequeue(out var name)) + { + if (name.Name == null || this.defaultAssemblies.Contains(name.Name)) + { + // base cases + continue; + } + + this.defaultAssemblies.Add(name.Name); + + // Load and find all dependencies of default assemblies. + // This sacrifices some performance for determinism in how transitive + // dependencies will be shared between host and plugin. + var assembly = this.defaultLoadContext.LoadFromAssemblyName(name); + + foreach (var reference in assembly.GetReferencedAssemblies()) + { + names.Enqueue(reference); + } + } + + return this; + } + + /// + /// Instructs the load context to first search for binaries from the default app context, even + /// if other assemblies in this load context express a dependency on a higher or lower version. + /// Use this when you need to exchange types created from within the load context with other contexts + /// or the default app context. + /// + /// This may mean the types loaded from within the context are force-downgraded to the version provided + /// by the host. can be used to selectively identify binaries + /// which should not be loaded from the default load context. + /// + /// + /// When true, first attemp to load binaries from the default load context. + /// The builder. + public AssemblyLoadContextBuilder PreferDefaultLoadContext(bool preferDefaultLoadContext) + { + this.preferDefaultLoadContext = preferDefaultLoadContext; + + return this; + } + + /// + /// Add a managed library to the load context. + /// + /// The managed library. + /// The builder. + public AssemblyLoadContextBuilder AddManagedLibrary(ManagedLibrary library) + { + ValidateRelativePath(library.AdditionalProbingPath); + + if (library.Name.Name != null) + { + this.managedLibraries.Add(library.Name.Name, library); + } + + return this; + } + + /// + /// Add a native library to the load context. + /// + /// A native library. + /// The builder. + public AssemblyLoadContextBuilder AddNativeLibrary(NativeLibrary library) + { + ValidateRelativePath(library.AppLocalPath); + ValidateRelativePath(library.AdditionalProbingPath); + + this.nativeLibraries.Add(library.Name, library); + + return this; + } + + /// + /// Add a that should be used to search for native and managed libraries. + /// + /// The file path. Must be a full file path. + /// The builder. + public AssemblyLoadContextBuilder AddProbingPath(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Value must not be null or empty.", nameof(path)); + + if (!Path.IsPathRooted(path)) + throw new ArgumentException("Argument must be a full path.", nameof(path)); + + this.additionalProbingPaths.Add(path); + + return this; + } + + /// + /// Add a that should be use to search for resource assemblies (aka satellite assemblies). + /// + /// The file path. Must be a full file path. + /// The builder. + public AssemblyLoadContextBuilder AddResourceProbingPath(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Value must not be null or empty.", nameof(path)); + + if (!Path.IsPathRooted(path)) + throw new ArgumentException("Argument must be a full path.", nameof(path)); + + this.resourceProbingPaths.Add(path); + + return this; + } + + /// + /// Enable unloading the assembly load context. + /// + /// The builder. + public AssemblyLoadContextBuilder EnableUnloading() + { + this.isCollectible = true; + + return this; + } + + /// + /// Read .dll files into memory to avoid locking the files. + /// This is not as efficient, so is not enabled by default, but is required for scenarios + /// like hot reloading. + /// + /// The builder. + public AssemblyLoadContextBuilder PreloadAssembliesIntoMemory() + { + this.loadInMemory = true; + + return this; + } + + /// + /// Shadow copy native libraries (unmanaged DLLs) to avoid locking of these files. + /// This is not as efficient, so is not enabled by default, but is required for scenarios + /// like hot reloading of plugins dependent on native libraries. + /// + /// The builder. + public AssemblyLoadContextBuilder ShadowCopyNativeLibraries() + { + this.shadowCopyNativeLibraries = true; + + return this; + } + + /// + /// Add a that should be use to search for resource assemblies (aka satellite assemblies) + /// relative to any paths specified as . + /// + /// The file path. Must not be a full file path since it will be appended to additional probing path roots. + /// The builder. + internal AssemblyLoadContextBuilder AddResourceProbingSubpath(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Value must not be null or empty.", nameof(path)); + + if (Path.IsPathRooted(path)) + throw new ArgumentException("Argument must be not a full path.", nameof(path)); + + this.resourceProbingSubpaths.Add(path); + + return this; + } + + private static void ValidateRelativePath(string probingPath) + { + if (string.IsNullOrEmpty(probingPath)) + throw new ArgumentException("Value must not be null or empty.", nameof(probingPath)); + + if (Path.IsPathRooted(probingPath)) + throw new ArgumentException("Argument must be a relative path.", nameof(probingPath)); + } + } +} diff --git a/Dalamud/Plugin/Internal/Loader/LICENSE.txt b/Dalamud/Plugin/Internal/Loader/LICENSE.txt new file mode 100644 index 000000000..e864ea39b --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/LICENSE.txt @@ -0,0 +1,178 @@ +https://github.com/natemcmaster/DotNetCorePlugins + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/Dalamud/Plugin/Internal/Loader/LibraryModel/ManagedLibrary.cs b/Dalamud/Plugin/Internal/Loader/LibraryModel/ManagedLibrary.cs new file mode 100644 index 000000000..353c07b96 --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/LibraryModel/ManagedLibrary.cs @@ -0,0 +1,72 @@ +// Copyright (c) Nate McMaster, Dalamud contributors. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; + +namespace Dalamud.Plugin.Internal.Loader.LibraryModel +{ + /// + /// Represents a managed, .NET assembly. + /// + [DebuggerDisplay("{Name} = {AdditionalProbingPath}")] + internal class ManagedLibrary + { + private ManagedLibrary(AssemblyName name, string additionalProbingPath, string appLocalPath) + { + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + this.AdditionalProbingPath = additionalProbingPath ?? throw new ArgumentNullException(nameof(additionalProbingPath)); + this.AppLocalPath = appLocalPath ?? throw new ArgumentNullException(nameof(appLocalPath)); + } + + /// + /// Gets the name of the managed library. + /// + public AssemblyName Name { get; } + + /// + /// Gets the path to file within an additional probing path root. This is typically a combination + /// of the NuGet package ID (lowercased), version, and path within the package. + /// + /// For example, microsoft.data.sqlite/1.0.0/lib/netstandard1.3/Microsoft.Data.Sqlite.dll. + /// + /// + public string AdditionalProbingPath { get; } + + /// + /// Gets the path to file within a deployed, framework-dependent application. + /// + /// For most managed libraries, this will be the file name. + /// For example, MyPlugin1.dll. + /// + /// + /// For runtime-specific managed implementations, this may include a sub folder path. + /// For example, runtimes/win/lib/netcoreapp2.0/System.Diagnostics.EventLog.dll. + /// + /// + public string AppLocalPath { get; } + + /// + /// Create an instance of from a NuGet package. + /// + /// The name of the package. + /// The version of the package. + /// The path within the NuGet package. + /// A managed library. + public static ManagedLibrary CreateFromPackage(string packageId, string packageVersion, string assetPath) + { + // When the asset comes from "lib/$tfm/", Microsoft.NET.Sdk will flatten this during publish based on the most compatible TFM. + // The SDK will not flatten managed libraries found under runtimes/ + var appLocalPath = assetPath.StartsWith("lib/") + ? Path.GetFileName(assetPath) + : assetPath; + + return new ManagedLibrary( + new AssemblyName(Path.GetFileNameWithoutExtension(assetPath)), + Path.Combine(packageId.ToLowerInvariant(), packageVersion, assetPath), + appLocalPath); + } + } +} diff --git a/Dalamud/Plugin/Internal/Loader/LibraryModel/NativeLibrary.cs b/Dalamud/Plugin/Internal/Loader/LibraryModel/NativeLibrary.cs new file mode 100644 index 000000000..35d6eb3e8 --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/LibraryModel/NativeLibrary.cs @@ -0,0 +1,68 @@ +// Copyright (c) Nate McMaster, Dalamud contributors. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +using System; +using System.Diagnostics; +using System.IO; + +namespace Dalamud.Plugin.Internal.Loader.LibraryModel +{ + /// + /// Represents an unmanaged library, such as `libsqlite3`, which may need to be loaded + /// for P/Invoke to work. + /// + [DebuggerDisplay("{Name} = {AdditionalProbingPath}")] + internal class NativeLibrary + { + private NativeLibrary(string name, string appLocalPath, string additionalProbingPath) + { + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + this.AppLocalPath = appLocalPath ?? throw new ArgumentNullException(nameof(appLocalPath)); + this.AdditionalProbingPath = additionalProbingPath ?? throw new ArgumentNullException(nameof(additionalProbingPath)); + } + + /// + /// Gets the name of the native library. This should match the name of the P/Invoke call. + /// + /// For example, if specifying `[DllImport("sqlite3")]`, should be sqlite3. + /// This may not match the exact file name as loading will attempt variations on the name according + /// to OS convention. On Windows, P/Invoke will attempt to load `sqlite3.dll`. On macOS, it will + /// attempt to find `sqlite3.dylib` and `libsqlite3.dylib`. On Linux, it will attempt to find + /// `sqlite3.so` and `libsqlite3.so`. + /// + /// + public string Name { get; } + + /// + /// Gets the path to file within a deployed, framework-dependent application. + /// + /// For example, runtimes/linux-x64/native/libsqlite.so. + /// + /// + public string AppLocalPath { get; } + + /// + /// Gets the path to file within an additional probing path root. This is typically a combination + /// of the NuGet package ID (lowercased), version, and path within the package. + /// + /// For example, sqlite/3.13.3/runtimes/linux-x64/native/libsqlite.so. + /// + /// + public string AdditionalProbingPath { get; } + + /// + /// Create an instance of from a NuGet package. + /// + /// The name of the package. + /// The version of the package. + /// The path within the NuGet package. + /// A native library. + public static NativeLibrary CreateFromPackage(string packageId, string packageVersion, string assetPath) + { + return new NativeLibrary( + Path.GetFileNameWithoutExtension(assetPath), + assetPath, + Path.Combine(packageId.ToLowerInvariant(), packageVersion, assetPath)); + } + } +} diff --git a/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs b/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs new file mode 100644 index 000000000..90cf44d07 --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/LoaderConfig.cs @@ -0,0 +1,77 @@ +// Copyright (c) Nate McMaster, Dalamud contributors. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; + +namespace Dalamud.Plugin.Internal.Loader +{ + /// + /// Represents the configuration for a plugin loader. + /// + internal class LoaderConfig + { + /// + /// Initializes a new instance of the class. + /// + /// The full file path to the main assembly for the plugin. + public LoaderConfig(string mainAssemblyPath) + { + if (string.IsNullOrEmpty(mainAssemblyPath)) + throw new ArgumentException("Value must be null or not empty", nameof(mainAssemblyPath)); + + if (!Path.IsPathRooted(mainAssemblyPath)) + throw new ArgumentException("Value must be an absolute file path", nameof(mainAssemblyPath)); + + if (!File.Exists(mainAssemblyPath)) + throw new ArgumentException("Value must exist", nameof(mainAssemblyPath)); + + this.MainAssemblyPath = mainAssemblyPath; + } + + /// + /// Gets the file path to the main assembly. + /// + public string MainAssemblyPath { get; } + + /// + /// Gets a list of assemblies which should be treated as private. + /// + public ICollection PrivateAssemblies { get; } = new List(); + + /// + /// Gets a list of assemblies which should be unified between the host and the plugin. + /// + /// what-are-shared-types + public ICollection SharedAssemblies { get; } = new List(); + + /// + /// Gets or sets a value indicating whether attempt to unify all types from a plugin with the host. + /// + /// This does not guarantee types will unify. + /// + /// what-are-shared-types + /// + public bool PreferSharedTypes { get; set; } + + /// + /// Gets or sets the default used by the . + /// Use this feature if the of the is not the Runtime's default load context. + /// i.e. (AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly) != . + /// + public AssemblyLoadContext DefaultContext { get; set; } = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default; + + /// + /// Gets or sets a value indicating whether the plugin can be unloaded from memory. + /// + public bool IsUnloadable { get; set; } + + /// + /// Gets or sets a value indicating whether to load assemblies into memory in order to not lock files. + /// + public bool LoadInMemory { get; set; } + } +} diff --git a/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs b/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs new file mode 100644 index 000000000..26bc3d7d3 --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/ManagedLoadContext.cs @@ -0,0 +1,397 @@ +// Copyright (c) Nate McMaster, Dalamud contributors. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; + +using Dalamud.Plugin.Internal.Loader.LibraryModel; + +namespace Dalamud.Plugin.Internal.Loader +{ + /// + /// An implementation of which attempts to load managed and native + /// binaries at runtime immitating some of the behaviors of corehost. + /// + [DebuggerDisplay("'{Name}' ({_mainAssemblyPath})")] + internal class ManagedLoadContext : AssemblyLoadContext + { + private readonly string basePath; + private readonly string mainAssemblyPath; + private readonly IReadOnlyDictionary managedAssemblies; + private readonly IReadOnlyDictionary nativeLibraries; + private readonly IReadOnlyCollection privateAssemblies; + private readonly ICollection defaultAssemblies; + private readonly IReadOnlyCollection additionalProbingPaths; + private readonly bool preferDefaultLoadContext; + private readonly string[] resourceRoots; + private readonly bool loadInMemory; + private readonly AssemblyLoadContext defaultLoadContext; + private readonly AssemblyDependencyResolver dependencyResolver; + private readonly bool shadowCopyNativeLibraries; + private readonly string unmanagedDllShadowCopyDirectoryPath; + + /// + /// Initializes a new instance of the class. + /// + /// Main assembly path. + /// Managed assemblies. + /// Native assemblies. + /// Private assemblies. + /// Default assemblies. + /// Additional probing paths. + /// Resource probing paths. + /// Default load context. + /// If the default load context should be prefered. + /// If the dll is collectible. + /// If the dll should be loaded in memory. + /// If native libraries should be shadow copied. + public ManagedLoadContext( + string mainAssemblyPath, + IReadOnlyDictionary managedAssemblies, + IReadOnlyDictionary nativeLibraries, + IReadOnlyCollection privateAssemblies, + IReadOnlyCollection defaultAssemblies, + IReadOnlyCollection additionalProbingPaths, + IReadOnlyCollection resourceProbingPaths, + AssemblyLoadContext defaultLoadContext, + bool preferDefaultLoadContext, + bool isCollectible, + bool loadInMemory, + bool shadowCopyNativeLibraries) + : base(Path.GetFileNameWithoutExtension(mainAssemblyPath), isCollectible) + { + if (resourceProbingPaths == null) + throw new ArgumentNullException(nameof(resourceProbingPaths)); + + this.mainAssemblyPath = mainAssemblyPath ?? throw new ArgumentNullException(nameof(mainAssemblyPath)); + this.dependencyResolver = new AssemblyDependencyResolver(mainAssemblyPath); + this.basePath = Path.GetDirectoryName(mainAssemblyPath) ?? throw new ArgumentException("Invalid assembly path", nameof(mainAssemblyPath)); + this.managedAssemblies = managedAssemblies ?? throw new ArgumentNullException(nameof(managedAssemblies)); + this.privateAssemblies = privateAssemblies ?? throw new ArgumentNullException(nameof(privateAssemblies)); + this.defaultAssemblies = defaultAssemblies != null ? defaultAssemblies.ToList() : throw new ArgumentNullException(nameof(defaultAssemblies)); + this.nativeLibraries = nativeLibraries ?? throw new ArgumentNullException(nameof(nativeLibraries)); + this.additionalProbingPaths = additionalProbingPaths ?? throw new ArgumentNullException(nameof(additionalProbingPaths)); + this.defaultLoadContext = defaultLoadContext; + this.preferDefaultLoadContext = preferDefaultLoadContext; + this.loadInMemory = loadInMemory; + + this.resourceRoots = new[] { this.basePath } + .Concat(resourceProbingPaths) + .ToArray(); + + this.shadowCopyNativeLibraries = shadowCopyNativeLibraries; + this.unmanagedDllShadowCopyDirectoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + if (shadowCopyNativeLibraries) + { + this.Unloading += _ => this.OnUnloaded(); + } + } + + /// + /// Load an assembly from a filepath. + /// + /// Assembly path. + /// A loaded assembly. + public Assembly LoadAssemblyFromFilePath(string path) + { + if (!this.loadInMemory) + return this.LoadFromAssemblyPath(path); + + using var file = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + + var pdbPath = Path.ChangeExtension(path, ".pdb"); + if (File.Exists(pdbPath)) + { + using var pdbFile = File.Open(pdbPath, FileMode.Open, FileAccess.Read, FileShare.Read); + return this.LoadFromStream(file, pdbFile); + } + + return this.LoadFromStream(file); + } + + /// + /// Load an assembly. + /// + /// Name of the assembly. + /// Loaded assembly. + protected override Assembly? Load(AssemblyName assemblyName) + { + if (assemblyName.Name == null) + { + // not sure how to handle this case. It's technically possible. + return null; + } + + if ((this.preferDefaultLoadContext || this.defaultAssemblies.Contains(assemblyName.Name)) && !this.privateAssemblies.Contains(assemblyName.Name)) + { + // If default context is preferred, check first for types in the default context unless the dependency has been declared as private + try + { + var defaultAssembly = this.defaultLoadContext.LoadFromAssemblyName(assemblyName); + if (defaultAssembly != null) + { + // Older versions used to return null here such that returned assembly would be resolved from the default ALC. + // However, with the addition of custom default ALCs, the Default ALC may not be the user's chosen ALC when + // this context was built. As such, we simply return the Assembly from the user's chosen default load context. + return defaultAssembly; + } + } + catch + { + // Swallow errors in loading from the default context + } + } + + var resolvedPath = this.dependencyResolver.ResolveAssemblyToPath(assemblyName); + if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath)) + { + return this.LoadAssemblyFromFilePath(resolvedPath); + } + + // Resource assembly binding does not use the TPA. Instead, it probes PLATFORM_RESOURCE_ROOTS (a list of folders) + // for $folder/$culture/$assemblyName.dll + // See https://github.com/dotnet/coreclr/blob/3fca50a36e62a7433d7601d805d38de6baee7951/src/binder/assemblybinder.cpp#L1232-L1290 + + if (!string.IsNullOrEmpty(assemblyName.CultureName) && !string.Equals(assemblyName.CultureName, "neutral")) + { + foreach (var resourceRoot in this.resourceRoots) + { + var resourcePath = Path.Combine(resourceRoot, assemblyName.CultureName, assemblyName.Name + ".dll"); + if (File.Exists(resourcePath)) + { + return this.LoadAssemblyFromFilePath(resourcePath); + } + } + + return null; + } + + if (this.managedAssemblies.TryGetValue(assemblyName.Name, out var library) && library != null) + { + if (this.SearchForLibrary(library, out var path) && path != null) + { + return this.LoadAssemblyFromFilePath(path); + } + } + else + { + // if an assembly was not listed in the list of known assemblies, + // fallback to the load context base directory + var dllName = assemblyName.Name + ".dll"; + foreach (var probingPath in this.additionalProbingPaths.Prepend(this.basePath)) + { + var localFile = Path.Combine(probingPath, dllName); + if (File.Exists(localFile)) + { + return this.LoadAssemblyFromFilePath(localFile); + } + } + } + + return null; + } + + /// + /// Loads the unmanaged binary using configured list of native libraries. + /// + /// Unmanaged DLL name. + /// The unmanaged dll handle. + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var resolvedPath = this.dependencyResolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath)) + { + return this.LoadUnmanagedDllFromResolvedPath(resolvedPath, normalizePath: false); + } + + foreach (var prefix in PlatformInformation.NativeLibraryPrefixes) + { + if (this.nativeLibraries.TryGetValue(prefix + unmanagedDllName, out var library)) + { + if (this.SearchForLibrary(library, prefix, out var path) && path != null) + { + return this.LoadUnmanagedDllFromResolvedPath(path); + } + } + else + { + // coreclr allows code to use [DllImport("sni")] or [DllImport("sni.dll")] + // This library treats the file name without the extension as the lookup name, + // so this loop is necessary to check if the unmanaged name matches a library + // when the file extension has been trimmed. + foreach (var suffix in PlatformInformation.NativeLibraryExtensions) + { + if (!unmanagedDllName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // check to see if there is a library entry for the library without the file extension + var trimmedName = unmanagedDllName.Substring(0, unmanagedDllName.Length - suffix.Length); + + if (this.nativeLibraries.TryGetValue(prefix + trimmedName, out library)) + { + if (this.SearchForLibrary(library, prefix, out var path) && path != null) + { + return this.LoadUnmanagedDllFromResolvedPath(path); + } + } + else + { + // fallback to native assets which match the file name in the plugin base directory + var prefixSuffixDllName = prefix + unmanagedDllName + suffix; + var prefixDllName = prefix + unmanagedDllName; + + foreach (var probingPath in this.additionalProbingPaths.Prepend(this.basePath)) + { + var localFile = Path.Combine(probingPath, prefixSuffixDllName); + if (File.Exists(localFile)) + { + return this.LoadUnmanagedDllFromResolvedPath(localFile); + } + + var localFileWithoutSuffix = Path.Combine(probingPath, prefixDllName); + if (File.Exists(localFileWithoutSuffix)) + { + return this.LoadUnmanagedDllFromResolvedPath(localFileWithoutSuffix); + } + } + } + } + } + } + + return base.LoadUnmanagedDll(unmanagedDllName); + } + + private bool SearchForLibrary(ManagedLibrary library, out string? path) + { + // 1. Check for in _basePath + app local path + var localFile = Path.Combine(this.basePath, library.AppLocalPath); + if (File.Exists(localFile)) + { + path = localFile; + return true; + } + + // 2. Search additional probing paths + foreach (var searchPath in this.additionalProbingPaths) + { + var candidate = Path.Combine(searchPath, library.AdditionalProbingPath); + if (File.Exists(candidate)) + { + path = candidate; + return true; + } + } + + // 3. Search in base path + foreach (var ext in PlatformInformation.ManagedAssemblyExtensions) + { + var local = Path.Combine(this.basePath, library.Name.Name + ext); + if (File.Exists(local)) + { + path = local; + return true; + } + } + + path = null; + return false; + } + + private bool SearchForLibrary(NativeLibrary library, string prefix, out string? path) + { + // 1. Search in base path + foreach (var ext in PlatformInformation.NativeLibraryExtensions) + { + var candidate = Path.Combine(this.basePath, $"{prefix}{library.Name}{ext}"); + if (File.Exists(candidate)) + { + path = candidate; + return true; + } + } + + // 2. Search in base path + app local (for portable deployments of netcoreapp) + var local = Path.Combine(this.basePath, library.AppLocalPath); + if (File.Exists(local)) + { + path = local; + return true; + } + + // 3. Search additional probing paths + foreach (var searchPath in this.additionalProbingPaths) + { + var candidate = Path.Combine(searchPath, library.AdditionalProbingPath); + if (File.Exists(candidate)) + { + path = candidate; + return true; + } + } + + path = null; + return false; + } + + private IntPtr LoadUnmanagedDllFromResolvedPath(string unmanagedDllPath, bool normalizePath = true) + { + if (normalizePath) + { + unmanagedDllPath = Path.GetFullPath(unmanagedDllPath); + } + + return this.shadowCopyNativeLibraries + ? this.LoadUnmanagedDllFromShadowCopy(unmanagedDllPath) + : this.LoadUnmanagedDllFromPath(unmanagedDllPath); + } + + private IntPtr LoadUnmanagedDllFromShadowCopy(string unmanagedDllPath) + { + var shadowCopyDllPath = this.CreateShadowCopy(unmanagedDllPath); + + return this.LoadUnmanagedDllFromPath(shadowCopyDllPath); + } + + private string CreateShadowCopy(string dllPath) + { + Directory.CreateDirectory(this.unmanagedDllShadowCopyDirectoryPath); + + var dllFileName = Path.GetFileName(dllPath); + var shadowCopyPath = Path.Combine(this.unmanagedDllShadowCopyDirectoryPath, dllFileName); + + if (!File.Exists(shadowCopyPath)) + { + File.Copy(dllPath, shadowCopyPath); + } + + return shadowCopyPath; + } + + private void OnUnloaded() + { + if (!this.shadowCopyNativeLibraries || !Directory.Exists(this.unmanagedDllShadowCopyDirectoryPath)) + { + return; + } + + // Attempt to delete shadow copies + try + { + Directory.Delete(this.unmanagedDllShadowCopyDirectoryPath, recursive: true); + } + catch (Exception) + { + // Files might be locked by host process. Nothing we can do about it, I guess. + } + } + } +} diff --git a/Dalamud/Plugin/Internal/Loader/PlatformInformation.cs b/Dalamud/Plugin/Internal/Loader/PlatformInformation.cs new file mode 100644 index 000000000..47a3d7acf --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/PlatformInformation.cs @@ -0,0 +1,32 @@ +// Copyright (c) Nate McMaster, Dalamud team. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +namespace Dalamud.Plugin.Internal.Loader +{ + /// + /// Platform specific information. + /// + internal class PlatformInformation + { + /// + /// Gets a list of native OS specific library extensions. + /// + public static string[] NativeLibraryExtensions => new[] { ".dll" }; + + /// + /// Gets a list of native OS specific library prefixes. + /// + public static string[] NativeLibraryPrefixes => new[] { string.Empty }; + + /// + /// Gets a list of native OS specific managed assembly extensions. + /// + public static string[] ManagedAssemblyExtensions => new[] + { + ".dll", + ".ni.dll", + ".exe", + ".ni.exe", + }; + } +} diff --git a/Dalamud/Plugin/Internal/Loader/PluginLoader.cs b/Dalamud/Plugin/Internal/Loader/PluginLoader.cs new file mode 100644 index 000000000..020739f3a --- /dev/null +++ b/Dalamud/Plugin/Internal/Loader/PluginLoader.cs @@ -0,0 +1,163 @@ +// Copyright (c) Nate McMaster, Dalamud team. +// Licensed under the Apache License, Version 2.0. See License.txt in the Loader root for license information. + +using System; +using System.Reflection; +using System.Runtime.Loader; + +namespace Dalamud.Plugin.Internal.Loader +{ + /// + /// This loader attempts to load binaries for execution (both managed assemblies and native libraries) + /// in the same way that .NET Core would if they were originally part of the .NET Core application. + /// + /// This loader reads configuration files produced by .NET Core (.deps.json and runtimeconfig.json) + /// as well as a custom file (*.config files). These files describe a list of .dlls and a set of dependencies. + /// The loader searches the plugin path, as well as any additionally specified paths, for binaries + /// which satisfy the plugin's requirements. + /// + /// + internal class PluginLoader : IDisposable + { + private readonly LoaderConfig config; + private readonly AssemblyLoadContextBuilder contextBuilder; + private ManagedLoadContext context; + private volatile bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration for the plugin. + public PluginLoader(LoaderConfig config) + { + this.config = config ?? throw new ArgumentNullException(nameof(config)); + this.contextBuilder = CreateLoadContextBuilder(config); + this.context = (ManagedLoadContext)this.contextBuilder.Build(); + } + + /// + /// Gets a value indicating whether this plugin is capable of being unloaded. + /// + public bool IsUnloadable + => this.context.IsCollectible; + + /// + /// Gets the assembly load context. + /// + public AssemblyLoadContext LoadContext => this.context; + + /// + /// Create a plugin loader for an assembly file. + /// + /// The file path to the main assembly for the plugin. + /// A function which can be used to configure advanced options for the plugin loader. + /// A loader. + public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + + var config = new LoaderConfig(assemblyFile); + configure(config); + return new PluginLoader(config); + } + + /// + /// The unloads and reloads the plugin assemblies. + /// This method throws if is false. + /// + public void Reload() + { + this.EnsureNotDisposed(); + + if (!this.IsUnloadable) + { + throw new InvalidOperationException("Reload cannot be used because IsUnloadable is false"); + } + + this.context.Unload(); + this.context = (ManagedLoadContext)this.contextBuilder.Build(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + /// + /// Load the main assembly for the plugin. + /// + /// The assembly. + public Assembly LoadDefaultAssembly() + { + this.EnsureNotDisposed(); + return this.context.LoadAssemblyFromFilePath(this.config.MainAssemblyPath); + } + + /// + /// Sets the scope used by some System.Reflection APIs which might trigger assembly loading. + /// + /// See https://github.com/dotnet/coreclr/blob/v3.0.0/Documentation/design-docs/AssemblyLoadContext.ContextualReflection.md for more details. + /// + /// + /// A contextual reflection scope. + public AssemblyLoadContext.ContextualReflectionScope EnterContextualReflection() + => this.context.EnterContextualReflection(); + + /// + /// Disposes the plugin loader. This only does something if is true. + /// When true, this will unload assemblies which which were loaded during the lifetime + /// of the plugin. + /// + public void Dispose() + { + if (this.disposed) + return; + + this.disposed = true; + + if (this.context.IsCollectible) + this.context.Unload(); + } + + private static AssemblyLoadContextBuilder CreateLoadContextBuilder(LoaderConfig config) + { + var builder = new AssemblyLoadContextBuilder(); + + builder.SetMainAssemblyPath(config.MainAssemblyPath); + builder.SetDefaultContext(config.DefaultContext); + + foreach (var ext in config.PrivateAssemblies) + { + builder.PreferLoadContextAssembly(ext); + } + + if (config.PreferSharedTypes) + { + builder.PreferDefaultLoadContext(true); + } + + if (config.IsUnloadable) + { + builder.EnableUnloading(); + } + + if (config.LoadInMemory) + { + builder.PreloadAssembliesIntoMemory(); + builder.ShadowCopyNativeLibraries(); + } + + foreach (var assemblyName in config.SharedAssemblies) + { + builder.PreferDefaultLoadContextAssembly(assemblyName); + } + + return builder; + } + + private void EnsureNotDisposed() + { + if (this.disposed) + throw new ObjectDisposedException(nameof(PluginLoader)); + } + } +} diff --git a/Dalamud/Plugin/Internal/LocalPlugin.cs b/Dalamud/Plugin/Internal/LocalPlugin.cs index 7310e5dca..8c959755f 100644 --- a/Dalamud/Plugin/Internal/LocalPlugin.cs +++ b/Dalamud/Plugin/Internal/LocalPlugin.cs @@ -8,8 +8,8 @@ using Dalamud.Game; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal.Exceptions; +using Dalamud.Plugin.Internal.Loader; using Dalamud.Plugin.Internal.Types; -using McMaster.NETCore.Plugins; namespace Dalamud.Plugin.Internal { @@ -37,17 +37,18 @@ namespace Dalamud.Plugin.Internal /// The plugin manifest. public LocalPlugin(FileInfo dllFile, LocalPluginManifest? manifest) { + if (dllFile.Name == "FFXIVClientStructs.Generators.dll") + { + // Could this be done another way? Sure. It is an extremely common source + // of errors in the log through, and should never be loaded as a plugin. + Log.Error($"Not a plugin: {dllFile.FullName}"); + throw new InvalidPluginException(dllFile); + } + this.DllFile = dllFile; this.State = PluginState.Unloaded; - this.loader = PluginLoader.CreateFromAssemblyFile( - this.DllFile.FullName, - config => - { - config.IsUnloadable = true; - config.LoadInMemory = true; - config.PreferSharedTypes = true; - }); + this.loader = PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, this.SetupLoaderConfig); try { @@ -252,14 +253,7 @@ namespace Dalamud.Plugin.Internal try { - this.loader ??= PluginLoader.CreateFromAssemblyFile( - this.DllFile.FullName, - config => - { - config.IsUnloadable = true; - config.LoadInMemory = true; - config.PreferSharedTypes = true; - }); + this.loader ??= PluginLoader.CreateFromAssemblyFile(this.DllFile.FullName, this.SetupLoaderConfig); if (reloading) { @@ -437,6 +431,15 @@ namespace Dalamud.Plugin.Internal this.SaveManifest(); } + private void SetupLoaderConfig(LoaderConfig config) + { + config.IsUnloadable = true; + config.LoadInMemory = true; + config.PreferSharedTypes = false; + config.SharedAssemblies.Add(typeof(Lumina.GameData).Assembly.GetName()); + config.SharedAssemblies.Add(typeof(Lumina.Excel.ExcelSheetImpl).Assembly.GetName()); + } + private void SaveManifest() => this.Manifest.Save(this.manifestFile); } }