diff --git a/Moonlight.ApiServer/Http/Controllers/PluginsStreamController.cs b/Moonlight.ApiServer/Http/Controllers/PluginsStreamController.cs new file mode 100644 index 00000000..1c4e1d72 --- /dev/null +++ b/Moonlight.ApiServer/Http/Controllers/PluginsStreamController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using MoonCore.Exceptions; +using MoonCore.Models; +using Moonlight.ApiServer.Services; + +namespace Moonlight.ApiServer.Http.Controllers; + +[ApiController] +[Route("api/pluginsStream")] +public class PluginsStreamController : Controller +{ + private readonly PluginService PluginService; + private readonly IMemoryCache Cache; + + public PluginsStreamController(PluginService pluginService, IMemoryCache cache) + { + PluginService = pluginService; + Cache = cache; + } + + [HttpGet] + public Task GetManifest() + { + var assembliesMap = Cache.GetOrCreate("clientPluginAssemblies", entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); + + return PluginService.GetAssemblies("client"); + })!; + + var entrypoints = Cache.GetOrCreate("clientPluginEntrypoints", entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); + + return PluginService.GetEntrypoints("client"); + })!; + + return Task.FromResult(new HostedPluginsManifest() + { + Assemblies = assembliesMap.Keys.ToArray(), + Entrypoints = entrypoints + }); + } + + [HttpGet("stream")] + public async Task GetAssembly([FromQuery(Name = "assembly")] string assembly) + { + var assembliesMap = Cache.GetOrCreate("clientPluginAssemblies", entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15); + + return PluginService.GetAssemblies("client"); + })!; + + if (assembliesMap.ContainsKey(assembly)) + throw new HttpApiException("The requested assembly could not be found", 404); + + var path = assembliesMap[assembly]; + + await Results.File(path).ExecuteAsync(HttpContext); + } +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Models/PluginManifest.cs b/Moonlight.ApiServer/Models/PluginManifest.cs new file mode 100644 index 00000000..a4393b46 --- /dev/null +++ b/Moonlight.ApiServer/Models/PluginManifest.cs @@ -0,0 +1,11 @@ +namespace Moonlight.ApiServer.Models; + +public class PluginManifest +{ + public string Id { get; set; } + public string Name { get; set; } + public string Author { get; set; } + public string[] Dependencies { get; set; } = []; + + public Dictionary Entrypoints { get; set; } = new(); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Models/PluginMeta.cs b/Moonlight.ApiServer/Models/PluginMeta.cs index 9e371d97..b60c5dfb 100644 --- a/Moonlight.ApiServer/Models/PluginMeta.cs +++ b/Moonlight.ApiServer/Models/PluginMeta.cs @@ -1,12 +1,7 @@ -namespace Moonlight.ApiServer.Models; +namespace Moonlight.ApiServer.Models; public class PluginMeta { - public string Id { get; set; } - public string Name { get; set; } - public string Author { get; set; } - public string? DonationUrl { get; set; } - public string? UpdateUrl { get; set; } - - public Dictionary Binaries { get; set; } = new(); + public PluginManifest Manifest { get; set; } + public string Path { get; set; } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index d3ad7e70..2b4af249 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -23,7 +23,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -38,7 +38,9 @@ - + + + diff --git a/Moonlight.ApiServer/Services/PluginService.cs b/Moonlight.ApiServer/Services/PluginService.cs index 28216c78..b55fa618 100644 --- a/Moonlight.ApiServer/Services/PluginService.cs +++ b/Moonlight.ApiServer/Services/PluginService.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using MoonCore.Helpers; using Moonlight.ApiServer.Models; @@ -6,11 +6,11 @@ namespace Moonlight.ApiServer.Services; public class PluginService { - private readonly Dictionary Plugins = new(); + public readonly List Plugins = new(); + + private static string PluginsFolder = PathBuilder.Dir("storage", "plugins"); private readonly ILogger Logger; - private static string PluginsPath = PathBuilder.Dir("storage", "plugins"); - public PluginService(ILogger logger) { Logger = logger; @@ -18,58 +18,91 @@ public class PluginService public async Task Load() { - Logger.LogInformation("Loading plugins..."); - - foreach (var pluginPath in Directory.EnumerateDirectories(PluginsPath)) + // Load all manifest files + foreach (var pluginFolder in Directory.EnumerateDirectories(PluginsFolder)) { - var metaPath = PathBuilder.File(PluginsPath, "meta.json"); + var manifestPath = PathBuilder.File(pluginFolder, "plugin.json"); - if (!File.Exists(metaPath)) + if (!File.Exists(manifestPath)) { - Logger.LogWarning("Ignoring folder '{name}'. No meta.json found", pluginPath); + Logger.LogWarning("Ignoring '{folder}' because no manifest has been found", pluginFolder); continue; } - var metaContent = await File.ReadAllTextAsync(metaPath); - PluginMeta meta; + PluginManifest manifest; try { - meta = JsonSerializer.Deserialize(metaContent) - ?? throw new JsonException(); + var manifestText = await File.ReadAllTextAsync(manifestPath); + manifest = JsonSerializer.Deserialize(manifestText)!; } catch (Exception e) { - Logger.LogCritical("Unable to deserialize meta.json: {e}", e); - continue; + Logger.LogError("An unhandled error occured while loading plugin manifest in '{folder}': {e}", + pluginFolder, e); + break; } - - Plugins.Add(pluginPath, meta); + + Logger.LogTrace("Loaded plugin manifest. Id: {id}", manifest.Id); + + Plugins.Add(new() + { + Manifest = manifest, + Path = pluginFolder + }); } + + // Check for missing dependencies + var pluginsToNotLoad = new List(); + + foreach (var plugin in Plugins) + { + foreach (var dependency in plugin.Manifest.Dependencies) + { + // Check if dependency is found + if (Plugins.Any(x => x.Manifest.Id == dependency)) + continue; + + Logger.LogError( + "Unable to load plugin '{id}' ({path}) because the dependency {dependency} is missing", + plugin.Manifest.Id, + plugin.Path, + dependency + ); + + pluginsToNotLoad.Add(plugin.Manifest.Id); + break; + } + } + + // Remove unloadable plugins from cache + Plugins.RemoveAll(x => pluginsToNotLoad.Contains(x.Manifest.Id)); } - public Task> GetBinariesBySection(string section) + public Dictionary GetAssemblies(string section) { - var bins = Plugins - .Values - .Where(x => x.Binaries.ContainsKey(section)) - .ToDictionary(x => x.Id, x => x.Binaries[section]); + var pathMappings = new Dictionary(); - return Task.FromResult(bins); + foreach (var plugin in Plugins) + { + foreach (var file in Directory.EnumerateFiles(PathBuilder.Dir(plugin.Path, "bin", section))) + { + if(!file.EndsWith(".dll")) + continue; + + var fileName = Path.GetFileName(file); + pathMappings[fileName] = file; + } + } + + return pathMappings; } - public Task GetBinaryStream(string plugin, string section, string fileName) + public string[] GetEntrypoints(string section) { - if (Plugins.All(x => x.Value.Id != plugin)) - return Task.FromResult(null); - - var pluginData = Plugins.First(x => x.Value.Id == plugin); - var binaryPath = PathBuilder.File(pluginData.Key, section, fileName); - - if (!File.Exists(binaryPath)) - return Task.FromResult(null); - - var fs = File.OpenRead(binaryPath); - return Task.FromResult(fs); + return Plugins + .Where(x => x.Manifest.Entrypoints.ContainsKey(section)) + .SelectMany(x => x.Manifest.Entrypoints[section]) + .ToArray(); } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Startup.cs b/Moonlight.ApiServer/Startup.cs index b4a87a4d..e6245733 100644 --- a/Moonlight.ApiServer/Startup.cs +++ b/Moonlight.ApiServer/Startup.cs @@ -14,6 +14,7 @@ using MoonCore.Extended.OAuth2.LocalProvider.Implementations; using MoonCore.Extensions; using MoonCore.Helpers; using MoonCore.PluginFramework.Extensions; +using MoonCore.Plugins; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Helpers; @@ -31,7 +32,7 @@ public static class Startup { public static async Task Main(string[] args) => await Run(args, []); - + public static async Task Run(string[] args, Assembly[]? additionalAssemblies = null) { // Cry about it @@ -51,7 +52,7 @@ public static class Startup Console.WriteLine(); -// Storage i guess + // Storage i guess Directory.CreateDirectory(PathBuilder.Dir("storage")); // Configure startup logger @@ -69,8 +70,15 @@ public static class Startup var startupLogger = startupLoggerFactory.CreateLogger("Startup"); - //TODO: Load plugin - + // Load plugins + var pluginService = new PluginService( + startupLoggerFactory.CreateLogger() + ); + + await pluginService.Load(); + + var pluginAssemblies = await LoadPlugins(pluginService, startupLoggerFactory); + // Configure startup interfaces var startupServiceCollection = new ServiceCollection(); @@ -93,11 +101,11 @@ public static class Startup // Configure assemblies to scan configuration.AddAssembly(typeof(Startup).Assembly); - - if(additionalAssemblies != null) + + if (additionalAssemblies != null) configuration.AddAssemblies(additionalAssemblies); - - //configuration.AddAssemblies(moduleAssemblies); + + configuration.AddAssemblies(pluginAssemblies); }); @@ -107,7 +115,7 @@ public static class Startup var config = startupServiceProvider.GetRequiredService(); ApplicationStateHelper.SetConfiguration(config); -// Start the actual app + // Start the actual app var builder = WebApplication.CreateBuilder(args); await ConfigureLogging(builder); @@ -118,7 +126,7 @@ public static class Startup startupServiceProvider.GetRequiredService() ); -// Call interfaces + // Call interfaces foreach (var startupInterface in appStartupInterfaces) { try @@ -136,15 +144,18 @@ public static class Startup } var controllerBuilder = builder.Services.AddControllers(); - + // Add current assemblies to the application part - //foreach (var moduleAssembly in moduleAssemblies) - // controllerBuilder.AddApplicationPart(moduleAssembly); - + foreach (var moduleAssembly in pluginAssemblies) + controllerBuilder.AddApplicationPart(moduleAssembly); + builder.Services.AddSingleton(config); + builder.Services.AddSingleton(pluginService); builder.Services.AutoAddServices(typeof(Startup).Assembly); builder.Services.AddHttpClient(); + await ConfigureCaching(builder, startupLogger, config); + await ConfigureOAuth2(builder, startupLogger, config); // Implementation service @@ -154,11 +165,11 @@ public static class Startup configuration.AddInterface(); configuration.AddAssembly(typeof(Startup).Assembly); - - if(additionalAssemblies != null) + + if (additionalAssemblies != null) configuration.AddAssemblies(additionalAssemblies); - - //configuration.AddAssemblies(moduleAssemblies); + + configuration.AddAssemblies(pluginAssemblies); }); var app = builder.Build(); @@ -180,7 +191,7 @@ public static class Startup await UseOAuth2(app); -// Call interfaces + // Call interfaces foreach (var startupInterface in appStartupInterfaces) { try @@ -201,7 +212,7 @@ public static class Startup app.UseMiddleware(); -// Call interfaces + // Call interfaces var endpointStartupInterfaces = startupServiceProvider.GetRequiredService(); foreach (var endpointStartup in endpointStartupInterfaces) @@ -332,7 +343,7 @@ public static class Startup builder.Services.AddScoped, LocalOAuth2Provider>(); builder.Services.AddScoped, LocalOAuth2Provider>(); } - + return Task.CompletedTask; } @@ -347,4 +358,34 @@ public static class Startup } #endregion + + #region Caching + + public static Task ConfigureCaching(WebApplicationBuilder builder, ILogger logger, AppConfiguration configuration) + { + builder.Services.AddMemoryCache(); + return Task.CompletedTask; + } + + #endregion + + #region Plugin loading + + private static async Task LoadPlugins(PluginService pluginService, ILoggerFactory loggerFactory) + { + var pluginLoader = new PluginLoaderService( + loggerFactory.CreateLogger() + ); + + var assemblyFiles = pluginService.GetAssemblies("apiServer").Values.ToArray(); + var entrypoints = pluginService.GetEntrypoints("apiServer"); + + pluginLoader.AddFilesSource(assemblyFiles, entrypoints); + + await pluginLoader.Load(); + + return pluginLoader.PluginAssemblies; + } + + #endregion } \ No newline at end of file diff --git a/Moonlight.Client/Moonlight.Client.csproj b/Moonlight.Client/Moonlight.Client.csproj index e2197adc..aa913cc9 100644 --- a/Moonlight.Client/Moonlight.Client.csproj +++ b/Moonlight.Client/Moonlight.Client.csproj @@ -22,7 +22,7 @@ - + diff --git a/Moonlight.Client/Program.cs b/Moonlight.Client/Program.cs deleted file mode 100644 index c30ad99e..00000000 --- a/Moonlight.Client/Program.cs +++ /dev/null @@ -1,3 +0,0 @@ -using Moonlight.Client; - -await Startup.Run(args, []); \ No newline at end of file diff --git a/Moonlight.Client/Startup.cs b/Moonlight.Client/Startup.cs index ece86d30..af7e9e15 100644 --- a/Moonlight.Client/Startup.cs +++ b/Moonlight.Client/Startup.cs @@ -9,6 +9,7 @@ using MoonCore.Blazor.Tailwind.Forms.Components; using MoonCore.Extensions; using MoonCore.Helpers; using MoonCore.PluginFramework.Extensions; +using MoonCore.Plugins; using Moonlight.Client.Interfaces; using Moonlight.Client.Services; using Moonlight.Client.UI; @@ -18,6 +19,9 @@ namespace Moonlight.Client; public class Startup { + public static async Task Main(string[] args) + => await Run(args, []); + public static async Task Run(string[] args, Assembly[] assemblies) { // Build pre run logger @@ -49,6 +53,16 @@ public class Startup // Building app var builder = WebAssemblyHostBuilder.CreateDefault(args); + + // Load plugins + var pluginLoader = new PluginLoaderService( + loggerFactory.CreateLogger() + ); + + pluginLoader.AddHttpHostedSource($"{builder.HostEnvironment.BaseAddress}api/pluginsStream"); + await pluginLoader.Load(); + + builder.Services.AddSingleton(pluginLoader); // Configure application logging builder.Logging.ClearProviders(); @@ -61,7 +75,7 @@ public class Startup builder.AddTokenAuthentication(); builder.AddOAuth2(); - + builder.Services.AddMoonCoreBlazorTailwind(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -76,7 +90,10 @@ public class Startup builder.Services.AddPlugins(configuration => { configuration.AddAssembly(typeof(Startup).Assembly); + configuration.AddAssemblies(assemblies); + + configuration.AddAssemblies(pluginLoader.PluginAssemblies); configuration.AddInterface(); configuration.AddInterface(); diff --git a/Moonlight.Client/UI/App.razor b/Moonlight.Client/UI/App.razor index e73c0755..fa3ba60d 100644 --- a/Moonlight.Client/UI/App.razor +++ b/Moonlight.Client/UI/App.razor @@ -1,9 +1,12 @@ @using Moonlight.Client.UI.Layouts @using MoonCore.Blazor.Components +@using MoonCore.Plugins + +@inject PluginLoaderService PluginLoaderService - +