diff --git a/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj b/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj
index 9f385a5d8..bf355f389 100644
--- a/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj
+++ b/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj
@@ -27,7 +27,6 @@
-
diff --git a/BTCPayServer.PluginPacker/Program.cs b/BTCPayServer.PluginPacker/Program.cs
index 5b67c533d..1ab9c08b8 100644
--- a/BTCPayServer.PluginPacker/Program.cs
+++ b/BTCPayServer.PluginPacker/Program.cs
@@ -9,7 +9,7 @@ using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
-using McMaster.NETCore.Plugins;
+using BTCPayServer.Plugins.Dotnet;
using NBitcoin.Crypto;
using NBitcoin.DataEncoders;
using NBitcoin.Secp256k1;
diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj
index 9e73eb74a..a61254988 100644
--- a/BTCPayServer/BTCPayServer.csproj
+++ b/BTCPayServer/BTCPayServer.csproj
@@ -62,7 +62,6 @@
-
diff --git a/BTCPayServer/Plugins/Dotnet/Internal/Debouncer.cs b/BTCPayServer/Plugins/Dotnet/Internal/Debouncer.cs
new file mode 100644
index 000000000..b8c31f4b4
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Internal/Debouncer.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BTCPayServer.Plugins.Dotnet.Internal
+{
+ internal class Debouncer : IDisposable
+ {
+ private readonly CancellationTokenSource _cts = new();
+ private readonly TimeSpan _waitTime;
+ private int _counter;
+
+ public Debouncer(TimeSpan waitTime)
+ {
+ _waitTime = waitTime;
+ }
+
+ public void Execute(Action action)
+ {
+ var current = Interlocked.Increment(ref _counter);
+
+ Task.Delay(_waitTime).ContinueWith(task =>
+ {
+ // Is this the last task that was queued?
+ if (current == _counter && !_cts.IsCancellationRequested)
+ {
+ action();
+ }
+
+ task.Dispose();
+ }, _cts.Token);
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/Internal/PlatformInformation.cs b/BTCPayServer/Plugins/Dotnet/Internal/PlatformInformation.cs
new file mode 100644
index 000000000..609f7671c
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Internal/PlatformInformation.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace BTCPayServer.Plugins.Dotnet.Internal
+{
+ internal class PlatformInformation
+ {
+ public static readonly string[] NativeLibraryExtensions;
+ public static readonly string[] NativeLibraryPrefixes;
+ public static readonly string[] ManagedAssemblyExtensions = new[]
+ {
+ ".dll",
+ ".ni.dll",
+ ".exe",
+ ".ni.exe"
+ };
+
+ static PlatformInformation()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ NativeLibraryPrefixes = new[] { "" };
+ NativeLibraryExtensions = new[] { ".dll" };
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ NativeLibraryPrefixes = new[] { "", "lib", };
+ NativeLibraryExtensions = new[] { ".dylib" };
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ NativeLibraryPrefixes = new[] { "", "lib" };
+ NativeLibraryExtensions = new[] { ".so", ".so.1" };
+ }
+ else
+ {
+ Debug.Fail("Unknown OS type");
+ NativeLibraryPrefixes = Array.Empty();
+ NativeLibraryExtensions = Array.Empty();
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/Internal/RuntimeConfig.cs b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeConfig.cs
new file mode 100644
index 000000000..472856365
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeConfig.cs
@@ -0,0 +1,11 @@
+#nullable enable
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace BTCPayServer.Plugins.Dotnet.Internal
+{
+ internal class RuntimeConfig
+ {
+ public RuntimeOptions? runtimeOptions { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/Internal/RuntimeOptions.cs b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeOptions.cs
new file mode 100644
index 000000000..3f7a5943c
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Internal/RuntimeOptions.cs
@@ -0,0 +1,13 @@
+#nullable enable
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace BTCPayServer.Plugins.Dotnet.Internal
+{
+ internal class RuntimeOptions
+ {
+ public string? Tfm { get; set; }
+
+ public string[]? AdditionalProbingPaths { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/LibraryModel/ManagedLibrary.cs b/BTCPayServer/Plugins/Dotnet/LibraryModel/ManagedLibrary.cs
new file mode 100644
index 000000000..2e65e7bd1
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/LibraryModel/ManagedLibrary.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+
+namespace BTCPayServer.Plugins.Dotnet.LibraryModel
+{
+ ///
+ /// Represents a managed, .NET assembly.
+ ///
+ [DebuggerDisplay("{Name} = {AdditionalProbingPath}")]
+ public class ManagedLibrary
+ {
+ private ManagedLibrary(AssemblyName name, string additionalProbingPath, string appLocalPath)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ AdditionalProbingPath = additionalProbingPath ?? throw new ArgumentNullException(nameof(additionalProbingPath));
+ AppLocalPath = appLocalPath ?? throw new ArgumentNullException(nameof(appLocalPath));
+ }
+
+ ///
+ /// Name of the managed library
+ ///
+ public AssemblyName Name { get; private set; }
+
+ ///
+ /// Contains 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; private set; }
+
+ ///
+ /// Contains 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; private set; }
+
+ ///
+ /// Create an instance of from a NuGet package.
+ ///
+ /// The name of the package.
+ /// The version of the package.
+ /// The path within the NuGet package.
+ ///
+ 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/BTCPayServer/Plugins/Dotnet/LibraryModel/NativeLibrary.cs b/BTCPayServer/Plugins/Dotnet/LibraryModel/NativeLibrary.cs
new file mode 100644
index 000000000..c33297454
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/LibraryModel/NativeLibrary.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace BTCPayServer.Plugins.Dotnet.LibraryModel
+{
+ ///
+ /// Represents an unmanaged library, such as `libsqlite3`, which may need to be loaded
+ /// for P/Invoke to work.
+ ///
+ [DebuggerDisplay("{Name} = {AdditionalProbingPath}")]
+ public class NativeLibrary
+ {
+ private NativeLibrary(string name, string appLocalPath, string additionalProbingPath)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ AppLocalPath = appLocalPath ?? throw new ArgumentNullException(nameof(appLocalPath));
+ AdditionalProbingPath = additionalProbingPath ?? throw new ArgumentNullException(nameof(additionalProbingPath));
+ }
+
+ ///
+ /// 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; private set; }
+
+ ///
+ /// Contains path to file within a deployed, framework-dependent application
+ ///
+ /// For example, runtimes/linux-x64/native/libsqlite.so
+ ///
+ ///
+ public string AppLocalPath { get; private set; }
+
+ ///
+ /// Contains 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; private set; }
+
+ ///
+ /// Create an instance of from a NuGet package.
+ ///
+ /// The name of the package.
+ /// The version of the package.
+ /// The path within the NuGet package.
+ ///
+ 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/BTCPayServer/Plugins/Dotnet/Loader/AssemblyLoadContextBuilder.cs b/BTCPayServer/Plugins/Dotnet/Loader/AssemblyLoadContextBuilder.cs
new file mode 100644
index 000000000..64e28ce90
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Loader/AssemblyLoadContextBuilder.cs
@@ -0,0 +1,369 @@
+#nullable enable
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.Loader;
+using BTCPayServer.Plugins.Dotnet.LibraryModel;
+
+namespace BTCPayServer.Plugins.Dotnet.Loader
+{
+ ///
+ /// A builder for creating an instance of .
+ ///
+ public 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 _lazyLoadReferences;
+
+ 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(_resourceProbingPaths);
+ foreach (var additionalPath in _additionalProbingPaths)
+ {
+ foreach (var subPath in _resourceProbingSubpaths)
+ {
+ resourceProbingPaths.Add(Path.Combine(additionalPath, subPath));
+ }
+ }
+
+ if (_mainAssemblyPath == null)
+ {
+ throw new InvalidOperationException($"Missing required property. You must call '{nameof(SetMainAssemblyPath)}' to configure the default assembly.");
+ }
+
+ return new ManagedLoadContext(
+ _mainAssemblyPath,
+ _managedLibraries,
+ _nativeLibraries,
+ _privateAssemblies,
+ _defaultAssemblies,
+ _additionalProbingPaths,
+ resourceProbingPaths,
+ _defaultLoadContext,
+ _preferDefaultLoadContext,
+ _lazyLoadReferences,
+ _isCollectible,
+ _loadInMemory,
+ _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));
+ }
+
+ _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)
+ {
+ _defaultLoadContext = context ?? throw new ArgumentException($"Bad Argument: AssemblyLoadContext in {nameof(AssemblyLoadContextBuilder)}.{nameof(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)
+ {
+ _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)
+ {
+ // Lazy loaded references have dependencies resolved as they are loaded inside the actual Load Context.
+ if (_lazyLoadReferences)
+ {
+ if (assemblyName.Name != null && !_defaultAssemblies.Contains(assemblyName.Name))
+ {
+ _defaultAssemblies.Add(assemblyName.Name);
+ var assembly = _defaultLoadContext.LoadFromAssemblyName(assemblyName);
+ foreach (var reference in assembly.GetReferencedAssemblies())
+ {
+ if (reference.Name != null)
+ {
+ _defaultAssemblies.Add(reference.Name);
+ }
+ }
+ }
+
+ return this;
+ }
+
+ var names = new Queue();
+ names.Enqueue(assemblyName);
+ while (names.TryDequeue(out var name))
+ {
+ if (name.Name == null || _defaultAssemblies.Contains(name.Name))
+ {
+ // base cases
+ continue;
+ }
+
+ _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 = _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)
+ {
+ _preferDefaultLoadContext = preferDefaultLoadContext;
+ return this;
+ }
+
+ ///
+ /// Instructs the load context to lazy load dependencies of all shared assemblies.
+ /// Reduces plugin load time at the expense of non-determinism in how transitive dependencies are loaded
+ /// between the plugin and the host.
+ ///
+ /// Please be aware of the danger of using this option:
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
+ ///
+ ///
+ /// True to lazy load, else false.
+ /// The builder.
+ public AssemblyLoadContextBuilder IsLazyLoaded(bool isLazyLoaded)
+ {
+ _lazyLoadReferences = isLazyLoaded;
+ 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)
+ {
+ _managedLibraries.Add(library.Name.Name, library);
+ }
+
+ return this;
+ }
+
+ ///
+ /// Add a native library to the load context.
+ ///
+ ///
+ ///
+ public AssemblyLoadContextBuilder AddNativeLibrary(NativeLibrary library)
+ {
+ ValidateRelativePath(library.AppLocalPath);
+ ValidateRelativePath(library.AdditionalProbingPath);
+
+ _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));
+ }
+
+ _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));
+ }
+
+ _resourceProbingPaths.Add(path);
+ return this;
+ }
+
+ ///
+ /// Enable unloading the assembly load context.
+ ///
+ /// The builder
+ public AssemblyLoadContextBuilder EnableUnloading()
+ {
+ _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()
+ {
+ _loadInMemory = true; // required to prevent dotnet from locking loaded files
+ 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()
+ {
+ _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));
+ }
+
+ _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/BTCPayServer/Plugins/Dotnet/Loader/ManagedLoadContext.cs b/BTCPayServer/Plugins/Dotnet/Loader/ManagedLoadContext.cs
new file mode 100644
index 000000000..f176cced4
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Loader/ManagedLoadContext.cs
@@ -0,0 +1,410 @@
+#nullable enable
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project 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 BTCPayServer.Plugins.Dotnet.Internal;
+using BTCPayServer.Plugins.Dotnet.LibraryModel;
+
+namespace BTCPayServer.Plugins.Dotnet.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 bool _lazyLoadReferences;
+ private readonly List _assemblyLoadContexts = new();
+ private readonly AssemblyDependencyResolver _dependencyResolver;
+ private readonly bool _shadowCopyNativeLibraries;
+ private readonly string _unmanagedDllShadowCopyDirectoryPath;
+
+ public ManagedLoadContext(string mainAssemblyPath,
+ IReadOnlyDictionary managedAssemblies,
+ IReadOnlyDictionary nativeLibraries,
+ IReadOnlyCollection privateAssemblies,
+ IReadOnlyCollection defaultAssemblies,
+ IReadOnlyCollection additionalProbingPaths,
+ IReadOnlyCollection resourceProbingPaths,
+ AssemblyLoadContext defaultLoadContext,
+ bool preferDefaultLoadContext,
+ bool lazyLoadReferences,
+ bool isCollectible,
+ bool loadInMemory,
+ bool shadowCopyNativeLibraries)
+ : base(Path.GetFileNameWithoutExtension(mainAssemblyPath), isCollectible)
+ {
+ if (resourceProbingPaths == null)
+ {
+ throw new ArgumentNullException(nameof(resourceProbingPaths));
+ }
+
+ _mainAssemblyPath = mainAssemblyPath ?? throw new ArgumentNullException(nameof(mainAssemblyPath));
+ _dependencyResolver = new AssemblyDependencyResolver(mainAssemblyPath);
+ _basePath = Path.GetDirectoryName(mainAssemblyPath) ?? throw new ArgumentException(nameof(mainAssemblyPath));
+ _managedAssemblies = managedAssemblies ?? throw new ArgumentNullException(nameof(managedAssemblies));
+ _privateAssemblies = privateAssemblies ?? throw new ArgumentNullException(nameof(privateAssemblies));
+ _defaultAssemblies = defaultAssemblies != null ? defaultAssemblies.ToList() : throw new ArgumentNullException(nameof(defaultAssemblies));
+ _nativeLibraries = nativeLibraries ?? throw new ArgumentNullException(nameof(nativeLibraries));
+ _additionalProbingPaths = additionalProbingPaths ?? throw new ArgumentNullException(nameof(additionalProbingPaths));
+ _assemblyLoadContexts.Add(defaultLoadContext);
+ _preferDefaultLoadContext = preferDefaultLoadContext;
+ _loadInMemory = loadInMemory;
+ _lazyLoadReferences = lazyLoadReferences;
+
+ _resourceRoots = new[] { _basePath }
+ .Concat(resourceProbingPaths)
+ .ToArray();
+
+ _shadowCopyNativeLibraries = shadowCopyNativeLibraries;
+ _unmanagedDllShadowCopyDirectoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ if (shadowCopyNativeLibraries)
+ {
+ Unloading += _ => OnUnloaded();
+ }
+ }
+
+ public void AddAssemblyLoadContexts(IEnumerable assemblyLoadContexts) => _assemblyLoadContexts.AddRange(assemblyLoadContexts);
+
+ ///
+ /// Load an 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 ((_preferDefaultLoadContext || _defaultAssemblies.Contains(assemblyName.Name)) && !_privateAssemblies.Contains(assemblyName.Name))
+ {
+ var name = new AssemblyName(assemblyName.Name);
+ var assembly = _assemblyLoadContexts.Select(p => TryLoadFromAssemblyName(p, name)).FirstOrDefault(a => a is not null);
+ if (assembly is not null)
+ return assembly;
+ }
+
+ var resolvedPath = _dependencyResolver.ResolveAssemblyToPath(assemblyName);
+ if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath))
+ {
+ return 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("neutral", assemblyName.CultureName))
+ {
+ foreach (var resourceRoot in _resourceRoots)
+ {
+ var resourcePath = Path.Combine(resourceRoot, assemblyName.CultureName, assemblyName.Name + ".dll");
+ if (File.Exists(resourcePath))
+ {
+ return LoadAssemblyFromFilePath(resourcePath);
+ }
+ }
+
+ return null;
+ }
+
+ if (_managedAssemblies.TryGetValue(assemblyName.Name, out var library) && library != null)
+ {
+ if (SearchForLibrary(library, out var path) && path != null)
+ {
+ return 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 _additionalProbingPaths.Prepend(_basePath))
+ {
+ var localFile = Path.Combine(probingPath, dllName);
+ if (File.Exists(localFile))
+ {
+ return LoadAssemblyFromFilePath(localFile);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private Assembly? TryLoadFromAssemblyName(AssemblyLoadContext context, 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 = context.LoadFromAssemblyName(name);
+ if (defaultAssembly != null)
+ {
+ // Add referenced assemblies to the list of default assemblies.
+ // This is basically lazy loading
+ if (_lazyLoadReferences)
+ {
+ foreach (var reference in defaultAssembly.GetReferencedAssemblies())
+ {
+ if (reference.Name != null && !_defaultAssemblies.Contains(reference.Name))
+ {
+ _defaultAssemblies.Add(reference.Name);
+ }
+ }
+ }
+
+ // 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
+ }
+
+ return null;
+ }
+
+ public Assembly LoadAssemblyFromFilePath(string path)
+ {
+ if (!_loadInMemory)
+ {
+ return 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 LoadFromStream(file, pdbFile);
+ }
+ return LoadFromStream(file);
+
+ }
+
+ ///
+ /// Loads the unmanaged binary using configured list of native libraries.
+ ///
+ ///
+ ///
+ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
+ {
+ var resolvedPath = _dependencyResolver.ResolveUnmanagedDllToPath(unmanagedDllName);
+ if (!string.IsNullOrEmpty(resolvedPath) && File.Exists(resolvedPath))
+ {
+ return LoadUnmanagedDllFromResolvedPath(resolvedPath, normalizePath: false);
+ }
+
+ foreach (var prefix in PlatformInformation.NativeLibraryPrefixes)
+ {
+ if (_nativeLibraries.TryGetValue(prefix + unmanagedDllName, out var library))
+ {
+ if (SearchForLibrary(library, prefix, out var path) && path != null)
+ {
+ return 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 (_nativeLibraries.TryGetValue(prefix + trimmedName, out library))
+ {
+ if (SearchForLibrary(library, prefix, out var path) && path != null)
+ {
+ return 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 _additionalProbingPaths.Prepend(_basePath))
+ {
+ var localFile = Path.Combine(probingPath, prefixSuffixDllName);
+ if (File.Exists(localFile))
+ {
+ return LoadUnmanagedDllFromResolvedPath(localFile);
+ }
+
+ var localFileWithoutSuffix = Path.Combine(probingPath, prefixDllName);
+ if (File.Exists(localFileWithoutSuffix))
+ {
+ return 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(_basePath, library.AppLocalPath);
+ if (File.Exists(localFile))
+ {
+ path = localFile;
+ return true;
+ }
+
+ // 2. Search additional probing paths
+ foreach (var searchPath in _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(_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(_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(_basePath, library.AppLocalPath);
+ if (File.Exists(local))
+ {
+ path = local;
+ return true;
+ }
+
+ // 3. Search additional probing paths
+ foreach (var searchPath in _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 _shadowCopyNativeLibraries
+ ? LoadUnmanagedDllFromShadowCopy(unmanagedDllPath)
+ : LoadUnmanagedDllFromPath(unmanagedDllPath);
+ }
+
+ private IntPtr LoadUnmanagedDllFromShadowCopy(string unmanagedDllPath)
+ {
+ var shadowCopyDllPath = CreateShadowCopy(unmanagedDllPath);
+
+ return LoadUnmanagedDllFromPath(shadowCopyDllPath);
+ }
+
+ private string CreateShadowCopy(string dllPath)
+ {
+ Directory.CreateDirectory(_unmanagedDllShadowCopyDirectoryPath);
+
+ var dllFileName = Path.GetFileName(dllPath);
+ var shadowCopyPath = Path.Combine(_unmanagedDllShadowCopyDirectoryPath, dllFileName);
+
+ if (!File.Exists(shadowCopyPath))
+ {
+ File.Copy(dllPath, shadowCopyPath);
+ }
+
+ return shadowCopyPath;
+ }
+
+ private void OnUnloaded()
+ {
+ if (!_shadowCopyNativeLibraries || !Directory.Exists(_unmanagedDllShadowCopyDirectoryPath))
+ {
+ return;
+ }
+
+ // Attempt to delete shadow copies
+ try
+ {
+ Directory.Delete(_unmanagedDllShadowCopyDirectoryPath, recursive: true);
+ }
+ catch (Exception)
+ {
+ // Files might be locked by host process. Nothing we can do about it, I guess.
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/Loader/RuntimeConfigExtensions.cs b/BTCPayServer/Plugins/Dotnet/Loader/RuntimeConfigExtensions.cs
new file mode 100644
index 000000000..91b23c8cd
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/Loader/RuntimeConfigExtensions.cs
@@ -0,0 +1,131 @@
+#nullable enable
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using BTCPayServer.Plugins.Dotnet.Internal;
+
+namespace BTCPayServer.Plugins.Dotnet.Loader
+{
+ ///
+ /// Extensions for creating a load context using settings from a runtimeconfig.json file
+ ///
+ public static class RuntimeConfigExtensions
+ {
+ private const string JsonExt = ".json";
+ private static readonly JsonSerializerOptions s_serializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ ///
+ /// Adds additional probing paths to a managed load context using settings found in the runtimeconfig.json
+ /// and runtimeconfig.dev.json files.
+ ///
+ /// The context builder
+ /// The path to the runtimeconfig.json file
+ /// Also read runtimeconfig.dev.json file, if present.
+ /// The error, if one occurs while parsing runtimeconfig.json
+ /// The builder.
+ public static AssemblyLoadContextBuilder TryAddAdditionalProbingPathFromRuntimeConfig(
+ this AssemblyLoadContextBuilder builder,
+ string runtimeConfigPath,
+ bool includeDevConfig,
+ out Exception? error)
+ {
+ error = null;
+ try
+ {
+ var config = TryReadConfig(runtimeConfigPath);
+ if (config == null)
+ {
+ return builder;
+ }
+
+ RuntimeConfig? devConfig = null;
+ if (includeDevConfig)
+ {
+ var configDevPath = runtimeConfigPath.Substring(0, runtimeConfigPath.Length - JsonExt.Length) + ".dev.json";
+ devConfig = TryReadConfig(configDevPath);
+ }
+
+ var tfm = config.runtimeOptions?.Tfm ?? devConfig?.runtimeOptions?.Tfm;
+
+ if (config.runtimeOptions != null)
+ {
+ AddProbingPaths(builder, config.runtimeOptions, tfm);
+ }
+
+ if (devConfig?.runtimeOptions != null)
+ {
+ AddProbingPaths(builder, devConfig.runtimeOptions, tfm);
+ }
+
+ if (tfm != null)
+ {
+ var dotnet = Process.GetCurrentProcess().MainModule?.FileName;
+ if (dotnet != null && string.Equals(Path.GetFileNameWithoutExtension(dotnet), "dotnet", StringComparison.OrdinalIgnoreCase))
+ {
+ var dotnetHome = Path.GetDirectoryName(dotnet);
+ if (dotnetHome != null)
+ {
+ builder.AddProbingPath(Path.Combine(dotnetHome, "store", RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant(), tfm));
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ error = ex;
+ }
+ return builder;
+ }
+
+ private static void AddProbingPaths(AssemblyLoadContextBuilder builder, RuntimeOptions options, string? tfm)
+ {
+ if (options.AdditionalProbingPaths == null)
+ {
+ return;
+ }
+
+ foreach (var item in options.AdditionalProbingPaths)
+ {
+ var path = item;
+ if (path.Contains("|arch|"))
+ {
+ path = path.Replace("|arch|", RuntimeInformation.OSArchitecture.ToString().ToLowerInvariant());
+ }
+
+ if (path.Contains("|tfm|"))
+ {
+ if (tfm == null)
+ {
+ // We don't have enough information to parse this
+ continue;
+ }
+
+ path = path.Replace("|tfm|", tfm);
+ }
+
+ builder.AddProbingPath(path);
+ }
+ }
+
+ private static RuntimeConfig? TryReadConfig(string path)
+ {
+ try
+ {
+ var file = File.ReadAllBytes(path);
+ return JsonSerializer.Deserialize(file, s_serializerOptions);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/MvcPluginExtensions.cs b/BTCPayServer/Plugins/Dotnet/MvcPluginExtensions.cs
new file mode 100644
index 000000000..748d6cb9a
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/MvcPluginExtensions.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.Dotnet
+{
+ ///
+ /// Extends the MVC builder.
+ ///
+ public static class MvcPluginExtensions
+ {
+ ///
+ /// Loads controllers and razor pages from a plugin assembly.
+ ///
+ /// This creates a loader with set to true.
+ /// If you need more control over shared types, use instead.
+ ///
+ ///
+ /// The MVC builder
+ /// Full path the main .dll file for the plugin.
+ /// The builder
+ public static IMvcBuilder AddPluginFromAssemblyFile(this IMvcBuilder mvcBuilder, string assemblyFile)
+ {
+ var plugin = PluginLoader.CreateFromAssemblyFile(
+ assemblyFile, // create a plugin from for the .dll file
+ config =>
+ // this ensures that the version of MVC is shared between this app and the plugin
+ config.PreferSharedTypes = true);
+
+ return mvcBuilder.AddPluginLoader(plugin);
+ }
+
+ ///
+ /// Loads controllers and razor pages from a plugin loader.
+ ///
+ /// In order for this to work, the PluginLoader instance must be configured to share the types
+ /// and
+ /// (comes from Microsoft.AspNetCore.Mvc.Core.dll). The easiest way to ensure that is done correctly
+ /// is to set to true.
+ ///
+ ///
+ /// The MVC builder
+ /// An instance of PluginLoader.
+ /// The builder
+ public static IMvcBuilder AddPluginLoader(this IMvcBuilder mvcBuilder, PluginLoader pluginLoader)
+ {
+ var pluginAssembly = pluginLoader.LoadDefaultAssembly();
+
+ // This loads MVC application parts from plugin assemblies
+ var partFactory = ApplicationPartFactory.GetApplicationPartFactory(pluginAssembly);
+ foreach (var part in partFactory.GetApplicationParts(pluginAssembly))
+ {
+ mvcBuilder.PartManager.ApplicationParts.Add(part);
+ }
+
+ // This piece finds and loads related parts, such as MvcAppPlugin1.Views.dll.
+ var relatedAssembliesAttrs = pluginAssembly.GetCustomAttributes();
+ foreach (var attr in relatedAssembliesAttrs)
+ {
+ var assembly = pluginLoader.LoadAssembly(attr.AssemblyFileName);
+ partFactory = ApplicationPartFactory.GetApplicationPartFactory(assembly);
+ foreach (var part in partFactory.GetApplicationParts(assembly))
+ {
+ mvcBuilder.PartManager.ApplicationParts.Add(part);
+ }
+ }
+
+ return mvcBuilder;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/PluginConfig.cs b/BTCPayServer/Plugins/Dotnet/PluginConfig.cs
new file mode 100644
index 000000000..5124d9cb3
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/PluginConfig.cs
@@ -0,0 +1,124 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.Loader;
+
+namespace BTCPayServer.Plugins.Dotnet
+{
+ ///
+ /// Represents the configuration for a .NET Core plugin.
+ ///
+ public class PluginConfig
+ {
+ ///
+ /// Initializes a new instance of
+ ///
+ /// The full file path to the main assembly for the plugin.
+ public PluginConfig(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));
+ }
+
+ MainAssemblyPath = mainAssemblyPath;
+ }
+
+ ///
+ /// The file path to the main assembly.
+ ///
+ public string MainAssemblyPath { get; }
+
+ ///
+ /// A list of assemblies which should be treated as private.
+ ///
+ public ICollection PrivateAssemblies { get; protected set; } = new List();
+
+ ///
+ /// A list of assemblies which should be unified between the host and the plugin.
+ ///
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
+ ///
+ public ICollection SharedAssemblies { get; protected set; } = new List();
+
+ ///
+ /// Attempt to unify all types from a plugin with the host.
+ ///
+ /// This does not guarantee types will unify.
+ ///
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
+ ///
+ ///
+ public bool PreferSharedTypes { get; set; }
+
+ ///
+ /// If enabled, will lazy load dependencies of all shared assemblies.
+ /// Reduces plugin load time at the expense of non-determinism in how transitive dependencies are loaded
+ /// between the plugin and the host.
+ ///
+ /// Please be aware of the danger of using this option:
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/pull/164#issuecomment-751557873
+ ///
+ ///
+ public bool IsLazyLoaded { get; set; } = false;
+
+ ///
+ /// If set, 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) !=
+ ///
+ public AssemblyLoadContext DefaultContext { get; set; } = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly()) ?? AssemblyLoadContext.Default;
+
+ private bool _isUnloadable;
+
+ ///
+ /// The plugin can be unloaded from memory.
+ ///
+ public bool IsUnloadable
+ {
+ get => _isUnloadable || EnableHotReload;
+ set => _isUnloadable = value;
+ }
+
+ private bool _loadInMemory;
+
+ ///
+ /// Loads assemblies into memory in order to not lock files.
+ /// As example use case here would be: no hot reloading but able to
+ /// replace files and reload manually at later time
+ ///
+ public bool LoadInMemory
+ {
+ get => _loadInMemory || EnableHotReload;
+ set => _loadInMemory = value;
+ }
+
+ ///
+ /// When any of the loaded files changes on disk, the plugin will be reloaded.
+ /// Use the event to be notified of changes.
+ ///
+ ///
+ /// It will load assemblies into memory in order to not lock files
+ ///
+ ///
+ public bool EnableHotReload { get; set; }
+
+ ///
+ /// Specifies the delay to reload a plugin, after file changes have been detected.
+ /// Default value is 200 milliseconds.
+ ///
+ public TimeSpan ReloadDelay { get; set; } = TimeSpan.FromMilliseconds(200);
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/PluginLoader.cs b/BTCPayServer/Plugins/Dotnet/PluginLoader.cs
new file mode 100644
index 000000000..68fdf2ea7
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/PluginLoader.cs
@@ -0,0 +1,401 @@
+#nullable enable
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Loader;
+using BTCPayServer.Plugins.Dotnet.Internal;
+using BTCPayServer.Plugins.Dotnet.Loader;
+
+namespace BTCPayServer.Plugins.Dotnet
+{
+ ///
+ /// 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.
+ ///
+ ///
+ public class PluginLoader : IDisposable
+ {
+ ///
+ /// Create a plugin loader for an assembly file.
+ ///
+ /// The file path to the main assembly for the plugin.
+ /// Enable unloading the plugin from memory.
+ ///
+ ///
+ /// A list of types which should be shared between the host and the plugin.
+ ///
+ ///
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
+ ///
+ ///
+ ///
+ /// A loader.
+ public static PluginLoader CreateFromAssemblyFile(string assemblyFile, bool isUnloadable, Type[] sharedTypes)
+ => CreateFromAssemblyFile(assemblyFile, isUnloadable, sharedTypes, _ => { });
+
+ ///
+ /// Create a plugin loader for an assembly file.
+ ///
+ /// The file path to the main assembly for the plugin.
+ /// Enable unloading the plugin from memory.
+ ///
+ ///
+ /// A list of types which should be shared between the host and the plugin.
+ ///
+ ///
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
+ ///
+ ///
+ ///
+ /// A function which can be used to configure advanced options for the plugin loader.
+ /// A loader.
+ public static PluginLoader CreateFromAssemblyFile(string assemblyFile, bool isUnloadable, Type[] sharedTypes, Action configure)
+ {
+ return CreateFromAssemblyFile(assemblyFile,
+ sharedTypes,
+ config =>
+ {
+ config.IsUnloadable = isUnloadable;
+ configure(config);
+ });
+ }
+
+ ///
+ /// Create a plugin loader for an assembly file.
+ ///
+ /// The file path to the main assembly for the plugin.
+ ///
+ ///
+ /// A list of types which should be shared between the host and the plugin.
+ ///
+ ///
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
+ ///
+ ///
+ ///
+ /// A loader.
+ public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sharedTypes)
+ => CreateFromAssemblyFile(assemblyFile, sharedTypes, _ => { });
+
+ ///
+ /// Create a plugin loader for an assembly file.
+ ///
+ /// The file path to the main assembly for the plugin.
+ ///
+ ///
+ /// A list of types which should be shared between the host and the plugin.
+ ///
+ ///
+ ///
+ /// https://github.com/natemcmaster/DotNetCorePlugins/blob/main/docs/what-are-shared-types.md
+ ///
+ ///
+ ///
+ /// A function which can be used to configure advanced options for the plugin loader.
+ /// A loader.
+ public static PluginLoader CreateFromAssemblyFile(string assemblyFile, Type[] sharedTypes, Action configure)
+ {
+ return CreateFromAssemblyFile(assemblyFile,
+ config =>
+ {
+ if (sharedTypes != null)
+ {
+ var uniqueAssemblies = new HashSet();
+ foreach (var type in sharedTypes)
+ {
+ uniqueAssemblies.Add(type.Assembly);
+ }
+
+ foreach (var assembly in uniqueAssemblies)
+ {
+ config.SharedAssemblies.Add(assembly.GetName());
+ }
+ }
+ configure(config);
+ });
+ }
+
+ ///
+ /// Create a plugin loader for an assembly file.
+ ///
+ /// The file path to the main assembly for the plugin.
+ /// A loader.
+ public static PluginLoader CreateFromAssemblyFile(string assemblyFile)
+ => CreateFromAssemblyFile(assemblyFile, _ => { });
+
+ ///
+ /// 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 PluginConfig(assemblyFile);
+ configure(config);
+ return new PluginLoader(config);
+ }
+
+ private readonly PluginConfig _config;
+ private ManagedLoadContext _context;
+ private readonly AssemblyLoadContextBuilder _contextBuilder;
+ private volatile bool _disposed;
+
+ private FileSystemWatcher? _fileWatcher;
+ private Debouncer? _debouncer;
+
+ ///
+ /// Initialize an instance of
+ ///
+ /// The configuration for the plugin.
+ public PluginLoader(PluginConfig config)
+ {
+ _config = config ?? throw new ArgumentNullException(nameof(config));
+ _contextBuilder = CreateLoadContextBuilder(config);
+ _context = (ManagedLoadContext)_contextBuilder.Build();
+ if (config.EnableHotReload)
+ {
+ StartFileWatcher();
+ }
+ }
+
+ ///
+ /// True when this plugin is capable of being unloaded.
+ ///
+ public bool IsUnloadable => _context.IsCollectible;
+
+
+ ///
+ /// This event is raised when the plugin has been reloaded.
+ /// If was set to true,
+ /// the plugin will be reloaded when files on disk are changed.
+ ///
+ public event PluginReloadedEventHandler? Reloaded;
+
+ ///
+ /// The unloads and reloads the plugin assemblies.
+ /// This method throws if is false.
+ ///
+ public void Reload()
+ {
+ EnsureNotDisposed();
+
+ if (!IsUnloadable)
+ {
+ throw new InvalidOperationException("Reload cannot be used because IsUnloadable is false");
+ }
+
+ _context.Unload();
+ _context = (ManagedLoadContext)_contextBuilder.Build();
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ Reloaded?.Invoke(this, new PluginReloadedEventArgs(this));
+ }
+
+ private void StartFileWatcher()
+ {
+ /*
+ This is a very simple implementation.
+ Some improvements that could be made in the future:
+
+ * Watch all directories which contain assemblies that could be loaded
+ * Support a polling file watcher.
+ * Handle delete/recreate better.
+
+ If you're interested in making improvements, feel free to send a pull request.
+ */
+
+ _debouncer = new Debouncer(_config.ReloadDelay);
+
+ var watchedDir = Path.GetDirectoryName(_config.MainAssemblyPath);
+ if (watchedDir == null)
+ {
+ throw new InvalidOperationException("Could not determine which directory to watch. "
+ + "Please set MainAssemblyPath to an absolute path so its parent directory can be discovered.");
+ }
+
+ _fileWatcher = new FileSystemWatcher
+ {
+ Path = watchedDir
+ };
+ _fileWatcher.Changed += OnFileChanged;
+ _fileWatcher.Filter = "*.dll";
+ _fileWatcher.NotifyFilter = NotifyFilters.LastWrite;
+ _fileWatcher.EnableRaisingEvents = true;
+ }
+
+ private void OnFileChanged(object source, FileSystemEventArgs e)
+ {
+ if (!_disposed)
+ {
+ _debouncer?.Execute(Reload);
+ }
+ }
+
+ internal AssemblyLoadContext LoadContext => _context;
+
+ ///
+ /// Load the main assembly for the plugin.
+ ///
+ public Assembly LoadDefaultAssembly()
+ {
+ EnsureNotDisposed();
+ return _context.LoadAssemblyFromFilePath(_config.MainAssemblyPath);
+ }
+
+ ///
+ /// Load an assembly by name.
+ ///
+ /// The assembly name.
+ /// The assembly.
+ public Assembly LoadAssembly(AssemblyName assemblyName)
+ {
+ EnsureNotDisposed();
+ return _context.LoadFromAssemblyName(assemblyName);
+ }
+
+ ///
+ /// Load an assembly from path.
+ ///
+ /// The assembly path.
+ /// The assembly.
+ public Assembly LoadAssemblyFromPath(string assemblyPath)
+ => _context.LoadAssemblyFromFilePath(assemblyPath);
+
+ ///
+ /// Load an assembly by name.
+ ///
+ /// The assembly name.
+ /// The assembly.
+ public Assembly LoadAssembly(string assemblyName)
+ {
+ EnsureNotDisposed();
+ return LoadAssembly(new AssemblyName(assemblyName));
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ ///
+ public AssemblyLoadContext.ContextualReflectionScope EnterContextualReflection()
+ => _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 (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ if (_fileWatcher != null)
+ {
+ _fileWatcher.EnableRaisingEvents = false;
+ _fileWatcher.Changed -= OnFileChanged;
+ _fileWatcher.Dispose();
+ }
+
+ _debouncer?.Dispose();
+
+ if (_context.IsCollectible)
+ {
+ _context.Unload();
+ }
+ }
+
+ private void EnsureNotDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(PluginLoader));
+ }
+ }
+
+ private static AssemblyLoadContextBuilder CreateLoadContextBuilder(PluginConfig 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 || config.EnableHotReload)
+ {
+ builder.EnableUnloading();
+ }
+
+ if (config.LoadInMemory)
+ {
+ builder.PreloadAssembliesIntoMemory();
+ builder.ShadowCopyNativeLibraries();
+ }
+
+ builder.IsLazyLoaded(config.IsLazyLoaded);
+ foreach (var assemblyName in config.SharedAssemblies)
+ {
+ builder.PreferDefaultLoadContextAssembly(assemblyName);
+ }
+
+ var baseDir = Path.GetDirectoryName(config.MainAssemblyPath);
+ var assemblyFileName = Path.GetFileNameWithoutExtension(config.MainAssemblyPath);
+
+ if (baseDir == null)
+ {
+ throw new InvalidOperationException("Could not determine which directory to watch. "
+ + "Please set MainAssemblyPath to an absolute path so its parent directory can be discovered.");
+ }
+
+ var pluginRuntimeConfigFile = Path.Combine(baseDir, assemblyFileName + ".runtimeconfig.json");
+
+ builder.TryAddAdditionalProbingPathFromRuntimeConfig(pluginRuntimeConfigFile, includeDevConfig: true, out _);
+
+ // Always include runtimeconfig.json from the host app.
+ // in some cases, like `dotnet test`, the entry assembly does not actually match with the
+ // runtime config file which is why we search for all files matching this extensions.
+ foreach (var runtimeconfig in Directory.GetFiles(AppContext.BaseDirectory, "*.runtimeconfig.json"))
+ {
+ builder.TryAddAdditionalProbingPathFromRuntimeConfig(runtimeconfig, includeDevConfig: true, out _);
+ }
+
+ return builder;
+ }
+
+ public void AddAssemblyLoadContexts(IEnumerable pluginLoaders) => _context.AddAssemblyLoadContexts(pluginLoaders.Select(p => p.LoadContext));
+ }
+}
diff --git a/BTCPayServer/Plugins/Dotnet/PluginReloadedEventHandler.cs b/BTCPayServer/Plugins/Dotnet/PluginReloadedEventHandler.cs
new file mode 100644
index 000000000..39775745a
--- /dev/null
+++ b/BTCPayServer/Plugins/Dotnet/PluginReloadedEventHandler.cs
@@ -0,0 +1,34 @@
+// Copyright (c) Nate McMaster.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace BTCPayServer.Plugins.Dotnet
+{
+ ///
+ /// Represents the method that will handle the event.
+ ///
+ /// The object sending the event
+ /// Data about the event.
+ public delegate void PluginReloadedEventHandler(object sender, PluginReloadedEventArgs eventArgs);
+
+ ///
+ /// Provides data for the event.
+ ///
+ public class PluginReloadedEventArgs : EventArgs
+ {
+ ///
+ /// Initializes .
+ ///
+ ///
+ public PluginReloadedEventArgs(PluginLoader loader)
+ {
+ Loader = loader;
+ }
+
+ ///
+ /// The plugin loader
+ ///
+ public PluginLoader Loader { get; }
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager.cs b/BTCPayServer/Plugins/PluginManager.cs
index bd5d270c9..0c00fb404 100644
--- a/BTCPayServer/Plugins/PluginManager.cs
+++ b/BTCPayServer/Plugins/PluginManager.cs
@@ -1,4 +1,6 @@
+#nullable enable
using System;
+using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -9,7 +11,7 @@ using System.Reflection;
using System.Text.RegularExpressions;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
-using McMaster.NETCore.Plugins;
+using BTCPayServer.Plugins.Dotnet;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -61,11 +63,57 @@ namespace BTCPayServer.Plugins
return false;
}
+ record PreloadedPlugin(IBTCPayServerPlugin Instance, PluginLoader? Loader, Assembly Assembly);
+
+ class PreloadedPlugins : IEnumerable
+ {
+ List _plugins = new();
+ readonly Dictionary _preloadedPluginsByIdentifier = new(StringComparer.OrdinalIgnoreCase);
+
+ public bool Contains(string identifier) => _preloadedPluginsByIdentifier.ContainsKey(identifier);
+ public void Add(PreloadedPlugin plugin)
+ {
+ if (!_preloadedPluginsByIdentifier.TryAdd(plugin.Instance.Identifier, plugin))
+ return;
+ _plugins.Add(plugin);
+ }
+
+ public IEnumerator GetEnumerator() => _plugins.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => _plugins.GetEnumerator();
+
+ public void Clear()
+ {
+ _plugins.Clear();
+ _preloadedPluginsByIdentifier.Clear();
+ }
+
+ public void TopologicalSort()
+ {
+ // We want to run all the system plugins first.
+ // Then the rest topologically sorted.
+ var ordered = new List(_plugins.Count);
+ var topological = _plugins.TopologicalSort(
+ p => p.Instance.Dependencies.Select(d => d.Identifier),
+ p => p.Instance.Identifier,
+ p=> p, Comparer.Create((a, b) => string.Compare(a.Instance.Identifier, b.Instance.Identifier, StringComparison.Ordinal))).ToList();
+ foreach (var p in topological.Where(t => t.Instance.SystemPlugin))
+ ordered.Add(p);
+ foreach (var p in topological.Where(t => !t.Instance.SystemPlugin))
+ ordered.Add(p);
+ _plugins = ordered;
+ }
+
+ public PreloadedPlugin? TryGet(string identifier)
+ {
+ _preloadedPluginsByIdentifier.TryGetValue(identifier, out var p);
+ return p;
+ }
+ }
+
public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection,
IConfiguration config, ILoggerFactory loggerFactory, ServiceProvider bootstrapServiceProvider)
{
- void LoadPluginsFromAssemblies(Assembly systemAssembly1, HashSet exclude, HashSet loadedPluginIdentifiers1,
- List btcPayServerPlugins)
+ void PreloadPluginsFromAssemblies(Assembly systemAssembly1, HashSet exclude, PreloadedPlugins preloadedPlugins)
{
var excludedAssemblies = "System.Private.CoreLib,testhost,System.Runtime,Microsoft.TestPlatform.CoreUtilities,System.Diagnostics.Tracing,System.Diagnostics.Debug,System.Runtime.Extensions,System.Diagnostics.Process,System.ComponentModel.Primitives,Microsoft.TestPlatform.PlatformAbstractions,System.Collections,Microsoft.TestPlatform.CrossPlatEngine,netstandard,Microsoft.TestPlatform.CommunicationUtilities,Microsoft.VisualStudio.TestPlatform.ObjectModel,Microsoft.VisualStudio.TestPlatform.Common,Newtonsoft.Json,System.Runtime.Serialization.Formatters,System.Collections.Concurrent,System.Diagnostics.TraceSource,System.Threading,System.IO.FileSystem,System.Runtime.InteropServices,System.Memory,Microsoft.Win32.Primitives,System.Threading.ThreadPool,System.Net.Primitives,System.Collections.NonGeneric,System.Net.Sockets,System.Private.Uri,System.Threading.Overlapped,System.Runtime.Intrinsics,System.Linq.Expressions,System.Runtime.Numerics,System.Linq,System.ComponentModel.TypeConverter,System.ObjectModel,System.Runtime.Serialization.Primitives,System.Data.Common,System.Xml.ReaderWriter,System.Private.Xml,System.ComponentModel,System.Reflection.Emit.ILGeneration,System.Reflection.Emit.Lightweight,System.Reflection.Primitives,Anonymously Hosted DynamicMethods Assembly,System.Runtime.Loader,System.Reflection.Metadata,System.IO.MemoryMappedFiles,System.Collections.Immutable,System.Text.Encoding.Extensions,xunit.runner.visualstudio.testadapter,System.Runtime.Serialization.Json,System.Private.DataContractSerialization,System.Runtime.Serialization.Xml,System.Resources.ResourceManager,xunit.runner.utility.netcoreapp10,xunit.abstractions,System.Text.RegularExpressions,System.Runtime.InteropServices.RuntimeInformation,NuGet.Frameworks,System.Xml.XDocument,System.Private.Xml.Linq,System.Threading.Thread,System.Globalization,System.IO,System.Reflection,System.Reflection.TypeExtensions,xunit.runner.reporters.netcoreapp10,System.Threading.Tasks,System.Net.Http,System.Reflection.Extensions,BTCPayServer.Tests,Microsoft.AspNetCore.Mvc.ViewFeatures,Microsoft.AspNetCore.Mvc.Core,Microsoft.AspNetCore.Mvc.Abstractions,xunit.core,BTCPayServer.Rating,Microsoft.Extensions.Logging.Abstractions,NBitpayClient,NBitcoin,BTCPayServer.Client,BTCPayServer.Data,Microsoft.AspNetCore.Http.Abstractions,BTCPayServer.Abstractions,BTCPayServer.Lightning.Common,Microsoft.EntityFrameworkCore,NBXplorer.Client,BTCPayServer.BIP78.Sender,Microsoft.Extensions.Identity.Stores,LNURL,Renci.SshNet,Microsoft.Extensions.Identity.Core,Newtonsoft.Json.Schema,Microsoft.AspNetCore.Razor.Language,Microsoft.CodeAnalysis.CSharp,Microsoft.CodeAnalysis,xunit.execution.dotnet,Microsoft.Extensions.Configuration.UserSecrets,System.Text.Encoding,BTCPayServer.Common,WebDriver,System.Drawing.Primitives,xunit.assert,Microsoft.Extensions.Configuration.Abstractions,Microsoft.Extensions.DependencyInjection,Microsoft.Extensions.DependencyInjection.Abstractions,Microsoft.Extensions.Configuration,Microsoft.Extensions.Primitives,StandardConfiguration,CommandLine,System.Security.Cryptography.Algorithms,System.Security.Cryptography,System.Security.Cryptography.Primitives,NBitcoin.Altcoins,ExchangeSharp,Microsoft.Extensions.Http,Microsoft.Extensions.Hosting.Abstractions,Microsoft.Extensions.Logging,Microsoft.Extensions.Options,Microsoft.Extensions.Diagnostics,System.Diagnostics.DiagnosticSource,System.Net.Security,System.Net.NameResolution,Microsoft.Extensions.Configuration.FileExtensions,Microsoft.Extensions.Configuration.Json,Microsoft.AspNetCore.Hosting,Microsoft.AspNetCore.Hosting.Abstractions,Microsoft.AspNetCore.Server.Kestrel,Microsoft.Extensions.Features,Microsoft.AspNetCore.Hosting.Server.Abstractions,Microsoft.Extensions.Configuration.EnvironmentVariables,CommandLine.Configuration,Microsoft.Extensions.Configuration.Ini,Microsoft.Extensions.FileProviders.Abstractions,Microsoft.Extensions.FileProviders.Physical,Microsoft.AspNetCore.Server.Kestrel.Transport.Quic,Microsoft.AspNetCore.Server.Kestrel.Transport.NamedPipes,System.Net.Quic,Microsoft.Win32.Registry,Microsoft.AspNetCore.Http,Microsoft.AspNetCore.Razor.Runtime,Microsoft.AspNetCore.Connections.Abstractions,Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets,Microsoft.AspNetCore.Server.Kestrel.Core,Microsoft.Extensions.ObjectPool,NicolasDorier.RateLimits,Microsoft.Extensions.Caching.Memory,Microsoft.AspNetCore.DataProtection,Microsoft.AspNetCore.Identity,Microsoft.AspNetCore.Identity.EntityFrameworkCore,Microsoft.AspNetCore.Authentication.Abstractions,Microsoft.AspNetCore.Authentication.Cookies,Microsoft.AspNetCore.Authentication,Microsoft.AspNetCore.Session,Microsoft.AspNetCore.SignalR,Microsoft.AspNetCore.SignalR.Core,Microsoft.AspNetCore.SignalR.Common,Fido2.Models,Fido2.AspNet,Fido2,Microsoft.AspNetCore.Mvc,Microsoft.AspNetCore.Mvc.Razor,Microsoft.AspNetCore.Mvc.NewtonsoftJson,Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation,Microsoft.AspNetCore.Mvc.DataAnnotations,Microsoft.AspNetCore.Components.Server,Microsoft.AspNetCore.Components.Endpoints,Microsoft.AspNetCore.Routing.Abstractions,Microsoft.Extensions.Caching.Abstractions,Microsoft.AspNetCore.DataProtection.Abstractions,Microsoft.AspNetCore.Cryptography.Internal,Microsoft.AspNetCore.Authentication.Core,System.Security.Claims,Microsoft.Extensions.WebEncoders,System.Text.Encodings.Web,Microsoft.Extensions.Localization.Abstractions,Microsoft.AspNetCore.Mvc.Localization,Npgsql.EntityFrameworkCore.PostgreSQL,Microsoft.AspNetCore.Diagnostics,HtmlSanitizer,Microsoft.AspNetCore.Authorization,Microsoft.AspNetCore.Authorization.Policy,Microsoft.AspNetCore.Cors,System.ComponentModel.Annotations,Serilog,Serilog.Sinks.File,Serilog.Extensions.Logging,Microsoft.Extensions.Configuration.Binder,Microsoft.AspNetCore.Http.Features,TwentyTwenty.Storage,TwentyTwenty.Storage.Azure,Microsoft.AspNetCore.WebSockets,Microsoft.AspNetCore.Http.Connections,Microsoft.AspNetCore.Routing,Microsoft.AspNetCore.SignalR.Protocols.Json,System.Text.Json,McMaster.NETCore.Plugins.Mvc,Microsoft.AspNetCore.Mvc.ApiExplorer,Microsoft.AspNetCore.Mvc.Cors,Microsoft.AspNetCore.Antiforgery,Microsoft.AspNetCore.Components,Microsoft.AspNetCore.Components.Web,Microsoft.JSInterop,Microsoft.AspNetCore.Mvc.TagHelpers,Microsoft.AspNetCore.Razor,Microsoft.AspNetCore.Mvc.RazorPages,Microsoft.AspNetCore.Html.Abstractions,McMaster.NETCore.Plugins,Selenium,Selenium.Support,Selenium.WebDriver,Selenium.WebDriver.ChromeDriver,WebDriver.Support"
.Split(',').ToHashSet();
@@ -75,18 +123,16 @@ namespace BTCPayServer.Plugins
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
var assemblyName = assembly.GetName().Name;
- if (excludedAssemblies.Contains(assemblyName))
+ if (assemblyName is null || excludedAssemblies.Contains(assemblyName))
continue;
- bool isSystemPlugin = assembly == systemAssembly1;
+ var isSystemPlugin = assembly == systemAssembly1;
if (!isSystemPlugin && exclude.Contains(assemblyName))
continue;
foreach (var plugin in GetPluginInstancesFromAssembly(assembly, true))
{
if (!isSystemPlugin && plugin.Identifier != assemblyName)
continue;
- if (!loadedPluginIdentifiers1.Add(plugin.Identifier))
- continue;
- btcPayServerPlugins.Add(plugin);
+ preloadedPlugins.Add(new PreloadedPlugin(plugin, null, assembly));
plugin.SystemPlugin = isSystemPlugin;
}
}
@@ -94,8 +140,7 @@ namespace BTCPayServer.Plugins
var logger = loggerFactory.CreateLogger(typeof(PluginManager));
var pluginsFolder = new DataDirectories().Configure(config).PluginDir;
- var plugins = new List();
- var loadedPluginIdentifiers = new HashSet();
+ var preloadedPlugins = new PreloadedPlugins();
serviceCollection.Configure(options =>
{
@@ -107,16 +152,15 @@ namespace BTCPayServer.Plugins
var disabledPluginIdentifiers = GetDisabledPluginIdentifiers(pluginsFolder);
var systemAssembly = typeof(Program).Assembly;
- LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);
+ PreloadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, preloadedPlugins);
- if (ExecuteCommands(pluginsFolder, plugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version)))
+ if (ExecuteCommands(pluginsFolder, preloadedPlugins.ToDictionary(p => p.Instance.Identifier, p => p.Instance.Version)))
{
- plugins.Clear();
- loadedPluginIdentifiers.Clear();
- LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);
+ preloadedPlugins.Clear();
+ PreloadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, preloadedPlugins);
}
- var pluginsToLoad = new List<(string PluginIdentifier, string PluginFilePath)>();
+ var pluginsToPreload = new List<(string PluginIdentifier, string PluginFilePath)>();
#if DEBUG
// Load from DEBUG_PLUGINS, in an optional appsettings.dev.json
@@ -124,19 +168,19 @@ namespace BTCPayServer.Plugins
foreach (var plugin in debugPlugins.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
// Formatted either as "::" or ""
- var idx = plugin.IndexOf("::");
+ var idx = plugin.IndexOf("::", StringComparison.Ordinal);
var filePath = plugin;
if (idx != -1)
{
filePath = plugin[(idx + 1)..];
filePath = Path.GetFullPath(filePath);
- pluginsToLoad.Add((plugin[0..idx], filePath));
+ pluginsToPreload.Add((plugin[0..idx], filePath));
}
else
{
filePath = Path.GetFullPath(filePath);
- pluginsToLoad.Add((Path.GetFileNameWithoutExtension(plugin), filePath));
+ pluginsToPreload.Add((Path.GetFileNameWithoutExtension(plugin), filePath));
}
}
#endif
@@ -150,100 +194,120 @@ namespace BTCPayServer.Plugins
continue;
if (disabledPluginIdentifiers.Contains(pluginIdentifier))
continue;
- pluginsToLoad.Add((pluginIdentifier, pluginFilePath));
+ pluginsToPreload.Add((pluginIdentifier, pluginFilePath));
}
- ReorderPlugins(pluginsFolder, pluginsToLoad);
- var crashedPlugins = new List();
- foreach (var toLoad in pluginsToLoad)
+ var toDisable = new List();
+
+ foreach (var toLoad in pluginsToPreload)
{
- if (!loadedPluginIdentifiers.Add(toLoad.PluginIdentifier))
+ if (preloadedPlugins.Contains(toLoad.PluginIdentifier))
continue;
try
{
-
- var plugin = PluginLoader.CreateFromAssemblyFile(
+ var loader = PluginLoader.CreateFromAssemblyFile(
toLoad.PluginFilePath, // create a plugin from for the .dll file
- config =>
+ c =>
{
// this ensures that the version of MVC is shared between this app and the plugin
- config.PreferSharedTypes = true;
- config.IsUnloadable = false;
+ c.PreferSharedTypes = true;
+ c.IsUnloadable = false;
});
- var pluginAssembly = plugin.LoadDefaultAssembly();
+ var pluginAssembly = loader.LoadDefaultAssembly();
- var p = GetPluginInstanceFromAssembly(toLoad.PluginIdentifier, pluginAssembly);
+ var p = GetPluginInstanceFromAssembly(toLoad.PluginIdentifier, pluginAssembly, silentlyFails: true);
if (p == null)
{
logger.LogError($"The plugin assembly doesn't contain the plugin {toLoad.PluginIdentifier}");
- crashedPlugins.Add(toLoad.PluginIdentifier);
+ toDisable.Add(toLoad.PluginIdentifier);
}
else
{
- mvcBuilder.AddPluginLoader(plugin);
- _pluginAssemblies.Add(pluginAssembly);
p.SystemPlugin = false;
- plugins.Add(p);
+ preloadedPlugins.Add(new(p, loader, pluginAssembly));
}
}
catch (Exception e)
{
logger.LogError(e, $"Error when loading plugin {toLoad.PluginIdentifier}.");
- crashedPlugins.Add(toLoad.PluginIdentifier);
+ toDisable.Add(toLoad.PluginIdentifier);
}
}
- foreach (var plugin in plugins)
+ preloadedPlugins.TopologicalSort();
+ var loadedPlugins = new List();
+ foreach (var preloadedPlugin in preloadedPlugins)
{
+ var plugin = preloadedPlugin.Instance;
try
{
+ AssertDependencies(plugin, loadedPlugins);
+ if (preloadedPlugin.Loader is { } loader)
+ loader.AddAssemblyLoadContexts(
+ plugin.Dependencies
+ .Select(d => preloadedPlugins.TryGet(d.Identifier)?.Loader)
+ .Where(d => d is not null)
+ .ToArray()!);
+
+ // silentlyFails is false, because we want this to throw if there is any missing assembly.
+ GetPluginInstanceFromAssembly(plugin.Identifier, preloadedPlugin.Assembly, silentlyFails: false);
+ if (preloadedPlugin.Loader is not null)
+ mvcBuilder.AddPluginLoader(preloadedPlugin.Loader);
+ _pluginAssemblies.Add(preloadedPlugin.Assembly);
+
logger.LogInformation(
$"Adding and executing plugin {plugin.Identifier} - {plugin.Version}");
var pluginServiceCollection = new PluginServiceCollection(serviceCollection, bootstrapServiceProvider);
plugin.Execute(pluginServiceCollection);
serviceCollection.AddSingleton(plugin);
+ loadedPlugins.Add(preloadedPlugin);
+ }
+ catch (MissingDependenciesException e)
+ {
+ // The difference is that we don't print the stacktrace and we do not disable it
+ logger.LogError($"Error when executing plugin {plugin.Identifier} - {plugin.Version}: {e.Message}");
}
catch (Exception e)
{
logger.LogError(e, $"Error when executing plugin {plugin.Identifier} - {plugin.Version}.");
- crashedPlugins.Add(plugin.Identifier);
- }
+ if (!plugin.SystemPlugin)
+ toDisable.Add(plugin.Identifier);
+ }
}
- if (crashedPlugins.Count > 0)
+ if (toDisable.Count > 0)
{
- foreach (var plugin in crashedPlugins)
+ foreach (var plugin in toDisable)
DisablePlugin(pluginsFolder, plugin);
- var crashedPluginsStr = String.Join(", ", crashedPlugins);
+ var crashedPluginsStr = string.Join(", ", toDisable);
throw new ConfigException($"The following plugin(s) crashed at startup, they will be disabled and the server will restart: {crashedPluginsStr}");
}
return mvcBuilder;
}
- private static void ReorderPlugins(string pluginsFolder, List<(string PluginIdentifier, string PluginFilePath)> pluginsToLoad)
+ class MissingDependenciesException(string message) : Exception(message);
+
+ private static void AssertDependencies(IBTCPayServerPlugin plugin, List loaded)
{
- Dictionary ordersByPlugin = new Dictionary();
- var orderFilePath = Path.Combine(pluginsFolder, "order");
- int order = 0;
- if (File.Exists(orderFilePath))
+ var missing = new List();
+ var installed = loaded.ToDictionary(l => l.Instance.Identifier, l => l.Instance.Version);
+ foreach (var d in plugin.Dependencies)
{
- foreach (var o in File.ReadLines(orderFilePath))
+ if (!DependencyMet(d, installed))
{
- if (ordersByPlugin.TryAdd(o, order))
- order++;
+ missing.Add(d);
}
}
- foreach (var p in pluginsToLoad)
+ if (missing.Any())
{
- if (ordersByPlugin.TryAdd(p.PluginIdentifier, order))
- order++;
+ throw new MissingDependenciesException(
+ $"Plugin {plugin.Identifier} is missing dependencies: {string.Join(", ", missing.Select(d => d.ToString()))}");
}
- pluginsToLoad.Sort((a, b) => ordersByPlugin[a.PluginIdentifier] - ordersByPlugin[b.PluginIdentifier]);
}
public static void UsePlugins(this IApplicationBuilder applicationBuilder)
{
- HashSet assemblies = new HashSet();
+ var assemblies = new HashSet();
foreach (var extension in applicationBuilder.ApplicationServices
.GetServices())
{
@@ -252,8 +316,8 @@ namespace BTCPayServer.Plugins
assemblies.Add(extension.GetType().Assembly);
}
- var webHostEnvironment = applicationBuilder.ApplicationServices.GetService();
- List providers = new List() { webHostEnvironment.WebRootFileProvider };
+ var webHostEnvironment = applicationBuilder.ApplicationServices.GetRequiredService();
+ var providers = new List() { webHostEnvironment.WebRootFileProvider };
providers.AddRange(assemblies.Select(a => new EmbeddedFileProvider(a)));
webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers);
}
@@ -263,7 +327,8 @@ namespace BTCPayServer.Plugins
return GetTypes(assembly, silentlyFails).Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) && type != typeof(PluginService.AvailablePlugin) &&
!type.IsAbstract).
- Select(type => (IBTCPayServerPlugin)Activator.CreateInstance(type, Array.Empty