Files
Kayori_Medusa.net/Core/Server/Services/PluginService.cs
T

245 lines
9.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Reflection;
using Abstractions;
using Abstractions.Handlers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Server.Plugins;
using Path = System.IO.Path;
namespace Server.Services;
public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPluginService
{
private readonly string _pluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins");
private readonly List<PluginSlot> _pendingSlots = [];
private IServiceProvider? _serviceProvider;
private WebApplication? _webApplication;
public void SetServiceProvider(IServiceProvider serviceProvider) =>
_serviceProvider = serviceProvider;
// ── Phase 1: before builder.Build() ─────────────────────────────────────
public async Task DiscoverPluginsAsync(WebApplicationBuilder builder)
{
var watch = Stopwatch.StartNew();
logger.LogInformation("Discovering plugins in {path}", _pluginPath);
if (!Directory.Exists(_pluginPath))
Directory.CreateDirectory(_pluginPath);
var dirs = Directory.EnumerateDirectories(_pluginPath).ToArray();
if (dirs.Length == 0)
{
logger.LogInformation("No plugin directories found");
return;
}
var loaded = 0;
for (var i = 0; i < dirs.Length; i++)
{
var dll = FindPluginDll(dirs[i]);
if (dll is null)
{
logger.LogWarning("Skipping directory '{dir}': no DLL found", Path.GetFileName(dirs[i]));
continue;
}
var slot = TryLoadSlot(dll);
if (slot is null) continue; // TryLoadSlot already logged the reason
logger.LogInformation("Registering plugin {} (gameCode={gameCode}, {verRange})",
slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer));
await slot.Plugin.OnBuilderInitialize(builder);
_pendingSlots.Add(slot);
loaded++;
logger.LogInformation("Registered plugin {currentCount} of {fullCount}", loaded, dirs.Length);
}
watch.Stop();
logger.LogInformation("Registered {registeredPluginsCount} plugin(s) in {elapsed} ms",
loaded, watch.ElapsedMilliseconds);
}
// ── Phase 2: after builder.Build() ──────────────────────────────────────
public async Task ActivatePluginsAsync(WebApplication app)
{
_webApplication = app;
foreach (var slot in _pendingSlots)
{
pluginRegistry.Add(slot);
await slot.Plugin.OnAppInitialize(app, slot.PluginServices);
logger.LogInformation("Plugin '{name}' activated (gameCode={gameCode}, {verRange})",
slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer));
}
_pendingSlots.Clear();
}
// ── Hot-reload ───────────────────────────────────────────────────────────
public async Task ReloadAsync(string pluginDllPath)
{
var name = Path.GetFileNameWithoutExtension(pluginDllPath);
logger.LogInformation("Hot-reloading plugin from '{dll}'", name);
var newSlot = TryLoadSlot(pluginDllPath);
if (newSlot is null) return; // TryLoadSlot already logged the reason
var old = pluginRegistry.Remove(newSlot.Key);
if (old is not null)
logger.LogInformation("Plugin '{name}' (gameCode={gameCode}, {verRange}) unloaded",
old.Plugin.Name, old.Plugin.GameCode, VerRange(old.Plugin.MinVer, old.Plugin.MaxVer));
pluginRegistry.Add(newSlot);
if (_webApplication is not null)
await newSlot.Plugin.OnAppInitialize(_webApplication, newSlot.PluginServices);
old?.Unload();
logger.LogInformation("Plugin '{name}' v{version} hot-reloaded successfully (gameCode={gameCode}, {verRange})",
newSlot.Plugin.Name, newSlot.Plugin.Version,
newSlot.Plugin.GameCode, VerRange(newSlot.Plugin.MinVer, newSlot.Plugin.MaxVer));
}
// ── Queries ──────────────────────────────────────────────────────────────
public IEnumerable<IMedusaPlugin> GetPlugins() =>
pluginRegistry.GetPlugins();
public IMedusaPlugin? FindPlugin(string gameCode, int? minVer = null, int? maxVer = null) =>
pluginRegistry.GetPlugins()
.Where(p => p.GameCode == gameCode)
.Where(p => minVer is null || p.MinVer <= minVer)
.Where(p => maxVer is null || p.MaxVer >= maxVer)
.FirstOrDefault();
public async Task<bool> DoesProfileExistAsync(IMedusaPlugin plugin, string cardId)
{
if (_serviceProvider is null)
throw new InvalidOperationException("PluginService has not been initialized with a service provider yet.");
var slot = pluginRegistry.GetSlots()
.FirstOrDefault(s => s.Plugin.GameCode == plugin.GameCode
&& s.Plugin.MinVer == plugin.MinVer
&& s.Plugin.MaxVer == plugin.MaxVer);
var method = plugin.DoesProfileExist;
var parameters = method.Method.GetParameters();
await using var hostScope = _serviceProvider.CreateAsyncScope();
IServiceProvider composite;
if (slot is not null)
{
await using var pluginScope = slot.PluginServices.CreateAsyncScope();
composite = new PluginServiceProvider(pluginScope.ServiceProvider, hostScope.ServiceProvider);
}
else
{
composite = hostScope.ServiceProvider;
}
var args = parameters.Select(p =>
{
if (p.Name == "cardId") return (object)cardId;
var isFromServices = p.GetCustomAttribute<FromServicesAttribute>() != null;
return isFromServices
? composite.GetRequiredService(p.ParameterType)
: throw new InvalidOperationException(
$"Don't know how to resolve parameter '{p.Name}' on plugin delegate DoesProfileExist");
}).ToArray();
return await (Task<bool>)method.DynamicInvoke(args)!;
}
// ── Internal ─────────────────────────────────────────────────────────────
private PluginSlot? TryLoadSlot(string dllPath)
{
var context = new PluginLoadContext(dllPath);
Assembly assembly;
try
{
assembly = context.LoadFromAssemblyPath(dllPath);
}
catch (Exception ex)
{
context.Unload();
logger.LogError("Failed to load assembly '{}': {}", Path.GetFileName(dllPath), ex.Message);
return null;
}
Type? pluginType;
IReadOnlyList<Type> handlerTypes;
try
{
pluginType = assembly.GetTypes()
.FirstOrDefault(t => t.GetInterfaces().Contains(typeof(IMedusaPlugin)));
handlerTypes = assembly.GetTypes()
.Where(t => typeof(BaseHandler).IsAssignableFrom(t)
&& t is { IsAbstract: false, IsInterface: false })
.ToList();
}
catch (ReflectionTypeLoadException ex)
{
context.Unload();
logger.LogWarning("Plugin '{}' appears to be outdated or incompatible. Please update it to the latest version of Medusa. Details: {}",
assembly.GetName().Name,
string.Join("; ", ex.LoaderExceptions.Select(e => e?.Message ?? "Unknown error")));
return null;
}
if (pluginType is null)
{
context.Unload();
logger.LogError("Could not find medusa plugin in dll {}", assembly.GetName().Name);
return null;
}
logger.LogInformation("Trying to register plugin {}", assembly.GetName().Name);
if (Activator.CreateInstance(pluginType) is not IMedusaPlugin instance)
{
context.Unload();
logger.LogError("Could not create instance of plugin {}", pluginType.Name);
return null;
}
var sc = new ServiceCollection();
instance.ConfigurePluginServices(sc);
var pluginSp = sc.BuildServiceProvider();
return new PluginSlot(context, instance, handlerTypes, pluginSp);
}
// Datecodes are YYYYMMDDXX, e.g. 2025070800 → "2025-07-08 r00"
private static string FormatVer(int ver)
{
var s = ver.ToString("D10");
return $"{s[..4]}-{s[4..6]}-{s[6..8]} r{s[8..]}";
}
private static string VerRange(int? minVer, int? maxVer) =>
(minVer, maxVer) switch
{
(null, null) => "all versions",
(not null, null) => $"{FormatVer(minVer.Value)}+",
(null, not null) => $"up to {FormatVer(maxVer.Value)}",
_ => $"{FormatVer(minVer.Value)} – {FormatVer(maxVer.Value)}"
};
private static string? FindPluginDll(string dir) =>
Directory.EnumerateFiles(dir, "*.dll")
.FirstOrDefault(f => !Path.GetFileNameWithoutExtension(f)
.Equals("Abstractions", StringComparison.OrdinalIgnoreCase));
}