feat(core): add PluginLoader with collectible ALC
This commit is contained in:
parent
9dfbc05052
commit
a7f0732026
8 changed files with 226 additions and 0 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
11
src/AcDream.Core/Plugins/LoadedPlugin.cs
Normal file
11
src/AcDream.Core/Plugins/LoadedPlugin.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
30
src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
Normal file
30
src/AcDream.Core/Plugins/PluginAssemblyLoadContext.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.Loader;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/AcDream.Core/Plugins/PluginLoader.cs
Normal file
34
src/AcDream.Core/Plugins/PluginLoader.cs
Normal file
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Private=false + ExcludeAssets=runtime is CRITICAL: prevents the fixture
|
||||||
|
from copying AcDream.Plugin.Abstractions.dll next to itself, which
|
||||||
|
would cause type-identity mismatch when the ALC loads the fixture
|
||||||
|
and the host already has the abstractions loaded. -->
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
|
||||||
|
<Private>false</Private>
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
20
tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs
Normal file
20
tests/AcDream.Core.Tests.Fixtures.HelloPlugin/HelloPlugin.cs
Normal file
|
|
@ -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++;
|
||||||
|
}
|
||||||
|
|
@ -22,4 +22,13 @@
|
||||||
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Build ordering only — do NOT reference at runtime. The tests locate the
|
||||||
|
fixture DLL by path and load it into a separate ALC. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Core.Tests.Fixtures.HelloPlugin\AcDream.Core.Tests.Fixtures.HelloPlugin.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
102
tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
Normal file
102
tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs
Normal file
|
|
@ -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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
var loaded = PluginLoader.Load(coreDllDir, manifest, host);
|
||||||
|
|
||||||
|
Assert.False(loaded.Success);
|
||||||
|
Assert.Contains("IAcDreamPlugin", loaded.Error ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue