Add project files.

This commit is contained in:
Exil Productions
2025-09-08 22:20:20 +02:00
parent a946407117
commit 3e8b70f869
13 changed files with 630 additions and 0 deletions

37
AddonLib.sln Normal file
View File

@@ -0,0 +1,37 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36414.22
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AddonLib", "AddonLib\AddonLib.csproj", "{F20927BC-313D-8EE1-5263-444B92411CF3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostApp", "HostApp\HostApp.csproj", "{B33C25BD-0E10-6867-A1D8-A08C7A680C6A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleAddon", "SampleAddon\SampleAddon.csproj", "{FC4F0E89-D513-CD24-E6A8-5E548CFF96BC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F20927BC-313D-8EE1-5263-444B92411CF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F20927BC-313D-8EE1-5263-444B92411CF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F20927BC-313D-8EE1-5263-444B92411CF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F20927BC-313D-8EE1-5263-444B92411CF3}.Release|Any CPU.Build.0 = Release|Any CPU
{B33C25BD-0E10-6867-A1D8-A08C7A680C6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B33C25BD-0E10-6867-A1D8-A08C7A680C6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B33C25BD-0E10-6867-A1D8-A08C7A680C6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B33C25BD-0E10-6867-A1D8-A08C7A680C6A}.Release|Any CPU.Build.0 = Release|Any CPU
{FC4F0E89-D513-CD24-E6A8-5E548CFF96BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FC4F0E89-D513-CD24-E6A8-5E548CFF96BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC4F0E89-D513-CD24-E6A8-5E548CFF96BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC4F0E89-D513-CD24-E6A8-5E548CFF96BC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {EC0F0193-2573-4F67-9021-1793935F7514}
EndGlobalSection
EndGlobal

16
AddonLib/AddonLib.csproj Normal file
View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Compile Remove="HostApp\**\*.cs" />
<Compile Remove="SampleAddon\**\*.cs" />
<None Include="HostApp\**\*" />
<None Include="SampleAddon\**\*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using System;
namespace AddonLib;
public enum AddonAccess
{
ReadOnly,
ReadWrite
}
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
public sealed class AddonAccessibleAttribute : Attribute
{
public AddonAccess Access { get; }
public string? Name { get; }
public AddonAccessibleAttribute(AddonAccess access = AddonAccess.ReadOnly, string? name = null)
{
Access = access;
Name = name;
}
}

View File

@@ -0,0 +1,18 @@
using System;
namespace AddonLib;
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class AddonAttribute : Attribute
{
public string Name { get; }
public string Author { get; }
public string Version { get; }
public AddonAttribute(string name, string author, string version)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Author = author ?? throw new ArgumentNullException(nameof(author));
Version = version ?? throw new ArgumentNullException(nameof(version));
}
}

View File

@@ -0,0 +1,14 @@
using System;
namespace AddonLib;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class AddonCallableAttribute : Attribute
{
public string? Name { get; }
public AddonCallableAttribute(string? name = null)
{
Name = name;
}
}

View File

@@ -0,0 +1,14 @@
using System;
namespace AddonLib;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public sealed class AddonCallbackAttribute : Attribute
{
public string? EventName { get; }
public AddonCallbackAttribute(string? eventName = null)
{
EventName = eventName; // if null, the method name will be used
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace AddonLib;
public abstract class AddonBase
{
internal AddonManager? Manager { get; set; }
public IServiceProvider? Services { get; internal set; }
public AddonContext Context { get; internal set; } = default!;
// Lifecycle hooks
public virtual void OnLoaded() {}
public virtual void OnUnloading() {}
// Helpers available to addons
protected object? CallHost(string name, params object?[] args)
=> (Manager ?? throw new InvalidOperationException("Addon is not attached to a manager")).CallHost(name, args);
protected object? GetHostValue(string name)
=> (Manager ?? throw new InvalidOperationException("Addon is not attached to a manager")).GetHostValue(name);
protected void SetHostValue(string name, object? value)
=> (Manager ?? throw new InvalidOperationException("Addon is not attached to a manager")).SetHostValue(name, value);
}
public sealed class AddonContext
{
public required string Name { get; init; }
public required string Author { get; init; }
public required string Version { get; init; }
public required Assembly Assembly { get; init; }
}

View File

@@ -0,0 +1,338 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
namespace AddonLib;
public sealed class AddonManager
{
private readonly List<LoadedAddon> _addons = new();
private readonly ConcurrentDictionary<string, List<MethodInfo>> _callbacksByEvent = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HostCallable> _hostCallables = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HostAccessible> _hostAccessibles = new(StringComparer.OrdinalIgnoreCase);
private readonly IServiceProvider? _defaultServices;
public string PluginsDirectory { get; }
// Default constructor keeps previous behavior (no auto init)
public AddonManager() : this(null, autoInitialize: false, services: null) { }
// New constructor with auto-initialization support
public AddonManager(string? pluginsDirectory, bool autoInitialize = true, IServiceProvider? services = null)
{
PluginsDirectory = ResolvePluginsDirectory(pluginsDirectory);
_defaultServices = services;
if (autoInitialize)
{
InitializePluginsDirectory(createIfMissing: true);
LoadAllFromPluginsDirectory();
}
}
public IReadOnlyList<LoadedAddon> LoadedAddons => _addons;
public void RegisterHostApi(object host)
{
if (host == null) throw new ArgumentNullException(nameof(host));
var hostType = host.GetType();
// instance methods
foreach (var method in hostType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
var callable = method.GetCustomAttribute<AddonCallableAttribute>();
if (callable == null) continue;
var name = callable.Name ?? method.Name;
_hostCallables[name] = new HostCallable(host, method);
}
// static methods on the same type (for convenience)
foreach (var method in hostType.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
{
var callable = method.GetCustomAttribute<AddonCallableAttribute>();
if (callable == null) continue;
var name = callable.Name ?? method.Name;
_hostCallables[name] = new HostCallable(null!, method);
}
// instance members
foreach (var member in hostType.GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
var acc = member.GetCustomAttribute<AddonAccessibleAttribute>();
if (acc == null) continue;
var name = acc.Name ?? member.Name;
_hostAccessibles[name] = new HostAccessible(host, member, acc.Access);
}
// static members on the same type (for convenience)
foreach (var member in hostType.GetMembers(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
{
var acc = member.GetCustomAttribute<AddonAccessibleAttribute>();
if (acc == null) continue;
var name = acc.Name ?? member.Name;
_hostAccessibles[name] = new HostAccessible(null!, member, acc.Access);
}
}
public void RegisterHostApi<T>(T host) where T : notnull => RegisterHostApi((object)host);
public void InitializePluginsDirectory(bool createIfMissing = true)
{
if (createIfMissing && !Directory.Exists(PluginsDirectory))
{
Directory.CreateDirectory(PluginsDirectory);
}
}
public IReadOnlyList<LoadedAddon> LoadAllFromPluginsDirectory(SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var results = new List<LoadedAddon>();
if (!Directory.Exists(PluginsDirectory)) return results;
foreach (var dll in Directory.EnumerateFiles(PluginsDirectory, "*.dll", searchOption))
{
try
{
var asm = Assembly.LoadFrom(dll);
// Attempt to load; skip if it isn't an addon
try
{
var loaded = LoadFrom(asm, _defaultServices);
results.Add(loaded);
}
catch (InvalidOperationException)
{
// Not an addon assembly or missing AddonBase impl, skip
}
}
catch
{
// Ignore any load errors and continue scanning
}
}
return results;
}
public LoadedAddon LoadFrom(string assemblyPath, IServiceProvider? services = null)
{
if (assemblyPath == null) throw new ArgumentNullException(nameof(assemblyPath));
var asm = Assembly.LoadFrom(assemblyPath);
return LoadFrom(asm, services);
}
public LoadedAddon LoadFrom(Assembly assembly, IServiceProvider? services = null)
{
var addonMeta = assembly.GetCustomAttribute<AddonAttribute>()
?? throw new InvalidOperationException("Assembly is not marked with [assembly: Addon(...)]");
var baseType = typeof(AddonBase);
var coreType = assembly.GetTypes().FirstOrDefault(t => !t.IsAbstract && baseType.IsAssignableFrom(t))
?? throw new InvalidOperationException("No AddonBase implementation found in assembly");
var instance = (AddonBase)Activator.CreateInstance(coreType)!;
instance.Services = services ?? _defaultServices;
instance.Context = new AddonContext
{
Name = addonMeta.Name,
Author = addonMeta.Author,
Version = addonMeta.Version,
Assembly = assembly
};
instance.Manager = this;
var loaded = new LoadedAddon(instance);
_addons.Add(loaded);
// discover callbacks
foreach (var m in coreType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
var cb = m.GetCustomAttribute<AddonCallbackAttribute>();
if (cb == null) continue;
var evt = cb.EventName ?? m.Name;
_callbacksByEvent.AddOrUpdate(evt, _ => new List<MethodInfo> { m }, (_, list) => { list.Add(m); return list; });
}
instance.OnLoaded();
return loaded;
}
public void Unload(LoadedAddon addon)
{
if (!_addons.Remove(addon)) return;
try { addon.Instance.OnUnloading(); } catch { /* swallow */ }
// remove callbacks for this addon
foreach (var kvp in _callbacksByEvent.ToArray())
{
kvp.Value.RemoveAll(m => m.DeclaringType == addon.Instance.GetType());
if (kvp.Value.Count == 0) _callbacksByEvent.TryRemove(kvp.Key, out _);
}
}
public void RaiseCallback(string eventName, params object?[] args)
{
if (!_callbacksByEvent.TryGetValue(eventName, out var list)) return;
foreach (var method in list.ToArray())
{
var addon = _addons.FirstOrDefault(a => a.Instance.GetType() == method.DeclaringType);
if (addon == null) continue;
InvokeAddonMethod(addon.Instance, method, args);
}
}
// Convenience: raise based on a caller method annotated with [AddonCallback]
public void RaiseCallback(MethodBase method, params object?[] args)
{
var cb = method.GetCustomAttribute<AddonCallbackAttribute>();
if (cb == null) return;
var eventName = cb.EventName ?? method.Name;
RaiseCallback(eventName, args);
}
public object? CallHost(string name, params object?[] args)
{
if (!_hostCallables.TryGetValue(name, out var callable))
throw new KeyNotFoundException($"Host method not found: {name}");
return callable.Method.Invoke(callable.Target, args);
}
public object? GetHostValue(string name)
{
if (!_hostAccessibles.TryGetValue(name, out var access))
throw new KeyNotFoundException($"Host member not found: {name}");
return access.GetValue();
}
public void SetHostValue(string name, object? value)
{
if (!_hostAccessibles.TryGetValue(name, out var access))
throw new KeyNotFoundException($"Host member not found: {name}");
access.SetValue(value);
}
private static void InvokeAddonMethod(AddonBase instance, MethodInfo method, object?[] args)
{
var parameters = method.GetParameters();
var invokeArgs = new object?[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
var p = parameters[i];
var targetType = p.ParameterType;
// Inject common services automatically
if (targetType == typeof(AddonContext))
{
invokeArgs[i] = instance.Context;
continue;
}
if (typeof(IServiceProvider).IsAssignableFrom(targetType) && instance.Services != null && targetType.IsInstanceOfType(instance.Services))
{
invokeArgs[i] = instance.Services;
continue;
}
if (targetType == typeof(AddonManager) && instance.Manager != null)
{
invokeArgs[i] = instance.Manager;
continue;
}
// Map positional args and attempt conversion when needed
if (i < args.Length && args[i] != null)
{
var supplied = args[i];
if (targetType.IsInstanceOfType(supplied))
{
invokeArgs[i] = supplied;
continue;
}
var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
try
{
if (supplied is IConvertible && typeof(IConvertible).IsAssignableFrom(underlying))
{
invokeArgs[i] = Convert.ChangeType(supplied, underlying);
continue;
}
}
catch
{
// ignore conversion errors and fall back below
}
}
if (p.HasDefaultValue)
{
invokeArgs[i] = p.DefaultValue;
}
else
{
invokeArgs[i] = targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
}
}
method.Invoke(instance, invokeArgs);
}
private static string ResolvePluginsDirectory(string? provided)
{
if (string.IsNullOrWhiteSpace(provided))
return Path.Combine(AppContext.BaseDirectory, "Plugins");
return Path.IsPathFullyQualified(provided!)
? provided!
: Path.Combine(AppContext.BaseDirectory, provided!);
}
}
public sealed class LoadedAddon
{
internal LoadedAddon(AddonBase instance) { Instance = instance; }
public AddonBase Instance { get; }
}
internal sealed record HostCallable(object Target, MethodInfo Method);
internal sealed class HostAccessible
{
private readonly object _target;
private readonly MemberInfo _member;
private readonly AddonAccess _access;
public HostAccessible(object target, MemberInfo member, AddonAccess access)
{
_target = target;
_member = member;
_access = access;
}
public object? GetValue()
{
return _member switch
{
FieldInfo f => f.GetValue(_target),
PropertyInfo p => p.GetValue(_target),
_ => throw new NotSupportedException("Unsupported member type")
};
}
public void SetValue(object? value)
{
if (_access == AddonAccess.ReadOnly)
throw new InvalidOperationException("Member is read-only for addons");
switch (_member)
{
case FieldInfo f:
f.SetValue(_target, value);
break;
case PropertyInfo p:
if (!p.CanWrite) throw new InvalidOperationException("Property is not writable");
p.SetValue(_target, value);
break;
default:
throw new NotSupportedException("Unsupported member type");
}
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace AddonLib;
public static class AddonExtensions
{
// Helper for host to raise callbacks sourced from a method marked with [AddonCallback]
public static void RaiseAddonCallbackFrom(this AddonManager manager, MethodBase method, params object?[] args)
{
if (manager == null) throw new ArgumentNullException(nameof(manager));
var cb = method.GetCustomAttribute<AddonCallbackAttribute>();
if (cb == null) return;
var eventName = cb.EventName ?? method.Name;
manager.RaiseCallback(eventName, args);
}
// Convenience overload: use caller member name when you intentionally rely on method name mapping
public static void RaiseAddonCallback(this AddonManager manager, [CallerMemberName] string? eventName = null, params object?[] args)
{
if (manager == null) throw new ArgumentNullException(nameof(manager));
if (string.IsNullOrEmpty(eventName)) return;
manager.RaiseCallback(eventName!, args);
}
public static bool TryGetAddonMetadata(this Assembly assembly, [MaybeNullWhen(false)] out AddonAttribute metadata)
{
metadata = assembly.GetCustomAttribute<AddonAttribute>();
return metadata != null;
}
}

11
HostApp/HostApp.csproj Normal file
View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AddonLib\AddonLib.csproj" />
</ItemGroup>
</Project>

47
HostApp/Program.cs Normal file
View File

@@ -0,0 +1,47 @@
using System.Reflection;
using AddonLib;
namespace HostApp;
public class HostApi
{
[AddonCallable]
public string Echo(string message) => $"Echo: {message}";
[AddonAccessible(AddonAccess.ReadWrite)]
public int Counter { get; set; }
[AddonCallback] // event name defaults to method name
public void OnTick()
{
// Raise callback with caller name, no need to specify event name
_manager.RaiseAddonCallback();
}
private readonly AddonManager _manager;
public HostApi(AddonManager manager) => _manager = manager;
}
internal class Program
{
private static void Main(string[] args)
{
// Initialize manager with Plugins folder and autoload
var manager = new AddonManager("Plugins", autoInitialize: true);
// Register host API so addons can call Echo and access Counter
var hostApi = new HostApi(manager);
manager.RegisterHostApi(hostApi);
Console.WriteLine("Host initialized. Press T to tick, Q to quit.");
while (true)
{
var key = Console.ReadKey(intercept: true).Key;
if (key == ConsoleKey.Q) break;
if (key == ConsoleKey.T)
{
hostApi.OnTick();
}
}
}
}

33
SampleAddon/AddonMain.cs Normal file
View File

@@ -0,0 +1,33 @@
using System.Reflection;
using AddonLib;
[assembly: Addon("Sample Addon", "Addon Author", "1.0.0")]
namespace SampleAddon;
public sealed class AddonMain : AddonBase
{
// Listen for host OnTick event by method name
[AddonCallback]
private void OnTick(AddonContext ctx)
{
Console.WriteLine($"[{ctx.Name}] Tick received. Counter is {GetHostValue("Counter")}");
var response = CallHost("Echo", $"Tick at {DateTime.Now:HH:mm:ss}");
Console.WriteLine(response);
// Increment host Counter
var counter = (int?)GetHostValue("Counter") ?? 0;
SetHostValue("Counter", counter + 1);
}
public override void OnLoaded()
{
Console.WriteLine("SampleAddon loaded");
}
public override void OnUnloading()
{
Console.WriteLine("SampleAddon unloading");
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AddonLib.csproj" />
<ProjectReference Include="..\AddonLib\AddonLib.csproj" />
</ItemGroup>
</Project>