mirror of
https://gitea.tendokyu.moe/Kayori/Medusa.net.git
synced 2026-09-22 22:38:00 +03:00
Reworked plugin loading into per-plugin hot-reloadable containers (PluginRegistry/PluginSlot/PluginLoadContext/PluginWatcher), so plugins get their own mini service provider composed with the host's. Added a GraphQL API (HotChocolate) for card query/mutation. Added OpenAPI docs via Scalar.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Abstractions;
|
||||
|
||||
@@ -16,7 +17,15 @@ public interface IMedusaPlugin
|
||||
Encoding? ForcedEncoding { get; }
|
||||
|
||||
Task OnBuilderInitialize(WebApplicationBuilder builder);
|
||||
Task OnAppInitialize(WebApplication app);
|
||||
|
||||
/// <summary>
|
||||
/// Register plugin-owned services (DbContext, repositories, etc.) into the plugin's
|
||||
/// own mini-container. Called for both initial load and hot-reload.
|
||||
/// Note: also register your DbContext in OnBuilderInitialize if you need migrations via OnAppInitialize.
|
||||
/// </summary>
|
||||
void ConfigurePluginServices(IServiceCollection services);
|
||||
|
||||
Task OnAppInitialize(WebApplication app, IServiceProvider pluginServices);
|
||||
|
||||
Delegate DoesProfileExist { get; }
|
||||
|
||||
}
|
||||
@@ -11,22 +11,16 @@ public static class ApplicationBuilderExtensions
|
||||
public static IApplicationBuilder UseHandlers(this IApplicationBuilder app)
|
||||
{
|
||||
var handlerService = app.ApplicationServices.GetRequiredService<IHandlerService>();
|
||||
var pluginService = app.ApplicationServices.GetService<IPluginService>();
|
||||
var pluginAssemblies = pluginService?.GetPlugins().Select(x => x.GetType().Assembly);
|
||||
var entryAssembly = Assembly.GetEntryAssembly() ?? throw new InvalidOperationException("Could not find entry assembly.");
|
||||
|
||||
var assemblies = new List<Assembly> { entryAssembly };
|
||||
|
||||
if (pluginAssemblies != null)
|
||||
{
|
||||
assemblies.AddRange(pluginAssemblies);
|
||||
}
|
||||
|
||||
foreach (var types in assemblies.Select(assembly => assembly.GetTypes().Where(a => a.GetInterfaces().Contains(typeof(IHandler)) ||
|
||||
(a.IsSubclassOf(typeof(Handler<,>)) && !a.IsAbstract))))
|
||||
{
|
||||
handlerService.Handlers.AddRange(types);
|
||||
}
|
||||
// Only register built-in server handlers here.
|
||||
// Plugin handler types live in PluginSlot.HandlerTypes and are dispatched
|
||||
// via PluginRegistry in HandlerService using the plugin's own mini-SP.
|
||||
var builtInHandlers = entryAssembly.GetTypes()
|
||||
.Where(t => t.GetInterfaces().Contains(typeof(IHandler)) ||
|
||||
(t.IsSubclassOf(typeof(Handler<,>)) && !t.IsAbstract));
|
||||
|
||||
handlerService.Handlers.AddRange(builtInHandlers);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Reflection;
|
||||
using HotChocolate.Execution.Configuration;
|
||||
|
||||
namespace Server.Extensions;
|
||||
|
||||
public static class RequestExecutorBuilderExtensions
|
||||
{
|
||||
extension(IRequestExecutorBuilder builder)
|
||||
{
|
||||
public IRequestExecutorBuilder AddTypeExtensionsInNamespaceOf(Type type)
|
||||
{
|
||||
var extensionNamespace = type.Namespace;
|
||||
|
||||
return extensionNamespace is null ?
|
||||
builder : builder.AddTypeExtensionsInNamespace(extensionNamespace);
|
||||
}
|
||||
|
||||
public IRequestExecutorBuilder AddTypeExtensionsInNamespaceOf<T>()
|
||||
{
|
||||
var type = typeof(T);
|
||||
var extensionNamespace = type.Namespace;
|
||||
|
||||
return extensionNamespace is null ?
|
||||
builder : builder.AddTypeExtensionsInNamespace(extensionNamespace);
|
||||
}
|
||||
|
||||
private IRequestExecutorBuilder AddTypeExtensionsInNamespace(string extensionNamespace)
|
||||
{
|
||||
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
|
||||
|
||||
var foundAssembly = assemblies.FirstOrDefault(x => x.DefinedTypes.Any(typeInfo => typeInfo.Namespace?.Equals(extensionNamespace) ?? false));
|
||||
if(foundAssembly is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loaded = Assembly.Load(extensionNamespace.Split('.')[0]);
|
||||
foundAssembly = loaded;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
var attributeType = typeof(ExtendObjectTypeAttribute);
|
||||
|
||||
var foundTypes = foundAssembly.DefinedTypes
|
||||
.Where(typeInfo =>
|
||||
(typeInfo.Namespace?.Equals(extensionNamespace) ?? false) &&
|
||||
typeInfo.GetCustomAttributes().Any(x => x.GetType() == attributeType))
|
||||
.ToArray();
|
||||
|
||||
foreach(var type in foundTypes)
|
||||
{
|
||||
var customAttributes = type.GetCustomAttributes();
|
||||
if (customAttributes.Any(x => x.GetType() == attributeType))
|
||||
builder.AddType(type.AsType());
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Security.Claims;
|
||||
using Abstractions.Entities;
|
||||
using Abstractions.Services;
|
||||
using HotChocolate.Authorization;
|
||||
|
||||
namespace Server.GraphQL;
|
||||
|
||||
[ExtendObjectType(OperationTypeNames.Mutation)]
|
||||
public class CardMutation
|
||||
{
|
||||
[Authorize]
|
||||
public async Task<IQueryable<Card>> AddCard([Service] ICardService cardService, [Service] AppDbContext appDbContext, string cardNumber, ClaimsPrincipal claimsPrincipal,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = int.TryParse(claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "", out var userId);
|
||||
|
||||
if (!parsed && userId is 0)
|
||||
{
|
||||
throw new ArgumentException("Cannot find userId");
|
||||
}
|
||||
|
||||
var existingCard = await cardService.FindByKonamiId(cardNumber);
|
||||
|
||||
if (existingCard is not null)
|
||||
{
|
||||
throw new Exception($"Card {cardNumber} is already exists");
|
||||
}
|
||||
|
||||
var cardId = cardService.ConvertKonamiIdToUid(cardNumber);
|
||||
|
||||
var card = new Card()
|
||||
{
|
||||
KonamiId = cardNumber,
|
||||
RawId = cardId,
|
||||
UserId = userId
|
||||
};
|
||||
|
||||
var entity = (await appDbContext.Cards.AddAsync(card, cancellationToken)).Entity;
|
||||
|
||||
await appDbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return appDbContext.Cards.Where(c => c.Id == entity.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Claims;
|
||||
using Abstractions.Entities;
|
||||
using HotChocolate.Authorization;
|
||||
|
||||
namespace Server.GraphQL;
|
||||
|
||||
[ExtendObjectType(OperationTypeNames.Query)]
|
||||
public class CardQuery
|
||||
{
|
||||
[UsePaging(IncludeTotalCount = true), UseFiltering, UseSorting, Authorize]
|
||||
public IQueryable<Card> GetMyCards([Service] AppDbContext appDbContext, ClaimsPrincipal claimsPrincipal, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = int.TryParse(claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "", out var userId);
|
||||
|
||||
if (!parsed && userId is 0)
|
||||
{
|
||||
throw new ArgumentException("Cannot find userId");
|
||||
}
|
||||
|
||||
var cards = appDbContext.Cards.Where(c => c.UserId == userId);
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Server.GraphQL;
|
||||
|
||||
public class Mutation
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Server.GraphQL;
|
||||
|
||||
public class Query
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using Path = System.IO.Path;
|
||||
|
||||
namespace Server.Plugins;
|
||||
|
||||
public sealed class PluginLoadContext(string pluginAssemblyPath)
|
||||
: AssemblyLoadContext(isCollectible: true)
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver = new(pluginAssemblyPath);
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
var path = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
|
||||
return path is not null ? LoadFromAssemblyPath(path) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Immutable;
|
||||
using Abstractions;
|
||||
|
||||
namespace Server.Plugins;
|
||||
|
||||
public sealed class PluginRegistry
|
||||
{
|
||||
private ImmutableDictionary<PluginSlotKey, PluginSlot> _slots = ImmutableDictionary<PluginSlotKey, PluginSlot>.Empty;
|
||||
|
||||
internal void Add(PluginSlot slot)
|
||||
{
|
||||
ImmutableInterlocked.AddOrUpdate(ref _slots, slot.Key, slot, (_, _) => slot);
|
||||
}
|
||||
|
||||
internal PluginSlot? Remove(PluginSlotKey key)
|
||||
{
|
||||
_slots.TryGetValue(key, out var old);
|
||||
ImmutableInterlocked.TryRemove(ref _slots, key, out _);
|
||||
return old;
|
||||
}
|
||||
|
||||
public PluginSlot? GetSlot(PluginSlotKey key)
|
||||
{
|
||||
_slots.TryGetValue(key, out var slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
public IEnumerable<PluginSlot> GetSlots() => _slots.Values;
|
||||
public IEnumerable<IMedusaPlugin> GetPlugins() => _slots.Values.Select(s => s.Plugin);
|
||||
}
|
||||
|
||||
public sealed record PluginSlotKey(string GameCode, int? MinVer, int? MaxVer);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Server.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves services from the plugin's own mini-container first,
|
||||
/// falling back to the host container for shared services (ICardService, IUserService, etc.).
|
||||
/// </summary>
|
||||
internal sealed class PluginServiceProvider(IServiceProvider plugin, IServiceProvider host) : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type t) => plugin.GetService(t) ?? host.GetService(t);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Abstractions;
|
||||
|
||||
namespace Server.Plugins;
|
||||
|
||||
public sealed class PluginSlot(
|
||||
PluginLoadContext context,
|
||||
IMedusaPlugin plugin,
|
||||
IReadOnlyList<Type> handlerTypes,
|
||||
IServiceProvider pluginServices)
|
||||
{
|
||||
public PluginLoadContext Context => context;
|
||||
public IMedusaPlugin Plugin => plugin;
|
||||
public IReadOnlyList<Type> HandlerTypes => handlerTypes;
|
||||
public PluginSlotKey Key => new(plugin.GameCode, plugin.MinVer, plugin.MaxVer);
|
||||
|
||||
/// <summary>Mini service provider built from the plugin's ConfigurePluginServices call.</summary>
|
||||
public IServiceProvider PluginServices => pluginServices;
|
||||
|
||||
public void Unload()
|
||||
{
|
||||
context.Unload();
|
||||
if (pluginServices is IDisposable d) d.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Server.Services;
|
||||
using Path = System.IO.Path;
|
||||
|
||||
namespace Server.Plugins;
|
||||
|
||||
public sealed class PluginWatcher(IPluginService pluginService, ILogger<PluginWatcher> logger)
|
||||
: IHostedService, IDisposable
|
||||
{
|
||||
private FileSystemWatcher? _watcher;
|
||||
private readonly string _pluginPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_watcher = new FileSystemWatcher(_pluginPath, "*.dll")
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName
|
||||
};
|
||||
_watcher.Changed += OnChanged;
|
||||
_watcher.Created += OnChanged;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnChanged(object _, FileSystemEventArgs e)
|
||||
{
|
||||
logger.LogInformation("Plugin file changed: {path}", e.FullPath);
|
||||
_ = pluginService.ReloadAsync(e.FullPath);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) { _watcher?.Dispose(); return Task.CompletedTask; }
|
||||
public void Dispose() => _watcher?.Dispose();
|
||||
|
||||
}
|
||||
+136
-147
@@ -23,6 +23,9 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Scalar.AspNetCore;
|
||||
using Server.GraphQL;
|
||||
using Server.Plugins;
|
||||
|
||||
var key =
|
||||
Convert.FromHexString("00000000000069D74627D985EE2187161570D08D93B12455035B6DF0D8205DF5");
|
||||
@@ -34,19 +37,6 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
|
||||
var logger = loggerFactory.CreateLogger("MedusaLogger");
|
||||
|
||||
var pluginService = new PluginService(logger);
|
||||
|
||||
// Add services to the container.
|
||||
|
||||
pluginService.RegisterPlugins();
|
||||
|
||||
var plugins = pluginService.GetPlugins();
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
await plugin.OnBuilderInitialize(builder);
|
||||
}
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddIdentityApiEndpoints<User>()
|
||||
.AddEntityFrameworkStores<AppDbContext>();
|
||||
@@ -56,7 +46,9 @@ builder.Services.AddHandlers();
|
||||
if (!File.Exists("database/Medusa.db"))
|
||||
{
|
||||
Directory.CreateDirectory("database");
|
||||
using(File.Create("database/Medusa.db")) { }
|
||||
await using (File.Create("database/Medusa.db"))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite("Data Source=database/Medusa.db;"));
|
||||
@@ -68,14 +60,49 @@ builder.Services.AddIdentityCore<User>(config =>
|
||||
config.Lockout.AllowedForNewUsers = true;
|
||||
}).AddEntityFrameworkStores<AppDbContext>();
|
||||
|
||||
builder.Services.AddTransient<ICardService, CardService>();
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
var graphQlService = builder.Services.AddGraphQLServer();
|
||||
|
||||
graphQlService
|
||||
.RegisterDbContextFactory<AppDbContext>()
|
||||
.AddAuthorization()
|
||||
.AddQueryType<Query>()
|
||||
.AddTypeExtensionsInNamespaceOf<Query>()
|
||||
.AddMutationType<Mutation>()
|
||||
.AddProjections()
|
||||
.AddFiltering()
|
||||
.AddSorting()
|
||||
.UseAutomaticPersistedOperationPipeline()
|
||||
.AddInMemoryOperationDocumentStorage()
|
||||
.AddCacheControl()
|
||||
.ModifyCostOptions(o =>
|
||||
{
|
||||
o.MaxFieldCost = 50000;
|
||||
o.MaxTypeCost = 50000;
|
||||
});
|
||||
;
|
||||
|
||||
builder.Services.AddOpenApi("v1");
|
||||
|
||||
// Create plugin infrastructure manually so DiscoverPluginsAsync can call
|
||||
// OnBuilderInitialize before the DI container is sealed by builder.Build().
|
||||
var pluginRegistry = new PluginRegistry();
|
||||
var pluginService = new PluginService(logger, pluginRegistry);
|
||||
builder.Services.AddSingleton(pluginRegistry);
|
||||
builder.Services.AddSingleton<IPluginService>(pluginService);
|
||||
builder.Services.AddHostedService<PluginWatcher>();
|
||||
|
||||
await pluginService.DiscoverPluginsAsync(builder);
|
||||
|
||||
builder.Services.AddTransient<ICardService, CardService>();
|
||||
builder.Services.AddTransient<IUserService, UserService>();
|
||||
builder.Services.AddSingleton<IXmlLogService, XmlLogService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
pluginService.SetServiceProvider(app.Services);
|
||||
await pluginService.ActivatePluginsAsync(app);
|
||||
|
||||
app.Lifetime.ApplicationStarted.Register(() =>
|
||||
{
|
||||
@@ -88,7 +115,8 @@ app.Lifetime.ApplicationStarted.Register(() =>
|
||||
foreach (var address in serverAddresses.Addresses)
|
||||
{
|
||||
var uri = new Uri(address);
|
||||
var displayAddress = Environment.GetEnvironmentVariable("MAIN_ADDRESS") ?? $"{uri.Scheme}://{localIp}:{uri.Port}";
|
||||
var displayAddress = Environment.GetEnvironmentVariable("MAIN_ADDRESS") ??
|
||||
$"{uri.Scheme}://{localIp}:{uri.Port}";
|
||||
Console.WriteLine($"Accessible at: {displayAddress}");
|
||||
Console.WriteLine($"EAmuse accessible at: {displayAddress}/eamuse");
|
||||
}
|
||||
@@ -100,14 +128,16 @@ app.UseStaticFiles();
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
//app.UseHttpsRedirection();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapScalarApiReference(o => o.AddDocument("v1"));
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseMiddleware<BodyParsingMiddleware>();
|
||||
app.UseHandlers();
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
await plugin.OnAppInitialize(app);
|
||||
}
|
||||
|
||||
var apiGroup = app.MapGroup("/api").WithTags("API");
|
||||
apiGroup.MapCardsApiEndpoints();
|
||||
apiGroup.MapUserApiEndpoints();
|
||||
@@ -115,129 +145,39 @@ apiGroup.MapIdentityApi();
|
||||
|
||||
var eamuseGroup = app.MapGroup("eamuse");
|
||||
|
||||
eamuseGroup.MapPost("/{model}/{module}/{method}", async (string model, string module, string method,
|
||||
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService, [FromServices] IPluginService pluginService) =>
|
||||
{
|
||||
Console.WriteLine(httpContext.Request.Headers.UserAgent);
|
||||
// Enable buffering to allow multiple reads of the request body
|
||||
httpContext.Request.EnableBuffering();
|
||||
var body = "";
|
||||
// The body is 932 encoded xml
|
||||
if(httpContext.Request.Body.Length != 0)
|
||||
{
|
||||
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
eamuseGroup.MapPost("/{model}/{module}/{method}", (string model, string module, string method,
|
||||
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService,
|
||||
[FromServices] IPluginService pluginService) =>
|
||||
{
|
||||
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
|
||||
|
||||
httpContext.Request.Body.Position = 0;
|
||||
return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService);
|
||||
});
|
||||
|
||||
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
|
||||
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
|
||||
eamuseGroup.MapPost("/{m}", (string m, [FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method,
|
||||
[FromQuery] string? f,
|
||||
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService,
|
||||
[FromServices] IPluginService pluginService) =>
|
||||
{
|
||||
var amusementRequest = BuildAmusementRequest(model, module, method, f);
|
||||
|
||||
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
|
||||
return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService);
|
||||
});
|
||||
|
||||
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
|
||||
eamuseGroup.MapPost("/", ([FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method,
|
||||
[FromQuery] string? f,
|
||||
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService,
|
||||
[FromServices] IPluginService pluginService) =>
|
||||
{
|
||||
var amusementRequest = BuildAmusementRequest(model, module, method, f);
|
||||
|
||||
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
|
||||
return HandleEAmuseRoute(amusementRequest, httpContext, logger, handlerService, pluginService);
|
||||
});
|
||||
|
||||
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
|
||||
var graphqlMap = app.MapGraphQL();
|
||||
|
||||
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
|
||||
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
|
||||
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
|
||||
app.MapFallbackToFile("/index.html");
|
||||
|
||||
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true", encoding, logger, handlerService, pluginService);
|
||||
|
||||
return TypedResults.Bytes(result, "application/octet-stream");
|
||||
});
|
||||
|
||||
eamuseGroup.MapPost("/{m}", async (string m, [FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method, [FromQuery] string? f,
|
||||
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService, [FromServices] IPluginService pluginService) =>
|
||||
{
|
||||
Console.WriteLine(httpContext.Request.Headers.UserAgent);
|
||||
// Enable buffering to allow multiple reads of the request body
|
||||
httpContext.Request.EnableBuffering();
|
||||
var body = "";
|
||||
// The body is 932 encoded xml
|
||||
if(httpContext.Request.Body.Length != 0)
|
||||
{
|
||||
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
httpContext.Request.Body.Position = 0;
|
||||
|
||||
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
|
||||
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
|
||||
|
||||
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
|
||||
|
||||
if(!string.IsNullOrEmpty(f))
|
||||
{
|
||||
var fParts = f.Split('.');
|
||||
amusementRequest.Module = fParts[0];
|
||||
amusementRequest.Method = fParts[1];
|
||||
}
|
||||
|
||||
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
|
||||
|
||||
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
|
||||
|
||||
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
|
||||
|
||||
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
|
||||
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
|
||||
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
|
||||
|
||||
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true", encoding, logger, handlerService, pluginService);
|
||||
|
||||
return TypedResults.Bytes(result, "application/octet-stream");
|
||||
});
|
||||
|
||||
eamuseGroup.MapPost("/", async ([FromQuery] string model, [FromQuery] string? module, [FromQuery] string? method, [FromQuery] string? f,
|
||||
HttpContext httpContext, [FromServices] ILogger<Program> logger, [FromServices] IHandlerService handlerService, [FromServices] IPluginService pluginService) =>
|
||||
{
|
||||
Console.WriteLine(httpContext.Request.Headers.UserAgent);
|
||||
// Enable buffering to allow multiple reads of the request body
|
||||
httpContext.Request.EnableBuffering();
|
||||
var body = "";
|
||||
// The body is 932 encoded xml
|
||||
if(httpContext.Request.Body.Length != 0)
|
||||
{
|
||||
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
httpContext.Request.Body.Position = 0;
|
||||
|
||||
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
|
||||
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
|
||||
|
||||
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
|
||||
|
||||
if(!string.IsNullOrEmpty(f))
|
||||
{
|
||||
var fParts = f.Split('.');
|
||||
amusementRequest.Module = fParts[0];
|
||||
amusementRequest.Method = fParts[1];
|
||||
}
|
||||
|
||||
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
|
||||
|
||||
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
|
||||
|
||||
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
|
||||
|
||||
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
|
||||
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
|
||||
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
|
||||
|
||||
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true", encoding, logger, handlerService, pluginService);
|
||||
|
||||
return TypedResults.Bytes(result, "application/octet-stream");
|
||||
});
|
||||
|
||||
app.MapFallbackToFile("/index.html");
|
||||
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
|
||||
@@ -248,13 +188,63 @@ await appDbContext.Database.MigrateAsync();
|
||||
app.Run();
|
||||
return;
|
||||
|
||||
async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, string info, bool compress, bool encrypt, bool isEncoded, string encoding, ILogger<Program> logger, IHandlerService handlerService, IPluginService pluginService)
|
||||
AmusementRequest BuildAmusementRequest(string model, string? module, string? method, string? f)
|
||||
{
|
||||
var amusementRequest = new AmusementRequest() { Model = model, Module = module ?? "", Method = method ?? "" };
|
||||
|
||||
if (string.IsNullOrEmpty(f)) return amusementRequest;
|
||||
|
||||
var fParts = f.Split('.');
|
||||
amusementRequest.Module = fParts[0];
|
||||
amusementRequest.Method = fParts[1];
|
||||
|
||||
return amusementRequest;
|
||||
}
|
||||
|
||||
async Task<IResult> HandleEAmuseRoute(AmusementRequest amusementRequest, HttpContext httpContext,
|
||||
ILogger<Program> logger, IHandlerService handlerService, IPluginService pluginService)
|
||||
{
|
||||
Console.WriteLine(httpContext.Request.Headers.UserAgent);
|
||||
// Enable buffering to allow multiple reads of the request body
|
||||
httpContext.Request.EnableBuffering();
|
||||
var body = "";
|
||||
// The body is 932 encoded xml
|
||||
if (httpContext.Request.Body.Length != 0)
|
||||
{
|
||||
using var reader = new StreamReader(httpContext.Request.Body, Encoding.GetEncoding(932), false, 1024, true);
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
httpContext.Request.Body.Position = 0;
|
||||
|
||||
var compress = httpContext.Request.Headers["X-Compress"].ToString().Contains("lz77");
|
||||
var encrypt = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() is not null;
|
||||
|
||||
var encoding = httpContext.Items["Encoding"]?.ToString() ?? "SHIFT_JIS";
|
||||
|
||||
httpContext.Request.Headers.TryGetValue("IsEncoded", out var isEncoded);
|
||||
|
||||
var originalInfo = httpContext.Request.Headers["X-Eamuse-Info"].FirstOrDefault() ?? "";
|
||||
|
||||
httpContext.Response.Headers.Append("X-Eamuse-Info", originalInfo);
|
||||
httpContext.Response.Headers.Append("X-Compress", compress ? "lz77" : "none");
|
||||
httpContext.Response.Headers.Append("User-Agent", "EAMUSE.Httpac/1.0");
|
||||
|
||||
var result = await HandleEAmuseRequest(amusementRequest, body, originalInfo, compress, encrypt, isEncoded == "true",
|
||||
encoding, logger, handlerService, pluginService);
|
||||
|
||||
return TypedResults.Bytes(result, "application/octet-stream");
|
||||
}
|
||||
|
||||
async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, string info, bool compress, bool encrypt,
|
||||
bool isEncoded, string encoding, ILogger<Program> logger, IHandlerService handlerService,
|
||||
IPluginService pluginService)
|
||||
{
|
||||
logger.LogInformation("Handling {Module} {Method}", request.Module, request.Method);
|
||||
|
||||
var document = new XDocument();
|
||||
|
||||
if(!string.IsNullOrEmpty(body))
|
||||
if (!string.IsNullOrEmpty(body))
|
||||
document = XDocument.Parse(body);
|
||||
|
||||
var responseXml = await handlerService.Handle(request.Model, request.Module, request.Method, document);
|
||||
@@ -272,14 +262,14 @@ async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, st
|
||||
_ => throw new ArgumentException($"Unknown encoding: {encoding}")
|
||||
};
|
||||
|
||||
if(forcedEncoding is not null)
|
||||
if (forcedEncoding is not null)
|
||||
{
|
||||
encodingEnum = forcedEncoding.ToKnownEncoding();
|
||||
}
|
||||
|
||||
|
||||
byte[] encodedBody;
|
||||
|
||||
if(!isEncoded)
|
||||
if (!isEncoded)
|
||||
{
|
||||
var encoder = Encoding.GetEncoding(encoding);
|
||||
encodedBody = encoder.GetBytes(responseXml.ToString());
|
||||
@@ -288,21 +278,20 @@ async Task<byte[]> HandleEAmuseRequest(AmusementRequest request, string body, st
|
||||
{
|
||||
encodedBody = KbinConverter.Write(responseXml, encodingEnum);
|
||||
}
|
||||
|
||||
if(compress)
|
||||
|
||||
if (compress)
|
||||
{
|
||||
encodedBody = LZ77.CompressEmpty(encodedBody);
|
||||
}
|
||||
|
||||
var originalInfo = info.Split('-');
|
||||
|
||||
if(!encrypt) return encodedBody;
|
||||
if (!encrypt) return encodedBody;
|
||||
var part = Convert.FromHexString((originalInfo[1] + originalInfo[2]));
|
||||
for(var i = 0; i < 6; i++)
|
||||
for (var i = 0; i < 6; i++)
|
||||
key[i] = part[i];
|
||||
var rc4Key = MD5.HashData(key);
|
||||
encodedBody = RC4.Encrypt(rc4Key, encodedBody);
|
||||
|
||||
return encodedBody;
|
||||
}
|
||||
|
||||
}
|
||||
+24
-12
@@ -7,7 +7,7 @@
|
||||
<UserSecretsId>43f0ff47-d090-4bfc-a428-9a16f4291fa3</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<SpaRoot>..\Client</SpaRoot>
|
||||
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
|
||||
<SpaProxyLaunchCommand>bun run dev</SpaProxyLaunchCommand>
|
||||
<SpaProxyServerUrl>https://localhost:5173</SpaProxyServerUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -26,29 +26,41 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Facet" Version="5.4.1" />
|
||||
<PackageReference Include="Facet.Extensions" Version="5.4.1" />
|
||||
<PackageReference Include="FastEndpoints" Version="7.2.0" />
|
||||
<PackageReference Include="FastEndpoints.ClientGen" Version="7.2.0" />
|
||||
<PackageReference Include="FastEndpoints.Security" Version="7.2.0" />
|
||||
<PackageReference Include="Facet" Version="6.6.8" />
|
||||
<PackageReference Include="Facet.Extensions" Version="6.6.8" />
|
||||
<PackageReference Include="FastEndpoints" Version="8.2.0" />
|
||||
<PackageReference Include="FastEndpoints.ClientGen" Version="8.2.0" />
|
||||
<PackageReference Include="FastEndpoints.Security" Version="8.2.0" />
|
||||
<PackageReference Include="HotChocolate.AspNetCore" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.Caching" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.Caching.Memory" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.Data" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.PersistedOperations.InMemory" Version="16.6.0" />
|
||||
<PackageReference Include="HotChocolate.Types.Scalars" Version="16.6.0" />
|
||||
<PackageReference Include="KbinXml.Net" Version="2.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaProxy">
|
||||
<Version>10.*-*</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NSwag.AspNetCore" Version="14.7.1" />
|
||||
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.7.1" />
|
||||
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.18" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,10 +5,11 @@ using System.Xml.Linq;
|
||||
using System.Xml.Serialization;
|
||||
using Abstractions.Handlers;
|
||||
using Abstractions.Services;
|
||||
using Server.Plugins;
|
||||
|
||||
namespace Server.Services;
|
||||
|
||||
public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<HandlerService> logger, IXmlLogService xmlLogService) : IHandlerService
|
||||
public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<HandlerService> logger, IXmlLogService xmlLogService, PluginRegistry pluginRegistry) : IHandlerService
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
|
||||
private readonly ILogger<HandlerService> _logger = logger;
|
||||
@@ -17,6 +18,7 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
|
||||
|
||||
public async Task<XDocument> Handle(string model, string module, string method, XDocument body)
|
||||
{
|
||||
// ── Built-in server handlers (host SP) ──────────────────────────────
|
||||
foreach(var handler in Handlers)
|
||||
{
|
||||
//Module and service are on the attribute
|
||||
@@ -24,7 +26,8 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
|
||||
|
||||
if(handlerAttribute is null)
|
||||
{
|
||||
var response = await HandleInheritanceClass(handler, model, module, method, body);
|
||||
await using var hostScope = _serviceScopeFactory.CreateAsyncScope();
|
||||
var response = await HandleInheritanceClass(handler, model, module, method, body, hostScope.ServiceProvider);
|
||||
|
||||
if(response is not null)
|
||||
{
|
||||
@@ -48,20 +51,33 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
|
||||
: (IHandler)ActivatorUtilities.CreateInstance(scope.ServiceProvider, handler);
|
||||
|
||||
var document = new XDocument();//await handlerInstance.HandleAsync(model);
|
||||
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
// ── Plugin handlers (composite SP: plugin first → host fallback) ────
|
||||
foreach (var slot in pluginRegistry.GetSlots())
|
||||
{
|
||||
await using var hostScope = _serviceScopeFactory.CreateAsyncScope();
|
||||
await using var pluginScope = slot.PluginServices.CreateAsyncScope();
|
||||
var composite = new PluginServiceProvider(pluginScope.ServiceProvider, hostScope.ServiceProvider);
|
||||
|
||||
foreach (var handlerType in slot.HandlerTypes)
|
||||
{
|
||||
var result = await HandleInheritanceClass(handlerType, model, module, method, body, composite);
|
||||
if (result is not null) return result;
|
||||
}
|
||||
}
|
||||
|
||||
//If no handler is found return an empty document
|
||||
_logger.LogWarning("No handler found for {model}/{module}/{method}", model, module, method);
|
||||
|
||||
return new XDocument();
|
||||
}
|
||||
|
||||
private async Task<XDocument?> HandleInheritanceClass(Type handler, string model, string module, string method, XDocument body)
|
||||
private async Task<XDocument?> HandleInheritanceClass(Type handler, string model, string module, string method, XDocument body, IServiceProvider serviceProvider)
|
||||
{
|
||||
var isHandlerWithoutRequest = false;
|
||||
await using var scope = _serviceScopeFactory.CreateAsyncScope();
|
||||
|
||||
var requiredXdocumentConstructor = handler.GetConstructors()
|
||||
.Any(c => c.GetParameters().Any(p => p.ParameterType == typeof(XDocument)));
|
||||
@@ -71,7 +87,7 @@ public class HandlerService(IServiceScopeFactory serviceScopeFactory, ILogger<Ha
|
||||
throw new InvalidOperationException($"Cannot create instance of abstract class: {handler.FullName}");
|
||||
}
|
||||
|
||||
if(ActivatorUtilities.CreateInstance(scope.ServiceProvider, handler) is not BaseHandler handlerInstance)
|
||||
if(ActivatorUtilities.CreateInstance(serviceProvider, handler) is not BaseHandler handlerInstance)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
using Abstractions;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Server.Services;
|
||||
|
||||
public interface IPluginService
|
||||
{
|
||||
void AddPlugin(IMedusaPlugin plugin);
|
||||
void RegisterPlugins();
|
||||
List<IMedusaPlugin> GetPlugins();
|
||||
/// <summary>Pre-build: scan plugin dirs, load slots, call OnBuilderInitialize on each.</summary>
|
||||
Task DiscoverPluginsAsync(WebApplicationBuilder builder);
|
||||
|
||||
/// <summary>Post-build: add slots to the registry, call OnAppInitialize on each.</summary>
|
||||
Task ActivatePluginsAsync(WebApplication app);
|
||||
|
||||
/// <summary>Hot-swap a single plugin DLL without restarting.</summary>
|
||||
Task ReloadAsync(string pluginDllPath);
|
||||
|
||||
void SetServiceProvider(IServiceProvider serviceProvider);
|
||||
|
||||
IEnumerable<IMedusaPlugin> GetPlugins();
|
||||
IMedusaPlugin? FindPlugin(string gameCode, int? minVer = null, int? maxVer = null);
|
||||
Task<bool> DoesProfileExistAsync(IMedusaPlugin plugin, string cardId);
|
||||
}
|
||||
@@ -1,110 +1,244 @@
|
||||
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) : IPluginService
|
||||
public class PluginService(ILogger logger, PluginRegistry pluginRegistry) : IPluginService
|
||||
{
|
||||
private List<IMedusaPlugin> Plugins { get; } = [];
|
||||
private readonly string _pluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "plugins") ;
|
||||
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)
|
||||
{
|
||||
public void SetServiceProvider(IServiceProvider serviceProvider) =>
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public void AddPlugin(IMedusaPlugin plugin)
|
||||
{
|
||||
Plugins.Add(plugin);
|
||||
}
|
||||
// ── Phase 1: before builder.Build() ─────────────────────────────────────
|
||||
|
||||
public void RegisterPlugins()
|
||||
public async Task DiscoverPluginsAsync(WebApplicationBuilder builder)
|
||||
{
|
||||
var watch = new Stopwatch();
|
||||
watch.Start();
|
||||
|
||||
logger.LogInformation("Registering plugins");
|
||||
var watch = Stopwatch.StartNew();
|
||||
|
||||
logger.LogInformation("Discovering plugins in {path}", _pluginPath);
|
||||
|
||||
if (!Directory.Exists(_pluginPath))
|
||||
Directory.CreateDirectory(_pluginPath);
|
||||
|
||||
var dlls = Directory.EnumerateFiles(_pluginPath, "*.dll").ToArray();
|
||||
for (var i = 0; i < dlls.Length; i++)
|
||||
|
||||
var dirs = Directory.EnumerateDirectories(_pluginPath).ToArray();
|
||||
|
||||
if (dirs.Length == 0)
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dlls[i]);
|
||||
Type? medusaPlugin = null;
|
||||
|
||||
try
|
||||
{
|
||||
medusaPlugin = assembly.GetTypes().FirstOrDefault(t => t.GetInterfaces().Contains(typeof(IMedusaPlugin)));
|
||||
}
|
||||
catch(ReflectionTypeLoadException ex)
|
||||
{
|
||||
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")));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (medusaPlugin is null)
|
||||
{
|
||||
logger.LogError("Could not find medusa plugin in dll {}", assembly.GetName().Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Trying to register plugin {}", assembly.GetName().Name);
|
||||
|
||||
if (Activator.CreateInstance(medusaPlugin) is not IMedusaPlugin instance)
|
||||
{
|
||||
logger.LogError("Could not create instance of plugin {}", medusaPlugin.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Registering plugin {}", instance.Name);
|
||||
AddPlugin(instance);
|
||||
logger.LogInformation("Registered plugin {currentCount} of {fullCount}", i + 1, dlls.Length);
|
||||
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", dlls.Length, watch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
public List<IMedusaPlugin> GetPlugins()
|
||||
{
|
||||
return Plugins;
|
||||
logger.LogInformation("Registered {registeredPluginsCount} plugin(s) in {elapsed} ms",
|
||||
loaded, watch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
public IMedusaPlugin? FindPlugin(string gameCode, int? minVer = null, int? maxVer = null)
|
||||
// ── Phase 2: after builder.Build() ──────────────────────────────────────
|
||||
|
||||
public async Task ActivatePluginsAsync(WebApplication app)
|
||||
{
|
||||
var foundPlugins = Plugins.Where(x => x.GameCode == gameCode);
|
||||
if (minVer != null)
|
||||
foundPlugins = foundPlugins.Where(x => x.MinVer <= minVer);
|
||||
if (maxVer != null)
|
||||
foundPlugins = foundPlugins.Where(x => x.MaxVer >= maxVer);
|
||||
return foundPlugins.FirstOrDefault();
|
||||
_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();
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
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;
|
||||
if (isFromServices) return scope.ServiceProvider.GetRequiredService(p.ParameterType);
|
||||
throw new InvalidOperationException($"Don't know how to resolve parameter '{p.Name}' on plugin delegate DoesProfileExist");
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user