mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.5.19
插件系统V2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal file
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal 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;
|
||||
}
|
||||
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginServiceExportDescriptor(
|
||||
string ProviderPluginId,
|
||||
Type ContractType,
|
||||
object ServiceInstance);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user