From a579dd4759973de22079266f1b32fd4edbf14e8a Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Tue, 13 May 2025 20:48:50 +0200 Subject: [PATCH] Finished compile time plugin loading. Refactored plugin loading. Extended build helper script --- Moonlight.ApiServer/Dockerfile | 7 +- .../Http/Controllers/FrontendController.cs | 20 +- .../Implementations/Startup/CoreStartup.cs | 24 +- .../Interfaces/Startup/IPluginStartup.cs | 8 - .../Models/FrontendConfigurationOption.cs | 7 + .../Moonlight.ApiServer.csproj | 130 +++--- Moonlight.ApiServer/Plugins/IPluginStartup.cs | 8 + .../Plugins/PluginStartupAttribute.cs | 7 + .../Services/FrontendService.cs | 65 +-- Moonlight.ApiServer/Startup.cs | 135 ++---- .../Implementations/CoreStartup.cs | 5 +- Moonlight.Client/Interfaces/IPluginStartup.cs | 9 - Moonlight.Client/Moonlight.Client.csproj | 121 +++-- Moonlight.Client/Plugins/IPluginStartup.cs | 9 + .../Plugins/PluginStartupAttribute.cs | 7 + Moonlight.Client/Startup.cs | 107 ++--- Moonlight.Client/wwwroot/js/moonlight.js | 9 + Moonlight.Shared/Moonlight.Shared.csproj | 40 +- Resources/Scripts/Commands/PackCommand.cs | 370 ++++++++++++++++ Resources/Scripts/Commands/PreBuildCommand.cs | 414 ++++++++++++++++++ .../Scripts/Functions/ContentFunctions.cs | 60 --- Resources/Scripts/Functions/SrcFunctions.cs | 47 -- .../Functions/StaticWebAssetsFunctions.cs | 93 ---- Resources/Scripts/Functions/TagsFunctions.cs | 75 ---- Resources/Scripts/Helpers/CommandHelper.cs | 30 ++ .../Scripts/Helpers/StartupClassDetector.cs | 46 ++ Resources/Scripts/Program.cs | 42 +- Resources/Scripts/Scripts.csproj | 15 +- 28 files changed, 1169 insertions(+), 741 deletions(-) delete mode 100644 Moonlight.ApiServer/Interfaces/Startup/IPluginStartup.cs create mode 100644 Moonlight.ApiServer/Models/FrontendConfigurationOption.cs create mode 100644 Moonlight.ApiServer/Plugins/IPluginStartup.cs create mode 100644 Moonlight.ApiServer/Plugins/PluginStartupAttribute.cs delete mode 100644 Moonlight.Client/Interfaces/IPluginStartup.cs create mode 100644 Moonlight.Client/Plugins/IPluginStartup.cs create mode 100644 Moonlight.Client/Plugins/PluginStartupAttribute.cs create mode 100644 Resources/Scripts/Commands/PackCommand.cs create mode 100644 Resources/Scripts/Commands/PreBuildCommand.cs delete mode 100644 Resources/Scripts/Functions/ContentFunctions.cs delete mode 100644 Resources/Scripts/Functions/SrcFunctions.cs delete mode 100644 Resources/Scripts/Functions/StaticWebAssetsFunctions.cs delete mode 100644 Resources/Scripts/Functions/TagsFunctions.cs create mode 100644 Resources/Scripts/Helpers/CommandHelper.cs create mode 100644 Resources/Scripts/Helpers/StartupClassDetector.cs diff --git a/Moonlight.ApiServer/Dockerfile b/Moonlight.ApiServer/Dockerfile index ca790bd6..baa1e81f 100644 --- a/Moonlight.ApiServer/Dockerfile +++ b/Moonlight.ApiServer/Dockerfile @@ -1,4 +1,9 @@ -# Prepare runtime docker image +# +# OUTDATED +# Use https://github.com/Moonlight-Panel/Deploy +# + +# Prepare runtime docker image FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble-chiseled AS base WORKDIR /app diff --git a/Moonlight.ApiServer/Http/Controllers/FrontendController.cs b/Moonlight.ApiServer/Http/Controllers/FrontendController.cs index ddd519b3..721ab6a6 100644 --- a/Moonlight.ApiServer/Http/Controllers/FrontendController.cs +++ b/Moonlight.ApiServer/Http/Controllers/FrontendController.cs @@ -12,31 +12,13 @@ namespace Moonlight.ApiServer.Http.Controllers; public class FrontendController : Controller { private readonly FrontendService FrontendService; - private readonly PluginService PluginService; - public FrontendController(FrontendService frontendService, PluginService pluginService) + public FrontendController(FrontendService frontendService) { FrontendService = frontendService; - PluginService = pluginService; } [HttpGet("frontend.json")] public async Task GetConfiguration() => await FrontendService.GetConfiguration(); - - [HttpGet("plugins/{assemblyName}")] - public async Task GetPluginAssembly(string assemblyName) - { - var assembliesMap = PluginService.GetAssemblies("client"); - - if (!assembliesMap.TryGetValue(assemblyName, out var path)) - throw new HttpApiException("The requested assembly could not be found", 404); - - var absolutePath = Path.Combine( - Directory.GetCurrentDirectory(), - path - ); - - await Results.File(absolutePath).ExecuteAsync(HttpContext); - } } \ No newline at end of file diff --git a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs index 6ca274db..b5293345 100644 --- a/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs +++ b/Moonlight.ApiServer/Implementations/Startup/CoreStartup.cs @@ -1,24 +1,20 @@ using Microsoft.OpenApi.Models; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database; -using Moonlight.ApiServer.Interfaces.Startup; +using Moonlight.ApiServer.Plugins; namespace Moonlight.ApiServer.Implementations.Startup; +[PluginStartup] public class CoreStartup : IPluginStartup { - private readonly AppConfiguration Configuration; - - public CoreStartup(AppConfiguration configuration) - { - Configuration = configuration; - } - - public Task BuildApplication(IHostApplicationBuilder builder) + public Task BuildApplication(IServiceProvider serviceProvider, IHostApplicationBuilder builder) { + var configuration = serviceProvider.GetRequiredService(); + #region Api Docs - if (Configuration.Development.EnableApiDocs) + if (configuration.Development.EnableApiDocs) { builder.Services.AddEndpointsApiExplorer(); @@ -53,14 +49,16 @@ public class CoreStartup : IPluginStartup return Task.CompletedTask; } - public Task ConfigureApplication(IApplicationBuilder app) + public Task ConfigureApplication(IServiceProvider serviceProvider, IApplicationBuilder app) { return Task.CompletedTask; } - public Task ConfigureEndpoints(IEndpointRouteBuilder routeBuilder) + public Task ConfigureEndpoints(IServiceProvider serviceProvider, IEndpointRouteBuilder routeBuilder) { - if(Configuration.Development.EnableApiDocs) + var configuration = serviceProvider.GetRequiredService(); + + if(configuration.Development.EnableApiDocs) routeBuilder.MapSwagger("/api/swagger/{documentName}"); return Task.CompletedTask; diff --git a/Moonlight.ApiServer/Interfaces/Startup/IPluginStartup.cs b/Moonlight.ApiServer/Interfaces/Startup/IPluginStartup.cs deleted file mode 100644 index c0735523..00000000 --- a/Moonlight.ApiServer/Interfaces/Startup/IPluginStartup.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Moonlight.ApiServer.Interfaces.Startup; - -public interface IPluginStartup -{ - public Task BuildApplication(IHostApplicationBuilder builder); - public Task ConfigureApplication(IApplicationBuilder app); - public Task ConfigureEndpoints(IEndpointRouteBuilder routeBuilder); -} \ No newline at end of file diff --git a/Moonlight.ApiServer/Models/FrontendConfigurationOption.cs b/Moonlight.ApiServer/Models/FrontendConfigurationOption.cs new file mode 100644 index 00000000..343a98d2 --- /dev/null +++ b/Moonlight.ApiServer/Models/FrontendConfigurationOption.cs @@ -0,0 +1,7 @@ +namespace Moonlight.ApiServer.Models; + +public class FrontendConfigurationOption +{ + public string[] Scripts { get; set; } = []; + public string[] Styles { get; set; } = []; +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Moonlight.ApiServer.csproj b/Moonlight.ApiServer/Moonlight.ApiServer.csproj index 0b648e2f..a3c03697 100644 --- a/Moonlight.ApiServer/Moonlight.ApiServer.csproj +++ b/Moonlight.ApiServer/Moonlight.ApiServer.csproj @@ -1,70 +1,62 @@ + - - - net8.0 - enable - enable - Linux - - - - - - - - - - - - - - .dockerignore - false - - - - - - Moonlight.ApiServer - 2.1.0 - Moonlight Panel - A build of the api server for moonlight development - https://github.com/Moonlight-Panel/Moonlight - true - true - true - - - - - - - - - - - - - - - - - - - - true - src - Never - - - true - src - Never - - - - - - - - + + net8.0 + enable + enable + Linux + apiserver + + + + + + + + + + + .dockerignore + false + + + + Moonlight.ApiServer + 2.1.0 + Moonlight Panel + A build of the api server for moonlight development + https://github.com/Moonlight-Panel/Moonlight + true + true + true + + + + + + + + + + + + + + + + + + true + src + Never + + + true + src + Never + + + + + + \ No newline at end of file diff --git a/Moonlight.ApiServer/Plugins/IPluginStartup.cs b/Moonlight.ApiServer/Plugins/IPluginStartup.cs new file mode 100644 index 00000000..92eac50a --- /dev/null +++ b/Moonlight.ApiServer/Plugins/IPluginStartup.cs @@ -0,0 +1,8 @@ +namespace Moonlight.ApiServer.Plugins; + +public interface IPluginStartup +{ + public Task BuildApplication(IServiceProvider serviceProvider, IHostApplicationBuilder builder); + public Task ConfigureApplication(IServiceProvider serviceProvider, IApplicationBuilder app); + public Task ConfigureEndpoints(IServiceProvider serviceProvider, IEndpointRouteBuilder routeBuilder); +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Plugins/PluginStartupAttribute.cs b/Moonlight.ApiServer/Plugins/PluginStartupAttribute.cs new file mode 100644 index 00000000..d38a0555 --- /dev/null +++ b/Moonlight.ApiServer/Plugins/PluginStartupAttribute.cs @@ -0,0 +1,7 @@ +namespace Moonlight.ApiServer.Plugins; + +[AttributeUsage(AttributeTargets.Class)] +public class PluginStartupAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/Moonlight.ApiServer/Services/FrontendService.cs b/Moonlight.ApiServer/Services/FrontendService.cs index 936d6f2b..cf478f82 100644 --- a/Moonlight.ApiServer/Services/FrontendService.cs +++ b/Moonlight.ApiServer/Services/FrontendService.cs @@ -6,6 +6,7 @@ using MoonCore.Attributes; using MoonCore.Exceptions; using MoonCore.Helpers; using Moonlight.ApiServer.Configuration; +using Moonlight.ApiServer.Models; using Moonlight.Shared.Misc; namespace Moonlight.ApiServer.Services; @@ -14,18 +15,18 @@ namespace Moonlight.ApiServer.Services; public class FrontendService { private readonly AppConfiguration Configuration; - private readonly PluginService PluginService; private readonly IWebHostEnvironment WebHostEnvironment; + private readonly IEnumerable ConfigurationOptions; public FrontendService( AppConfiguration configuration, - PluginService pluginService, - IWebHostEnvironment webHostEnvironment + IWebHostEnvironment webHostEnvironment, + IEnumerable configurationOptions ) { Configuration = configuration; - PluginService = pluginService; WebHostEnvironment = webHostEnvironment; + ConfigurationOptions = configurationOptions; } public async Task GetConfiguration() @@ -48,33 +49,15 @@ public class FrontendService .Deserialize>(variablesJson) ?? new(); } - // Collect assemblies for the 'client' section - configuration.Assemblies = PluginService - .GetAssemblies("client") - .Keys - .ToArray(); - // Collect scripts to execute - configuration.Scripts = PluginService - .LoadedPlugins - .Keys + configuration.Scripts = ConfigurationOptions .SelectMany(x => x.Scripts) .ToArray(); // Collect styles - var styles = new List(); - - styles.AddRange( - PluginService - .LoadedPlugins - .Keys - .SelectMany(x => x.Styles) - ); - - // Add bundle css - styles.Add("css/bundle.min.css"); - - configuration.Styles = styles.ToArray(); + configuration.Styles = ConfigurationOptions + .SelectMany(x => x.Styles) + .ToArray(); return configuration; } @@ -111,42 +94,12 @@ public class FrontendService // Add blazor files await ArchiveFsItem(zipArchive, blazorPath, blazorPath, "_framework/"); - // Add bundle.css - var bundleContent = await File.ReadAllBytesAsync(Path.Combine("storage", "tmp", "bundle.css")); - await ArchiveBytes(zipArchive, "css/bundle.css", bundleContent); - // Add frontend.json var frontendConfig = await GetConfiguration(); frontendConfig.HostEnvironment = "Static"; var frontendJson = JsonSerializer.Serialize(frontendConfig); await ArchiveText(zipArchive, "frontend.json", frontendJson); - // Add plugin wwwroot files - foreach (var pluginPath in PluginService.LoadedPlugins.Values) - { - var wwwRootPluginPath = Path.Combine(pluginPath, "wwwroot/"); - - if (!Directory.Exists(wwwRootPluginPath)) - continue; - - await ArchiveFsItem(zipArchive, wwwRootPluginPath, wwwRootPluginPath); - } - - // Add plugin assemblies for client to the zip file - var assembliesMap = PluginService.GetAssemblies("client"); - - foreach (var assemblyName in assembliesMap.Keys) - { - var path = assembliesMap[assemblyName]; - - await ArchiveFsItem( - zipArchive, - path, - path, - $"plugins/{assemblyName}" - ); - } - // Finish zip archive and reset stream so the code calling this function can process it zipArchive.Dispose(); await memoryStream.FlushAsync(); diff --git a/Moonlight.ApiServer/Startup.cs b/Moonlight.ApiServer/Startup.cs index baf2ee51..c37f9343 100644 --- a/Moonlight.ApiServer/Startup.cs +++ b/Moonlight.ApiServer/Startup.cs @@ -1,13 +1,10 @@ -using System.Reflection; using System.Runtime.Loader; using System.Text; using System.Text.Json; using Hangfire; using Hangfire.EntityFrameworkCore; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; -using MoonCore.Configuration; using MoonCore.EnvConfiguration; using MoonCore.Extended.Abstractions; using MoonCore.Extended.Extensions; @@ -15,15 +12,14 @@ using MoonCore.Extended.Helpers; using MoonCore.Extended.JwtInvalidation; using MoonCore.Extensions; using MoonCore.Helpers; -using MoonCore.Services; using Moonlight.ApiServer.Configuration; using Moonlight.ApiServer.Database; using Moonlight.ApiServer.Database.Entities; using Moonlight.ApiServer.Helpers; using Moonlight.ApiServer.Implementations; +using Moonlight.ApiServer.Implementations.Startup; using Moonlight.ApiServer.Interfaces; -using Moonlight.ApiServer.Interfaces.Startup; -using Moonlight.ApiServer.Models; +using Moonlight.ApiServer.Plugins; using Moonlight.ApiServer.Services; namespace Moonlight.ApiServer; @@ -34,8 +30,6 @@ namespace Moonlight.ApiServer; public class Startup { private string[] Args; - private Assembly[] AdditionalAssemblies; - private PluginManifest[] AdditionalPluginManifests; // Logging private ILoggerProvider[] LoggerProviders; @@ -51,24 +45,20 @@ public class Startup private WebApplicationBuilder WebApplicationBuilder; // Plugin Loading - private PluginService PluginService; - private AssemblyLoadContext PluginLoadContext; - private IPluginStartup[] PluginStartups; + private IPluginStartup[] AdditionalPlugins; + private IServiceProvider PluginLoadServiceProvider; - public async Task Run(string[] args, Assembly[]? additionalAssemblies = null, - PluginManifest[]? additionalManifests = null) + public async Task Run(string[] args, IPluginStartup[]? additionalPlugins = null) { Args = args; - AdditionalAssemblies = additionalAssemblies ?? []; - AdditionalPluginManifests = additionalManifests ?? []; + AdditionalPlugins = additionalPlugins ?? []; await PrintVersion(); await CreateStorage(); await SetupAppConfiguration(); await SetupLogging(); - await LoadPlugins(); await InitializePlugins(); await CreateWebApplicationBuilder(); @@ -139,17 +129,13 @@ public class Startup // Add pre-existing services WebApplicationBuilder.Services.AddSingleton(Configuration); - WebApplicationBuilder.Services.AddSingleton(PluginService); // Configure controllers var mvcBuilder = WebApplicationBuilder.Services.AddControllers(); - // Add plugin and additional assemblies as application parts - foreach (var pluginAssembly in PluginLoadContext.Assemblies) - mvcBuilder.AddApplicationPart(pluginAssembly); - - foreach (var additionalAssembly in AdditionalAssemblies) - mvcBuilder.AddApplicationPart(additionalAssembly); + // Add plugin assemblies as application parts + foreach (var pluginStartup in PluginStartups.Select(x => x.GetType().Assembly).Distinct()) + mvcBuilder.AddApplicationPart(pluginStartup.GetType().Assembly); return Task.CompletedTask; } @@ -199,90 +185,34 @@ public class Startup #region Plugin Loading - private async Task LoadPlugins() - { - // Load plugins - PluginService = new PluginService( - LoggerFactory.CreateLogger() - ); - - // Add plugins manually if specified in the startup - foreach (var manifest in AdditionalPluginManifests) - PluginService.LoadedPlugins.Add(manifest, Directory.GetCurrentDirectory()); - - // Search and load all plugins - await PluginService.Load(); - - // Search up assemblies for the apiServer - var assemblyFiles = PluginService.GetAssemblies("apiServer") - .Values - .ToArray(); - - // Create the load context and add assemblies - PluginLoadContext = new AssemblyLoadContext(null); - - foreach (var assemblyFile in assemblyFiles) - { - try - { - PluginLoadContext.LoadFromAssemblyPath( - Path.Combine(Directory.GetCurrentDirectory(), assemblyFile) - ); - } - catch (Exception e) - { - Logger.LogError("Unable to load plugin assembly '{assemblyFile}': {e}", assemblyFile, e); - } - } - } - private Task InitializePlugins() { - // Define minimal service collection - var startupSc = new ServiceCollection(); + // Create service provider for starting up + var serviceCollection = new ServiceCollection(); - // Configure base services for initialisation - startupSc.AddSingleton(Configuration); - - startupSc.AddLogging(builder => + serviceCollection.AddSingleton(Configuration); + + serviceCollection.AddLogging(builder => { builder.ClearProviders(); builder.AddProviders(LoggerProviders); }); - // - var startupSp = startupSc.BuildServiceProvider(); + PluginLoadServiceProvider = serviceCollection.BuildServiceProvider(); + + // Collect startups + var pluginStartups = new List(); + + pluginStartups.Add(new CoreStartup()); + + pluginStartups.AddRange(AdditionalPlugins); // Used by the development server + + // Do NOT remove the following comment, as its used to place the plugin startup register calls + // MLBUILD_PLUGIN_STARTUP_HERE - // Initialize plugin startups - var startups = new List(); - var startupType = typeof(IPluginStartup); - - var assembliesToScan = new List(); - - assembliesToScan.Add(typeof(Startup).Assembly); - assembliesToScan.AddRange(PluginLoadContext.Assemblies); - assembliesToScan.AddRange(AdditionalAssemblies); - - foreach (var pluginAssembly in assembliesToScan) - { - var startupTypes = pluginAssembly - .ExportedTypes - .Where(x => !x.IsAbstract && !x.IsInterface && x.IsAssignableTo(startupType)) - .ToArray(); - - foreach (var type in startupTypes) - { - var startup = ActivatorUtilities.CreateInstance(startupSp, type) as IPluginStartup; - - if (startup == null) - continue; - - startups.Add(startup); - } - } - - PluginStartups = startups.ToArray(); + PluginStartups = pluginStartups.ToArray(); + return Task.CompletedTask; } @@ -298,11 +228,6 @@ public class Startup FileProvider = new BundleAssetFileProvider() }); - WebApplication.UseStaticFiles(new StaticFileOptions() - { - FileProvider = PluginService.WwwRootFileProvider - }); - return Task.CompletedTask; } @@ -314,7 +239,7 @@ public class Startup { try { - await pluginAppStartup.BuildApplication(WebApplicationBuilder); + await pluginAppStartup.BuildApplication(PluginLoadServiceProvider, WebApplicationBuilder); } catch (Exception e) { @@ -333,7 +258,7 @@ public class Startup { try { - await pluginAppStartup.ConfigureApplication(WebApplication); + await pluginAppStartup.ConfigureApplication(PluginLoadServiceProvider, WebApplication); } catch (Exception e) { @@ -352,7 +277,7 @@ public class Startup { try { - await pluginEndpointStartup.ConfigureEndpoints(WebApplication); + await pluginEndpointStartup.ConfigureEndpoints(PluginLoadServiceProvider, WebApplication); } catch (Exception e) { diff --git a/Moonlight.Client/Implementations/CoreStartup.cs b/Moonlight.Client/Implementations/CoreStartup.cs index 248d45ce..55005986 100644 --- a/Moonlight.Client/Implementations/CoreStartup.cs +++ b/Moonlight.Client/Implementations/CoreStartup.cs @@ -1,11 +1,12 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Moonlight.Client.Interfaces; +using Moonlight.Client.Plugins; namespace Moonlight.Client.Implementations; public class CoreStartup : IPluginStartup { - public Task BuildApplication(WebAssemblyHostBuilder builder) + public Task BuildApplication(IServiceProvider serviceProvider, WebAssemblyHostBuilder builder) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -13,6 +14,6 @@ public class CoreStartup : IPluginStartup return Task.CompletedTask; } - public Task ConfigureApplication(WebAssemblyHost app) + public Task ConfigureApplication(IServiceProvider serviceProvider, WebAssemblyHost app) => Task.CompletedTask; } \ No newline at end of file diff --git a/Moonlight.Client/Interfaces/IPluginStartup.cs b/Moonlight.Client/Interfaces/IPluginStartup.cs deleted file mode 100644 index 3d084ed1..00000000 --- a/Moonlight.Client/Interfaces/IPluginStartup.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; - -namespace Moonlight.Client.Interfaces; - -public interface IPluginStartup -{ - public Task BuildApplication(WebAssemblyHostBuilder builder); - public Task ConfigureApplication(WebAssemblyHost app); -} \ No newline at end of file diff --git a/Moonlight.Client/Moonlight.Client.csproj b/Moonlight.Client/Moonlight.Client.csproj index fdcf16e2..cb4db178 100644 --- a/Moonlight.Client/Moonlight.Client.csproj +++ b/Moonlight.Client/Moonlight.Client.csproj @@ -1,71 +1,62 @@ + - - - false - net8.0 - enable - enable - Linux - + + false + net8.0 + enable + enable + Linux + **\bin\**;**\obj\**;**\node_modules\**;**\Styles\*.json - True - - - - - Moonlight.Client - 2.1.0 - Moonlight Panel - A build of the client for moonlight development - https://github.com/Moonlight-Panel/Moonlight - true - true - true - - - - - - - - - - - - - - - - true - src - Never - - - true - styles - Never - - - - - - - - - - service-worker-assets.js - - - - - - - - - - - + + service-worker-assets.js + + + + + + + + \ No newline at end of file diff --git a/Moonlight.Client/Plugins/IPluginStartup.cs b/Moonlight.Client/Plugins/IPluginStartup.cs new file mode 100644 index 00000000..68f5532e --- /dev/null +++ b/Moonlight.Client/Plugins/IPluginStartup.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +namespace Moonlight.Client.Plugins; + +public interface IPluginStartup +{ + public Task BuildApplication(IServiceProvider serviceProvider, WebAssemblyHostBuilder builder); + public Task ConfigureApplication(IServiceProvider serviceProvider, WebAssemblyHost app); +} \ No newline at end of file diff --git a/Moonlight.Client/Plugins/PluginStartupAttribute.cs b/Moonlight.Client/Plugins/PluginStartupAttribute.cs new file mode 100644 index 00000000..d55d5491 --- /dev/null +++ b/Moonlight.Client/Plugins/PluginStartupAttribute.cs @@ -0,0 +1,7 @@ +namespace Moonlight.Client.Plugins; + +[AttributeUsage(AttributeTargets.Class)] +public class PluginStartupAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/Moonlight.Client/Startup.cs b/Moonlight.Client/Startup.cs index 5c8cb94a..b8166a16 100644 --- a/Moonlight.Client/Startup.cs +++ b/Moonlight.Client/Startup.cs @@ -9,7 +9,9 @@ using MoonCore.Blazor.Tailwind.Extensions; using MoonCore.Blazor.Tailwind.Auth; using MoonCore.Extensions; using MoonCore.Helpers; +using Moonlight.Client.Implementations; using Moonlight.Client.Interfaces; +using Moonlight.Client.Plugins; using Moonlight.Client.Services; using Moonlight.Shared.Misc; using Moonlight.Client.UI; @@ -33,15 +35,14 @@ public class Startup private WebAssemblyHost WebAssemblyHost; // Plugin Loading - private AssemblyLoadContext PluginLoadContext; - private Assembly[] AdditionalAssemblies; - + private IPluginStartup[] AdditionalPlugins; private IPluginStartup[] PluginStartups; + private IServiceProvider PluginLoadServiceProvider; - public async Task Run(string[] args, Assembly[]? additionalAssemblies = null) + public async Task Run(string[] args, IPluginStartup[]? additionalPlugins = null) { Args = args; - AdditionalAssemblies = additionalAssemblies ?? []; + AdditionalPlugins = additionalPlugins ?? []; await PrintVersion(); await SetupLogging(); @@ -49,7 +50,6 @@ public class Startup await CreateWebAssemblyHostBuilder(); await LoadConfiguration(); - await LoadPlugins(); await InitializePlugins(); await RegisterLogging(); @@ -94,7 +94,7 @@ public class Startup httpClient.BaseAddress = new Uri(WebAssemblyHostBuilder.HostEnvironment.BaseAddress); var jsonText = await httpClient.GetStringAsync("frontend.json"); - + Configuration = JsonSerializer.Deserialize(jsonText, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true @@ -120,7 +120,7 @@ public class Startup BaseAddress = new Uri(Configuration.ApiUrl) } ); - + WebAssemblyHostBuilder.Services.AddScoped(sp => { var httpClient = sp.GetRequiredService(); @@ -146,7 +146,7 @@ public class Startup WebAssemblyHostBuilder.Services.AddScoped(); WebAssemblyHostBuilder.Services.AddScoped(); - + WebAssemblyHostBuilder.Services.AutoAddServices(); return Task.CompletedTask; @@ -160,39 +160,15 @@ public class Startup foreach (var scriptName in Configuration.Scripts) await jsRuntime.InvokeVoidAsync("moonlight.assets.loadJavascript", scriptName); + + foreach (var styleName in Configuration.Styles) + await jsRuntime.InvokeVoidAsync("moonlight.assets.loadStylesheet", styleName); } #endregion #region Plugins - private async Task LoadPlugins() - { - // Create everything required to stream plugins - using var clientForStreaming = new HttpClient(); - - clientForStreaming.BaseAddress = new Uri(Configuration.HostEnvironment == "ApiServer" - ? Configuration.ApiUrl - : WebAssemblyHostBuilder.HostEnvironment.BaseAddress - ); - - PluginLoadContext = new AssemblyLoadContext(null); - - foreach (var assembly in Configuration.Assemblies) - { - var assemblyStream = await clientForStreaming.GetStreamAsync($"plugins/{assembly}"); - PluginLoadContext.LoadFromStream(assemblyStream); - } - - // Add application assembly service - var appAssemblyService = new ApplicationAssemblyService(); - - appAssemblyService.Assemblies.AddRange(AdditionalAssemblies); - appAssemblyService.Assemblies.AddRange(PluginLoadContext.Assemblies); - - WebAssemblyHostBuilder.Services.AddSingleton(appAssemblyService); - } - private Task InitializePlugins() { // Define minimal service collection @@ -205,38 +181,31 @@ public class Startup builder.AddProviders(LoggerProviders); }); - // - var startupSp = startupSc.BuildServiceProvider(); - - // Initialize plugin startups - var startups = new List(); - var startupType = typeof(IPluginStartup); - - var assembliesToScan = new List(); - - assembliesToScan.Add(typeof(Startup).Assembly); - assembliesToScan.AddRange(AdditionalAssemblies); - assembliesToScan.AddRange(PluginLoadContext.Assemblies); + PluginLoadServiceProvider = startupSc.BuildServiceProvider(); - foreach (var pluginAssembly in assembliesToScan) - { - var startupTypes = pluginAssembly - .ExportedTypes - .Where(x => !x.IsAbstract && !x.IsInterface && x.IsAssignableTo(startupType)) - .ToArray(); + // Collect startups + var pluginStartups = new List(); - foreach (var type in startupTypes) - { - var startup = ActivatorUtilities.CreateInstance(startupSp, type) as IPluginStartup; - - if(startup == null) - continue; - - startups.Add(startup); - } - } + pluginStartups.Add(new CoreStartup()); - PluginStartups = startups.ToArray(); + pluginStartups.AddRange(AdditionalPlugins); // Used by the development server + + // Do NOT remove the following comment, as its used to place the plugin startup register calls + // MLBUILD_PLUGIN_STARTUP_HERE + + + PluginStartups = pluginStartups.ToArray(); + + // Add application assembly service + var appAssemblyService = new ApplicationAssemblyService(); + + appAssemblyService.Assemblies.AddRange( + PluginStartups + .Select(x => x.GetType().Assembly) + .Distinct() + ); + + WebAssemblyHostBuilder.Services.AddSingleton(appAssemblyService); return Task.CompletedTask; } @@ -249,7 +218,7 @@ public class Startup { try { - await pluginAppStartup.BuildApplication(WebAssemblyHostBuilder); + await pluginAppStartup.BuildApplication(PluginLoadServiceProvider, WebAssemblyHostBuilder); } catch (Exception e) { @@ -268,7 +237,7 @@ public class Startup { try { - await pluginAppStartup.ConfigureApplication(WebAssemblyHost); + await pluginAppStartup.ConfigureApplication(PluginLoadServiceProvider, WebAssemblyHost); } catch (Exception e) { @@ -336,9 +305,9 @@ public class Startup { WebAssemblyHostBuilder.Services.AddAuthorizationCore(); WebAssemblyHostBuilder.Services.AddCascadingAuthenticationState(); - + WebAssemblyHostBuilder.Services.AddAuthenticationStateManager(); - + return Task.CompletedTask; } diff --git a/Moonlight.Client/wwwroot/js/moonlight.js b/Moonlight.Client/wwwroot/js/moonlight.js index fae07bd5..a57ec020 100644 --- a/Moonlight.Client/wwwroot/js/moonlight.js +++ b/Moonlight.Client/wwwroot/js/moonlight.js @@ -34,6 +34,15 @@ window.moonlight = { scriptElement.type = 'text/javascript'; (document.head || document.documentElement).appendChild(scriptElement); + }, + loadStylesheet: function (url) { + let linkElement = document.createElement('link'); + + linkElement.href = url; + linkElement.type = 'text/css'; + linkElement.rel = 'stylesheet'; + + (document.head || document.documentElement).appendChild(linkElement); } } }; \ No newline at end of file diff --git a/Moonlight.Shared/Moonlight.Shared.csproj b/Moonlight.Shared/Moonlight.Shared.csproj index 69f33b08..84b8261b 100644 --- a/Moonlight.Shared/Moonlight.Shared.csproj +++ b/Moonlight.Shared/Moonlight.Shared.csproj @@ -1,20 +1,20 @@ - - - - net8.0 - enable - enable - - - - Moonlight.Shared - 2.1.0 - Moonlight Panel - A build of the shared classes for moonlight development - https://github.com/Moonlight-Panel/Moonlight - true - true - true - - - + + + + net8.0 + enable + enable + Moonlight.Shared + shared + + + Moonlight.Shared + 2.1.0 + Moonlight Panel + A build of the shared classes for moonlight development + https://github.com/Moonlight-Panel/Moonlight + true + true + true + + \ No newline at end of file diff --git a/Resources/Scripts/Commands/PackCommand.cs b/Resources/Scripts/Commands/PackCommand.cs new file mode 100644 index 00000000..e0121d89 --- /dev/null +++ b/Resources/Scripts/Commands/PackCommand.cs @@ -0,0 +1,370 @@ +using System.IO.Compression; +using System.Xml.Linq; +using Cocona; +using Scripts.Helpers; + +namespace Scripts.Commands; + +public class PackCommand +{ + private readonly CommandHelper CommandHelper; + private readonly string TmpDir = "/tmp/mlbuild"; + + public PackCommand(CommandHelper commandHelper) + { + CommandHelper = commandHelper; + } + + [Command("pack", Description = "Packs the specified folder/solution into nuget packages")] + public async Task Pack( + [Argument] string solutionDirectory, + [Argument] string outputLocation, + [Option] string buildConfiguration = "Debug" + ) + { + if (!Directory.Exists(solutionDirectory)) + { + Console.WriteLine("The specified solution directory does not exist"); + return; + } + + if (!Directory.Exists(outputLocation)) + Directory.CreateDirectory(outputLocation); + + if (Directory.Exists(TmpDir)) + Directory.Delete(TmpDir, true); + + Directory.CreateDirectory(TmpDir); + + // Find the project files + Console.WriteLine("Searching for projects inside the specified folder"); + var csProjFiles = Directory.GetFiles(solutionDirectory, "*csproj", SearchOption.AllDirectories); + + // Show the user + Console.WriteLine($"Found {csProjFiles.Length} project(s) to check:"); + + foreach (var csProjFile in csProjFiles) + Console.WriteLine($"- {Path.GetFullPath(csProjFile)}"); + + // Filter out project files which have specific tags specified + Console.WriteLine("Filtering projects by tags"); + + List apiServerProjects = []; + List frontendProjects = []; + List sharedProjects = []; + + foreach (var csProjFile in csProjFiles) + { + await using var fs = File.Open( + csProjFile, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); + + var document = await XDocument.LoadAsync(fs, LoadOptions.None, CancellationToken.None); + fs.Close(); + + // Search for tag definitions + var packageTagsElements = document.Descendants("PackageTags").ToArray(); + + if (packageTagsElements.Length == 0) + { + Console.WriteLine($"No package tags found in {Path.GetFullPath(csProjFile)}. Skipping it when packing"); + continue; + } + + var packageTags = packageTagsElements.First().Value; + + if (packageTags.Contains("apiserver", StringComparison.InvariantCultureIgnoreCase)) + apiServerProjects.Add(csProjFile); + + if (packageTags.Contains("frontend", StringComparison.InvariantCultureIgnoreCase)) + frontendProjects.Add(csProjFile); + + if (packageTags.Contains("shared", StringComparison.InvariantCultureIgnoreCase)) + sharedProjects.Add(csProjFile); + } + + Console.WriteLine( + $"Found {apiServerProjects.Count} api server project(s), {frontendProjects.Count} frontend project(s) and {sharedProjects.Count} shared project(s)"); + + // Now build all these projects so we can pack them + Console.WriteLine("Building and packing api server project(s)"); + + foreach (var apiServerProject in apiServerProjects) + { + await BuildProject( + apiServerProject, + buildConfiguration + ); + + var nugetFilePath = await PackProject( + apiServerProject, + TmpDir, + buildConfiguration + ); + + var nugetPackage = ZipFile.Open( + nugetFilePath, + ZipArchiveMode.Update + ); + + await RemoveContentFiles(nugetPackage); + + await CleanDependencies(nugetPackage); + + Console.WriteLine("Finishing package and copying to output directory"); + + nugetPackage.Dispose(); + File.Move(nugetFilePath, Path.Combine(outputLocation, Path.GetFileName(nugetFilePath)), true); + } + + Console.WriteLine("Building and packing frontend projects"); + + foreach (var frontendProject in frontendProjects) + { + await BuildProject( + frontendProject, + buildConfiguration + ); + + var nugetFilePath = await PackProject( + frontendProject, + TmpDir, + buildConfiguration + ); + + var nugetPackage = ZipFile.Open( + nugetFilePath, + ZipArchiveMode.Update + ); + + foreach (var entry in nugetPackage.Entries.ToArray()) + { + if (!entry.FullName.StartsWith("staticwebassets/_framework")) + continue; + + Console.WriteLine($"Removing framework file: {entry.FullName}"); + entry.Delete(); + } + + var buildTargetEntry = nugetPackage.Entries.FirstOrDefault(x => + x.FullName == "build/Microsoft.AspNetCore.StaticWebAssets.props" + ); + + if (buildTargetEntry != null) + { + Console.WriteLine("Removing framework file references"); + + await ModifyXmlInPackage(nugetPackage, buildTargetEntry, + document => document + .Descendants("StaticWebAsset") + .Where(x => + { + var relativePath = x.Element("RelativePath")!.Value; + + if (relativePath.StartsWith("_framework")) + return true; + + if (relativePath.StartsWith("css/style.min.css")) + return true; + + return false; + }) + ); + } + + await CleanDependencies(nugetPackage); + + await RemoveContentFiles(nugetPackage); + + // Pack razor and html files into src folder + var additionalSrcFiles = new List(); + var basePath = Path.GetDirectoryName(frontendProject)!; + + additionalSrcFiles.AddRange( + Directory.GetFiles(basePath, "*.razor", SearchOption.AllDirectories) + ); + + additionalSrcFiles.AddRange( + Directory.GetFiles(basePath, "index.html", SearchOption.AllDirectories) + ); + + foreach (var additionalSrcFile in additionalSrcFiles) + { + var relativePath = "src/" + additionalSrcFile.Replace(basePath, "").Trim('/'); + + Console.WriteLine($"Adding additional files as src: {relativePath}"); + + await using var fs = File.Open( + additionalSrcFile, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); + + var entry = nugetPackage.CreateEntry(relativePath); + await using var entryFs = entry.Open(); + + await fs.CopyToAsync(entryFs); + await entryFs.FlushAsync(); + + fs.Close(); + entryFs.Close(); + } + + Console.WriteLine("Finishing package and copying to output directory"); + + nugetPackage.Dispose(); + File.Move(nugetFilePath, Path.Combine(outputLocation, Path.GetFileName(nugetFilePath)), true); + } + + Console.WriteLine("Building and packing shared projects"); + + foreach (var sharedProject in sharedProjects) + { + await BuildProject( + sharedProject, + buildConfiguration + ); + + var nugetFilePath = await PackProject( + sharedProject, + TmpDir, + buildConfiguration + ); + + File.Move(nugetFilePath, Path.Combine(outputLocation, Path.GetFileName(nugetFilePath)), true); + } + } + + private async Task BuildProject(string file, string configuration) + { + var basePath = Path.GetFullPath(Path.GetDirectoryName(file)!); + var fileName = Path.GetFileName(file); + + await CommandHelper.Run( + "/usr/bin/dotnet", + $"build {fileName} --configuration {configuration}", + basePath + ); + } + + private async Task PackProject(string file, string output, string configuration) + { + var basePath = Path.GetFullPath(Path.GetDirectoryName(file)!); + var fileName = Path.GetFileName(file); + + await CommandHelper.Run( + "/usr/bin/dotnet", + $"pack {fileName} --output {output} --configuration {configuration}", + basePath + ); + + var nugetFilesPaths = Directory.GetFiles(TmpDir, "*.nupkg", SearchOption.TopDirectoryOnly); + + if (nugetFilesPaths.Length == 0) + throw new Exception("No nuget packages were built"); + + if (nugetFilesPaths.Length > 1) + throw new Exception("More than one nuget package has been built"); + + return nugetFilesPaths.First(); + } + + private async Task CleanDependencies(ZipArchive nugetPackage) + { + var nuspecEntry = nugetPackage.Entries.FirstOrDefault(x => x.Name.EndsWith(".nuspec")); + + if (nuspecEntry == null) + { + Console.WriteLine("No nuspec file to modify found in nuget package"); + return; + } + + await ModifyXmlInPackage(nugetPackage, nuspecEntry, document => + { + var ns = document.Root!.GetDefaultNamespace(); + + var metadata = document.Root!.Element(ns + "metadata")!; + + var id = metadata.Element(ns + "id")!.Value; + + // Skip the removal of moonlight references when + // we are packing moonlight itself + if (id.StartsWith("Moonlight.")) + return []; + + return document + .Descendants(ns + "dependency") + .Where(x => x.Attribute("id")?.Value.StartsWith("Moonlight.") ?? false); + }); + } + + private async Task ModifyXmlInPackage( + ZipArchive archive, + ZipArchiveEntry entry, + Func> filter + ) + { + var oldPath = entry.FullName; + await using var oldFs = entry.Open(); + + var document = await XDocument.LoadAsync( + oldFs, + LoadOptions.None, + CancellationToken.None + ); + + var itemsToRemove = filter.Invoke(document); + var items = itemsToRemove.ToArray(); + + foreach (var item in items) + item.Remove(); + + oldFs.Close(); + entry.Delete(); + + var newEntry = archive.CreateEntry(oldPath); + var newFs = newEntry.Open(); + + await document.SaveAsync(newFs, SaveOptions.None, CancellationToken.None); + + await newFs.FlushAsync(); + newFs.Close(); + + return newEntry; + } + + private async Task RemoveContentFiles(ZipArchive nugetPackage) + { + // Remove all content files + foreach (var entry in nugetPackage.Entries.ToArray()) + { + if (!entry.FullName.StartsWith("contentFiles") && !entry.FullName.StartsWith("content")) + continue; + + Console.WriteLine($"Removing content file: {entry.FullName}"); + entry.Delete(); + } + + // Remove references to those files in the nuspec file + var nuspecFile = nugetPackage.Entries.FirstOrDefault(x => x.Name.EndsWith(".nuspec")); + + if (nuspecFile != null) + { + Console.WriteLine("Removing references to content files in the nuspec files"); + + await ModifyXmlInPackage( + nugetPackage, + nuspecFile, + document => + { + var ns = document.Root!.GetDefaultNamespace(); + return document.Descendants(ns + "contentFiles"); + } + ); + } + } +} \ No newline at end of file diff --git a/Resources/Scripts/Commands/PreBuildCommand.cs b/Resources/Scripts/Commands/PreBuildCommand.cs new file mode 100644 index 00000000..d911d6e3 --- /dev/null +++ b/Resources/Scripts/Commands/PreBuildCommand.cs @@ -0,0 +1,414 @@ +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; +using Cocona; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Scripts.Helpers; + +namespace Scripts.Commands; + +public class PreBuildCommand +{ + private readonly CommandHelper CommandHelper; + private const string GeneratedStart = "// MLBUILD Generated Start"; + private const string GeneratedEnd = "// MLBUILD Generated End"; + private const string GeneratedHook = "// MLBUILD_PLUGIN_STARTUP_HERE"; + + public PreBuildCommand(CommandHelper commandHelper) + { + CommandHelper = commandHelper; + } + + [Command("prebuild")] + public async Task Prebuild( + [Argument] string moonlightDir, + [Argument] string nugetDir + ) + { + var dependencies = await GetDependenciesFromNuget(nugetDir); + + Console.WriteLine("Following plugins found:"); + + foreach (var dependency in dependencies) + { + Console.WriteLine($"{dependency.Id} ({dependency.Version}) [{dependency.Tags}]"); + } + + var csProjFiles = Directory.GetFiles(moonlightDir, "*.csproj", SearchOption.AllDirectories); + + try + { + Console.WriteLine("Adjusting csproj files"); + foreach (var csProjFile in csProjFiles) + { + await using var fs = File.Open( + csProjFile, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); + + var document = await XDocument.LoadAsync(fs, LoadOptions.None, CancellationToken.None); + fs.Close(); + + // Search for tag definitions + var packageTagsElements = document.Descendants("PackageTags").ToArray(); + + if (packageTagsElements.Length == 0) + { + Console.WriteLine($"No package tags found in {Path.GetFullPath(csProjFile)}. Skipping it"); + continue; + } + + var packageTags = packageTagsElements.First().Value; + + var dependenciesToAdd = dependencies + .Where(x => x.Tags.Contains(packageTags, StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); + + await RemoveDependencies(csProjFile); + await AddDependencies(csProjFile, dependenciesToAdd); + } + + Console.WriteLine("Restoring projects"); + foreach (var csProjFile in csProjFiles) + { + await RestoreProject(csProjFile, nugetDir); + } + + Console.WriteLine("Generating plugin startup"); + + string[] validTags = ["apiserver", "frontend"]; + + foreach (var currentTag in validTags) + { + Console.WriteLine($"Checking for '{currentTag}' projects"); + + foreach (var csProjFile in csProjFiles) + { + var tags = await GetTagsFromCsproj(csProjFile); + + if (string.IsNullOrEmpty(tags)) + { + Console.WriteLine($"No package tags found in {Path.GetFullPath(csProjFile)}. Skipping it"); + continue; + } + + if (!tags.Contains(currentTag)) + continue; + + var currentDeps = dependencies + .Where(x => x.Tags.Contains(currentTag)) + .ToArray(); + + var classPaths = await FindStartupClasses(currentDeps); + + var code = new StringBuilder(); + + code.AppendLine(GeneratedStart); + + foreach (var path in classPaths) + code.AppendLine($"pluginStartups.Add(new global::{path}());"); + + code.AppendLine(GeneratedEnd); + + var filesToSearch = Directory.GetFiles( + Path.GetDirectoryName(csProjFile)!, + "*.cs", + SearchOption.AllDirectories + ); + + foreach (var file in filesToSearch) + { + var content = await File.ReadAllTextAsync(file); + + if (!content.Contains(GeneratedHook, StringComparison.InvariantCultureIgnoreCase)) + continue; + + Console.WriteLine($"Injecting generated code to: {Path.GetFullPath(file)}"); + + content = content.Replace( + GeneratedHook, + code.ToString(), + StringComparison.InvariantCultureIgnoreCase + ); + + await File.WriteAllTextAsync(file, content); + } + } + } + } + catch (Exception) + { + Console.WriteLine("An error occured while prebuilding moonlight. Removing csproj modifications"); + + foreach (var csProjFile in csProjFiles) + await RemoveDependencies(csProjFile); + + await RemoveGeneratedCode(moonlightDir); + + throw; + } + } + + [Command("prebuild-reset")] + public async Task PrebuildReset( + [Argument] string moonlightDir + ) + { + var csProjFiles = Directory.GetFiles(moonlightDir, "*.csproj", SearchOption.AllDirectories); + + Console.WriteLine("Reverting csproj changes"); + + foreach (var csProjFile in csProjFiles) + await RemoveDependencies(csProjFile); + + Console.WriteLine("Removing generated code"); + await RemoveGeneratedCode(moonlightDir); + } + + [Command("test")] + public async Task Test( + [Argument] string nugetDir + ) + { + var dependencies = await GetDependenciesFromNuget(nugetDir); + + await FindStartupClasses(dependencies); + } + + private async Task GetDependenciesFromNuget(string nugetDir) + { + var nugetFiles = Directory.GetFiles(nugetDir, "*.nupkg", SearchOption.AllDirectories); + var dependencies = new List(); + + foreach (var nugetFile in nugetFiles) + { + var dependency = await GetDependencyFromPackage(nugetFile); + dependencies.Add(dependency); + } + + return dependencies.ToArray(); + } + + private async Task GetDependencyFromPackage(string path) + { + using var nugetPackage = ZipFile.Open(path, ZipArchiveMode.Read); + + var nuspecEntry = nugetPackage.Entries.First(x => x.Name.EndsWith(".nuspec")); + await using var nuspecFs = nuspecEntry.Open(); + + var nuspec = await XDocument.LoadAsync(nuspecFs, LoadOptions.None, CancellationToken.None); + + var ns = nuspec.Root!.GetDefaultNamespace(); + var metadata = nuspec.Root!.Element(ns + "metadata")!; + + var id = metadata.Element(ns + "id")!.Value; + var version = metadata.Element(ns + "version")!.Value; + var tags = metadata.Element(ns + "tags")!.Value; + + nuspecFs.Close(); + + return new Dependency() + { + Id = id, + Version = version, + Tags = tags + }; + } + + private async Task AddDependencies(string path, Dependency[] dependencies) + { + await using var fs = File.Open(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + fs.Position = 0; + + var csProj = await XDocument.LoadAsync(fs, LoadOptions.None, CancellationToken.None); + + var project = csProj.Element("Project")!; + + var itemGroup = new XElement("ItemGroup"); + itemGroup.SetAttributeValue("Label", "MoonlightBuildDeps"); + + foreach (var dependency in dependencies) + { + var depElement = new XElement("PackageReference"); + depElement.SetAttributeValue("Include", dependency.Id); + depElement.SetAttributeValue("Version", dependency.Version); + + itemGroup.Add(depElement); + } + + project.Add(itemGroup); + + fs.Position = 0; + await csProj.SaveAsync(fs, SaveOptions.None, CancellationToken.None); + } + + private Task RemoveDependencies(string path) + { + var csProj = XDocument.Load(path, LoadOptions.None); + + var itemGroupsToRemove = csProj + .Descendants("ItemGroup") + .Where(x => x.Attribute("Label")?.Value.Contains("MoonlightBuildDeps") ?? false) + .ToArray(); + + itemGroupsToRemove.Remove(); + + csProj.Save(path, SaveOptions.None); + + return Task.CompletedTask; + } + + private async Task RestoreProject(string file, string nugetPath) + { + var basePath = Path.GetFullPath(Path.GetDirectoryName(file)!); + var fileName = Path.GetFileName(file); + var nugetPathFull = Path.GetFullPath(nugetPath); + + Console.WriteLine($"Restore: {basePath} - {fileName}"); + + await CommandHelper.Run( + "/usr/bin/dotnet", + $"restore {fileName} --source {nugetPathFull}", + basePath + ); + } + + private async Task FindStartupClasses(Dependency[] dependencies) + { + var result = new List(); + + var nugetPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", + "packages" + ); + + var filesToScan = dependencies.SelectMany(dependency => + { + var dependencySrcPath = Path.Combine(nugetPath, dependency.Id.ToLower(), dependency.Version, "src"); + + Console.WriteLine($"Checking {dependencySrcPath}"); + + if (!Directory.Exists(dependencySrcPath)) + return []; + + return Directory.GetFiles(dependencySrcPath, "*.cs", SearchOption.AllDirectories); + } + ).ToArray(); + + var mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location); + + var trees = new List(); + + foreach (var file in filesToScan) + { + Console.WriteLine($"Reading {file}"); + + var content = await File.ReadAllTextAsync(file); + var tree = CSharpSyntaxTree.ParseText(content); + trees.Add(tree); + } + + var compilation = CSharpCompilation.Create("Analysis", trees, [mscorlib]); + + foreach (var tree in trees) + { + var model = compilation.GetSemanticModel(tree); + var root = await tree.GetRootAsync(); + + var classDeclarations = root + .DescendantNodes() + .OfType(); + + foreach (var classDeclaration in classDeclarations) + { + var symbol = model.GetDeclaredSymbol(classDeclaration); + + if (symbol == null) + continue; + + var hasAttribute = symbol.GetAttributes().Any(attr => + { + if (attr.AttributeClass == null) + return false; + + return attr.AttributeClass.Name == "PluginStartup"; + }); + + if (!hasAttribute) + continue; + + var classPath = + $"{symbol.ContainingNamespace.ToDisplayString()}.{classDeclaration.Identifier.ValueText}"; + + Console.WriteLine($"Detected startup in class: {classPath}"); + result.Add(classPath); + } + } + + return result.ToArray(); + } + + private async Task GetTagsFromCsproj(string csProjFile) + { + await using var fs = File.Open( + csProjFile, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.ReadWrite + ); + + var document = await XDocument.LoadAsync(fs, LoadOptions.None, CancellationToken.None); + fs.Close(); + + // Search for tag definitions + var packageTagsElements = document.Descendants("PackageTags").ToArray(); + + if (packageTagsElements.Length == 0) + return ""; + + return packageTagsElements.First().Value; + } + + private async Task RemoveGeneratedCode(string dir) + { + var filesToSearch = Directory.GetFiles( + dir, + "*.cs", + SearchOption.AllDirectories + ); + + foreach (var file in filesToSearch) + { + // We dont want to replace ourself + if (file.Contains("PreBuildCommand.cs")) + continue; + + var content = await File.ReadAllTextAsync(file); + + if (!content.Contains(GeneratedStart) || !content.Contains(GeneratedEnd)) + continue; + + var startIndex = content.IndexOf(GeneratedStart, StringComparison.InvariantCultureIgnoreCase); + var endIndex = content.IndexOf(GeneratedEnd, startIndex, StringComparison.InvariantCultureIgnoreCase) + + GeneratedEnd.Length; + + var cutOut = content.Substring(startIndex, endIndex - startIndex); + + content = content.Replace(cutOut, GeneratedHook); + + await File.WriteAllTextAsync(file, content); + } + } + + private record Dependency + { + public string Id { get; set; } + public string Version { get; set; } + public string Tags { get; set; } + } +} \ No newline at end of file diff --git a/Resources/Scripts/Functions/ContentFunctions.cs b/Resources/Scripts/Functions/ContentFunctions.cs deleted file mode 100644 index 2319445d..00000000 --- a/Resources/Scripts/Functions/ContentFunctions.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.IO.Compression; -using System.Text.RegularExpressions; - -namespace Scripts.Functions; - -public static class ContentFunctions -{ - public static async Task Run(string[] args) - { - if (args.Length < 2) - { - Console.WriteLine("Please provide the path to a nuget file and at least one regex expression"); - return; - } - - var nugetPath = args[0]; - - var regexs = args - .Skip(1) - .Select(x => new Regex(x)) - .ToArray(); - - Console.WriteLine(string.Join(", ", args - .Skip(1) - .Select(x => new Regex(x)))); - - if (!File.Exists(nugetPath)) - { - Console.WriteLine("The provided file does not exist"); - return; - } - - Console.WriteLine("Modding nuget package..."); - using var zipFile = ZipFile.Open(nugetPath, ZipArchiveMode.Update); - - foreach (var zipArchiveEntry in zipFile.Entries) - { - Console.WriteLine(zipArchiveEntry.FullName); - } - - Console.WriteLine("Searching for files to remove"); - var files = zipFile.Entries - .Where(x => x.FullName.Trim('/').StartsWith("content")) - .Where(x => - { - var name = x.FullName - .Replace("contentFiles/", "") - .Replace("content/", ""); - - Console.WriteLine(name); - - return regexs.Any(y => y.IsMatch(name)); - }) - .ToArray(); - - Console.WriteLine($"Found {files.Length} file(s) to remove"); - foreach (var file in files) - file.Delete(); - } -} \ No newline at end of file diff --git a/Resources/Scripts/Functions/SrcFunctions.cs b/Resources/Scripts/Functions/SrcFunctions.cs deleted file mode 100644 index 3af2ccd7..00000000 --- a/Resources/Scripts/Functions/SrcFunctions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.IO.Compression; - -namespace Scripts.Functions; - -public static class SrcFunctions -{ - public static async Task Run(string[] args) - { - if (args.Length != 3) - { - Console.WriteLine("Please provide the path to a nuget file, a search pattern and a path"); - return; - } - - var nugetPath = args[0]; - var path = args[1]; - var pattern = args[2]; - - if (!File.Exists(nugetPath)) - { - Console.WriteLine("The provided file does not exist"); - return; - } - - Console.WriteLine("Modding nuget package..."); - using var zipFile = ZipFile.Open(nugetPath, ZipArchiveMode.Update); - - var filesToAdd = Directory.GetFiles(path, pattern, SearchOption.AllDirectories); - - foreach (var file in filesToAdd) - { - var name = file.Replace(path, "").Replace("\\", "/"); - - Console.WriteLine($"{file} => /src/{name}"); - - var entry = zipFile.CreateEntry($"src/{name}"); - await using var entryStream = entry.Open(); - - await using var fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - await fs.CopyToAsync(entryStream); - fs.Close(); - - await entryStream.FlushAsync(); - entryStream.Close(); - } - } -} \ No newline at end of file diff --git a/Resources/Scripts/Functions/StaticWebAssetsFunctions.cs b/Resources/Scripts/Functions/StaticWebAssetsFunctions.cs deleted file mode 100644 index de6318c1..00000000 --- a/Resources/Scripts/Functions/StaticWebAssetsFunctions.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.IO.Compression; -using System.Text.RegularExpressions; -using System.Xml.Linq; - -namespace Scripts.Functions; - -public static class StaticWebAssetsFunctions -{ - public static async Task Run(string[] args) - { - if (args.Length < 2) - { - Console.WriteLine("Please provide the path to a nuget file and at least one regex expression"); - return; - } - - var nugetPath = args[0]; - - var regexs = args - .Skip(1) - .Select(x => new Regex(x)) - .ToArray(); - - if (!File.Exists(nugetPath)) - { - Console.WriteLine("The provided file does not exist"); - return; - } - - Console.WriteLine("Modding nuget package..."); - using var zipFile = ZipFile.Open(nugetPath, ZipArchiveMode.Update); - - Console.WriteLine("Searching for files to remove"); - var files = zipFile.Entries - .Where(x => x.FullName.Trim('/').StartsWith("staticwebassets")) - .Where(x => - { - var name = x.FullName.Replace("staticwebassets/", ""); - - return regexs.Any(y => y.IsMatch(name)); - }) - .ToArray(); - - Console.WriteLine($"Found {files.Length} file(s) to remove"); - foreach (var file in files) - file.Delete(); - - Console.WriteLine("Modifying static web assets build target"); - var oldBuildTargetEntry = zipFile - .Entries - .FirstOrDefault(x => x.FullName == "build/Microsoft.AspNetCore.StaticWebAssets.props"); - - if (oldBuildTargetEntry == null) - { - Console.WriteLine("Build target file not found in nuget packages"); - return; - } - - await using var oldBuildTargetStream = oldBuildTargetEntry.Open(); - - var contentXml = await XDocument.LoadAsync( - oldBuildTargetStream, - LoadOptions.None, - CancellationToken.None - ); - - oldBuildTargetStream.Close(); - oldBuildTargetEntry.Delete(); - - var assetRefsToRemove = contentXml - .Descendants("StaticWebAsset") - .Where(asset => - { - var element = asset.Element("RelativePath"); - - if (element == null) - return false; - - return regexs.Any(y => y.IsMatch(element.Value)); - }) - .ToArray(); - - foreach (var asset in assetRefsToRemove) - asset.Remove(); - - var newBuildTargetEntry = zipFile.CreateEntry("build/Microsoft.AspNetCore.StaticWebAssets.props"); - await using var newBuildTargetStream = newBuildTargetEntry.Open(); - - await contentXml.SaveAsync(newBuildTargetStream, SaveOptions.None, CancellationToken.None); - - newBuildTargetStream.Close(); - } -} \ No newline at end of file diff --git a/Resources/Scripts/Functions/TagsFunctions.cs b/Resources/Scripts/Functions/TagsFunctions.cs deleted file mode 100644 index 290755ff..00000000 --- a/Resources/Scripts/Functions/TagsFunctions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Xml.Linq; - -namespace Scripts.Functions; - -public class TagsFunctions -{ - public static async Task Run(string[] args) - { - if (args.Length < 3) - { - Console.WriteLine("You need to specify a directory, tag and at least one command"); - return; - } - - var rootDir = args[0]; - var tag = args[1]; - var commands = args.Skip(2).ToArray(); - - var csprojFiles = Directory.GetFiles( - rootDir, - "*.csproj", - SearchOption.AllDirectories - ); - - foreach (var csprojFile in csprojFiles) - { - try - { - // Load the .csproj file - var csprojXml = XElement.Load(csprojFile); - - // Check if exists within the .csproj file - var packageTagsElement = csprojXml.Descendants("PackageTags").FirstOrDefault(); - if (packageTagsElement != null) - { - if(!packageTagsElement.Value.Contains(tag)) - continue; - - var projectName = Path.GetFileName(Path.GetDirectoryName(csprojFile))!; - var projectFile = Path.GetFileName(csprojFile); - - foreach (var command in commands) - { - // Replace PROJECT_NAME and PROJECT_FILE with the actual values - var bashCommand = command.Replace("PROJECT_NAME", projectName).Replace("PROJECT_FILE", projectFile); - RunBashCommand(bashCommand); - } - } - } - catch (Exception ex) - { - Console.WriteLine($"Error processing {csprojFile}: {ex.Message}"); - } - } - } - - static void RunBashCommand(string command) - { - try - { - var processStartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "bash", - Arguments = $"-c \"{command}\"", - }; - - var process = System.Diagnostics.Process.Start(processStartInfo); - process!.WaitForExit(); - } - catch (Exception ex) - { - Console.WriteLine($"Error running bash command: {ex.Message}"); - } - } -} \ No newline at end of file diff --git a/Resources/Scripts/Helpers/CommandHelper.cs b/Resources/Scripts/Helpers/CommandHelper.cs new file mode 100644 index 00000000..162759b9 --- /dev/null +++ b/Resources/Scripts/Helpers/CommandHelper.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; + +namespace Scripts.Helpers; + +public class CommandHelper +{ + public async Task Run(string program, string arguments, string? workingDir = null) + { + var process = await RunRaw(program, arguments, workingDir); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + throw new Exception($"The command '{program} {arguments}' failed with exit code: {process.ExitCode}"); + } + + private Task RunRaw(string program, string arguments, string? workingDir = null) + { + var psi = new ProcessStartInfo() + { + FileName = program, + Arguments = arguments, + WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Directory.GetCurrentDirectory() : workingDir + }; + + var process = Process.Start(psi)!; + + return Task.FromResult(process); + } +} \ No newline at end of file diff --git a/Resources/Scripts/Helpers/StartupClassDetector.cs b/Resources/Scripts/Helpers/StartupClassDetector.cs new file mode 100644 index 00000000..cf76665d --- /dev/null +++ b/Resources/Scripts/Helpers/StartupClassDetector.cs @@ -0,0 +1,46 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Scripts.Helpers; + +public class StartupClassDetector +{ + public bool Check(string content, out string fullName) + { + var tree = CSharpSyntaxTree.ParseText(content); + var root = tree.GetCompilationUnitRoot(); + + var mscorlib = MetadataReference.CreateFromFile(typeof(object).Assembly.Location); + var compilation = CSharpCompilation.Create("MyAnalysis", [tree], [mscorlib]); + + var model = compilation.GetSemanticModel(tree); + + var classDeclarations = root.DescendantNodes().OfType(); + + foreach (var classDecl in classDeclarations) + { + var symbol = model.GetDeclaredSymbol(classDecl); + + if(symbol == null) + continue; + + var hasAttribute = symbol.GetAttributes().Any(attribute => + { + if (attribute.AttributeClass == null) + return false; + + return attribute.AttributeClass.Name.Contains("PluginStartup"); + }); + + if (hasAttribute) + { + fullName = symbol.ContainingNamespace.ToDisplayString() + "." + classDecl.Identifier.ValueText; + return true; + } + } + + fullName = ""; + return false; + } +} \ No newline at end of file diff --git a/Resources/Scripts/Program.cs b/Resources/Scripts/Program.cs index aa258951..a255daf4 100644 --- a/Resources/Scripts/Program.cs +++ b/Resources/Scripts/Program.cs @@ -1,29 +1,19 @@ -using Scripts.Functions; +using Cocona; +using Microsoft.Extensions.DependencyInjection; +using Scripts.Commands; +using Scripts.Helpers; -if (args.Length == 0) -{ - Console.WriteLine("You need to specify a module to run"); - return; -} +Console.WriteLine("Moonlight Build Helper Script"); +Console.WriteLine(); -var module = args[0]; -var moduleArgs = args.Skip(1).ToArray(); +var builder = CoconaApp.CreateBuilder(args); -switch (module) -{ - case "staticWebAssets": - await StaticWebAssetsFunctions.Run(moduleArgs); - break; - case "content": - await ContentFunctions.Run(moduleArgs); - break; - case "src": - await SrcFunctions.Run(moduleArgs); - break; - case "tags": - await TagsFunctions.Run(moduleArgs); - break; - default: - Console.WriteLine($"No module named {module} found"); - break; -} \ No newline at end of file +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.AddCommands(); +app.AddCommands(); + +await app.RunAsync(); \ No newline at end of file diff --git a/Resources/Scripts/Scripts.csproj b/Resources/Scripts/Scripts.csproj index 2150e379..46dbb3b6 100644 --- a/Resources/Scripts/Scripts.csproj +++ b/Resources/Scripts/Scripts.csproj @@ -1,10 +1,17 @@ - - + + Exe net8.0 enable enable - - + + + + + + + + + \ No newline at end of file