using System.Diagnostics; using System.Reflection; using Abstractions; using Abstractions.Handlers; using Microsoft.AspNetCore.Mvc; using Server.Api; using Server.Plugins; using Path = System.IO.Path; namespace Server.Services; public class PluginService(ILogger logger, PluginRegistry pluginRegistry, PluginUiEventBroadcaster uiEventBroadcaster) : IPluginService { private readonly string _pluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins"); private readonly List _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; foreach (var dir in dirs) { var slot = TryLoadSlotFromDirectory(dir); if (slot is null) continue; 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 pluginDir) { logger.LogInformation("Hot-reloading plugin in '{dir}'", Path.GetFileName(pluginDir)); // Re-scan the whole directory rather than assuming any particular dll is the plugin - // package-referenced plugins ship every dependency dll (AssetsTools.NET, EFCore, ...) // alongside their own, so which file actually declares IMedusaPlugin isn't known up front. var newSlot = TryLoadSlotFromDirectory(pluginDir); if (newSlot is null) return; // TryLoadSlotFromDirectory 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)); if (newSlot.Plugin.UiManifest is not null) uiEventBroadcaster.Broadcast( new PluginUiEvent("reloaded", PluginsApi.DerivePluginUiId(newSlot.Plugin))); } public Task UnloadAsync(string deletedPath) { // deletedPath can be the plugin's DLL itself (deleted in place) or the plugin's // whole subdirectory removed wholesale (e.g. `rm -rf plugins/Foo`) - match either // the exact assembly path or anything loaded from underneath the deleted directory. var prefix = deletedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var slots = pluginRegistry.GetSlots() .Where(s => s.Context.AssemblyPath == deletedPath || s.Context.AssemblyPath.StartsWith(prefix, StringComparison.Ordinal)) .ToList(); if (slots.Count == 0) { logger.LogWarning("No loaded plugin matches deleted path '{path}'", deletedPath); return Task.CompletedTask; } foreach (var slot in slots) { pluginRegistry.Remove(slot.Key); slot.Unload(); logger.LogInformation( "Plugin '{name}' (gameCode={gameCode}, {verRange}) unloaded because '{path}' was deleted", slot.Plugin.Name, slot.Plugin.GameCode, VerRange(slot.Plugin.MinVer, slot.Plugin.MaxVer), deletedPath); if (slot.Plugin.UiManifest is not null) uiEventBroadcaster.Broadcast( new PluginUiEvent("unloaded", PluginsApi.DerivePluginUiId(slot.Plugin))); } return Task.CompletedTask; } // ── Queries ────────────────────────────────────────────────────────────── public IEnumerable 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) .FirstOrDefault(p => maxVer is null || p.MaxVer >= maxVer); public async Task 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(); if (slot is null) return await InvokeAsync(hostScope.ServiceProvider); // pluginScope must stay alive until the delegate has actually run, since // composite (and anything resolved from it) is only valid while it's open. await using var pluginScope = slot.PluginServices.CreateAsyncScope(); var composite = new PluginServiceProvider(pluginScope.ServiceProvider, hostScope.ServiceProvider); return await InvokeAsync(composite); Task InvokeAsync(IServiceProvider serviceProvider) { var args = parameters.Select(p => { if (p.Name == "cardId") return cardId; var isFromServices = p.GetCustomAttribute() != null; return isFromServices ? serviceProvider.GetRequiredService(p.ParameterType) : throw new InvalidOperationException( $"Don't know how to resolve parameter '{p.Name}' on plugin delegate DoesProfileExist"); }).ToArray(); return (Task)method.DynamicInvoke(args)!; } } // ── Internal ───────────────────────────────────────────────────────────── // Dependencies ship alongside the plugin DLL, so we don't know which file hosts // IMedusaPlugin until we look. Loading each candidate for real would mean ~10 // wasted PluginLoadContext cycles per plugin, and AssemblyLoadContext.Unload() // is asynchronous (needs GC) — a scanned dependency can still be resident when // the real plugin later loads and needs that same assembly, causing type-identity // collisions even on cold boot. MetadataLoadContext reads metadata without // executing code; Dispose() is immediate and leaves no stale state. Note that // types returned from it are not runtime types, so match IMedusaPlugin by // full name rather than direct interface comparison. private PluginSlot? TryLoadSlotFromDirectory(string dir) { var dlls = Directory.EnumerateFiles(dir, "*.dll").ToArray(); if (dlls.Length == 0) { logger.LogWarning("Skipping directory '{dir}': no DLL found", Path.GetFileName(dir)); return null; } var pluginDll = FindPluginDllByMetadata(dir, dlls); if (pluginDll is null) { logger.LogWarning("No medusa plugin found among {count} dll(s) in '{dir}'", dlls.Length, Path.GetFileName(dir)); return null; } return TryLoadSlot(pluginDll); } private string? FindPluginDllByMetadata(string dir, string[] dlls) { var pluginInterfaceName = typeof(IMedusaPlugin).FullName; // Precedence for name -> path when the same simple assembly name shows up more than // once: the plugin's own bundled copy first, falling back to the host's base // directory (Abstractions.dll normally isn't bundled at all - PluginLoadContext // resolves it to the host by design), then the runtime's own reference assemblies. var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var path in Directory.EnumerateFiles(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "*.dll")) byName.TryAdd(Path.GetFileNameWithoutExtension(path), path); foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll")) byName[Path.GetFileNameWithoutExtension(path)] = path; foreach (var path in dlls) byName[Path.GetFileNameWithoutExtension(path)] = path; using var mlc = new MetadataLoadContext(new PathAssemblyResolver(byName.Values)); foreach (var dllFile in dlls) { if (dllFile.Contains("Abstractions.dll")) continue; try { var assembly = mlc.LoadFromAssemblyPath(dllFile); var hasPlugin = assembly.GetTypes() .Any(t => t.GetInterfaces().Any(i => i.FullName == pluginInterfaceName)); if (hasPlugin) return dllFile; } catch (Exception ex) when (ex is BadImageFormatException or ReflectionTypeLoadException or FileNotFoundException) { // Not a .NET assembly, or its own dependencies didn't resolve for metadata // purposes - neither means it's our plugin, so just move on to the next dll. logger.LogDebug(ex, "Skipping '{file}' while scanning for the plugin dll", Path.GetFileName(dllFile)); } } return null; } // A non-atomic deploy (plain multi-file cp, editor autosave, etc.) can leave a dll // partially written when FileSystemWatcher's debounce fires. That surfaces as // BadImageFormatException ("Invalid token", "Invalid assembly public key", ...) from // reading a truncated PE - a transient condition, not a real failure, so retry through // it briefly before giving up. Any other exception (genuinely bad/incompatible dll) // fails immediately as before. private const int LoadRetryAttempts = 5; private static readonly TimeSpan LoadRetryDelay = TimeSpan.FromMilliseconds(150); private PluginSlot? TryLoadSlot(string dllPath) { var context = new PluginLoadContext(dllPath); Assembly assembly; try { assembly = LoadWithRetry(context, dllPath); } catch (Exception ex) { context.Unload(); logger.LogError("Failed to load assembly '{path}': {message}", Path.GetFileName(dllPath), ex.Message); return null; } Type? pluginType; IReadOnlyList handlerTypes; try { var pluginTypes = assembly.GetTypes(); pluginType = pluginTypes .FirstOrDefault(t => t.GetInterfaces().Contains(typeof(IMedusaPlugin))); if (pluginType is null) { // Not the plugin dll itself - just one of its dependencies (AssetsTools.NET, // EFCore, ...) picked up while scanning the whole directory. Not an error on // its own, so no log here; TryLoadSlotFromDirectory logs if *none* of them match. context.Unload(); return null; } handlerTypes = assembly.GetTypes() .Where(t => typeof(BaseHandler).IsAssignableFrom(t) && t is { IsAbstract: false, IsInterface: false }) .ToList(); } catch (ReflectionTypeLoadException ex) { context.Unload(); logger.LogWarning( "Plugin '{name}' 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; } 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); } private Assembly LoadWithRetry(PluginLoadContext context, string dllPath) { var attempt = 1; do { try { return LoadFromPathAsStream(context, dllPath); } catch (BadImageFormatException) when (attempt < LoadRetryAttempts) { logger.LogDebug( "'{path}' looks like a partial write (attempt {attempt}/{retryAttempts}), retrying...", Path.GetFileName(dllPath), attempt, LoadRetryAttempts); Thread.Sleep(LoadRetryDelay); } attempt++; } while (attempt <= LoadRetryAttempts); throw new InvalidOperationException("Failed to load assembly."); } private static Assembly LoadFromPathAsStream(PluginLoadContext context, string dllPath) { using var dllStream = new MemoryStream(File.ReadAllBytes(dllPath)); var pdbPath = Path.ChangeExtension(dllPath, ".pdb"); if (!File.Exists(pdbPath)) return context.LoadFromStream(dllStream); using var pdbStream = new MemoryStream(File.ReadAllBytes(pdbPath)); return context.LoadFromStream(dllStream, pdbStream); } // 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)}" }; }