diff --git a/LabApi/LabApi.csproj b/LabApi/LabApi.csproj index edd29d88..04e4906d 100644 --- a/LabApi/LabApi.csproj +++ b/LabApi/LabApi.csproj @@ -62,12 +62,17 @@ + + + + + diff --git a/LabApi/Loader/Features/Configuration/LabApiConfig.cs b/LabApi/Loader/Features/Configuration/LabApiConfig.cs index 47bea231..e9206027 100644 --- a/LabApi/Loader/Features/Configuration/LabApiConfig.cs +++ b/LabApi/Loader/Features/Configuration/LabApiConfig.cs @@ -27,4 +27,28 @@ public class LabApiConfig /// [Description("Whether to allow loading plugins even if they were built for a different major version of LabAPI.")] public bool LoadUnsupportedPlugins { get; set; } + + /// + /// Gets or sets the list of NuGet package source URLs used when resolving + /// and downloading dependencies from NuGet repositories. + /// + /// + /// Each entry in this list represents a NuGet feed endpoint (for example, + /// the official https://api.nuget.org/v3/index.json source). + /// Multiple sources can be specified to support private or custom feeds. + /// + [Description("List of NuGet package sources to use when resolving dependencies via NuGet.")] + public string[] NugetPackageSources { get; set; } = ["https://api.nuget.org/v3/index.json"]; + + /// + /// Gets or sets a value indicating whether dependencies should be automatically + /// downloaded from the configured NuGet sources when they are missing or outdated. + /// + /// + /// When set to , the system attempts to retrieve and install + /// required packages automatically during dependency resolution. + /// Disabling this option may require manual dependency management. + /// + [Description("Automatically download dependencies from NuGet when missing or outdated.")] + public bool AutomaticallyDownloadDependencies { get; set; } = true; } diff --git a/LabApi/Loader/Features/NuGet/Models/NuGetDependency.cs b/LabApi/Loader/Features/NuGet/Models/NuGetDependency.cs new file mode 100644 index 00000000..f473db23 --- /dev/null +++ b/LabApi/Loader/Features/NuGet/Models/NuGetDependency.cs @@ -0,0 +1,89 @@ +using LabApi.Features.Console; +using System; +using System.Reflection; + +namespace LabApi.Loader.Features.NuGet.Models; + +/// +/// Represents a dependency entry within a NuGet package, +/// including its identifier and version, and provides +/// helper methods for installation and status checking. +/// +public class NuGetDependency +{ + /// + /// Gets or sets the unique identifier (name) of the NuGet dependency. + /// + public required string Id { get; set; } + + /// + /// Gets or sets the semantic version string of the dependency (e.g. "1.2.3"). + /// + public required string Version { get; set; } + + /// + /// Installs this NuGet dependency by downloading it from the configured source. + /// + /// + /// A instance representing the downloaded package, + /// or if the installation failed or the package could not be found. + /// + /// + /// This method delegates to . + /// + public NuGetPackage? Install() => NuGetPackagesManager.DownloadNugetPackage(Id, Version); + + /// + /// Determines whether this dependency is already installed + /// or loaded in the current AppDomain. + /// + /// + /// true if the dependency is installed or the corresponding assembly is already loaded; + /// otherwise, false. + /// + public bool IsInstalled() + { + if (NuGetPackagesManager.Packages.TryGetValue($"{Id}.{Version}", out NuGetPackage package)) + { + return string.Equals(package.Version, Version, StringComparison.OrdinalIgnoreCase); + } + + if (IsAssemblyAlreadyLoaded(Id)) + { + return true; + } + + return false; + } + + /// + /// Checks whether an assembly with the given identifier is already loaded + /// into the current application domain. + /// + /// The dependency or assembly identifier to check. + /// + /// true if an assembly with the specified ID is already loaded; + /// otherwise, false. + /// + private bool IsAssemblyAlreadyLoaded(string id) + { + try + { + foreach (Assembly? asm in AppDomain.CurrentDomain.GetAssemblies()) + { + string asmName = asm.GetName().Name ?? string.Empty; + + if (asmName.Equals(id, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + catch (Exception ex) + { + Logger.Warn($"{NuGetPackagesManager.Prefix} Failed to check if assembly '{id}' is already loaded: {ex.Message}"); + } + + return false; + } +} diff --git a/LabApi/Loader/Features/NuGet/Models/NuGetPackage.cs b/LabApi/Loader/Features/NuGet/Models/NuGetPackage.cs new file mode 100644 index 00000000..0edc773c --- /dev/null +++ b/LabApi/Loader/Features/NuGet/Models/NuGetPackage.cs @@ -0,0 +1,173 @@ +using LabApi.Features.Console; +using LabApi.Loader.Features.Misc; +using LabApi.Loader.Features.Paths; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace LabApi.Loader.Features.NuGet.Models; + +/// +/// Represents a NuGet package loaded by LabApi, including its metadata, +/// content, and dependency information. +/// +public class NuGetPackage +{ + /// + /// Identifies the plugin tag used to mark NuGet package as plugin. + /// + private const string LabApiPluginTag = "labapi-plugin"; + + /// + /// Gets or sets the unique package identifier (name). + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the package version string (e.g. "1.2.3"). + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the tag metadata from the .nuspec file (used to identify plugins, etc.). + /// + public string Tags { get; set; } = string.Empty; + + /// + /// Gets or sets the compiled assembly content of the package (if applicable). + /// + public byte[]? RawAssembly { get; set; } = null; + + /// + /// Gets or sets the full path of NuGet package. + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the name of the NuGet package file (e.g. "MyPlugin.1.0.0.nupkg"). + /// + public required string FileName { get; set; } + + /// + /// Gets or sets the raw file contents of the NuGet package (.nupkg file). + /// + public required byte[] FileContent { get; set; } + + /// + /// Gets or sets the list of dependencies defined by this package. + /// + public List Dependencies { get; set; } = new List(); + + /// + /// Gets a value indicating whether this package is marked as a LabApi plugin. + /// Determined by the presence of the "labapi-plugin" tag. + /// + public bool IsPlugin => Tags + .ToLower() + .Contains(LabApiPluginTag); + + /// + /// Gets or sets a value indicating whether the package is already loaded. + /// + public bool IsLoaded { get; set; } + + /// + /// Extracts the NuGet package file (.nupkg) to the appropriate directory + /// (plugins or dependencies), depending on whether it is a plugin. + /// + /// + /// The full path to the extracted file if successful; otherwise, null. + /// + public string? ExtractToFolder() + { + string? folder = GetFinalFolder(); + + if (folder == null) + { + Logger.Warn($"{NuGetPackagesManager.Prefix} Could not extract package '{Id}' v{Version} to {(IsPlugin ? "plugins" : "dependencies")} folder: no valid path found!"); + return null; + } + + string targetFile = Path.Combine(folder, FileName); + + File.WriteAllBytes(targetFile, FileContent); + + FilePath = targetFile; + + return targetFile; + } + + /// + /// Loads package. + /// + public void Load() + { + if (IsLoaded) + { + return; + } + + if (RawAssembly?.Length == 0) + { + Logger.Warn($"{NuGetPackagesManager.Prefix} Package '{Id}' v{Version} does not contain a valid assembly, skipping..."); + return; + } + + Assembly assembly = Assembly.Load(RawAssembly); + + if (IsPlugin) + { + try + { + AssemblyUtils.ResolveEmbeddedResources(assembly); + } + catch (Exception e) + { + Logger.Error($"{NuGetPackagesManager.Prefix} Failed to resolve embedded resources for package '{Id}' v{Version}"); + Logger.Error(e); + } + + try + { + PluginLoader.InstantiatePlugins(assembly.GetTypes(), assembly, FilePath); + } + catch (Exception e) + { + Logger.Error($"{NuGetPackagesManager.Prefix} Couldn't load the plugin inside package '{Id}' v{Version}"); + Logger.Error(e); + } + } + else + { + PluginLoader.Dependencies.Add(assembly); + } + + Logger.Info($"{NuGetPackagesManager.Prefix} Package '{Id}' v{Version} was loaded!"); + + IsLoaded = true; + } + + /// + /// Resolves and returns the final folder path for the package extraction, + /// creating directories if necessary. + /// + /// + /// The full directory path where the package should be extracted, + /// or null if no valid path could be determined. + /// + private string? GetFinalFolder() + { + foreach (string path in IsPlugin ? PluginLoader.Config.PluginPaths : PluginLoader.Config.DependencyPaths) + { + string resolvedPath = PluginLoader.ResolvePath(path); + string fullPath = Path.Combine(IsPlugin ? PathManager.Plugins.FullName : PathManager.Dependencies.FullName, resolvedPath); + + Directory.CreateDirectory(fullPath); + + return fullPath; + } + + return null; + } +} diff --git a/LabApi/Loader/Features/NuGet/Models/NuGetPackageIndex.cs b/LabApi/Loader/Features/NuGet/Models/NuGetPackageIndex.cs new file mode 100644 index 00000000..79a788cc --- /dev/null +++ b/LabApi/Loader/Features/NuGet/Models/NuGetPackageIndex.cs @@ -0,0 +1,26 @@ +using System.Runtime.Serialization; + +namespace LabApi.Loader.Features.NuGet.Models; + +/// +/// Represents the root structure of a NuGet service index (index.json), +/// which describes the available service endpoints for a NuGet source. +/// +/// +/// The NuGet service index (usually located at https://api.nuget.org/v3/index.json) +/// provides metadata about the repository’s available APIs, such as +/// PackageBaseAddress, SearchQueryService, and others. +/// +public class NuGetPackageIndex +{ + /// + /// Gets or sets the list of service resources exposed by the NuGet source. + /// + /// + /// Each resource entry describes an endpoint (for example, + /// a package base address or search service) and its supported version. + /// These are typically represented by the type. + /// + [DataMember(Name = "resources")] + public NuGetPackageResource[] Resources { get; set; } = []; +} diff --git a/LabApi/Loader/Features/NuGet/Models/NuGetPackageResource.cs b/LabApi/Loader/Features/NuGet/Models/NuGetPackageResource.cs new file mode 100644 index 00000000..1b8ff500 --- /dev/null +++ b/LabApi/Loader/Features/NuGet/Models/NuGetPackageResource.cs @@ -0,0 +1,37 @@ +using System.Runtime.Serialization; + +namespace LabApi.Loader.Features.NuGet.Models; + +/// +/// Represents a single resource entry within a NuGet service index (index.json). +/// +/// +/// Each resource describes a specific NuGet service endpoint and its purpose, +/// such as the PackageBaseAddress (used to download packages) or +/// SearchQueryService (used to search packages). +/// +public class NuGetPackageResource +{ + /// + /// Gets or sets the type of the NuGet service resource. + /// + /// + /// The @type field defines the role of the resource, for example: + /// + /// PackageBaseAddress/3.0.0 — base URL for downloading package files + /// + /// + [DataMember(Name = "@type")] + public string Type { get; set; } = string.Empty; + + /// + /// Gets or sets the absolute URL of the service endpoint. + /// + /// + /// The @id value is typically a fully qualified HTTPS URL that identifies + /// the service’s base address. For example: + /// https://api.nuget.org/v3-flatcontainer/ or + /// + [DataMember(Name = "@id")] + public string Id { get; set; } = string.Empty; +} diff --git a/LabApi/Loader/Features/Nuget/NugetPackagesManager.cs b/LabApi/Loader/Features/Nuget/NugetPackagesManager.cs new file mode 100644 index 00000000..4b310a90 --- /dev/null +++ b/LabApi/Loader/Features/Nuget/NugetPackagesManager.cs @@ -0,0 +1,486 @@ +using LabApi.Features.Console; +using LabApi.Loader.Features.Configuration; +using LabApi.Loader.Features.NuGet.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Xml.Linq; +using Utf8Json; + +namespace LabApi.Loader.Features.NuGet; + +/// +/// Provides functionality for reading, downloading, extracting, +/// and managing NuGet packages within the LabApi loader system. +/// +public class NuGetPackagesManager +{ + /// + /// Prefix used for console log messages originating from NuGet operations. + /// + public const string Prefix = "[NUGET]"; + + private static HttpClient _client = new HttpClient(); + + private static readonly Dictionary _packageBaseAddressCache = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Defines the ordered list of human-readable framework names (e.g. ".NETFramework4.8"), + /// used to determine preferred dependency groups in nuspec metadata. + /// + private static string[] _frameworkPriority = new[] + { + ".NETFramework4.8", + ".NETFramework4.7.2", + ".NETFramework4.7.1", + ".NETFramework4.7", + ".NETFramework4.6.2", + ".NETFramework4.6.1", + ".NETFramework4.6", + ".NETFramework4.5.2", + ".NETFramework4.5.1", + ".NETFramework4.5", + ".NETFramework4.0", + ".NETStandard2.1", + ".NETStandard2.0", + ".NETStandard1.6", + ".NETStandard1.5", + ".NETStandard1.4", + ".NETStandard1.3", + ".NETStandard1.2", + ".NETStandard1.1", + ".NETStandard1.0", + }; + + /// + /// Defines the ordered list of NuGet Target Framework Monikers (TFMs) + /// used to prioritize assembly selection inside .nupkg archives. + /// + private static string[] _frameworkVersionPriorities = new[] + { + "net48", + "net472", + "net471", + "net47", + "net462", + "net461", + "net46", + "net452", + "net451", + "net45", + "net40", + "netstandard2.1", + "netstandard2.0", + "netstandard1.6", + "netstandard1.5", + "netstandard1.4", + "netstandard1.3", + "netstandard1.2", + "netstandard1.1", + "netstandard1.0", + }; + + /// + /// Gets the dictionary of all loaded NuGet dependencies, + /// indexed by their package ID (case-insensitive). + /// + /// + /// This collection stores all NuGet packages that have been + /// successfully loaded or installed by the loader. + /// Keys represent the dependency ID, and values contain the corresponding + /// instance and its metadata. + /// + public static Dictionary Packages { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Resolves any missing NuGet dependencies by checking all loaded packages + /// and automatically downloading missing ones from configured NuGet sources. + /// + public static void ResolveMissingNuGetDependencies() + { + Logger.Info($"{Prefix} Resolving missing NuGet dependencies..."); + + int resolvedCount = 0; + + Queue packagesToResolve = new Queue(Packages.Values); + + while (packagesToResolve.Count != 0) + { + NuGetPackage package = packagesToResolve.Dequeue(); + + foreach (NuGetDependency dep in package.Dependencies) + { + if (dep.IsInstalled()) + { + continue; + } + + Logger.Warn($"{Prefix} Package '{package.Id}' v{package.Version} has missing dependency '{dep.Id}' v{dep.Version}{(PluginLoader.Config.AutomaticallyDownloadDependencies ? ", attempting to resolve..." : ".")}"); + + if (!PluginLoader.Config.AutomaticallyDownloadDependencies) + { + continue; + } + + try + { + NuGetPackage? downloadedPackage = dep.Install(); + + if (downloadedPackage == null) + { + continue; + } + + packagesToResolve.Enqueue(downloadedPackage); + resolvedCount++; + } + catch (Exception ex) + { + Logger.Error($"{Prefix} Failed to resolve dependency '{dep.Id}' v{dep.Version}"); + Logger.Error(ex); + } + } + } + } + + /// + /// Reads and parses a NuGet package from the specified file path. + /// + /// The full file system path to the .nupkg file. + /// A populated instance. + /// Thrown when the specified package file does not exist. + public static NuGetPackage ReadPackage(string path) + { + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Package file not found: {path}"); + } + + byte[] data = File.ReadAllBytes(path); + string name = Path.GetFileName(path); + + return ReadPackage(name, path, data); + } + + /// + /// Reads and parses a NuGet package from a byte array. + /// + /// The file name of the package. + /// The file full path. + /// The binary contents of the .nupkg file. + /// A populated instance. + public static NuGetPackage ReadPackage(string name, string fullPath, byte[] data) + { + using MemoryStream ms = new MemoryStream(data); + return ReadPackage(name, fullPath, ms); + } + + /// + /// Reads and parses a NuGet package from a memory stream. + /// + /// The package file name. + /// The full path of a package. + /// A memory stream containing the .nupkg content. + /// A populated with metadata, dependencies, and assembly data loaded. + public static NuGetPackage ReadPackage(string name, string fullPath, MemoryStream stream) + { + NuGetPackage package = new NuGetPackage() + { + FilePath = fullPath, + FileName = name, + FileContent = stream.ToArray(), + }; + + using ZipArchive archive = new ZipArchive(stream); + ZipArchiveEntry bestEntry = GetBestVersion(archive); + + if (bestEntry != null) + { + using MemoryStream ms = new MemoryStream(); + using Stream entryStream = bestEntry.Open(); + + entryStream.CopyTo(ms); + + package.RawAssembly = ms.ToArray(); + } + + string? nuspecXml = ReadNuspecFromNupkg(archive); + + if (nuspecXml == null) + { + return package; + } + + GetMetadata(package, nuspecXml); + + return package; + } + + /// + /// Downloads a NuGet package from the official NuGet repository and installs it + /// into the appropriate directory (plugins or dependencies). + /// + /// The ID of the package to download. + /// The version of the package to download. + public static NuGetPackage? DownloadNugetPackage(string packageId, string version) + { + string[] sources = PluginLoader.Config.NugetPackageSources; + + if (sources == null || sources.Length == 0) + { + Logger.Error($"{Prefix} No NuGet package sources defined in configuration!"); + return null; + } + + byte[]? packageData = null; + string? successfulSource = null; + + foreach (string source in sources) + { + string baseAddress = GetCachedPackageBaseAddress(source); + string idLower = packageId.ToLowerInvariant(); + string verLower = version.ToLowerInvariant(); + + if (string.IsNullOrEmpty(baseAddress)) + { + continue; + } + + string downloadUrl = $"{baseAddress.TrimEnd('/')}/{idLower}/{verLower}/{idLower}.{verLower}.nupkg"; + + try + { + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); + + using HttpResponseMessage response = _client.SendAsync(request).GetAwaiter().GetResult(); + + if (response.IsSuccessStatusCode) + { + packageData = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + successfulSource = source; + break; + } + else + { + Logger.Warn($"{Prefix} Failed to download '{packageId}' v{version} from {downloadUrl}"); + Logger.Error($"{Prefix} HTTP {(int)response.StatusCode} - {response.ReasonPhrase}"); + } + } + catch (HttpRequestException ex) + { + Logger.Warn($"{Prefix} Failed to download '{packageId}' v{version} from {downloadUrl}"); + Logger.Error($"{Prefix} HTTP error: {ex.Message}"); + } + catch (Exception ex) + { + Logger.Warn($"{Prefix} Unexpected error while downloading from '{source}': {ex.Message}"); + } + } + + if (packageData == null) + { + Logger.Error($"{Prefix} Failed to download package '{packageId}' v{version} from all configured sources."); + return null; + } + + // Proceed to install + NuGetPackage package = ReadPackage($"{packageId}.{version}.nupkg", string.Empty, packageData); + + // Extracts nuget package to specific folder if thats plugin or dependency. + string? path = package.ExtractToFolder(); + + if (path == null) + { + Logger.Error($"{Prefix} Failed to extract package '{packageId}' v{version}"); + return null; + } + + Packages.Add($"{package.Id}.{package.Version}", package); + + Logger.Info($"{Prefix} Successfully downloaded '{packageId}' v{version} from {successfulSource}"); + return package; + } + + /// + /// Resolves and caches the PackageBaseAddress for a given NuGet v3 source. + /// If the source does not expose a valid index.json or lacks the resource, returns an empty string. + /// + /// The NuGet feed base URL (e.g. https://api.nuget.org/v3/index.json). + /// The resolved PackageBaseAddress URL, or an empty string if unavailable. + private static string GetCachedPackageBaseAddress(string sourceUrl) + { + if (_packageBaseAddressCache.TryGetValue(sourceUrl, out string cached)) + { + return cached; + } + + string normalized = sourceUrl.TrimEnd('/'); + + string indexUrl = normalized.EndsWith("index.json", StringComparison.OrdinalIgnoreCase) + ? normalized + : $"{normalized}/index.json"; + + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, indexUrl); + + using HttpResponseMessage response = _client.SendAsync(request).GetAwaiter().GetResult(); + + string data; + if (response.IsSuccessStatusCode) + { + data = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + } + else + { + Logger.Warn($"{Prefix} Failed to read packages source from '{indexUrl}'"); + + _packageBaseAddressCache[sourceUrl] = string.Empty; + return string.Empty; + } + + NuGetPackageIndex index = JsonSerializer.Deserialize(data); + + foreach (NuGetPackageResource resource in index.Resources) + { + if (!resource.Type.Contains("PackageBaseAddress")) + { + continue; + } + + _packageBaseAddressCache[sourceUrl] = resource.Id; + return resource.Id; + } + + _packageBaseAddressCache[sourceUrl] = string.Empty; + return string.Empty; + } + + /// + /// Selects the most appropriate assembly file from a NuGet archive + /// based on the internal framework version priority list. + /// + /// The opened representing the .nupkg file. + /// The that best matches the preferred framework; otherwise null. + private static ZipArchiveEntry GetBestVersion(ZipArchive archive) + { + IEnumerable libEntries = archive.Entries.Where(x => x.FullName.StartsWith("lib/net") && x.FullName.EndsWith(".dll")); + + Dictionary sortedEntries = new Dictionary(); + + foreach (ZipArchiveEntry entry in libEntries) + { + string[] name = entry.FullName.Split('/'); + + string frameworkVersion = name[1]; + + if (!_frameworkVersionPriorities.Contains(frameworkVersion)) + { + continue; + } + + int index = _frameworkVersionPriorities.IndexOf(frameworkVersion); + + sortedEntries.Add(index, entry); + } + + KeyValuePair bestEntry = sortedEntries.OrderBy(x => x.Key).FirstOrDefault(); + + return bestEntry.Value; + } + + /// + /// Extracts and returns the XML content of the .nuspec file from within a NuGet archive. + /// + /// The instance representing the package. + /// The XML string of the .nuspec file, or null if not found. + private static string? ReadNuspecFromNupkg(ZipArchive entry) + { + ZipArchiveEntry nuspecEntry = entry.Entries.FirstOrDefault(e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + + if (nuspecEntry == null) + { + return null; + } + + using StreamReader reader = new StreamReader(nuspecEntry.Open()); + return reader.ReadToEnd(); + } + + /// + /// Reads metadata and dependency information from the .nuspec XML + /// and populates the corresponding fields in the instance. + /// + /// The package instance to populate. + /// The raw XML content of the .nuspec file. + private static void GetMetadata(NuGetPackage package, string nuspecXml) + { + XDocument doc = XDocument.Parse(nuspecXml); + XNamespace ns = doc.Root.GetDefaultNamespace(); + + XElement metadata = doc.Descendants(ns + "metadata").FirstOrDefault(); + + if (metadata == null) + { + return; + } + + package.Id = metadata.Element(ns + "id")?.Value ?? string.Empty; + package.Version = metadata.Element(ns + "version")?.Value ?? string.Empty; + package.Tags = metadata.Element(ns + "tags")?.Value ?? string.Empty; + + XElement depsElement = metadata.Element(ns + "dependencies"); + + if (depsElement == null) + { + return; + } + + IEnumerable groups = depsElement.Elements(ns + "group"); + XElement? selectedGroup = null; + + foreach (string framework in _frameworkPriority) + { + selectedGroup = groups.FirstOrDefault(g => + string.Equals(g.Attribute("targetFramework")?.Value, framework, StringComparison.OrdinalIgnoreCase)); + + if (selectedGroup != null) + { + break; + } + } + + if (selectedGroup == null) + { + selectedGroup = groups.LastOrDefault(); + } + + List dependencies = new List(); + + if (selectedGroup != null) + { + string? groupFramework = selectedGroup.Attribute("targetFramework")?.Value; + foreach (XElement dep in selectedGroup.Elements(ns + "dependency")) + { + dependencies.Add(new NuGetDependency + { + Id = dep.Attribute("id")?.Value ?? string.Empty, + Version = dep.Attribute("version")?.Value ?? string.Empty, + }); + } + } + + foreach (XElement dep in depsElement.Elements(ns + "dependency")) + { + dependencies.Add(new NuGetDependency + { + Id = dep.Attribute("id")?.Value ?? string.Empty, + Version = dep.Attribute("version")?.Value ?? string.Empty, + }); + } + + package.Dependencies = dependencies; + } +} diff --git a/LabApi/Loader/PluginLoader.cs b/LabApi/Loader/PluginLoader.cs index 0e86b10f..378f3ea5 100644 --- a/LabApi/Loader/PluginLoader.cs +++ b/LabApi/Loader/PluginLoader.cs @@ -5,6 +5,8 @@ using LabApi.Features.Wrappers; using LabApi.Loader.Features.Configuration; using LabApi.Loader.Features.Misc; +using LabApi.Loader.Features.NuGet; +using LabApi.Loader.Features.NuGet.Models; using LabApi.Loader.Features.Paths; using LabApi.Loader.Features.Plugins; using LabApi.Loader.Features.Plugins.Enums; @@ -25,6 +27,7 @@ public static partial class PluginLoader { private const string LoggerPrefix = "[LOADER]"; private const string DllSearchPattern = "*.dll"; + private const string NupkgSearchPattern = "*.nupkg"; private const string PdbFileExtension = ".pdb"; private const string LabApiConfigName = "LabApi-{0}.yml"; @@ -75,6 +78,8 @@ public static void Initialize() // We register all the commands in LabAPI to avoid plugin command conflicts. CommandLoader.RegisterCommands(); + RegisterNuGetPackage(); + // We first load all the dependencies and store them in the dependencies list LoadAllDependencies(); @@ -100,6 +105,16 @@ public static void LoadAllDependencies() // We load all the dependencies from the configured dependency directories Logger.Info($"{LoggerPrefix} Loading all dependencies"); + foreach (NuGetPackage package in NuGetPackagesManager.Packages.Values) + { + if (package.IsPlugin) + { + continue; + } + + package.Load(); + } + foreach (string dependencyPath in Config.DependencyPaths) { string resolvedPath = ResolvePath(dependencyPath); @@ -121,13 +136,8 @@ public static void LoadDependencies(IEnumerable files) { try { - // We load the assembly from the specified file. Assembly assembly = Assembly.Load(File.ReadAllBytes(file.FullName)); - - // And we add the assembly to the dependencies list. Dependencies.Add(assembly); - - // We finally log that the dependency has been loaded. Logger.Info($"{LoggerPrefix} Successfully loaded {assembly.FullName}"); } catch (Exception e) @@ -161,6 +171,16 @@ public static void LoadAllPlugins() } } + foreach (NuGetPackage package in NuGetPackagesManager.Packages.Values) + { + if (!package.IsPlugin) + { + continue; + } + + package.Load(); + } + // Then we finally enable all the plugins Logger.Info($"{LoggerPrefix} Enabling all plugins"); EnablePlugins(Plugins.Keys.OrderBy(static plugin => plugin.Priority)); @@ -291,6 +311,67 @@ public static void EnablePlugin(Plugin plugin) } } + private static void RegisterNuGetPackage() + { + List files = new List(); + + foreach (string dependencyPath in Config.DependencyPaths) + { + string resolvedPath = ResolvePath(dependencyPath); + string fullPath = Path.Combine(PathManager.Dependencies.FullName, resolvedPath); + + Directory.CreateDirectory(fullPath); + + files.AddRange(new DirectoryInfo(fullPath).GetFiles(NupkgSearchPattern)); + } + + foreach (string pluginPath in Config.PluginPaths) + { + string resolvedPath = ResolvePath(pluginPath); + string fullPath = Path.Combine(PathManager.Plugins.FullName, resolvedPath); + + Directory.CreateDirectory(fullPath); + + files.AddRange(new DirectoryInfo(fullPath).GetFiles(NupkgSearchPattern)); + } + + foreach (FileInfo file in files) + { + RegisterNuGetPackage(file); + } + + NuGetPackagesManager.ResolveMissingNuGetDependencies(); + } + + /// + /// Loads package info from a NuGet package (.nupkg). + /// + private static void RegisterNuGetPackage(FileInfo file) + { + try + { + NuGetPackage package = NuGetPackagesManager.ReadPackage(file.FullName); + + string id = $"{package.Id}.{package.Version}"; + + if (NuGetPackagesManager.Packages.ContainsKey(id)) + { + Logger.Warn($"{LoggerPrefix} Duplicate NuGet package dependency '{id}' found in '{file.FullName}', skipping..."); + return; + } + + NuGetPackagesManager.Packages.Add(id, package); + return; + } + catch (Exception e) + { + Logger.Error($"{LoggerPrefix} Failed to read package '{file.FullName}'"); + Logger.Error(e); + } + + return; + } + /// /// Loads or creates the LabAPI configuration file. /// @@ -353,7 +434,7 @@ private static void ResolveTransparentlyModdedFlag() } } - private static string ResolvePath(string path) + public static string ResolvePath(string path) { return path.Replace("$port", Server.Port.ToString()); } @@ -428,7 +509,7 @@ Are you running an older version of the server? return false; } - private static void InstantiatePlugins(Type[] types, Assembly assembly, string filePath) + internal static void InstantiatePlugins(Type[] types, Assembly assembly, string filePath) { foreach (Type type in types) {