6520ebf221
Self-hosted, plugin-extensible personal finance manager built with ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17. Backend (8 .NET projects): - Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces - Data: EF Core DbContext, 16 entity configurations, category seeder - API: 14 controllers, 15 services, JWT auth, SignalR, middleware - Plugins: Abstractions + OFX/CSV/QIF file parsers - Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers) Frontend (Vue 3 + Vuetify 3 + TypeScript): - 13 views, Pinia stores, Axios API services with JWT interceptors - Dashboard, accounts, transactions, categories, imports, and more Deployment: - Docker Compose (PostgreSQL 17 + .NET API + nginx frontend) - Auto-migration on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
113 lines
3.7 KiB
C#
113 lines
3.7 KiB
C#
using System.Reflection;
|
|
using System.Runtime.Loader;
|
|
using System.Text.Json;
|
|
using Purrse.Plugins.Abstractions;
|
|
|
|
namespace Purrse.Api.Services;
|
|
|
|
public class PluginService
|
|
{
|
|
private readonly ILogger<PluginService> _logger;
|
|
private readonly string _pluginsPath;
|
|
private readonly Dictionary<string, LoadedPlugin> _loadedPlugins = new();
|
|
|
|
public PluginService(ILogger<PluginService> logger, IConfiguration config)
|
|
{
|
|
_logger = logger;
|
|
_pluginsPath = config["Plugins:Path"] ?? Path.Combine(AppContext.BaseDirectory, "plugins");
|
|
}
|
|
|
|
public IEnumerable<IPlugin> GetPlugins() => _loadedPlugins.Values.Select(p => p.Instance);
|
|
public IEnumerable<T> GetPlugins<T>() where T : IPlugin => _loadedPlugins.Values.Select(p => p.Instance).OfType<T>();
|
|
|
|
public async Task DiscoverAndLoadPluginsAsync()
|
|
{
|
|
if (!Directory.Exists(_pluginsPath))
|
|
{
|
|
Directory.CreateDirectory(_pluginsPath);
|
|
return;
|
|
}
|
|
|
|
foreach (var dir in Directory.GetDirectories(_pluginsPath))
|
|
{
|
|
var manifestPath = Path.Combine(dir, "plugin.json");
|
|
if (!File.Exists(manifestPath)) continue;
|
|
|
|
try
|
|
{
|
|
var json = await File.ReadAllTextAsync(manifestPath);
|
|
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
|
if (manifest == null) continue;
|
|
|
|
await LoadPluginAsync(dir, manifest);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load plugin from {Directory}", dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task LoadPluginAsync(string directory, PluginManifest manifest)
|
|
{
|
|
var assemblyPath = Path.Combine(directory, manifest.EntryAssembly);
|
|
if (!File.Exists(assemblyPath))
|
|
{
|
|
_logger.LogWarning("Plugin assembly not found: {Path}", assemblyPath);
|
|
return;
|
|
}
|
|
|
|
var loadContext = new AssemblyLoadContext(manifest.Id, isCollectible: true);
|
|
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
|
var pluginType = assembly.GetType(manifest.EntryType);
|
|
|
|
if (pluginType == null)
|
|
{
|
|
_logger.LogWarning("Plugin type not found: {Type}", manifest.EntryType);
|
|
loadContext.Unload();
|
|
return;
|
|
}
|
|
|
|
if (Activator.CreateInstance(pluginType) is not IPlugin plugin)
|
|
{
|
|
_logger.LogWarning("Type {Type} does not implement IPlugin", manifest.EntryType);
|
|
loadContext.Unload();
|
|
return;
|
|
}
|
|
|
|
var context = new DefaultPluginContext(directory);
|
|
await plugin.InitializeAsync(context);
|
|
|
|
_loadedPlugins[manifest.Id] = new LoadedPlugin(manifest, plugin, loadContext);
|
|
_logger.LogInformation("Loaded plugin: {Name} v{Version}", manifest.Name, manifest.Version);
|
|
}
|
|
|
|
public async Task UnloadPluginAsync(string pluginId)
|
|
{
|
|
if (_loadedPlugins.TryGetValue(pluginId, out var loaded))
|
|
{
|
|
await loaded.Instance.ShutdownAsync();
|
|
loaded.LoadContext.Unload();
|
|
_loadedPlugins.Remove(pluginId);
|
|
}
|
|
}
|
|
|
|
private record LoadedPlugin(PluginManifest Manifest, IPlugin Instance, AssemblyLoadContext LoadContext);
|
|
}
|
|
|
|
internal class DefaultPluginContext : IPluginContext
|
|
{
|
|
public string PluginDataDirectory { get; }
|
|
public IServiceProvider Services { get; } = null!;
|
|
|
|
public DefaultPluginContext(string dataDir)
|
|
{
|
|
PluginDataDirectory = dataDir;
|
|
}
|
|
|
|
public void Log(string message, Purrse.Plugins.Abstractions.LogLevel level = Purrse.Plugins.Abstractions.LogLevel.Information)
|
|
{
|
|
Console.WriteLine($"[{level}] {message}");
|
|
}
|
|
}
|