Files
LanMountainDesktop/LanMountainDesktop/plugins/PluginLoader.cs
lincube b71687cecd Introduce render gate and chart caching
Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout.
2026-05-06 16:00:45 +08:00

1186 lines
44 KiB
C#

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.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.IPC;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Plugins;
public sealed class PluginLoader
{
private readonly PluginLoaderOptions _options;
public PluginLoader(PluginLoaderOptions? options = null)
{
_options = options ?? new PluginLoaderOptions();
}
public IReadOnlyList<PluginLoadResult> LoadAll(
string pluginsRootDirectory,
IServiceProvider? services = null,
IReadOnlyDictionary<string, object?>? properties = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsRootDirectory);
if (!Directory.Exists(pluginsRootDirectory))
{
return Array.Empty<PluginLoadResult>();
}
var results = new List<PluginLoadResult>();
var candidates = DiscoverCandidates(pluginsRootDirectory, results);
var selectedPluginIds = new HashSet<string>(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<string, object?>? 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<string, object?>? 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<string, object?>? 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<string, object?>? properties)
{
PluginLoadContext? loadContext = null;
IPlugin? plugin = null;
PluginRuntimeContext? runtimeContext = null;
ServiceProvider? pluginServices = null;
IReadOnlyList<IHostedService> hostedServices = Array.Empty<IHostedService>();
try
{
Directory.CreateDirectory(dataDirectory);
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory, _options.IsDevMode);
AppLogger.Info(
"PluginLoader",
$"LoadCore starting. PluginId='{manifest.Id}'; AssemblyPath='{assemblyPath}'; PluginDirectory='{pluginDirectory}'; DataDirectory='{dataDirectory}'.");
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
AppLogger.Info("PluginLoader", $"Assembly loaded. PluginId='{manifest.Id}'; Assembly='{assembly.FullName}'.");
var pluginType = ResolvePluginType(assembly);
plugin = CreatePluginInstance(pluginType);
AppLogger.Info("PluginLoader", $"Plugin instance created. PluginId='{manifest.Id}'; PluginType='{pluginType.FullName}'.");
runtimeContext = CreateRuntimeContext(manifest, pluginDirectory, dataDirectory, properties, services);
var serviceCollection = CreateServiceCollection(runtimeContext, services);
var hostBuilderContext = CreateHostBuilderContext(runtimeContext);
plugin.Initialize(hostBuilderContext, serviceCollection);
AppLogger.Info("PluginLoader", $"Plugin Initialize completed. PluginId='{manifest.Id}'.");
pluginServices = serviceCollection.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = false,
ValidateOnBuild = true
});
AppLogger.Info("PluginLoader", $"Service provider built. PluginId='{manifest.Id}'.");
runtimeContext.SetServices(pluginServices);
var settingsSections = pluginServices
.GetServices<PluginSettingsSectionRegistration>()
.OrderBy(section => section.SortOrder)
.ThenBy(section => section.TitleLocalizationKey, StringComparer.OrdinalIgnoreCase)
.ToArray();
var desktopComponents = pluginServices
.GetServices<PluginDesktopComponentRegistration>()
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var desktopComponentEditors = pluginServices
.GetServices<PluginDesktopComponentEditorRegistration>()
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
.ToArray();
var exportedServices = ResolveExports(manifest, pluginServices);
var publicIpcServices = ResolvePublicIpcServices(manifest, pluginServices);
AppLogger.Info(
"PluginLoader",
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}; PublicIpcServices={publicIpcServices.Count}.");
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
StartHostedServices(hostedServices);
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
var loadedPlugin = new LoadedPlugin(
manifest,
sourcePath,
assemblyPath,
assembly,
plugin,
runtimeContext,
pluginServices,
settingsSections,
desktopComponents,
desktopComponentEditors,
exportedServices,
publicIpcServices,
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<string, object?>? 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<string, object?>? 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<string, object?>? properties,
IServiceProvider? hostServices)
{
return new PluginRuntimeContext(
manifest,
pluginDirectory,
dataDirectory,
CreateReadOnlyProperties(properties),
BuildAppearanceSnapshot(hostServices));
}
private ServiceCollection CreateServiceCollection(
PluginRuntimeContext runtimeContext,
IServiceProvider? hostServices)
{
var services = new ServiceCollection();
services.AddSingleton(runtimeContext);
services.AddSingleton<IPluginRuntimeContext>(runtimeContext);
services.AddSingleton<IPluginAppearanceContext>(runtimeContext.Appearance);
services.AddSingleton(runtimeContext.Manifest);
services.AddSingleton<IReadOnlyDictionary<string, object?>>(runtimeContext.Properties);
services.AddSingleton<IPluginMessageBus, PluginMessageBus>();
services.AddSingleton<IPluginSettingsService>(provider =>
new PluginScopedSettingsService(
runtimeContext.Manifest.Id,
provider.GetRequiredService<ISettingsService>()));
RegisterHostService<IPluginPackageManager>(services, hostServices);
RegisterHostService<IHostApplicationLifecycle>(services, hostServices);
RegisterHostService<IPluginExportRegistry>(services, hostServices);
RegisterHostService<ISettingsFacadeService>(services, hostServices);
RegisterHostService<ISettingsService>(services, hostServices);
RegisterHostService<ISettingsCatalog>(services, hostServices);
RegisterHostService<IAppearanceThemeService>(services, hostServices);
RegisterHostService<IMaterialColorService>(services, hostServices);
RegisterHostService<IExternalIpcNotificationPublisher>(services, hostServices);
return services;
}
private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices)
{
var defaultSnapshot = new PluginAppearanceSnapshot(
CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24),
ThemeVariant: "Unknown");
try
{
if (hostServices?.GetService(typeof(IMaterialColorService)) is IMaterialColorService materialColorService)
{
return PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(materialColorService.GetMaterialColorSnapshot());
}
if (hostServices?.GetService(typeof(IAppearanceThemeService)) is IAppearanceThemeService appearanceThemeService)
{
return PluginAppearanceSnapshotMapper.FromAppearanceSnapshot(appearanceThemeService.GetCurrent());
}
return defaultSnapshot;
}
catch (Exception ex)
{
AppLogger.Warn("PluginLoader", "Failed to resolve host appearance snapshot for plugin runtime context.", ex);
return defaultSnapshot;
}
}
private static void RegisterHostService<TService>(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<object, object>());
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<PluginServiceExportDescriptor> ResolveExports(
PluginManifest manifest,
IServiceProvider services)
{
return services
.GetServices<PluginServiceExportRegistration>()
.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 IReadOnlyList<PluginPublicIpcServiceDescriptor> ResolvePublicIpcServices(
PluginManifest manifest,
IServiceProvider services)
{
var descriptors = new List<PluginPublicIpcServiceDescriptor>();
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var registration in services.GetServices<PluginPublicIpcServiceRegistration>())
{
var implementation = services.GetService(registration.ContractType)
?? throw new InvalidOperationException(
$"Plugin '{manifest.Id}' registered public IPC contract '{registration.ContractType.FullName}', but no singleton service instance was found.");
AddDescriptor(registration.ContractType, implementation, registration.ObjectId, registration.NotifyIds);
}
var builder = new RuntimePluginPublicIpcBuilder(services, AddDescriptor);
foreach (var contributor in services.GetServices<IPluginPublicIpcContributor>())
{
contributor.ConfigurePublicIpc(builder);
}
return descriptors;
void AddDescriptor(Type contractType, object implementation, string? objectId, IEnumerable<string>? notifyIds)
{
EnsurePublicIpcContract(manifest, contractType);
var normalizedObjectId = objectId ?? string.Empty;
var dedupeKey = $"{contractType.AssemblyQualifiedName}::{normalizedObjectId}";
if (!seenKeys.Add(dedupeKey))
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' registered duplicate public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}'.");
}
descriptors.Add(new PluginPublicIpcServiceDescriptor(
contractType,
implementation,
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
notifyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? []));
}
}
private static void EnsurePublicIpcContract(PluginManifest manifest, Type contractType)
{
if (!contractType.IsInterface)
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' public IPC contract '{contractType.FullName}' must be an interface.");
}
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
}
}
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<IHostedService> hostedServices)
{
foreach (var hostedService in hostedServices)
{
AppLogger.Info("PluginLoader", $"Starting hosted service '{hostedService.GetType().FullName}'.");
hostedService.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
}
}
private static void StopHostedServices(IEnumerable<IHostedService> hostedServices)
{
foreach (var hostedService in hostedServices.Reverse())
{
try
{
hostedService.StopAsync(CancellationToken.None).GetAwaiter().GetResult();
}
catch
{
// Ignore best-effort shutdown during failed startup.
}
}
}
private IReadOnlyList<PluginCandidate> DiscoverCandidates(
string pluginsRootDirectory,
List<PluginLoadResult> preparationFailures)
{
var candidates = new List<PluginCandidate>();
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<string> 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);
// 检查是否可以跳过解压(缓存有效)
if (ShouldSkipExtraction(packagePath, extractionDirectory))
{
AppLogger.Info(
"PluginLoader",
$"Skipping extraction for '{packagePath}'. Cache is up-to-date.");
return extractionDirectory;
}
AppLogger.Info(
"PluginLoader",
$"Extracting package '{packagePath}' to '{extractionDirectory}'.");
RecreateDirectory(extractionDirectory);
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
// 保存解压元数据用于后续缓存检查
SaveExtractionMetadata(packagePath, extractionDirectory);
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 bool ShouldSkipExtraction(string packagePath, string extractionDirectory)
{
// 如果解压目录不存在,必须解压
if (!Directory.Exists(extractionDirectory))
{
return false;
}
// 检查元数据文件是否存在
var metadataPath = Path.Combine(extractionDirectory, ".extraction-metadata.json");
if (!File.Exists(metadataPath))
{
return false;
}
try
{
var packageInfo = new FileInfo(packagePath);
var metadata = ReadExtractionMetadata(metadataPath);
// 如果包文件修改时间晚于解压时间,需要重新解压
// 同时检查文件大小是否匹配
return packageInfo.Length == metadata.PackageSize &&
packageInfo.LastWriteTimeUtc <= metadata.ExtractedAt;
}
catch (Exception ex)
{
AppLogger.Warn(
"PluginLoader",
$"Failed to read extraction metadata for '{packagePath}'. Will re-extract.",
ex);
return false;
}
}
private void SaveExtractionMetadata(string packagePath, string extractionDirectory)
{
try
{
var packageInfo = new FileInfo(packagePath);
var metadata = new ExtractionMetadata
{
PackagePath = Path.GetFullPath(packagePath),
ExtractedAt = DateTime.UtcNow,
PackageSize = packageInfo.Length,
PackageLastWriteTime = packageInfo.LastWriteTimeUtc
};
var metadataPath = Path.Combine(extractionDirectory, ".extraction-metadata.json");
var json = JsonSerializer.Serialize(metadata, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(metadataPath, json);
}
catch (Exception ex)
{
AppLogger.Warn(
"PluginLoader",
$"Failed to save extraction metadata for '{packagePath}'.",
ex);
}
}
private static ExtractionMetadata ReadExtractionMetadata(string metadataPath)
{
var json = File.ReadAllText(metadataPath);
return JsonSerializer.Deserialize<ExtractionMetadata>(json)
?? throw new InvalidOperationException("Failed to deserialize extraction metadata.");
}
private sealed class ExtractionMetadata
{
public string PackagePath { get; set; } = string.Empty;
public DateTime ExtractedAt { get; set; }
public long PackageSize { get; set; }
public DateTime PackageLastWriteTime { get; set; }
}
private static ReadOnlyDictionary<string, object?> CreateReadOnlyProperties(
IReadOnlyDictionary<string, object?>? properties)
{
if (properties is null || properties.Count == 0)
{
return new ReadOnlyDictionary<string, object?>(
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
}
var map = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in properties)
{
map[pair.Key] = pair.Value;
}
return new ReadOnlyDictionary<string, object?>(map);
}
private static void ValidatePluginRuntimeAssets(
PluginManifest manifest,
string assemblyPath,
string pluginDirectory,
bool isDevMode)
{
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
if (!File.Exists(depsFilePath))
{
if (isDevMode)
{
AppLogger.Warn(
"PluginLoader",
$"Plugin '{manifest.Id}' is missing '{Path.GetFileName(depsFilePath)}'. In developer mode this is allowed, but dependency resolution may fail at runtime.");
}
else
{
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
{
private readonly PluginAppearanceContext _appearanceContext;
public PluginRuntimeContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IReadOnlyDictionary<string, object?> properties,
PluginAppearanceSnapshot appearanceSnapshot)
{
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Properties = properties;
_appearanceContext = new PluginAppearanceContext(appearanceSnapshot);
Appearance = _appearanceContext;
Services = NullServiceProvider.Instance;
}
public PluginManifest Manifest { get; }
public string PluginDirectory { get; }
public string DataDirectory { get; }
public IServiceProvider Services { get; private set; }
public IReadOnlyDictionary<string, object?> Properties { get; }
public IPluginAppearanceContext Appearance { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(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));
}
/// <summary>
/// 更新外观快照并通知插件。
/// </summary>
internal void UpdateAppearanceSnapshot(PluginAppearanceSnapshot newSnapshot, IReadOnlyCollection<AppearanceProperty> changedProperties)
{
_appearanceContext.UpdateSnapshot(newSnapshot, changedProperties);
}
}
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable
{
private readonly Dictionary<Type, List<Subscription>> _subscriptions = [];
private readonly object _gate = new();
private int _disposed;
public IDisposable Subscribe<TMessage>(Action<TMessage> 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>(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<object?> handler)
{
_owner = owner;
MessageType = messageType;
Handler = handler;
}
public Type MessageType { get; }
public Action<object?> 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);
private sealed class RuntimePluginPublicIpcBuilder : IPluginPublicIpcBuilder
{
private readonly IServiceProvider _services;
private readonly Action<Type, object, string?, IEnumerable<string>?> _register;
public RuntimePluginPublicIpcBuilder(
IServiceProvider services,
Action<Type, object, string?, IEnumerable<string>?> register)
{
_services = services;
_register = register;
}
public IPluginPublicIpcBuilder AddService<TContract>(
string? objectId = null,
IEnumerable<string>? notifyIds = null)
where TContract : class
{
var implementation = _services.GetService(typeof(TContract))
?? throw new InvalidOperationException(
$"Plugin public IPC contributor requested contract '{typeof(TContract).FullName}', but no singleton service was registered.");
_register(typeof(TContract), implementation, objectId, notifyIds);
return this;
}
public IPluginPublicIpcBuilder AddService(
Type contractType,
object implementation,
string? objectId = null,
IEnumerable<string>? notifyIds = null)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementation);
_register(contractType, implementation, objectId, notifyIds);
return this;
}
}
}