Two gaps from the MossTank shipment review. **apiVersion was declared in every manifest and checked by nothing.** The loader now refuses an unsupported contract BEFORE loading any code from the plugin — checking after the fact is not equivalent, because by then the assembly is in a collectible context and the mismatch surfaces as a type-load or missing-member failure from inside the plugin, which reads like the plugin is broken rather than built for a different host. PluginApi (Current / MinimumSupported) lives in Plugin.Abstractions beside the contract it versions, and the refusal is a distinct PluginApiVersionException so callers can tell "update the client or the plugin" from "this plugin is broken". The tests pin the ordering too: a manifest with a future apiVersion AND a missing dll must fail on the version, a supported one on the dll. **A launcher-launched client loaded no plugins until the user typed ids.** LA5 distinguishes an omitted allow-list (load all) from an explicit empty one (load none); a fresh character profile's list is empty, so it composed to load-none. Direct launches pass null and load everything -- which is why the gap never showed in development: the two launch paths disagreed and the launcher was the one users get. This REVERSES the LA5 default deliberately: "nothing configured" now composes to the omitted list, so plugins are on by default, including ones installed later. The opt-out is kept -- losing it would be a real regression for stripped sessions -- respelled as the literal id "none", and the launcher's plugin box says so. The cross-host shared fixture composes its explicit-load-none case through the new spelling, keeping the reader-side contract tests (App and Headless both preserve an explicit empty list) exactly as they were. Complete Release suite: 14,469 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
3.8 KiB
C#
93 lines
3.8 KiB
C#
using System.Reflection;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Core.Plugins;
|
|
|
|
public static class PluginLoader
|
|
{
|
|
/// <summary>
|
|
/// Load a plugin DLL from <paramref name="pluginDirectory"/> into a collectible
|
|
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/>, find the first type
|
|
/// implementing <see cref="IAcDreamPlugin"/>, instantiate it, and call its
|
|
/// <see cref="IAcDreamPlugin.Initialize"/> with the supplied host. Any failure
|
|
/// is returned as a failed <see cref="LoadedPlugin"/> rather than thrown.
|
|
/// A returned partial plugin/context remains caller-owned; this method never
|
|
/// requests unload because the caller must close host registrations first.
|
|
/// </summary>
|
|
public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
|
ArgumentNullException.ThrowIfNull(manifest);
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
|
|
// Refuse a contract we cannot honour BEFORE loading any code from it.
|
|
// Checking after the fact is not equivalent: the assembly is already in
|
|
// a collectible context, and the mismatch surfaces as a type-load or
|
|
// missing-member failure from inside the plugin, which reads like the
|
|
// plugin is broken rather than built for a different host.
|
|
if (!PluginApi.IsSupported(manifest.ApiVersion))
|
|
return new LoadedPlugin(
|
|
manifest,
|
|
Plugin: null,
|
|
LoadContext: null,
|
|
Error: new PluginApiVersionException(
|
|
$"plugin '{manifest.Id}' declares apiVersion {manifest.ApiVersion}, "
|
|
+ $"but this build supports {PluginApi.MinimumSupported}"
|
|
+ $"..{PluginApi.Current}"));
|
|
|
|
var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll);
|
|
if (!File.Exists(dllPath))
|
|
return new LoadedPlugin(
|
|
manifest,
|
|
Plugin: null,
|
|
LoadContext: null,
|
|
Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath));
|
|
|
|
PluginAssemblyLoadContext? alc = null;
|
|
IAcDreamPlugin? instance = null;
|
|
try
|
|
{
|
|
alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath);
|
|
var asm = alc.LoadFromAssemblyPath(dllPath);
|
|
|
|
IEnumerable<Type> types;
|
|
try
|
|
{
|
|
types = asm.GetTypes();
|
|
}
|
|
catch (ReflectionTypeLoadException rtle)
|
|
{
|
|
types = rtle.Types.OfType<Type>();
|
|
}
|
|
|
|
var pluginType = types
|
|
.FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t));
|
|
|
|
if (pluginType is null)
|
|
{
|
|
return new LoadedPlugin(
|
|
manifest,
|
|
Plugin: null,
|
|
LoadContext: alc,
|
|
Error: new InvalidOperationException(
|
|
$"no IAcDreamPlugin implementation found in {manifest.EntryDll}"));
|
|
}
|
|
|
|
instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!;
|
|
instance.Initialize(host);
|
|
return new LoadedPlugin(manifest, instance, alc, Error: null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The caller owns rollback for a partial instance/context. In
|
|
// particular, Initialize may already have attached host callbacks;
|
|
// the per-plugin host scope must remove those registrations before
|
|
// Disable or any collectible unload request can run.
|
|
return new LoadedPlugin(
|
|
manifest,
|
|
Plugin: instance,
|
|
LoadContext: alc,
|
|
Error: ex);
|
|
}
|
|
}
|
|
}
|