Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Services/PluginService.cs
T
Catherine Renelle 6520ebf221 Initial commit: Purrse personal finance app
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>
2026-02-08 08:56:46 -05:00

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}");
}
}