From 3e8b70f86986b1fc5fd8abeba8533e1a25d478da Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Mon, 8 Sep 2025 22:20:20 +0200 Subject: [PATCH] Add project files. --- AddonLib.sln | 37 ++ AddonLib/AddonLib.csproj | 16 + .../Attributes/AddonAccessibleAttribute.cs | 22 ++ AddonLib/Attributes/AddonAttribute.cs | 18 + AddonLib/Attributes/AddonCallableAttribute.cs | 14 + AddonLib/Attributes/AddonCallbackAttribute.cs | 14 + AddonLib/Core/AddonBase.cs | 35 ++ AddonLib/Core/AddonManager.cs | 338 ++++++++++++++++++ AddonLib/Core/Extensions.cs | 34 ++ HostApp/HostApp.csproj | 11 + HostApp/Program.cs | 47 +++ SampleAddon/AddonMain.cs | 33 ++ SampleAddon/SampleAddon.csproj | 11 + 13 files changed, 630 insertions(+) create mode 100644 AddonLib.sln create mode 100644 AddonLib/AddonLib.csproj create mode 100644 AddonLib/Attributes/AddonAccessibleAttribute.cs create mode 100644 AddonLib/Attributes/AddonAttribute.cs create mode 100644 AddonLib/Attributes/AddonCallableAttribute.cs create mode 100644 AddonLib/Attributes/AddonCallbackAttribute.cs create mode 100644 AddonLib/Core/AddonBase.cs create mode 100644 AddonLib/Core/AddonManager.cs create mode 100644 AddonLib/Core/Extensions.cs create mode 100644 HostApp/HostApp.csproj create mode 100644 HostApp/Program.cs create mode 100644 SampleAddon/AddonMain.cs create mode 100644 SampleAddon/SampleAddon.csproj diff --git a/AddonLib.sln b/AddonLib.sln new file mode 100644 index 0000000..7a40e3e --- /dev/null +++ b/AddonLib.sln @@ -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 diff --git a/AddonLib/AddonLib.csproj b/AddonLib/AddonLib.csproj new file mode 100644 index 0000000..f141a70 --- /dev/null +++ b/AddonLib/AddonLib.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + + + + + + + + + + diff --git a/AddonLib/Attributes/AddonAccessibleAttribute.cs b/AddonLib/Attributes/AddonAccessibleAttribute.cs new file mode 100644 index 0000000..fa1e530 --- /dev/null +++ b/AddonLib/Attributes/AddonAccessibleAttribute.cs @@ -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; + } +} diff --git a/AddonLib/Attributes/AddonAttribute.cs b/AddonLib/Attributes/AddonAttribute.cs new file mode 100644 index 0000000..51374de --- /dev/null +++ b/AddonLib/Attributes/AddonAttribute.cs @@ -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)); + } +} diff --git a/AddonLib/Attributes/AddonCallableAttribute.cs b/AddonLib/Attributes/AddonCallableAttribute.cs new file mode 100644 index 0000000..724df7e --- /dev/null +++ b/AddonLib/Attributes/AddonCallableAttribute.cs @@ -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; + } +} diff --git a/AddonLib/Attributes/AddonCallbackAttribute.cs b/AddonLib/Attributes/AddonCallbackAttribute.cs new file mode 100644 index 0000000..2652ed0 --- /dev/null +++ b/AddonLib/Attributes/AddonCallbackAttribute.cs @@ -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 + } +} diff --git a/AddonLib/Core/AddonBase.cs b/AddonLib/Core/AddonBase.cs new file mode 100644 index 0000000..97d29a5 --- /dev/null +++ b/AddonLib/Core/AddonBase.cs @@ -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; } +} diff --git a/AddonLib/Core/AddonManager.cs b/AddonLib/Core/AddonManager.cs new file mode 100644 index 0000000..ec173d9 --- /dev/null +++ b/AddonLib/Core/AddonManager.cs @@ -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 _addons = new(); + private readonly ConcurrentDictionary> _callbacksByEvent = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _hostCallables = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _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 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(); + 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(); + 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(); + 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(); + if (acc == null) continue; + var name = acc.Name ?? member.Name; + _hostAccessibles[name] = new HostAccessible(null!, member, acc.Access); + } + } + + public void RegisterHostApi(T host) where T : notnull => RegisterHostApi((object)host); + + public void InitializePluginsDirectory(bool createIfMissing = true) + { + if (createIfMissing && !Directory.Exists(PluginsDirectory)) + { + Directory.CreateDirectory(PluginsDirectory); + } + } + + public IReadOnlyList LoadAllFromPluginsDirectory(SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + var results = new List(); + 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() + ?? 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(); + if (cb == null) continue; + var evt = cb.EventName ?? m.Name; + _callbacksByEvent.AddOrUpdate(evt, _ => new List { 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(); + 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"); + } + } +} diff --git a/AddonLib/Core/Extensions.cs b/AddonLib/Core/Extensions.cs new file mode 100644 index 0000000..202db5a --- /dev/null +++ b/AddonLib/Core/Extensions.cs @@ -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(); + 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(); + return metadata != null; + } +} diff --git a/HostApp/HostApp.csproj b/HostApp/HostApp.csproj new file mode 100644 index 0000000..78386bd --- /dev/null +++ b/HostApp/HostApp.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/HostApp/Program.cs b/HostApp/Program.cs new file mode 100644 index 0000000..a64a370 --- /dev/null +++ b/HostApp/Program.cs @@ -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(); + } + } + } +} diff --git a/SampleAddon/AddonMain.cs b/SampleAddon/AddonMain.cs new file mode 100644 index 0000000..7f8a384 --- /dev/null +++ b/SampleAddon/AddonMain.cs @@ -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"); + } +} diff --git a/SampleAddon/SampleAddon.csproj b/SampleAddon/SampleAddon.csproj new file mode 100644 index 0000000..0ff0fdb --- /dev/null +++ b/SampleAddon/SampleAddon.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + +