From a7f073202672b1a210c67fa13b5c94d8770fef2a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 09:51:16 +0200 Subject: [PATCH] feat(core): add PluginLoader with collectible ALC --- AcDream.slnx | 1 + src/AcDream.Core/Plugins/LoadedPlugin.cs | 11 ++ .../Plugins/PluginAssemblyLoadContext.cs | 30 ++++++ src/AcDream.Core/Plugins/PluginLoader.cs | 34 ++++++ ...eam.Core.Tests.Fixtures.HelloPlugin.csproj | 19 ++++ .../HelloPlugin.cs | 20 ++++ .../AcDream.Core.Tests.csproj | 9 ++ .../Plugins/PluginLoaderTests.cs | 102 ++++++++++++++++++ 8 files changed, 226 insertions(+) create mode 100644 src/AcDream.Core/Plugins/LoadedPlugin.cs create mode 100644 src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs create mode 100644 src/AcDream.Core/Plugins/PluginLoader.cs create mode 100644 tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj create mode 100644 tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index b87ab62..16bf362 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -6,6 +6,7 @@ + diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs new file mode 100644 index 0000000..ee20c14 --- /dev/null +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -0,0 +1,11 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public sealed record LoadedPlugin( + PluginManifest Manifest, + IAcDreamPlugin? Plugin, + string? Error) +{ + public bool Success => Plugin is not null && Error is null; +} diff --git a/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs b/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs new file mode 100644 index 0000000..9fbc42c --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace AcDream.Core.Plugins; + +/// +/// Collectible ALC for a single plugin. Resolves assemblies from the plugin's +/// own directory EXCEPT for AcDream.Plugin.Abstractions, which must come from +/// the default (host) context so type identity for IAcDreamPlugin is preserved. +/// +internal sealed class PluginAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public PluginAssemblyLoadContext(string pluginDirectory, string pluginEntryPath) + : base(name: pluginDirectory, isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginEntryPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + // Share the abstractions assembly with the host — do NOT reload it in the plugin ALC + if (assemblyName.Name == "AcDream.Plugin.Abstractions") + return null; + + var path = _resolver.ResolveAssemblyToPath(assemblyName); + return path is null ? null : LoadFromAssemblyPath(path); + } +} diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs new file mode 100644 index 0000000..d3743e6 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public static class PluginLoader +{ + public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) + { + var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll); + if (!File.Exists(dllPath)) + return new LoadedPlugin(manifest, null, $"entry dll not found: {dllPath}"); + + try + { + var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); + var asm = alc.LoadFromAssemblyPath(dllPath); + + var pluginType = asm.GetTypes() + .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); + + if (pluginType is null) + return new LoadedPlugin(manifest, null, $"no IAcDreamPlugin implementation found in {manifest.EntryDll}"); + + var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; + instance.Initialize(host); + return new LoadedPlugin(manifest, instance, null); + } + catch (Exception ex) + { + return new LoadedPlugin(manifest, null, $"{ex.GetType().Name}: {ex.Message}"); + } + } +} diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj new file mode 100644 index 0000000..46a3efe --- /dev/null +++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + latest + false + + + + + false + runtime + + + diff --git a/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs new file mode 100644 index 0000000..8048fb6 --- /dev/null +++ b/tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs @@ -0,0 +1,20 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Fixtures.HelloPlugin; + +public sealed class HelloPlugin : IAcDreamPlugin +{ + public int InitializeCount { get; private set; } + public int EnableCount { get; private set; } + public int DisableCount { get; private set; } + public IPluginHost? ReceivedHost { get; private set; } + + public void Initialize(IPluginHost host) + { + ReceivedHost = host; + InitializeCount++; + } + + public void Enable() => EnableCount++; + public void Disable() => DisableCount++; +} diff --git a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj index 8bd478d..2104378 100644 --- a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj +++ b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj @@ -22,4 +22,13 @@ + + + + false + true + + + \ No newline at end of file diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs new file mode 100644 index 0000000..03a07c7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -0,0 +1,102 @@ +using AcDream.Core.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +public class PluginLoaderTests +{ + private static string FixturePluginPath() + { + // walk up from the test bin dir to the repo root, then into the fixture's build output + var baseDir = AppContext.BaseDirectory; + var configuration = new DirectoryInfo(baseDir).Parent!.Name; // Debug / Release + var repoRoot = FindRepoRoot(baseDir); + return Path.Combine( + repoRoot, + "tests", "AcDream.Core.Tests.Fixtures.HelloPlugin", "bin", configuration, "net10.0", + "AcDream.Core.Tests.Fixtures.HelloPlugin.dll"); + } + + private static string FindRepoRoot(string startDir) + { + var dir = new DirectoryInfo(startDir); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "AcDream.slnx"))) + dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } + + private sealed class StubHost : IPluginHost + { + public IPluginLogger Log { get; } = new StubLogger(); + } + + private sealed class StubLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? exception = null) { } + } + + [Fact] + public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize() + { + var dllPath = FixturePluginPath(); + Assert.True(File.Exists(dllPath), $"fixture dll not found: {dllPath}"); + + var host = new StubHost(); + var manifest = new PluginManifest( + Id: "acdream.test.hello", + DisplayName: "Hello", + Version: "0.0.1", + EntryDll: Path.GetFileName(dllPath), + ApiVersion: 1, + Dependencies: Array.Empty()); + + var loaded = PluginLoader.Load( + pluginDirectory: Path.GetDirectoryName(dllPath)!, + manifest: manifest, + host: host); + + Assert.True(loaded.Success); + Assert.NotNull(loaded.Plugin); + Assert.Equal("HelloPlugin", loaded.Plugin!.GetType().Name); + } + + [Fact] + public void Load_MissingDll_ReturnsFailure() + { + var host = new StubHost(); + var manifest = new PluginManifest( + Id: "x", + DisplayName: "X", + Version: "0.0.1", + EntryDll: "nope.dll", + ApiVersion: 1, + Dependencies: Array.Empty()); + + var loaded = PluginLoader.Load("/does/not/exist", manifest, host); + + Assert.False(loaded.Success); + Assert.NotNull(loaded.Error); + } + + [Fact] + public void Load_DllWithNoPluginImpl_ReturnsFailure() + { + // Use AcDream.Core.dll itself — it has no IAcDreamPlugin impl + var coreDllDir = AppContext.BaseDirectory; + var host = new StubHost(); + var manifest = new PluginManifest( + Id: "x", + DisplayName: "X", + Version: "0.0.1", + EntryDll: "AcDream.Core.dll", + ApiVersion: 1, + Dependencies: Array.Empty()); + + var loaded = PluginLoader.Load(coreDllDir, manifest, host); + + Assert.False(loaded.Success); + Assert.Contains("IAcDreamPlugin", loaded.Error ?? ""); + } +}