插件系统V2
This commit is contained in:
lincube
2026-03-12 09:22:03 +08:00
parent 57c5e41a5c
commit d3356f3319
26 changed files with 1025 additions and 254 deletions

View File

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

View File

@@ -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<string, object?> Properties { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);
void RegisterService<TService>(TService service)
where TService : class;
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
}

View File

@@ -0,0 +1,13 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginExportRegistry
{
IReadOnlyList<PluginServiceExportDescriptor> GetExports();
IReadOnlyList<PluginServiceExportDescriptor> GetExports(Type contractType);
PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId);
TContract? GetExport<TContract>(string providerPluginId)
where TContract : class;
}

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginRuntimeContext
{
PluginManifest Manifest { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IServiceProvider Services { get; }
IReadOnlyDictionary<string, object?> Properties { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);
}

View File

@@ -1,15 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Version>2.0.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -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)
{
}
}

View File

@@ -7,7 +7,7 @@ public sealed class PluginDesktopComponentRegistration
public PluginDesktopComponentRegistration(
string componentId,
string displayName,
Func<PluginDesktopComponentContext, Control> controlFactory,
Func<IServiceProvider, PluginDesktopComponentContext, Control> 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<PluginDesktopComponentContext, Control> 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<double, double>? 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<PluginDesktopComponentContext, Control> ControlFactory { get; }
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; }

View File

@@ -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));

View File

@@ -9,7 +9,8 @@ public sealed record PluginManifest(
string? Description = null,
string? Author = null,
string? Version = null,
string? ApiVersion = null)
string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? 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<PluginSharedContractReference> NormalizeSharedContracts(
string manifestPath,
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
{
if (sharedContracts is null || sharedContracts.Count == 0)
{
return Array.Empty<PluginSharedContractReference>();
}
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
var seenIds = new HashSet<string>(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;

View File

@@ -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";

View File

@@ -0,0 +1,95 @@
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public static class PluginServiceCollectionExtensions
{
public static IServiceCollection AddPluginSettingsPage<TControl>(
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<TControl>(provider),
sortOrder));
return services;
}
public static IServiceCollection AddPluginDesktopComponent<TControl>(
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<double, double>? cornerRadiusResolver = null)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(new PluginDesktopComponentRegistration(
componentId,
displayName,
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
iconKey,
category,
minWidthCells,
minHeightCells,
allowDesktopPlacement,
allowStatusBarPlacement,
resizeMode,
displayNameLocalizationKey,
cornerRadiusResolver));
return services;
}
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsureSingletonRegistration<TContract, TImplementation>(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<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
if (contractDescriptor is null)
{
services.AddSingleton<TContract, TImplementation>();
return;
}
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
{
throw new InvalidOperationException(
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
}
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginServiceExportDescriptor(
string ProviderPluginId,
Type ContractType,
object ServiceInstance);

View File

@@ -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; }
}

View File

@@ -7,7 +7,7 @@ public sealed class PluginSettingsPageRegistration
public PluginSettingsPageRegistration(
string id,
string title,
Func<Control> contentFactory,
Func<IServiceProvider, Control> contentFactory,
int sortOrder = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
@@ -20,11 +20,20 @@ public sealed class PluginSettingsPageRegistration
SortOrder = sortOrder;
}
public PluginSettingsPageRegistration(
string id,
string title,
Func<Control> contentFactory,
int sortOrder = 0)
: this(id, title, _ => contentFactory(), sortOrder)
{
}
public string Id { get; }
public string Title { get; }
public int SortOrder { get; }
public Func<Control> ContentFactory { get; }
public Func<IServiceProvider, Control> ContentFactory { get; }
}

View File

@@ -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();
}
}