diff --git a/LanMountainDesktop.PluginSdk/IPlugin.cs b/LanMountainDesktop.PluginSdk/IPlugin.cs index cb0699d..33173d7 100644 --- a/LanMountainDesktop.PluginSdk/IPlugin.cs +++ b/LanMountainDesktop.PluginSdk/IPlugin.cs @@ -1,6 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + namespace LanMountainDesktop.PluginSdk; public interface IPlugin { - void Initialize(IPluginContext context); + void Initialize(HostBuilderContext context, IServiceCollection services); } diff --git a/LanMountainDesktop.PluginSdk/IPluginContext.cs b/LanMountainDesktop.PluginSdk/IPluginContext.cs index bdbe62d..dc37ece 100644 --- a/LanMountainDesktop.PluginSdk/IPluginContext.cs +++ b/LanMountainDesktop.PluginSdk/IPluginContext.cs @@ -1,27 +1,6 @@ -using System.Collections.Generic; - namespace LanMountainDesktop.PluginSdk; -public interface IPluginContext +[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")] +public interface IPluginContext : IPluginRuntimeContext { - PluginManifest Manifest { get; } - - string PluginDirectory { get; } - - string DataDirectory { get; } - - IServiceProvider Services { get; } - - IReadOnlyDictionary Properties { get; } - - T? GetService(); - - bool TryGetProperty(string key, out T? value); - - void RegisterService(TService service) - where TService : class; - - void RegisterSettingsPage(PluginSettingsPageRegistration registration); - - void RegisterDesktopComponent(PluginDesktopComponentRegistration registration); } diff --git a/LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs b/LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs new file mode 100644 index 0000000..35744bc --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs @@ -0,0 +1,13 @@ +namespace LanMountainDesktop.PluginSdk; + +public interface IPluginExportRegistry +{ + IReadOnlyList GetExports(); + + IReadOnlyList GetExports(Type contractType); + + PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId); + + TContract? GetExport(string providerPluginId) + where TContract : class; +} diff --git a/LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs b/LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs new file mode 100644 index 0000000..e55a758 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs @@ -0,0 +1,18 @@ +namespace LanMountainDesktop.PluginSdk; + +public interface IPluginRuntimeContext +{ + PluginManifest Manifest { get; } + + string PluginDirectory { get; } + + string DataDirectory { get; } + + IServiceProvider Services { get; } + + IReadOnlyDictionary Properties { get; } + + T? GetService(); + + bool TryGetProperty(string key, out T? value); +} diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj index f9a5f8f..4fa6673 100644 --- a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj +++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj @@ -1,15 +1,17 @@ - + net10.0 enable enable - 1.0.0 + 2.0.0 + + diff --git a/LanMountainDesktop.PluginSdk/PluginBase.cs b/LanMountainDesktop.PluginSdk/PluginBase.cs index 4b0e1e7..22c173d 100644 --- a/LanMountainDesktop.PluginSdk/PluginBase.cs +++ b/LanMountainDesktop.PluginSdk/PluginBase.cs @@ -1,8 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + namespace LanMountainDesktop.PluginSdk; public abstract class PluginBase : IPlugin { - public virtual void Initialize(IPluginContext context) + public virtual void Initialize(HostBuilderContext context, IServiceCollection services) { } } diff --git a/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs b/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs index 16dd239..a4213e0 100644 --- a/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs +++ b/LanMountainDesktop.PluginSdk/PluginDesktopComponentRegistration.cs @@ -7,7 +7,7 @@ public sealed class PluginDesktopComponentRegistration public PluginDesktopComponentRegistration( string componentId, string displayName, - Func controlFactory, + Func controlFactory, string iconKey = "PuzzlePiece", string category = "Plugins", int minWidthCells = 2, @@ -40,13 +40,42 @@ public sealed class PluginDesktopComponentRegistration CornerRadiusResolver = cornerRadiusResolver; } + public PluginDesktopComponentRegistration( + string componentId, + string displayName, + Func controlFactory, + string iconKey = "PuzzlePiece", + string category = "Plugins", + int minWidthCells = 2, + int minHeightCells = 2, + bool allowDesktopPlacement = true, + bool allowStatusBarPlacement = false, + PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional, + string? displayNameLocalizationKey = null, + Func? cornerRadiusResolver = null) + : this( + componentId, + displayName, + (_, context) => controlFactory(context), + iconKey, + category, + minWidthCells, + minHeightCells, + allowDesktopPlacement, + allowStatusBarPlacement, + resizeMode, + displayNameLocalizationKey, + cornerRadiusResolver) + { + } + public string ComponentId { get; } public string DisplayName { get; } public string? DisplayNameLocalizationKey { get; } - public Func ControlFactory { get; } + public Func ControlFactory { get; } public string IconKey { get; } diff --git a/LanMountainDesktop.PluginSdk/PluginLocalizer.cs b/LanMountainDesktop.PluginSdk/PluginLocalizer.cs index c1e905c..3dc58ee 100644 --- a/LanMountainDesktop.PluginSdk/PluginLocalizer.cs +++ b/LanMountainDesktop.PluginSdk/PluginLocalizer.cs @@ -26,7 +26,7 @@ public sealed class PluginLocalizer public string LanguageCode { get; } - public static PluginLocalizer Create(IPluginContext context) + public static PluginLocalizer Create(IPluginRuntimeContext context) { ArgumentNullException.ThrowIfNull(context); return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties)); diff --git a/LanMountainDesktop.PluginSdk/PluginManifest.cs b/LanMountainDesktop.PluginSdk/PluginManifest.cs index 1b18e66..04a0fc8 100644 --- a/LanMountainDesktop.PluginSdk/PluginManifest.cs +++ b/LanMountainDesktop.PluginSdk/PluginManifest.cs @@ -9,7 +9,8 @@ public sealed record PluginManifest( string? Description = null, string? Author = null, string? Version = null, - string? ApiVersion = null) + string? ApiVersion = null, + IReadOnlyList? SharedContracts = null) { private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -57,6 +58,7 @@ public sealed record PluginManifest( private PluginManifest NormalizeAndValidate(string manifestPath) { + var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts); var normalized = this with { Id = RequireValue(Id, nameof(Id), manifestPath), @@ -65,7 +67,8 @@ public sealed record PluginManifest( Description = NormalizeOptionalValue(Description), Author = NormalizeOptionalValue(Author), Version = NormalizeOptionalValue(Version), - ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion + ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion, + SharedContracts = normalizedSharedContracts }; if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion)) @@ -82,7 +85,41 @@ public sealed record PluginManifest( if (requestedVersion.Major != currentVersion.Major) { throw new InvalidOperationException( - $"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'."); + $"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'. Upgrade the plugin to API {PluginSdkInfo.ApiVersion}."); + } + + return normalized; + } + + private static IReadOnlyList NormalizeSharedContracts( + string manifestPath, + IReadOnlyList? sharedContracts) + { + if (sharedContracts is null || sharedContracts.Count == 0) + { + return Array.Empty(); + } + + var normalized = new List(sharedContracts.Count); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var contract in sharedContracts) + { + if (contract is null) + { + throw new InvalidOperationException( + $"Plugin manifest '{manifestPath}' contains a null shared contract declaration."); + } + + var normalizedContract = contract.NormalizeAndValidate(manifestPath); + var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}"; + if (!seenIds.Add(contractKey)) + { + throw new InvalidOperationException( + $"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'."); + } + + normalized.Add(normalizedContract); } return normalized; diff --git a/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs b/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs index 6915515..0ff6581 100644 --- a/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs +++ b/LanMountainDesktop.PluginSdk/PluginSdkInfo.cs @@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk; public static class PluginSdkInfo { - public const string ApiVersion = "1.0.0"; + public const string ApiVersion = "2.0.0"; public const string ManifestFileName = "plugin.json"; public const string PackageFileExtension = ".laapp"; public const string DataDirectoryName = "Data"; diff --git a/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs b/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs new file mode 100644 index 0000000..944233d --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs @@ -0,0 +1,95 @@ +using Avalonia.Controls; +using Microsoft.Extensions.DependencyInjection; + +namespace LanMountainDesktop.PluginSdk; + +public static class PluginServiceCollectionExtensions +{ + public static IServiceCollection AddPluginSettingsPage( + this IServiceCollection services, + string id, + string title, + int sortOrder = 0) + where TControl : Control + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(new PluginSettingsPageRegistration( + id, + title, + provider => ActivatorUtilities.CreateInstance(provider), + sortOrder)); + return services; + } + + public static IServiceCollection AddPluginDesktopComponent( + this IServiceCollection services, + string componentId, + string displayName, + string iconKey = "PuzzlePiece", + string category = "Plugins", + int minWidthCells = 2, + int minHeightCells = 2, + bool allowDesktopPlacement = true, + bool allowStatusBarPlacement = false, + PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional, + string? displayNameLocalizationKey = null, + Func? cornerRadiusResolver = null) + where TControl : Control + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(new PluginDesktopComponentRegistration( + componentId, + displayName, + (provider, context) => ActivatorUtilities.CreateInstance(provider, context), + iconKey, + category, + minWidthCells, + minHeightCells, + allowDesktopPlacement, + allowStatusBarPlacement, + resizeMode, + displayNameLocalizationKey, + cornerRadiusResolver)); + return services; + } + + public static IServiceCollection AddPluginExport(this IServiceCollection services) + where TContract : class + where TImplementation : class, TContract + { + ArgumentNullException.ThrowIfNull(services); + + EnsureSingletonRegistration(services); + + if (!services.Any(descriptor => + descriptor.ServiceType == typeof(PluginServiceExportRegistration) && + descriptor.ImplementationInstance is PluginServiceExportRegistration existing && + existing.ContractType == typeof(TContract) && + existing.ImplementationType == typeof(TImplementation))) + { + services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation))); + } + + return services; + } + + private static void EnsureSingletonRegistration(IServiceCollection services) + where TContract : class + where TImplementation : class, TContract + { + var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract)); + if (contractDescriptor is null) + { + services.AddSingleton(); + return; + } + + if (contractDescriptor.Lifetime != ServiceLifetime.Singleton) + { + throw new InvalidOperationException( + $"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton."); + } + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginServiceExportDescriptor.cs b/LanMountainDesktop.PluginSdk/PluginServiceExportDescriptor.cs new file mode 100644 index 0000000..f5aea71 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginServiceExportDescriptor.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed record PluginServiceExportDescriptor( + string ProviderPluginId, + Type ContractType, + object ServiceInstance); diff --git a/LanMountainDesktop.PluginSdk/PluginServiceExportRegistration.cs b/LanMountainDesktop.PluginSdk/PluginServiceExportRegistration.cs new file mode 100644 index 0000000..016b5b4 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginServiceExportRegistration.cs @@ -0,0 +1,17 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed class PluginServiceExportRegistration +{ + public PluginServiceExportRegistration(Type contractType, Type implementationType) + { + ArgumentNullException.ThrowIfNull(contractType); + ArgumentNullException.ThrowIfNull(implementationType); + + ContractType = contractType; + ImplementationType = implementationType; + } + + public Type ContractType { get; } + + public Type ImplementationType { get; } +} diff --git a/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs b/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs index 8fa6051..1db33bd 100644 --- a/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs +++ b/LanMountainDesktop.PluginSdk/PluginSettingsPageRegistration.cs @@ -7,7 +7,7 @@ public sealed class PluginSettingsPageRegistration public PluginSettingsPageRegistration( string id, string title, - Func contentFactory, + Func contentFactory, int sortOrder = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(id); @@ -20,11 +20,20 @@ public sealed class PluginSettingsPageRegistration SortOrder = sortOrder; } + public PluginSettingsPageRegistration( + string id, + string title, + Func contentFactory, + int sortOrder = 0) + : this(id, title, _ => contentFactory(), sortOrder) + { + } + public string Id { get; } public string Title { get; } public int SortOrder { get; } - public Func ContentFactory { get; } + public Func ContentFactory { get; } } diff --git a/LanMountainDesktop.PluginSdk/PluginSharedContractReference.cs b/LanMountainDesktop.PluginSdk/PluginSharedContractReference.cs new file mode 100644 index 0000000..43e5095 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginSharedContractReference.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.PluginSdk; + +public sealed record PluginSharedContractReference( + string Id, + string Version, + string AssemblyName) +{ + [JsonIgnore] + public string NormalizedId => Id.Trim(); + + [JsonIgnore] + public string NormalizedVersion => Version.Trim(); + + [JsonIgnore] + public string NormalizedAssemblyName => AssemblyName.Trim(); + + internal PluginSharedContractReference NormalizeAndValidate(string manifestPath) + { + var normalized = this with + { + Id = RequireValue(Id, nameof(Id), manifestPath), + Version = RequireValue(Version, nameof(Version), manifestPath), + AssemblyName = RequireValue(AssemblyName, nameof(AssemblyName), manifestPath) + }; + + if (!System.Version.TryParse(normalized.Version, out _)) + { + throw new InvalidOperationException( + $"Plugin manifest '{manifestPath}' declares invalid shared contract version '{normalized.Version}' for '{normalized.Id}'."); + } + + return normalized; + } + + private static string RequireValue(string? value, string propertyName, string manifestPath) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException( + $"Plugin manifest '{manifestPath}' is missing required shared contract property '{propertyName}'."); + } + + return value.Trim(); + } +} diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 71df87b..b2e7c78 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -47,6 +47,8 @@ + + diff --git a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs index dbb0686..379941e 100644 --- a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs +++ b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs @@ -118,13 +118,13 @@ public static class DesktopComponentRegistryFactory contribution.Plugin.Manifest, contribution.Plugin.Context.PluginDirectory, contribution.Plugin.Context.DataDirectory, - contribution.Plugin.Context.Services, + contribution.Plugin.Services, contribution.Plugin.Context.Properties, contribution.Registration.ComponentId, context.PlacementId, context.CellSize); - return contribution.Registration.ControlFactory(pluginContext); + return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext); } catch (Exception ex) { diff --git a/LanMountainDesktop/plugins/LoadedPlugin.cs b/LanMountainDesktop/plugins/LoadedPlugin.cs index fee7b13..9fe6d26 100644 --- a/LanMountainDesktop/plugins/LoadedPlugin.cs +++ b/LanMountainDesktop/plugins/LoadedPlugin.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.PluginSdk; +using Microsoft.Extensions.Hosting; namespace LanMountainDesktop.Plugins; @@ -18,9 +19,12 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable string assemblyPath, Assembly assembly, IPlugin plugin, - IPluginContext context, + IPluginRuntimeContext runtimeContext, + IServiceProvider services, IReadOnlyList settingsPages, IReadOnlyList desktopComponents, + IReadOnlyList exportedServices, + IReadOnlyList hostedServices, PluginLoadContext loadContext) { Manifest = manifest; @@ -28,9 +32,12 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable AssemblyPath = assemblyPath; Assembly = assembly; Plugin = plugin; - Context = context; + RuntimeContext = runtimeContext; + Services = services; SettingsPages = settingsPages; DesktopComponents = desktopComponents; + ExportedServices = exportedServices; + HostedServices = hostedServices; LoadContext = loadContext; } @@ -44,14 +51,22 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable public IPlugin Plugin { get; } - public IPluginContext Context { get; } + public IPluginRuntimeContext RuntimeContext { get; } + + public IPluginRuntimeContext Context => RuntimeContext; + + public IServiceProvider Services { get; } public IReadOnlyList SettingsPages { get; } public IReadOnlyList DesktopComponents { get; } + public IReadOnlyList ExportedServices { get; } + public PluginLoadContext LoadContext { get; } + private IReadOnlyList HostedServices { get; } + public void Dispose() { DisposeAsync().AsTask().GetAwaiter().GetResult(); @@ -64,6 +79,18 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable return; } + for (var i = HostedServices.Count - 1; i >= 0; i--) + { + try + { + await HostedServices[i].StopAsync(CancellationToken.None); + } + catch + { + // Ignore plugin hosted service shutdown failures to allow unload cleanup. + } + } + if (Plugin is IAsyncDisposable asyncDisposable) { await asyncDisposable.DisposeAsync(); @@ -73,11 +100,20 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable disposable.Dispose(); } - if (Context is IAsyncDisposable asyncContext) + if (Services is IAsyncDisposable asyncServices) + { + await asyncServices.DisposeAsync(); + } + else if (Services is IDisposable disposableServices) + { + disposableServices.Dispose(); + } + + if (RuntimeContext is IAsyncDisposable asyncContext) { await asyncContext.DisposeAsync(); } - else if (Context is IDisposable disposableContext) + else if (RuntimeContext is IDisposable disposableContext) { disposableContext.Dispose(); } diff --git a/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs b/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs index 6e7d3fc..b7c8b65 100644 --- a/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs +++ b/LanMountainDesktop/plugins/MainWindow.PluginSettingsHost.cs @@ -85,7 +85,7 @@ public partial class MainWindow Control content; try { - content = contribution.Registration.ContentFactory(); + content = contribution.Registration.ContentFactory(contribution.Plugin.Services); } catch (Exception ex) { diff --git a/LanMountainDesktop/plugins/PluginExportRegistry.cs b/LanMountainDesktop/plugins/PluginExportRegistry.cs new file mode 100644 index 0000000..2bf0044 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginExportRegistry.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.Plugins; + +internal sealed class PluginExportRegistry : IPluginExportRegistry +{ + private readonly object _gate = new(); + private readonly List _exports = []; + + public IReadOnlyList GetExports() + { + lock (_gate) + { + return _exports.ToArray(); + } + } + + public IReadOnlyList GetExports(Type contractType) + { + ArgumentNullException.ThrowIfNull(contractType); + + lock (_gate) + { + return _exports + .Where(descriptor => descriptor.ContractType == contractType) + .ToArray(); + } + } + + public PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId) + { + ArgumentNullException.ThrowIfNull(contractType); + ArgumentException.ThrowIfNullOrWhiteSpace(providerPluginId); + + lock (_gate) + { + return _exports.FirstOrDefault(descriptor => + descriptor.ContractType == contractType && + string.Equals(descriptor.ProviderPluginId, providerPluginId, StringComparison.OrdinalIgnoreCase)); + } + } + + public TContract? GetExport(string providerPluginId) + where TContract : class + { + return GetExport(typeof(TContract), providerPluginId)?.ServiceInstance as TContract; + } + + public void ReplaceExports(string pluginId, IEnumerable descriptors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentNullException.ThrowIfNull(descriptors); + + lock (_gate) + { + _exports.RemoveAll(descriptor => + string.Equals(descriptor.ProviderPluginId, pluginId, StringComparison.OrdinalIgnoreCase)); + _exports.AddRange(descriptors); + } + } + + public void RemoveExports(string pluginId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + + lock (_gate) + { + _exports.RemoveAll(descriptor => + string.Equals(descriptor.ProviderPluginId, pluginId, StringComparison.OrdinalIgnoreCase)); + } + } + + public void Clear() + { + lock (_gate) + { + _exports.Clear(); + } + } +} diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs index ab9bfc1..847ebf9 100644 --- a/LanMountainDesktop/plugins/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -12,6 +12,8 @@ using System.Threading.Tasks; using LanMountainDesktop.Services; using LanMountainDesktop.PluginSdk; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace LanMountainDesktop.Plugins; @@ -135,19 +137,45 @@ public sealed class PluginLoader { PluginLoadContext? loadContext = null; IPlugin? plugin = null; - PluginContext? context = 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); - context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties); + runtimeContext = CreateRuntimeContext(manifest, pluginDirectory, dataDirectory, properties); + var serviceCollection = CreateServiceCollection(runtimeContext, services); + var hostBuilderContext = CreateHostBuilderContext(runtimeContext); - plugin.Initialize(context); - var settingsPages = context.GetSettingsPagesSnapshot(); - var desktopComponents = context.GetDesktopComponentsSnapshot(); + 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, @@ -155,17 +183,22 @@ public sealed class PluginLoader assemblyPath, assembly, plugin, - context, + runtimeContext, + pluginServices, settingsPages, desktopComponents, + exportedServices, + hostedServices, loadContext); return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin); } catch (Exception ex) { + StopHostedServices(hostedServices); + DisposeInstance(pluginServices); DisposeInstance(plugin); - DisposeInstance(context); + DisposeInstance(runtimeContext); loadContext?.Unload(); return PluginLoadResult.Failure(sourcePath, manifest, ex); } @@ -243,23 +276,124 @@ public sealed class PluginLoader } } - private PluginContext CreateContext( + private PluginRuntimeContext CreateRuntimeContext( PluginManifest manifest, string pluginDirectory, string dataDirectory, - IServiceProvider? services, IReadOnlyDictionary? properties) { - Directory.CreateDirectory(dataDirectory); - - return new PluginContext( + return new PluginRuntimeContext( manifest, pluginDirectory, dataDirectory, - services ?? NullServiceProvider.Instance, 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) @@ -436,6 +570,27 @@ public sealed class PluginLoader 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) @@ -543,34 +698,19 @@ public sealed class PluginLoader } } - private sealed class PluginContext : IPluginContext, IDisposable, IAsyncDisposable + private sealed class PluginRuntimeContext : IPluginRuntimeContext { - private readonly Dictionary _settingsPages = - new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _desktopComponents = - new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _registeredServices = []; - private readonly List _serviceRegistrationOrder = []; - private readonly object _serviceGate = new(); - private readonly IServiceProvider _hostServices; - private int _disposed; - - public PluginContext( + public PluginRuntimeContext( PluginManifest manifest, string pluginDirectory, string dataDirectory, - IServiceProvider services, IReadOnlyDictionary properties) { Manifest = manifest; PluginDirectory = pluginDirectory; DataDirectory = dataDirectory; - _hostServices = services; - Services = new PluginCompositeServiceProvider(this); Properties = properties; - - RegisterBuiltInService(this); - RegisterBuiltInService(new PluginMessageBus()); + Services = NullServiceProvider.Instance; } public PluginManifest Manifest { get; } @@ -579,7 +719,7 @@ public sealed class PluginLoader public string DataDirectory { get; } - public IServiceProvider Services { get; } + public IServiceProvider Services { get; private set; } public IReadOnlyDictionary Properties { get; } @@ -602,181 +742,9 @@ public sealed class PluginLoader return false; } - public void RegisterService(TService service) - where TService : class + public void SetServices(IServiceProvider services) { - RegisterServiceCore(typeof(TService), service, allowOverride: false); - } - - public void RegisterSettingsPage(PluginSettingsPageRegistration registration) - { - ArgumentNullException.ThrowIfNull(registration); - ThrowIfDisposed(); - - if (!_settingsPages.TryAdd(registration.Id, registration)) - { - throw new InvalidOperationException( - $"Plugin '{Manifest.Id}' already registered a settings page with id '{registration.Id}'."); - } - } - - public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration) - { - ArgumentNullException.ThrowIfNull(registration); - ThrowIfDisposed(); - - if (!_desktopComponents.TryAdd(registration.ComponentId, registration)) - { - throw new InvalidOperationException( - $"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'."); - } - } - - public IReadOnlyList GetSettingsPagesSnapshot() - { - ThrowIfDisposed(); - return _settingsPages.Values - .OrderBy(page => page.SortOrder) - .ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - public IReadOnlyList GetDesktopComponentsSnapshot() - { - ThrowIfDisposed(); - return _desktopComponents.Values - .OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase) - .ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - internal object? ResolveService(Type serviceType) - { - if (Volatile.Read(ref _disposed) != 0) - { - return null; - } - - if (serviceType == typeof(IServiceProvider)) - { - return Services; - } - - lock (_serviceGate) - { - if (_registeredServices.TryGetValue(serviceType, out var service)) - { - return service; - } - - foreach (var registeredService in _registeredServices.Values) - { - if (serviceType.IsInstanceOfType(registeredService)) - { - return registeredService; - } - } - } - - return _hostServices.GetService(serviceType); - } - - private void RegisterBuiltInService(TService service) - where TService : class - { - RegisterServiceCore(typeof(TService), service, allowOverride: true); - } - - public void Dispose() - { - DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - object[] services; - lock (_serviceGate) - { - services = _serviceRegistrationOrder.ToArray(); - _registeredServices.Clear(); - _serviceRegistrationOrder.Clear(); - } - - _settingsPages.Clear(); - _desktopComponents.Clear(); - - var disposedServices = new HashSet(ReferenceEqualityComparer.Instance); - for (var i = services.Length - 1; i >= 0; i--) - { - var service = services[i]; - if (ReferenceEquals(service, this) || !disposedServices.Add(service)) - { - continue; - } - - if (service is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - } - else if (service is IDisposable disposable) - { - disposable.Dispose(); - } - } - } - - private void RegisterServiceCore(Type serviceType, object service, bool allowOverride) - { - ArgumentNullException.ThrowIfNull(serviceType); - ArgumentNullException.ThrowIfNull(service); - ThrowIfDisposed(); - - if (!serviceType.IsInstanceOfType(service)) - { - throw new InvalidOperationException( - $"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'."); - } - - lock (_serviceGate) - { - if (!allowOverride && _registeredServices.ContainsKey(serviceType)) - { - throw new InvalidOperationException( - $"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'."); - } - - _registeredServices[serviceType] = service; - _serviceRegistrationOrder.Add(service); - } - } - - private void ThrowIfDisposed() - { - if (Volatile.Read(ref _disposed) != 0) - { - throw new ObjectDisposedException(nameof(PluginContext)); - } - } - } - - private sealed class PluginCompositeServiceProvider : IServiceProvider - { - private readonly PluginContext _context; - - public PluginCompositeServiceProvider(PluginContext context) - { - _context = context; - } - - public object? GetService(Type serviceType) - { - ArgumentNullException.ThrowIfNull(serviceType); - return _context.ResolveService(serviceType); + Services = services ?? throw new ArgumentNullException(nameof(services)); } } diff --git a/LanMountainDesktop/plugins/PluginMarketModels.cs b/LanMountainDesktop/plugins/PluginMarketModels.cs index 41c5416..f018bba 100644 --- a/LanMountainDesktop/plugins/PluginMarketModels.cs +++ b/LanMountainDesktop/plugins/PluginMarketModels.cs @@ -215,6 +215,8 @@ internal sealed class AirAppMarketIndexDocument public DateTimeOffset GeneratedAt { get; init; } + public List Contracts { get; init; } = []; + public List Plugins { get; init; } = []; public static AirAppMarketIndexDocument Load(string json, string sourceName) @@ -236,6 +238,23 @@ internal sealed class AirAppMarketIndexDocument private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName) { + var contracts = Contracts ?? []; + var normalizedContracts = new List(contracts.Count); + var seenContracts = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var contract in contracts) + { + var normalizedContract = contract.ValidateAndNormalize(sourceName); + var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}"; + if (!seenContracts.Add(contractKey)) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' contains duplicate shared contract '{contractKey}'."); + } + + normalizedContracts.Add(normalizedContract); + } + var plugins = Plugins ?? []; var normalizedPlugins = new List(plugins.Count); var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -260,6 +279,10 @@ internal sealed class AirAppMarketIndexDocument GeneratedAt = GeneratedAt == default ? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.") : GeneratedAt, + Contracts = normalizedContracts + .OrderBy(contract => contract.Id, StringComparer.OrdinalIgnoreCase) + .ThenBy(contract => contract.Version, StringComparer.OrdinalIgnoreCase) + .ToList(), Plugins = normalizedPlugins .OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase) .ToList() @@ -373,6 +396,56 @@ internal sealed class AirAppMarketIndexDocument } } +internal sealed class AirAppMarketSharedContractEntry +{ + public string Id { get; init; } = string.Empty; + + public string Version { get; init; } = string.Empty; + + public string AssemblyName { get; init; } = string.Empty; + + public string DownloadUrl { get; init; } = string.Empty; + + public string Sha256 { get; init; } = string.Empty; + + public long PackageSizeBytes { get; init; } + + public AirAppMarketSharedContractEntry ValidateAndNormalize(string sourceName) + { + var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant() + ?? throw new InvalidOperationException( + $"Market index '{sourceName}' is missing required property '{nameof(Sha256)}' for a shared contract."); + if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch))) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for shared contract '{Id}'."); + } + + var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl) + ?? throw new InvalidOperationException( + $"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}' for shared contract '{Id}'."); + AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName); + + if (PackageSizeBytes <= 0) + { + throw new InvalidOperationException( + $"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for shared contract '{Id}'."); + } + + return new AirAppMarketSharedContractEntry + { + Id = AirAppMarketIndexDocument.NormalizeValue(Id) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing a shared contract id."), + Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName), + AssemblyName = AirAppMarketIndexDocument.NormalizeValue(AssemblyName) + ?? throw new InvalidOperationException($"Market index '{sourceName}' is missing assemblyName for shared contract '{Id}'."), + DownloadUrl = normalizedDownloadUrl, + Sha256 = normalizedSha, + PackageSizeBytes = PackageSizeBytes + }; + } +} + internal sealed class AirAppMarketPluginEntry { public string Id { get; init; } = string.Empty; diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index cf4a844..c5b52f0 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -12,6 +12,8 @@ using Avalonia.Markup.Xaml; using LanMountainDesktop.Models; using LanMountainDesktop.Plugins; using LanMountainDesktop.PluginSdk; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace LanMountainDesktop.Services; @@ -19,9 +21,12 @@ public sealed class PluginRuntimeService : IDisposable { private const string PendingDeletionFileName = ".pending-plugin-deletions.json"; + private readonly PluginLoaderOptions _loaderOptions; private readonly PluginLoader _loader; private readonly AppSettingsService _appSettingsService = new(); private readonly IHostApplicationLifecycle _applicationLifecycle = new HostApplicationLifecycleService(); + private readonly PluginExportRegistry _exportRegistry = new(); + private readonly PluginSharedContractManager _sharedContractManager; private readonly IServiceProvider _hostServices; private readonly IPluginPackageManager _packageManager; private readonly List _loadedPlugins = []; @@ -34,9 +39,12 @@ public sealed class PluginRuntimeService : IDisposable public PluginRuntimeService() { PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins"); + _sharedContractManager = new PluginSharedContractManager( + Path.Combine(GetUserDataRootDirectory(), "PluginMarket")); _packageManager = new PluginRuntimePackageManager(this); - _hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle); - _loader = new PluginLoader(CreateOptions()); + _hostServices = new PluginHostServiceProvider(_packageManager, _applicationLifecycle, _exportRegistry); + _loaderOptions = CreateOptions(); + _loader = new PluginLoader(_loaderOptions); } public string PluginsDirectory { get; } @@ -51,6 +59,8 @@ public sealed class PluginRuntimeService : IDisposable public IReadOnlyList DesktopComponents => _desktopComponents; + public IPluginExportRegistry ExportRegistry => _exportRegistry; + public void LoadInstalledPlugins() { Directory.CreateDirectory(PluginsDirectory); @@ -101,6 +111,27 @@ public sealed class PluginRuntimeService : IDisposable continue; } + try + { + RegisterSharedContractsForLoad(candidate.Manifest); + } + catch (Exception ex) + { + var dependencyFailure = PluginLoadResult.Failure(candidate.SourcePath, candidate.Manifest, ex); + _loadResults.Add(dependencyFailure); + _catalog.Add(new PluginCatalogEntry( + candidate.Manifest, + candidate.SourcePath, + candidate.SourceKind == PluginCatalogSourceKind.Package, + true, + false, + ex.Message, + 0, + 0)); + Debug.WriteLine($"[PluginRuntime] Failed to prepare dependencies for '{candidate.Manifest.Id}': {ex}"); + continue; + } + var loadResult = candidate.SourceKind switch { PluginCatalogSourceKind.Package => _loader.LoadFromPackage( @@ -277,6 +308,7 @@ public sealed class PluginRuntimeService : IDisposable Directory.CreateDirectory(PluginsDirectory); var manifest = ReadManifestFromPackage(fullPackagePath); + _sharedContractManager.EnsureInstalled(manifest); AppLogger.Info( "PluginRuntime", $"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'."); @@ -308,6 +340,7 @@ public sealed class PluginRuntimeService : IDisposable } var manifest = ReadManifestFromPackage(fullPackagePath); + _sharedContractManager.EnsureInstalled(manifest); AppLogger.Info( "PluginRuntime", $"Registering externally installed package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'."); @@ -319,16 +352,19 @@ public sealed class PluginRuntimeService : IDisposable public void Dispose() { UnloadInstalledPlugins(); + _sharedContractManager.Dispose(); } private void UnloadInstalledPlugins() { for (var i = _loadedPlugins.Count - 1; i >= 0; i--) { + _exportRegistry.RemoveExports(_loadedPlugins[i].Manifest.Id); _loadedPlugins[i].Dispose(); } _loadedPlugins.Clear(); + _exportRegistry.Clear(); _loadResults.Clear(); _catalog.Clear(); _settingsPages.Clear(); @@ -483,10 +519,23 @@ public sealed class PluginRuntimeService : IDisposable : path + Path.DirectorySeparatorChar; } + private static string GetUserDataRootDirectory() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + return Path.Combine(AppContext.BaseDirectory, "Data"); + } + + return Path.Combine(localAppData, "LanMountainDesktop"); + } + private static PluginLoaderOptions CreateOptions() { var options = new PluginLoaderOptions(); AddSharedAssembly(options, typeof(App).Assembly); + AddSharedAssembly(options, typeof(IServiceCollection).Assembly); + AddSharedAssembly(options, typeof(HostBuilderContext).Assembly); foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) { @@ -517,6 +566,8 @@ public sealed class PluginRuntimeService : IDisposable private void CollectContributions(LoadedPlugin loadedPlugin) { + _exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices); + foreach (var settingsPage in loadedPlugin.SettingsPages) { _settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage)); @@ -528,6 +579,17 @@ public sealed class PluginRuntimeService : IDisposable } } + private void RegisterSharedContractsForLoad(PluginManifest manifest) + { + foreach (var assemblyName in _sharedContractManager.PrepareForLoad(manifest)) + { + if (!string.IsNullOrWhiteSpace(assemblyName)) + { + _loaderOptions.SharedAssemblyNames.Add(assemblyName); + } + } + } + private void ApplyPendingPluginDeletions() { var pendingPaths = ReadPendingPluginDeletions(); @@ -681,13 +743,16 @@ public sealed class PluginRuntimeService : IDisposable { private readonly IPluginPackageManager _packageManager; private readonly IHostApplicationLifecycle _applicationLifecycle; + private readonly IPluginExportRegistry _exportRegistry; public PluginHostServiceProvider( IPluginPackageManager packageManager, - IHostApplicationLifecycle applicationLifecycle) + IHostApplicationLifecycle applicationLifecycle, + IPluginExportRegistry exportRegistry) { _packageManager = packageManager; _applicationLifecycle = applicationLifecycle; + _exportRegistry = exportRegistry; } public object? GetService(Type serviceType) @@ -702,6 +767,11 @@ public sealed class PluginRuntimeService : IDisposable return _applicationLifecycle; } + if (serviceType == typeof(IPluginExportRegistry)) + { + return _exportRegistry; + } + return null; } } diff --git a/LanMountainDesktop/plugins/PluginSharedContractManager.cs b/LanMountainDesktop/plugins/PluginSharedContractManager.cs new file mode 100644 index 0000000..36de8e1 --- /dev/null +++ b/LanMountainDesktop/plugins/PluginSharedContractManager.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Runtime.Loader; +using System.Security.Cryptography; +using System.Threading; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Views.SettingsPages; + +namespace LanMountainDesktop.Plugins; + +internal sealed class PluginSharedContractManager : IDisposable +{ + private readonly string _contractsDirectory; + private readonly AirAppMarketIndexService _indexService; + private readonly HttpClient _httpClient; + private readonly object _gate = new(); + private readonly Dictionary _loadedContracts = + new(StringComparer.OrdinalIgnoreCase); + + public PluginSharedContractManager(string cacheDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory); + + _contractsDirectory = Path.Combine( + GetSharedContractRootDirectory(), + "SharedContracts"); + _indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(cacheDirectory)); + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromMinutes(2) + }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-SharedContracts/1.0"); + } + + public string ContractsDirectory => _contractsDirectory; + + public void EnsureInstalled(PluginManifest manifest, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.SharedContracts is not { Count: > 0 }) + { + return; + } + + var document = LoadIndex(cancellationToken); + foreach (var reference in manifest.SharedContracts) + { + EnsureInstalled(document, reference, cancellationToken); + } + } + + public IReadOnlyList PrepareForLoad(PluginManifest manifest, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.SharedContracts is not { Count: > 0 }) + { + return Array.Empty(); + } + + var assemblyNames = new List(manifest.SharedContracts.Count); + foreach (var reference in manifest.SharedContracts) + { + var assemblyPath = GetInstalledAssemblyPath(reference); + if (!File.Exists(assemblyPath)) + { + throw new InvalidOperationException( + $"Plugin '{manifest.Id}' requires shared contract '{reference.Id}' version '{reference.Version}', but '{assemblyPath}' is not installed. Install the dependency from the market first."); + } + + var loaded = LoadSharedAssembly(reference, assemblyPath); + assemblyNames.Add(loaded.AssemblyName); + } + + return assemblyNames; + } + + public void Dispose() + { + _httpClient.Dispose(); + _indexService.Dispose(); + } + + private void EnsureInstalled( + AirAppMarketIndexDocument document, + PluginSharedContractReference reference, + CancellationToken cancellationToken) + { + var entry = document.Contracts.FirstOrDefault(candidate => + string.Equals(candidate.Id, reference.Id, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Version, reference.Version, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + throw new InvalidOperationException( + $"Shared contract '{reference.Id}' version '{reference.Version}' is not published in the configured market index."); + } + + if (!string.Equals(entry.AssemblyName, reference.AssemblyName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Shared contract '{reference.Id}' version '{reference.Version}' expects assembly '{reference.AssemblyName}', but the market entry provides '{entry.AssemblyName}'."); + } + + var destinationPath = GetInstalledAssemblyPath(reference); + if (IsInstalledAndMatches(destinationPath, entry)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + var temporaryPath = destinationPath + ".download"; + try + { + if (AirAppMarketDefaults.TryResolveWorkspaceFile(entry.DownloadUrl, out var localSourcePath)) + { + File.Copy(localSourcePath, temporaryPath, overwrite: true); + } + else + { + using var response = _httpClient.GetAsync(entry.DownloadUrl, cancellationToken) + .GetAwaiter() + .GetResult(); + response.EnsureSuccessStatusCode(); + using var responseStream = response.Content.ReadAsStreamAsync(cancellationToken) + .GetAwaiter() + .GetResult(); + using var fileStream = File.Create(temporaryPath); + responseStream.CopyTo(fileStream); + } + + ValidateInstalledFile(temporaryPath, entry); + File.Move(temporaryPath, destinationPath, overwrite: true); + } + finally + { + TryDeleteFile(temporaryPath); + } + } + + private AirAppMarketIndexDocument LoadIndex(CancellationToken cancellationToken) + { + var result = _indexService.LoadAsync(cancellationToken).GetAwaiter().GetResult(); + if (!result.Success || result.Document is null) + { + throw new InvalidOperationException( + $"Failed to load market index for shared contract resolution: {result.ErrorMessage ?? "Unknown error"}"); + } + + return result.Document; + } + + private LoadedSharedContract LoadSharedAssembly( + PluginSharedContractReference reference, + string assemblyPath) + { + var assemblyName = AssemblyLoadContext.GetAssemblyName(assemblyPath).Name + ?? throw new InvalidOperationException($"Failed to determine assembly name of '{assemblyPath}'."); + + lock (_gate) + { + if (_loadedContracts.TryGetValue(assemblyName, out var existing)) + { + if (!string.Equals(existing.ContractId, reference.Id, StringComparison.OrdinalIgnoreCase) || + !string.Equals(existing.ContractVersion, reference.Version, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Shared contract assembly '{assemblyName}' is already loaded as '{existing.ContractId}' version '{existing.ContractVersion}', so plugin dependency '{reference.Id}' version '{reference.Version}' cannot be activated in the same host process."); + } + + return existing; + } + + var assembly = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(candidate => + string.Equals(candidate.GetName().Name, assemblyName, StringComparison.OrdinalIgnoreCase)) + ?? AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); + + var loaded = new LoadedSharedContract(reference.Id, reference.Version, assemblyName, assemblyPath, assembly); + _loadedContracts[assemblyName] = loaded; + return loaded; + } + } + + private static bool IsInstalledAndMatches(string assemblyPath, AirAppMarketSharedContractEntry entry) + { + if (!File.Exists(assemblyPath)) + { + return false; + } + + try + { + ValidateInstalledFile(assemblyPath, entry); + return true; + } + catch + { + return false; + } + } + + private static void ValidateInstalledFile(string assemblyPath, AirAppMarketSharedContractEntry entry) + { + var actualSize = new FileInfo(assemblyPath).Length; + if (actualSize != entry.PackageSizeBytes) + { + throw new InvalidOperationException( + $"Shared contract '{entry.Id}' version '{entry.Version}' size mismatch. Expected {entry.PackageSizeBytes}, actual {actualSize}."); + } + + using var stream = File.OpenRead(assemblyPath); + var actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + if (!string.Equals(actualHash, entry.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Shared contract '{entry.Id}' version '{entry.Version}' hash mismatch. Expected {entry.Sha256}, actual {actualHash}."); + } + } + + private string GetInstalledAssemblyPath(PluginSharedContractReference reference) + { + return Path.Combine( + _contractsDirectory, + Sanitize(reference.Id), + Sanitize(reference.Version), + reference.AssemblyName); + } + + private static string GetSharedContractRootDirectory() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + return Path.Combine(AppContext.BaseDirectory, "Data"); + } + + return Path.Combine(localAppData, "LanMountainDesktop"); + } + + private static string Sanitize(string value) + { + var invalidChars = Path.GetInvalidFileNameChars(); + return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Ignore cleanup failures. + } + } + + private sealed record LoadedSharedContract( + string ContractId, + string ContractVersion, + string AssemblyName, + string AssemblyPath, + Assembly Assembly); +} diff --git a/LanMountainDesktop/plugins/README.md b/LanMountainDesktop/plugins/README.md index 0ebde62..33693af 100644 --- a/LanMountainDesktop/plugins/README.md +++ b/LanMountainDesktop/plugins/README.md @@ -47,6 +47,9 @@ This directory contains the host-side plugin runtime for LanMountainDesktop. - load plugin assemblies - integrate plugin settings pages and desktop components - expose market and plugin management in the host UI +- build a plugin-scoped `IServiceCollection`/`ServiceProvider` for API `2.0.0` plugins +- resolve shared contract assemblies into a version-isolated cache before plugin activation +- expose explicit cross-plugin exports through `IPluginExportRegistry` ### Market install order @@ -55,3 +58,9 @@ This directory contains the host-side plugin runtime for LanMountainDesktop. 3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`. 4. Plugin details always come from the repository root `README.md`. 5. Market installs are staged and take effect after restart. + +### Dependency model + +- Plugin-private managed and native NuGet dependencies remain plugin-local and are resolved through `AssemblyDependencyResolver`. +- Shared contract assemblies are downloaded from the official market index, cached under `LocalAppData/LanMountainDesktop/SharedContracts///`, and loaded into the default context so host and plugins share the same contract types. +- Different contract versions are isolated on disk. If two active plugins request incompatible versions of the same shared assembly name in one process, the host fails the later activation with a clear error instead of loading an ambiguous contract. diff --git a/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs index 0383fde..ed1d40a 100644 --- a/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs +++ b/LanMountainDesktop/plugins/SettingsWindow.PluginSettingsHost.cs @@ -77,7 +77,7 @@ public partial class SettingsWindow Control content; try { - content = contribution.Registration.ContentFactory(); + content = contribution.Registration.ContentFactory(contribution.Plugin.Services); } catch (Exception ex) {