Skip to content

Commit b4ba7d8

Browse files
authored
feat(experimental): add NuGet Dependency Resolver for Plugins (#1012)
1 parent 0eb73eb commit b4ba7d8

File tree

6 files changed

+236
-4
lines changed

6 files changed

+236
-4
lines changed

configs/addons/counterstrikesharp/configs/core.example.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"FollowCS2ServerGuidelines": true,
55
"PluginHotReloadEnabled": true,
66
"PluginAutoLoadEnabled": true,
7+
"PluginResolveNugetPackages": false,
78
"ServerLanguage": "en",
89
"UnlockConCommands": true,
910
"UnlockConVars": true,

docfx/docs/features/shared-plugin-api.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ description: How to add inter-plugin communication to CounterStrikeSharp plugins
55

66
# Shared Plugin API
77

8+
> [!NOTE]
9+
> **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.**
10+
811
How to expose and use shared plugin APIs between multiple plugins.
912

1013
## Creating a Contract Library
@@ -65,3 +68,36 @@ balance.Add(500);
6568
```
6669

6770
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.
71+
72+
73+
## Dependency Resolution
74+
75+
CounterStrikeSharp supports two complementary ways to resolve **external** assemblies used by your plugins and shared contracts:
76+
77+
1. **Shared Folder Resolution (manual)**: copy dependency DLLs into `shared/<PackageName>/<Assembly>.dll`.
78+
2. **NuGet Dependency Resolver (auto)**: when enabled, resolves missing assemblies from the local **NuGet packages root**
79+
80+
### Enabling the NuGet Resolver
81+
82+
Add the following property to your core config (disabled by default):
83+
84+
```json
85+
{
86+
...
87+
"PluginResolveNugetPackages": true
88+
...
89+
}
90+
```
91+
92+
> [!NOTE]
93+
> 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).
94+
95+
### Dependencies Resolution Order
96+
97+
When the NuGet resolver is **enabled**, resolution proceeds in this general order:
98+
99+
1. **Plugins directory** (in-place assemblies)
100+
2. `shared/` **folder** (existing shared assemblies mechanism)
101+
3. **NuGet cache** (auto-resolver)
102+
103+
This lets you keep proven `shared/` workflows while reducing manual copying for common NuGet dependencies.

managed/CounterStrikeSharp.API/Core/CoreConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ internal sealed partial class CoreConfigData
5353
[JsonPropertyName("PluginAutoLoadEnabled")]
5454
public bool PluginAutoLoadEnabled { get; set; } = true;
5555

56+
[JsonPropertyName("PluginResolveNugetPackages")]
57+
public bool PluginResolveNugetPackages { get; set; }
58+
5659
[JsonPropertyName("ServerLanguage")]
5760
public string ServerLanguage { get; set; } = "en";
5861

@@ -115,6 +118,8 @@ public partial class CoreConfig
115118
/// </summary>
116119
public static bool PluginAutoLoadEnabled => _coreConfig.PluginAutoLoadEnabled;
117120

121+
public static bool PluginResolveNugetPackages => _coreConfig.PluginResolveNugetPackages;
122+
118123
public static string ServerLanguage => _coreConfig.ServerLanguage;
119124

120125
public static bool UnlockConCommands => _coreConfig.UnlockConCommands;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace CounterStrikeSharp.API.Core.Plugin.Host;
2+
3+
public interface IPluginContextDependencyResolver
4+
{
5+
public string? ResolvePath();
6+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Reflection;
2+
using Microsoft.Extensions.DependencyModel;
3+
4+
namespace CounterStrikeSharp.API.Core.Plugin.Host;
5+
6+
public class PluginContextNuGetDependencyResolver : IPluginContextDependencyResolver
7+
{
8+
private const string NuGetPackagesEnvName = "NUGET_PACKAGES";
9+
10+
private readonly string _rootAssemblyName;
11+
private readonly string _rootAssemblyPath;
12+
private readonly AssemblyName _assemblyName;
13+
14+
public PluginContextNuGetDependencyResolver(string rootAssemblyName,
15+
string rootAssemblyPath,
16+
AssemblyName assemblyName)
17+
{
18+
_rootAssemblyName = rootAssemblyName;
19+
_rootAssemblyPath = rootAssemblyPath;
20+
_assemblyName = assemblyName;
21+
}
22+
23+
public string? ResolvePath()
24+
{
25+
var packagesRoot = GetNuGetPackagesRoot();
26+
if (string.IsNullOrWhiteSpace(packagesRoot))
27+
{
28+
return null;
29+
}
30+
31+
var packageName = _assemblyName.Name;
32+
if (string.IsNullOrWhiteSpace(packageName))
33+
{
34+
return null;
35+
}
36+
37+
var dependenciesPath = Path.Combine(_rootAssemblyPath, $"{_rootAssemblyName}.deps.json");
38+
if (!File.Exists(dependenciesPath))
39+
{
40+
return null;
41+
}
42+
43+
using var dependenciesStream = File.OpenRead(dependenciesPath);
44+
45+
using var dependencyReader = new DependencyContextJsonReader();
46+
var context = dependencyReader.Read(dependenciesStream);
47+
48+
var dependencyPath = string.Empty;
49+
foreach (var dependency in context.RuntimeLibraries)
50+
{
51+
if (dependency.Name == packageName)
52+
{
53+
if (string.IsNullOrWhiteSpace(dependency.Path) || !dependency.RuntimeAssemblyGroups.Any())
54+
{
55+
return null;
56+
}
57+
58+
var runtimeAssemblyGroup = dependency.RuntimeAssemblyGroups[0];
59+
if (!runtimeAssemblyGroup.AssetPaths.Any())
60+
{
61+
return null;
62+
}
63+
64+
dependencyPath = Path.Combine(dependency.Path, runtimeAssemblyGroup.AssetPaths[0]);
65+
break;
66+
}
67+
}
68+
69+
if (string.IsNullOrWhiteSpace(dependencyPath))
70+
{
71+
return null;
72+
}
73+
74+
return Path.Combine(packagesRoot, dependencyPath);
75+
}
76+
77+
private static string? GetNuGetPackagesRoot()
78+
{
79+
var nugetPath = Environment.GetEnvironmentVariable(NuGetPackagesEnvName);
80+
if (!string.IsNullOrWhiteSpace(nugetPath) && Directory.Exists(nugetPath))
81+
{
82+
return nugetPath;
83+
}
84+
85+
var userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
86+
if (string.IsNullOrWhiteSpace(userProfilePath))
87+
{
88+
return null;
89+
}
90+
91+
return Path.Combine(userProfilePath, ".nuget", "packages");
92+
}
93+
}

managed/CounterStrikeSharp.API/Core/Plugin/Host/PluginManager.cs

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Concurrent;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Reflection;
45
using System.Runtime.Loader;
@@ -36,6 +37,17 @@ private void LoadLibrary(string path)
3637
config => { config.PreferSharedTypes = true; });
3738
var assembly = loader.LoadDefaultAssembly();
3839

40+
if (CoreConfig.PluginResolveNugetPackages)
41+
{
42+
foreach (var assemblyName in assembly.GetReferencedAssemblies())
43+
{
44+
if (TryLoadDependency(path, assembly.GetName().Name, assemblyName, out var dependency))
45+
{
46+
_sharedAssemblies.TryAdd(dependency.GetName().Name, dependency);
47+
}
48+
}
49+
}
50+
3951
_sharedAssemblies[assembly.GetName().Name] = assembly;
4052
}
4153

@@ -46,7 +58,7 @@ private void LoadSharedLibraries()
4658
.Select(dir => Path.Combine(dir, Path.GetFileName(dir) + ".dll"))
4759
.Where(File.Exists)
4860
.ToArray();
49-
61+
5062
foreach (var sharedAssemblyPath in sharedAssemblyPaths)
5163
{
5264
try
@@ -78,6 +90,11 @@ public void Load()
7890

7991
if (!_sharedAssemblies.TryGetValue(name.Name, out var assembly))
8092
{
93+
if (CoreConfig.PluginResolveNugetPackages && TryLoadExternalLibrary(name, out assembly))
94+
{
95+
return assembly;
96+
}
97+
8198
return null;
8299
}
83100

@@ -98,7 +115,7 @@ public void Load()
98115
}
99116
}
100117
}
101-
118+
102119
foreach (var plugin in _loadedPluginContexts)
103120
{
104121
try
@@ -112,6 +129,57 @@ public void Load()
112129
}
113130
}
114131

132+
private bool TryLoadExternalLibrary(AssemblyName assemblyName, out Assembly? assembly)
133+
{
134+
assembly = null;
135+
if (!TryResolveReflectionAssemblyPath(out var pluginName, out var pluginPath))
136+
{
137+
return false;
138+
}
139+
140+
if (!TryLoadDependency(pluginPath, pluginName, assemblyName, out assembly))
141+
{
142+
return false;
143+
}
144+
145+
return true;
146+
}
147+
148+
private bool TryLoadDependency(string pluginAssemblyPath,
149+
string pluginAssemblyName,
150+
AssemblyName dependencyAssemblyName,
151+
out Assembly? assembly)
152+
{
153+
assembly = null;
154+
155+
var dependencyName = dependencyAssemblyName.Name!;
156+
if (string.IsNullOrEmpty(pluginAssemblyPath) || _sharedAssemblies.ContainsKey(dependencyName))
157+
{
158+
return false;
159+
}
160+
161+
var resolver = new PluginContextNuGetDependencyResolver(
162+
rootAssemblyName: pluginAssemblyName,
163+
rootAssemblyPath: Path.GetDirectoryName(pluginAssemblyPath)!,
164+
assemblyName: dependencyAssemblyName);
165+
166+
var dependencyPath = resolver.ResolvePath();
167+
if (string.IsNullOrWhiteSpace(dependencyPath))
168+
{
169+
return false;
170+
}
171+
172+
var loader = PluginLoader.CreateFromAssemblyFile(dependencyPath, configure: c =>
173+
{
174+
c.PreferSharedTypes = true;
175+
});
176+
177+
assembly = loader.LoadDefaultAssembly();
178+
_sharedAssemblies[dependencyAssemblyName.Name!] = assembly;
179+
180+
return true;
181+
}
182+
115183
public IEnumerable<PluginContext> GetLoadedPlugins()
116184
{
117185
return _loadedPluginContexts;
@@ -124,4 +192,27 @@ public void LoadPlugin(string path)
124192
_loadedPluginContexts.Add(plugin);
125193
plugin.Load();
126194
}
127-
}
195+
196+
private static bool TryResolveReflectionAssemblyPath(out string? assemblyName, out string? assemblyPath)
197+
{
198+
assemblyPath = null;
199+
assemblyName = null;
200+
201+
if (AssemblyLoadContext.CurrentContextualReflectionContext is var reflectionContext && reflectionContext is null)
202+
{
203+
return false;
204+
}
205+
206+
var mainAssemblyPathField = reflectionContext
207+
.GetType()
208+
.GetField("_mainAssemblyPath", BindingFlags.NonPublic | BindingFlags.Instance);
209+
210+
if (mainAssemblyPathField is null)
211+
{
212+
return false;
213+
}
214+
215+
assemblyPath = (string)mainAssemblyPathField.GetValue(reflectionContext)!;
216+
return !string.IsNullOrEmpty(assemblyPath);
217+
}
218+
}

0 commit comments

Comments
 (0)