From b4ba7d8ca02bdf487ee9424f2bdb119510ab1d2c Mon Sep 17 00:00:00 2001 From: dxqshka <39024297+dxqwww@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:35:40 +0300 Subject: [PATCH] feat(experimental): add NuGet Dependency Resolver for Plugins (#1012) --- .../configs/core.example.json | 1 + docfx/docs/features/shared-plugin-api.md | 36 +++++++ .../CounterStrikeSharp.API/Core/CoreConfig.cs | 5 + .../Host/IPluginContextDependencyResolver.cs | 6 ++ .../PluginContextNuGetDependencyResolver.cs | 93 +++++++++++++++++ .../Core/Plugin/Host/PluginManager.cs | 99 ++++++++++++++++++- 6 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 managed/CounterStrikeSharp.API/Core/Plugin/Host/IPluginContextDependencyResolver.cs create mode 100644 managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginContextNuGetDependencyResolver.cs diff --git a/configs/addons/counterstrikesharp/configs/core.example.json b/configs/addons/counterstrikesharp/configs/core.example.json index e3c59f71..d2497f3f 100644 --- a/configs/addons/counterstrikesharp/configs/core.example.json +++ b/configs/addons/counterstrikesharp/configs/core.example.json @@ -4,6 +4,7 @@ "FollowCS2ServerGuidelines": true, "PluginHotReloadEnabled": true, "PluginAutoLoadEnabled": true, + "PluginResolveNugetPackages": false, "ServerLanguage": "en", "UnlockConCommands": true, "UnlockConVars": true, diff --git a/docfx/docs/features/shared-plugin-api.md b/docfx/docs/features/shared-plugin-api.md index ba8f3503..24dc5793 100644 --- a/docfx/docs/features/shared-plugin-api.md +++ b/docfx/docs/features/shared-plugin-api.md @@ -5,6 +5,9 @@ description: How to add inter-plugin communication to CounterStrikeSharp plugins # Shared Plugin API +> [!NOTE] +> **New (experimental)**: You can now resolve plugin dependencies directly from your local **NuGet packages cache** instead of copying every DLL into the `shared/` folder. See **Dependency Resolution** below. This feature **disabled by default.** + How to expose and use shared plugin APIs between multiple plugins. ## Creating a Contract Library @@ -65,3 +68,36 @@ balance.Add(500); ``` This value _MUST_ be checked for null, as if there are no plugins providing implementations for a given capability, this method will return null, and you must handle this flow in your plugin. + + +## Dependency Resolution + +CounterStrikeSharp supports two complementary ways to resolve **external** assemblies used by your plugins and shared contracts: + +1. **Shared Folder Resolution (manual)**: copy dependency DLLs into `shared//.dll`. +2. **NuGet Dependency Resolver (auto)**: when enabled, resolves missing assemblies from the local **NuGet packages root** + +### Enabling the NuGet Resolver + +Add the following property to your core config (disabled by default): + +```json +{ + ... + "PluginResolveNugetPackages": true + ... +} +``` + +> [!NOTE] +> The engine looks for assemblies in the NuGet cache defined by the `NUGET_PACKAGES` environment variable, or falls back to the default user cache (e.g., `~/.nuget/packages` on Linux/macOS, `%UserProfile%\.nuget\packages` on Windows). + +### Dependencies Resolution Order + +When the NuGet resolver is **enabled**, resolution proceeds in this general order: + +1. **Plugins directory** (in-place assemblies) +2. `shared/` **folder** (existing shared assemblies mechanism) +3. **NuGet cache** (auto-resolver) + +This lets you keep proven `shared/` workflows while reducing manual copying for common NuGet dependencies. \ No newline at end of file diff --git a/managed/CounterStrikeSharp.API/Core/CoreConfig.cs b/managed/CounterStrikeSharp.API/Core/CoreConfig.cs index 2a9cec61..d9d11c2b 100644 --- a/managed/CounterStrikeSharp.API/Core/CoreConfig.cs +++ b/managed/CounterStrikeSharp.API/Core/CoreConfig.cs @@ -53,6 +53,9 @@ namespace CounterStrikeSharp.API.Core [JsonPropertyName("PluginAutoLoadEnabled")] public bool PluginAutoLoadEnabled { get; set; } = true; + [JsonPropertyName("PluginResolveNugetPackages")] + public bool PluginResolveNugetPackages { get; set; } + [JsonPropertyName("ServerLanguage")] public string ServerLanguage { get; set; } = "en"; @@ -115,6 +118,8 @@ namespace CounterStrikeSharp.API.Core /// public static bool PluginAutoLoadEnabled => _coreConfig.PluginAutoLoadEnabled; + public static bool PluginResolveNugetPackages => _coreConfig.PluginResolveNugetPackages; + public static string ServerLanguage => _coreConfig.ServerLanguage; public static bool UnlockConCommands => _coreConfig.UnlockConCommands; diff --git a/managed/CounterStrikeSharp.API/Core/Plugin/Host/IPluginContextDependencyResolver.cs b/managed/CounterStrikeSharp.API/Core/Plugin/Host/IPluginContextDependencyResolver.cs new file mode 100644 index 00000000..b4b3942a --- /dev/null +++ b/managed/CounterStrikeSharp.API/Core/Plugin/Host/IPluginContextDependencyResolver.cs @@ -0,0 +1,6 @@ +namespace CounterStrikeSharp.API.Core.Plugin.Host; + +public interface IPluginContextDependencyResolver +{ + public string? ResolvePath(); +} diff --git a/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginContextNuGetDependencyResolver.cs b/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginContextNuGetDependencyResolver.cs new file mode 100644 index 00000000..fca8c689 --- /dev/null +++ b/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginContextNuGetDependencyResolver.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyModel; + +namespace CounterStrikeSharp.API.Core.Plugin.Host; + +public class PluginContextNuGetDependencyResolver : IPluginContextDependencyResolver +{ + private const string NuGetPackagesEnvName = "NUGET_PACKAGES"; + + private readonly string _rootAssemblyName; + private readonly string _rootAssemblyPath; + private readonly AssemblyName _assemblyName; + + public PluginContextNuGetDependencyResolver(string rootAssemblyName, + string rootAssemblyPath, + AssemblyName assemblyName) + { + _rootAssemblyName = rootAssemblyName; + _rootAssemblyPath = rootAssemblyPath; + _assemblyName = assemblyName; + } + + public string? ResolvePath() + { + var packagesRoot = GetNuGetPackagesRoot(); + if (string.IsNullOrWhiteSpace(packagesRoot)) + { + return null; + } + + var packageName = _assemblyName.Name; + if (string.IsNullOrWhiteSpace(packageName)) + { + return null; + } + + var dependenciesPath = Path.Combine(_rootAssemblyPath, $"{_rootAssemblyName}.deps.json"); + if (!File.Exists(dependenciesPath)) + { + return null; + } + + using var dependenciesStream = File.OpenRead(dependenciesPath); + + using var dependencyReader = new DependencyContextJsonReader(); + var context = dependencyReader.Read(dependenciesStream); + + var dependencyPath = string.Empty; + foreach (var dependency in context.RuntimeLibraries) + { + if (dependency.Name == packageName) + { + if (string.IsNullOrWhiteSpace(dependency.Path) || !dependency.RuntimeAssemblyGroups.Any()) + { + return null; + } + + var runtimeAssemblyGroup = dependency.RuntimeAssemblyGroups[0]; + if (!runtimeAssemblyGroup.AssetPaths.Any()) + { + return null; + } + + dependencyPath = Path.Combine(dependency.Path, runtimeAssemblyGroup.AssetPaths[0]); + break; + } + } + + if (string.IsNullOrWhiteSpace(dependencyPath)) + { + return null; + } + + return Path.Combine(packagesRoot, dependencyPath); + } + + private static string? GetNuGetPackagesRoot() + { + var nugetPath = Environment.GetEnvironmentVariable(NuGetPackagesEnvName); + if (!string.IsNullOrWhiteSpace(nugetPath) && Directory.Exists(nugetPath)) + { + return nugetPath; + } + + var userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfilePath)) + { + return null; + } + + return Path.Combine(userProfilePath, ".nuget", "packages"); + } +} diff --git a/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginManager.cs b/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginManager.cs index f37bcb86..48890fe5 100644 --- a/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginManager.cs +++ b/managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginManager.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Runtime.Loader; @@ -36,6 +37,17 @@ public class PluginManager : IPluginManager config => { config.PreferSharedTypes = true; }); var assembly = loader.LoadDefaultAssembly(); + if (CoreConfig.PluginResolveNugetPackages) + { + foreach (var assemblyName in assembly.GetReferencedAssemblies()) + { + if (TryLoadDependency(path, assembly.GetName().Name, assemblyName, out var dependency)) + { + _sharedAssemblies.TryAdd(dependency.GetName().Name, dependency); + } + } + } + _sharedAssemblies[assembly.GetName().Name] = assembly; } @@ -46,7 +58,7 @@ public class PluginManager : IPluginManager .Select(dir => Path.Combine(dir, Path.GetFileName(dir) + ".dll")) .Where(File.Exists) .ToArray(); - + foreach (var sharedAssemblyPath in sharedAssemblyPaths) { try @@ -78,6 +90,11 @@ public class PluginManager : IPluginManager if (!_sharedAssemblies.TryGetValue(name.Name, out var assembly)) { + if (CoreConfig.PluginResolveNugetPackages && TryLoadExternalLibrary(name, out assembly)) + { + return assembly; + } + return null; } @@ -98,7 +115,7 @@ public class PluginManager : IPluginManager } } } - + foreach (var plugin in _loadedPluginContexts) { try @@ -112,6 +129,57 @@ public class PluginManager : IPluginManager } } + private bool TryLoadExternalLibrary(AssemblyName assemblyName, out Assembly? assembly) + { + assembly = null; + if (!TryResolveReflectionAssemblyPath(out var pluginName, out var pluginPath)) + { + return false; + } + + if (!TryLoadDependency(pluginPath, pluginName, assemblyName, out assembly)) + { + return false; + } + + return true; + } + + private bool TryLoadDependency(string pluginAssemblyPath, + string pluginAssemblyName, + AssemblyName dependencyAssemblyName, + out Assembly? assembly) + { + assembly = null; + + var dependencyName = dependencyAssemblyName.Name!; + if (string.IsNullOrEmpty(pluginAssemblyPath) || _sharedAssemblies.ContainsKey(dependencyName)) + { + return false; + } + + var resolver = new PluginContextNuGetDependencyResolver( + rootAssemblyName: pluginAssemblyName, + rootAssemblyPath: Path.GetDirectoryName(pluginAssemblyPath)!, + assemblyName: dependencyAssemblyName); + + var dependencyPath = resolver.ResolvePath(); + if (string.IsNullOrWhiteSpace(dependencyPath)) + { + return false; + } + + var loader = PluginLoader.CreateFromAssemblyFile(dependencyPath, configure: c => + { + c.PreferSharedTypes = true; + }); + + assembly = loader.LoadDefaultAssembly(); + _sharedAssemblies[dependencyAssemblyName.Name!] = assembly; + + return true; + } + public IEnumerable GetLoadedPlugins() { return _loadedPluginContexts; @@ -124,4 +192,27 @@ public class PluginManager : IPluginManager _loadedPluginContexts.Add(plugin); plugin.Load(); } -} + + private static bool TryResolveReflectionAssemblyPath(out string? assemblyName, out string? assemblyPath) + { + assemblyPath = null; + assemblyName = null; + + if (AssemblyLoadContext.CurrentContextualReflectionContext is var reflectionContext && reflectionContext is null) + { + return false; + } + + var mainAssemblyPathField = reflectionContext + .GetType() + .GetField("_mainAssemblyPath", BindingFlags.NonPublic | BindingFlags.Instance); + + if (mainAssemblyPathField is null) + { + return false; + } + + assemblyPath = (string)mainAssemblyPathField.GetValue(reflectionContext)!; + return !string.IsNullOrEmpty(assemblyPath); + } +}