using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.Services; using LanMountainDesktop.PluginSdk; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace LanMountainDesktop.Plugins; public sealed class PluginLoader { private readonly PluginLoaderOptions _options; public PluginLoader(PluginLoaderOptions? options = null) { _options = options ?? new PluginLoaderOptions(); } public IReadOnlyList LoadAll( string pluginsRootDirectory, IServiceProvider? services = null, IReadOnlyDictionary? properties = null) { ArgumentException.ThrowIfNullOrWhiteSpace(pluginsRootDirectory); if (!Directory.Exists(pluginsRootDirectory)) { return Array.Empty(); } var results = new List(); var candidates = DiscoverCandidates(pluginsRootDirectory, results); var selectedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var candidate in candidates) { if (!selectedPluginIds.Add(candidate.Manifest.Id)) { results.Add(PluginLoadResult.Failure( candidate.SourcePath, candidate.Manifest, new InvalidOperationException( $"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."))); continue; } results.Add(candidate.SourceKind switch { PluginSourceKind.Package => LoadFromPackage( candidate.SourcePath, pluginsRootDirectory, candidate.Manifest, services, properties), _ => LoadFromManifest( candidate.SourcePath, candidate.Manifest, services, properties) }); } return results; } public PluginLoadResult LoadFromManifest( string manifestPath, IServiceProvider? services = null, IReadOnlyDictionary? properties = null) { PluginManifest? manifest = null; try { manifest = PluginManifest.Load(manifestPath); return LoadFromManifest(manifestPath, manifest, services, properties); } catch (Exception ex) { return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex); } } public PluginLoadResult LoadFromPackage( string packagePath, string pluginsRootDirectory, IServiceProvider? services = null, IReadOnlyDictionary? properties = null) { PluginManifest? manifest = null; try { manifest = ReadManifestFromPackage(packagePath); return LoadFromPackage(packagePath, pluginsRootDirectory, manifest, services, properties); } catch (Exception ex) { return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex); } } public PluginLoadResult LoadFromAssembly( string assemblyPath, PluginManifest manifest, IServiceProvider? services = null, IReadOnlyDictionary? properties = null) { ArgumentException.ThrowIfNullOrWhiteSpace(assemblyPath); ArgumentNullException.ThrowIfNull(manifest); var fullAssemblyPath = Path.GetFullPath(assemblyPath); var pluginDirectory = Path.GetDirectoryName(fullAssemblyPath) ?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{fullAssemblyPath}'."); var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName); return LoadCore(fullAssemblyPath, fullAssemblyPath, pluginDirectory, dataDirectory, manifest, services, properties); } private PluginLoadResult LoadCore( string sourcePath, string assemblyPath, string pluginDirectory, string dataDirectory, PluginManifest manifest, IServiceProvider? services, IReadOnlyDictionary? properties) { PluginLoadContext? loadContext = null; IPlugin? plugin = null; PluginRuntimeContext? runtimeContext = null; ServiceProvider? pluginServices = null; IReadOnlyList hostedServices = Array.Empty(); try { Directory.CreateDirectory(dataDirectory); ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory); loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames); var assembly = loadContext.LoadFromAssemblyPath(assemblyPath); var pluginType = ResolvePluginType(assembly); plugin = CreatePluginInstance(pluginType); runtimeContext = CreateRuntimeContext(manifest, pluginDirectory, dataDirectory, properties); var serviceCollection = CreateServiceCollection(runtimeContext, services); var hostBuilderContext = CreateHostBuilderContext(runtimeContext); plugin.Initialize(hostBuilderContext, serviceCollection); pluginServices = serviceCollection.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = false, ValidateOnBuild = true }); runtimeContext.SetServices(pluginServices); var settingsPages = pluginServices .GetServices() .OrderBy(page => page.SortOrder) .ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase) .ToArray(); var desktopComponents = pluginServices .GetServices() .OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase) .ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase) .ToArray(); var exportedServices = ResolveExports(manifest, pluginServices); hostedServices = pluginServices.GetServices().ToArray(); StartHostedServices(hostedServices); var loadedPlugin = new LoadedPlugin( manifest, sourcePath, assemblyPath, assembly, plugin, runtimeContext, pluginServices, settingsPages, desktopComponents, exportedServices, hostedServices, loadContext); return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin); } catch (Exception ex) { StopHostedServices(hostedServices); DisposeInstance(pluginServices); DisposeInstance(plugin); DisposeInstance(runtimeContext); loadContext?.Unload(); return PluginLoadResult.Failure(sourcePath, manifest, ex); } } private PluginLoadResult LoadFromManifest( string manifestPath, PluginManifest manifest, IServiceProvider? services, IReadOnlyDictionary? properties) { try { var fullManifestPath = Path.GetFullPath(manifestPath); var assemblyPath = manifest.ResolveEntranceAssemblyPath(fullManifestPath); if (!File.Exists(assemblyPath)) { throw new FileNotFoundException( $"Plugin '{manifest.Id}' entrance assembly '{assemblyPath}' was not found.", assemblyPath); } var pluginDirectory = Path.GetDirectoryName(assemblyPath) ?? throw new InvalidOperationException($"Failed to determine the plugin directory of '{assemblyPath}'."); var dataDirectory = Path.Combine(pluginDirectory, _options.DataDirectoryName); return LoadCore(fullManifestPath, assemblyPath, pluginDirectory, dataDirectory, manifest, services, properties); } catch (Exception ex) { return PluginLoadResult.Failure(Path.GetFullPath(manifestPath), manifest, ex); } } private PluginLoadResult LoadFromPackage( string packagePath, string pluginsRootDirectory, PluginManifest manifest, IServiceProvider? services, IReadOnlyDictionary? properties) { try { var fullPackagePath = Path.GetFullPath(packagePath); var extractionDirectory = ExtractPackage(fullPackagePath, pluginsRootDirectory); var extractedManifestPath = Path.Combine(extractionDirectory, _options.ManifestFileName); if (!File.Exists(extractedManifestPath)) { throw new FileNotFoundException( $"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'.", extractedManifestPath); } var extractedManifest = PluginManifest.Load(extractedManifestPath); if (!string.Equals(extractedManifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"Plugin package '{fullPackagePath}' manifest id changed after extraction. Expected '{manifest.Id}', actual '{extractedManifest.Id}'."); } var assemblyPath = extractedManifest.ResolveEntranceAssemblyPath(extractedManifestPath); if (!File.Exists(assemblyPath)) { throw new FileNotFoundException( $"Plugin '{extractedManifest.Id}' entrance assembly '{assemblyPath}' was not found after package extraction.", assemblyPath); } var dataDirectory = GetPackagedDataDirectory(pluginsRootDirectory, extractedManifest); return LoadCore(fullPackagePath, assemblyPath, extractionDirectory, dataDirectory, extractedManifest, services, properties); } catch (Exception ex) { return PluginLoadResult.Failure(Path.GetFullPath(packagePath), manifest, ex); } } private PluginRuntimeContext CreateRuntimeContext( PluginManifest manifest, string pluginDirectory, string dataDirectory, IReadOnlyDictionary? properties) { return new PluginRuntimeContext( manifest, pluginDirectory, dataDirectory, CreateReadOnlyProperties(properties)); } private ServiceCollection CreateServiceCollection( PluginRuntimeContext runtimeContext, IServiceProvider? hostServices) { var services = new ServiceCollection(); services.AddSingleton(runtimeContext); services.AddSingleton(runtimeContext); services.AddSingleton(runtimeContext.Manifest); services.AddSingleton>(runtimeContext.Properties); services.AddSingleton(); RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); return services; } private static void RegisterHostService(IServiceCollection services, IServiceProvider? hostServices) where TService : class { if (hostServices?.GetService(typeof(TService)) is TService service) { services.AddSingleton(service); } } private static HostBuilderContext CreateHostBuilderContext(PluginRuntimeContext runtimeContext) { var hostBuilderContext = new HostBuilderContext(new Dictionary()); hostBuilderContext.Properties["LanMountainDesktop.PluginManifest"] = runtimeContext.Manifest; hostBuilderContext.Properties["LanMountainDesktop.PluginDirectory"] = runtimeContext.PluginDirectory; hostBuilderContext.Properties["LanMountainDesktop.PluginDataDirectory"] = runtimeContext.DataDirectory; hostBuilderContext.Properties["LanMountainDesktop.PluginRuntimeContext"] = runtimeContext; foreach (var pair in runtimeContext.Properties) { if (pair.Value is not null) { hostBuilderContext.Properties[pair.Key] = pair.Value; } } return hostBuilderContext; } private static IReadOnlyList ResolveExports( PluginManifest manifest, IServiceProvider services) { return services .GetServices() .Select(registration => { if (!IsSupportedExportContract(manifest, registration.ContractType)) { throw new InvalidOperationException( $"Plugin '{manifest.Id}' exported contract '{registration.ContractType.FullName}', but export contracts must come from LanMountainDesktop.PluginSdk or a manifest-declared shared contract assembly."); } return new PluginServiceExportDescriptor( manifest.Id, registration.ContractType, services.GetService(registration.ContractType) ?? throw new InvalidOperationException( $"Plugin '{manifest.Id}' exported contract '{registration.ContractType.FullName}', but no singleton service instance was registered.")); }) .ToArray(); } private static bool IsSupportedExportContract(PluginManifest manifest, Type contractType) { if (contractType.Assembly == typeof(IPlugin).Assembly) { return true; } var assemblyFileName = contractType.Assembly.GetName().Name + ".dll"; return manifest.SharedContracts?.Any(contract => string.Equals(contract.AssemblyName, assemblyFileName, StringComparison.OrdinalIgnoreCase)) == true; } private static void StartHostedServices(IEnumerable hostedServices) { foreach (var hostedService in hostedServices) { hostedService.StartAsync(CancellationToken.None).GetAwaiter().GetResult(); } } private static void StopHostedServices(IEnumerable hostedServices) { foreach (var hostedService in hostedServices.Reverse()) { try { hostedService.StopAsync(CancellationToken.None).GetAwaiter().GetResult(); } catch { // Ignore best-effort shutdown during failed startup. } } } private IReadOnlyList DiscoverCandidates( string pluginsRootDirectory, List preparationFailures) { var candidates = new List(); foreach (var packagePath in EnumerateCandidatePaths( pluginsRootDirectory, "*" + NormalizePackageExtension(_options.PackageFileExtension))) { try { var manifest = ReadManifestFromPackage(packagePath); candidates.Add(new PluginCandidate(Path.GetFullPath(packagePath), manifest, PluginSourceKind.Package)); } catch (Exception ex) { preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(packagePath), null, ex)); } } foreach (var manifestPath in EnumerateCandidatePaths(pluginsRootDirectory, _options.ManifestFileName)) { try { var manifest = PluginManifest.Load(manifestPath); candidates.Add(new PluginCandidate(Path.GetFullPath(manifestPath), manifest, PluginSourceKind.Manifest)); } catch (Exception ex) { preparationFailures.Add(PluginLoadResult.Failure(Path.GetFullPath(manifestPath), null, ex)); } } return candidates .OrderBy(candidate => candidate.SourceKind) .ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase) .ToArray(); } private IEnumerable EnumerateCandidatePaths(string pluginsRootDirectory, string searchPattern) { var runtimeRootDirectory = EnsureTrailingSeparator(GetRuntimeRootDirectory(pluginsRootDirectory)); return Directory .EnumerateFiles(pluginsRootDirectory, searchPattern, SearchOption.AllDirectories) .Select(Path.GetFullPath) .Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)) .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); } private PluginManifest ReadManifestFromPackage(string packagePath) { ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); var fullPackagePath = Path.GetFullPath(packagePath); if (!File.Exists(fullPackagePath)) { throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath); } using var archive = ZipFile.OpenRead(fullPackagePath); var manifestEntries = archive.Entries .Where(entry => !string.IsNullOrWhiteSpace(entry.Name) && string.Equals(entry.Name, _options.ManifestFileName, StringComparison.OrdinalIgnoreCase)) .ToArray(); if (manifestEntries.Length == 0) { throw new InvalidOperationException( $"Plugin package '{fullPackagePath}' does not contain '{_options.ManifestFileName}'."); } if (manifestEntries.Length > 1) { throw new InvalidOperationException( $"Plugin package '{fullPackagePath}' contains multiple '{_options.ManifestFileName}' files."); } using var stream = manifestEntries[0].Open(); return PluginManifest.Load(stream, $"{fullPackagePath}!/{manifestEntries[0].FullName}"); } private string ExtractPackage(string packagePath, string pluginsRootDirectory) { var extractionDirectory = GetPackageExtractionDirectory(pluginsRootDirectory, packagePath); AppLogger.Info( "PluginLoader", $"Extracting package '{packagePath}' to '{extractionDirectory}'."); RecreateDirectory(extractionDirectory); ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true); return extractionDirectory; } private string GetPackageExtractionDirectory(string pluginsRootDirectory, string packagePath) { var packageName = SanitizeDirectoryName(Path.GetFileNameWithoutExtension(packagePath)); var packageHash = Convert.ToHexString( SHA256.HashData(Encoding.UTF8.GetBytes(Path.GetFullPath(packagePath)))) .Substring(0, 12); return Path.Combine( GetRuntimeRootDirectory(pluginsRootDirectory), _options.ExtractedPackagesDirectoryName, $"{packageName}_{packageHash}"); } private string GetPackagedDataDirectory(string pluginsRootDirectory, PluginManifest manifest) { return Path.Combine( GetRuntimeRootDirectory(pluginsRootDirectory), _options.PackagedDataDirectoryName, SanitizeDirectoryName(manifest.Id)); } private string GetRuntimeRootDirectory(string pluginsRootDirectory) { return Path.Combine(Path.GetFullPath(pluginsRootDirectory), _options.RuntimeDirectoryName); } private static void RecreateDirectory(string directoryPath) { if (Directory.Exists(directoryPath)) { FileOperationRetryHelper.DeleteDirectoryWithRetry(directoryPath, recursive: true, "PluginLoader"); } Directory.CreateDirectory(directoryPath); } private static string NormalizePackageExtension(string extension) { ArgumentException.ThrowIfNullOrWhiteSpace(extension); return extension.StartsWith(".", StringComparison.Ordinal) ? extension : "." + extension; } private static string EnsureTrailingSeparator(string path) { var fullPath = Path.GetFullPath(path); return fullPath.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? fullPath : fullPath + Path.DirectorySeparatorChar; } private static string SanitizeDirectoryName(string value) { var invalidCharacters = Path.GetInvalidFileNameChars(); var builder = new StringBuilder(value.Length); foreach (var ch in value) { builder.Append(invalidCharacters.Contains(ch) ? '_' : ch); } return string.IsNullOrWhiteSpace(builder.ToString()) ? "_plugin" : builder.ToString().Trim(); } private static ReadOnlyDictionary CreateReadOnlyProperties( IReadOnlyDictionary? properties) { if (properties is null || properties.Count == 0) { return new ReadOnlyDictionary( new Dictionary(StringComparer.OrdinalIgnoreCase)); } var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pair in properties) { map[pair.Key] = pair.Value; } return new ReadOnlyDictionary(map); } private static void ValidatePluginRuntimeAssets( PluginManifest manifest, string assemblyPath, string pluginDirectory) { var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json"); if (!File.Exists(depsFilePath)) { throw new InvalidOperationException( $"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly."); } var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes"); if (Directory.Exists(runtimesDirectory) && !Directory.EnumerateFiles(runtimesDirectory, "*", SearchOption.AllDirectories).Any()) { throw new InvalidOperationException( $"Plugin '{manifest.Id}' contains an empty 'runtimes' directory. Native/runtime assets must be packaged together with the plugin."); } } private static Type ResolvePluginType(Assembly assembly) { var candidateTypes = GetLoadableTypes(assembly) .Where(type => typeof(IPlugin).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface && !type.ContainsGenericParameters) .ToArray(); if (candidateTypes.Length == 0) { throw new InvalidOperationException( $"Assembly '{assembly.Location}' does not contain a concrete type implementing '{nameof(IPlugin)}'."); } var attributedTypes = candidateTypes .Where(type => type.IsDefined(typeof(PluginEntranceAttribute), inherit: false)) .ToArray(); if (attributedTypes.Length == 1) { return attributedTypes[0]; } if (attributedTypes.Length > 1) { throw new InvalidOperationException( $"Assembly '{assembly.Location}' contains multiple plugin entrance types. Mark only one type with '{nameof(PluginEntranceAttribute)}'."); } if (candidateTypes.Length == 1) { return candidateTypes[0]; } throw new InvalidOperationException( $"Assembly '{assembly.Location}' contains multiple '{nameof(IPlugin)}' implementations. Mark the intended entrance type with '{nameof(PluginEntranceAttribute)}'."); } private static IPlugin CreatePluginInstance(Type pluginType) { if (pluginType.GetConstructor(Type.EmptyTypes) is null) { throw new InvalidOperationException( $"Plugin type '{pluginType.FullName}' must expose a public parameterless constructor."); } if (Activator.CreateInstance(pluginType) is not IPlugin plugin) { throw new InvalidOperationException( $"Failed to create plugin instance of type '{pluginType.FullName}'."); } return plugin; } private static void DisposeInstance(object? instance) { if (instance is null) { return; } try { if (instance is IAsyncDisposable asyncDisposable) { asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult(); return; } if (instance is IDisposable disposable) { disposable.Dispose(); } } catch (Exception disposeError) { System.Diagnostics.Debug.WriteLine( $"[PluginLoader] Disposal of '{instance.GetType().FullName}' failed: {disposeError}"); } } private static Type[] GetLoadableTypes(Assembly assembly) { try { return assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { var loaderMessages = ex.LoaderExceptions .Where(exception => exception is not null) .Select(exception => exception!.Message) .ToArray(); var detail = loaderMessages.Length == 0 ? "No additional loader diagnostics were provided." : string.Join(Environment.NewLine, loaderMessages); throw new InvalidOperationException( $"Failed to inspect plugin assembly '{assembly.Location}'.{Environment.NewLine}{detail}", ex); } } private sealed class PluginRuntimeContext : IPluginRuntimeContext { public PluginRuntimeContext( PluginManifest manifest, string pluginDirectory, string dataDirectory, IReadOnlyDictionary properties) { Manifest = manifest; PluginDirectory = pluginDirectory; DataDirectory = dataDirectory; Properties = properties; Services = NullServiceProvider.Instance; } public PluginManifest Manifest { get; } public string PluginDirectory { get; } public string DataDirectory { get; } public IServiceProvider Services { get; private set; } public IReadOnlyDictionary Properties { get; } public T? GetService() { return (T?)Services.GetService(typeof(T)); } public bool TryGetProperty(string key, out T? value) { ArgumentException.ThrowIfNullOrWhiteSpace(key); if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue) { value = typedValue; return true; } value = default; return false; } public void SetServices(IServiceProvider services) { Services = services ?? throw new ArgumentNullException(nameof(services)); } } private sealed class PluginMessageBus : IPluginMessageBus, IDisposable { private readonly Dictionary> _subscriptions = []; private readonly object _gate = new(); private int _disposed; public IDisposable Subscribe(Action handler) { ArgumentNullException.ThrowIfNull(handler); if (Volatile.Read(ref _disposed) != 0) { throw new ObjectDisposedException(nameof(PluginMessageBus)); } var subscription = new Subscription(this, typeof(TMessage), message => handler((TMessage)message!)); lock (_gate) { if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers)) { handlers = []; _subscriptions[subscription.MessageType] = handlers; } handlers.Add(subscription); } return subscription; } public void Publish(TMessage message) { if (Volatile.Read(ref _disposed) != 0) { return; } Subscription[] handlers; lock (_gate) { if (!_subscriptions.TryGetValue(typeof(TMessage), out var subscriptions) || subscriptions.Count == 0) { return; } handlers = subscriptions.ToArray(); } foreach (var handler in handlers) { try { handler.Invoke(message); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine( $"[PluginMessageBus] Handler for '{typeof(TMessage).FullName}' failed: {ex}"); } } } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } lock (_gate) { _subscriptions.Clear(); } } private void Unsubscribe(Subscription subscription) { lock (_gate) { if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers)) { return; } handlers.Remove(subscription); if (handlers.Count == 0) { _subscriptions.Remove(subscription.MessageType); } } } private sealed class Subscription : IDisposable { private readonly PluginMessageBus _owner; private int _disposed; public Subscription(PluginMessageBus owner, Type messageType, Action handler) { _owner = owner; MessageType = messageType; Handler = handler; } public Type MessageType { get; } public Action Handler { get; } public void Invoke(object? message) { if (_disposed != 0) { return; } Handler(message); } public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } _owner.Unsubscribe(this); } } } private sealed class NullServiceProvider : IServiceProvider { public static NullServiceProvider Instance { get; } = new(); private NullServiceProvider() { } public object? GetService(Type serviceType) { return null; } } private enum PluginSourceKind { Package = 0, Manifest = 1 } private sealed record PluginCandidate( string SourcePath, PluginManifest Manifest, PluginSourceKind SourceKind); }