mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
0.5.1
插件系统试验
This commit is contained in:
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class DesktopComponentRegistryFactory
|
||||
{
|
||||
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registry = ComponentRegistry
|
||||
.CreateDefault()
|
||||
.RegisterExtensions(
|
||||
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
||||
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
||||
|
||||
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
|
||||
return pluginDefinitions.Count == 0
|
||||
? registry
|
||||
: registry.RegisterComponents(pluginDefinitions);
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
|
||||
ComponentRegistry componentRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
|
||||
var registeredIds = new HashSet<string>(
|
||||
registrations.Select(registration => registration.ComponentId),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (pluginRuntimeService is not null)
|
||||
{
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!registeredIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
registrations.Add(new DesktopComponentRuntimeRegistration(
|
||||
registration.ComponentId,
|
||||
registration.DisplayNameLocalizationKey,
|
||||
factoryContext => CreatePluginControl(contribution, factoryContext),
|
||||
registration.CornerRadiusResolver));
|
||||
}
|
||||
}
|
||||
|
||||
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
|
||||
}
|
||||
|
||||
private static List<DesktopComponentDefinition> GetPluginDefinitions(
|
||||
ComponentRegistry baseRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var definitions = new List<DesktopComponentDefinition>();
|
||||
if (pluginRuntimeService is null)
|
||||
{
|
||||
return definitions;
|
||||
}
|
||||
|
||||
var knownIds = new HashSet<string>(
|
||||
baseRegistry.GetAll().Select(definition => definition.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!knownIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.Add(new DesktopComponentDefinition(
|
||||
registration.ComponentId,
|
||||
registration.DisplayName,
|
||||
registration.IconKey,
|
||||
registration.Category,
|
||||
registration.MinWidthCells,
|
||||
registration.MinHeightCells,
|
||||
registration.AllowStatusBarPlacement,
|
||||
registration.AllowDesktopPlacement,
|
||||
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
|
||||
? DesktopComponentResizeMode.Free
|
||||
: DesktopComponentResizeMode.Proportional));
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static Control CreatePluginControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
DesktopComponentControlFactoryContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Context.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize);
|
||||
|
||||
return contribution.Registration.ControlFactory(pluginContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
|
||||
return CreatePluginErrorControl(contribution, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Control CreatePluginErrorControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
Exception exception)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(12),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = contribution.Registration.DisplayName,
|
||||
FontSize = 14,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = exception.Message,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
19
LanMountainDesktop/Services/PluginCatalogEntry.cs
Normal file
19
LanMountainDesktop/Services/PluginCatalogEntry.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public enum PluginCatalogSourceKind
|
||||
{
|
||||
Package = 0,
|
||||
Manifest = 1
|
||||
}
|
||||
|
||||
public sealed record PluginCatalogEntry(
|
||||
PluginManifest Manifest,
|
||||
string SourcePath,
|
||||
bool IsPackage,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
string? ErrorMessage,
|
||||
int SettingsPageCount,
|
||||
int WidgetCount);
|
||||
11
LanMountainDesktop/Services/PluginContributions.cs
Normal file
11
LanMountainDesktop/Services/PluginContributions.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record PluginSettingsPageContribution(
|
||||
LoadedPlugin Plugin,
|
||||
PluginSettingsPageRegistration Registration);
|
||||
|
||||
public sealed record PluginDesktopComponentContribution(
|
||||
LoadedPlugin Plugin,
|
||||
PluginDesktopComponentRegistration Registration);
|
||||
315
LanMountainDesktop/Services/PluginRuntimeService.cs
Normal file
315
LanMountainDesktop/Services/PluginRuntimeService.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class PluginRuntimeService : IDisposable
|
||||
{
|
||||
private readonly PluginLoader _loader;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||
private readonly List<PluginLoadResult> _loadResults = [];
|
||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||
private readonly List<PluginSettingsPageContribution> _settingsPages = [];
|
||||
private readonly List<PluginDesktopComponentContribution> _desktopComponents = [];
|
||||
|
||||
public PluginRuntimeService()
|
||||
{
|
||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||
_loader = new PluginLoader(CreateOptions());
|
||||
}
|
||||
|
||||
public string PluginsDirectory { get; }
|
||||
|
||||
public IReadOnlyList<LoadedPlugin> LoadedPlugins => _loadedPlugins;
|
||||
|
||||
public IReadOnlyList<PluginLoadResult> LoadResults => _loadResults;
|
||||
|
||||
public IReadOnlyList<PluginCatalogEntry> Catalog => _catalog;
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageContribution> SettingsPages => _settingsPages;
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentContribution> DesktopComponents => _desktopComponents;
|
||||
|
||||
public void LoadInstalledPlugins()
|
||||
{
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
UnloadInstalledPlugins();
|
||||
|
||||
var disabledPluginIds = GetDisabledPluginIds();
|
||||
var hostProperties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["HostApplicationName"] = "LanMountainDesktop",
|
||||
["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(),
|
||||
["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion
|
||||
};
|
||||
|
||||
var discoveryFailures = new List<PluginLoadResult>();
|
||||
var candidates = DiscoverCandidates(discoveryFailures);
|
||||
_loadResults.AddRange(discoveryFailures);
|
||||
|
||||
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!selectedPluginIds.Add(candidate.Manifest.Id))
|
||||
{
|
||||
var duplicateFailure = 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."));
|
||||
_loadResults.Add(duplicateFailure);
|
||||
continue;
|
||||
}
|
||||
|
||||
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
|
||||
if (!isEnabled)
|
||||
{
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
candidate.Manifest,
|
||||
candidate.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
0,
|
||||
0));
|
||||
continue;
|
||||
}
|
||||
|
||||
var loadResult = candidate.SourceKind switch
|
||||
{
|
||||
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
|
||||
candidate.SourcePath,
|
||||
PluginsDirectory,
|
||||
services: null,
|
||||
hostProperties),
|
||||
_ => _loader.LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
services: null,
|
||||
hostProperties)
|
||||
};
|
||||
|
||||
_loadResults.Add(loadResult);
|
||||
|
||||
if (loadResult.IsSuccess && loadResult.LoadedPlugin is not null)
|
||||
{
|
||||
_loadedPlugins.Add(loadResult.LoadedPlugin);
|
||||
CollectContributions(loadResult.LoadedPlugin);
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
loadResult.LoadedPlugin.Manifest,
|
||||
loadResult.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
loadResult.LoadedPlugin.SettingsPages.Count,
|
||||
loadResult.LoadedPlugin.DesktopComponents.Count));
|
||||
Debug.WriteLine($"[PluginRuntime] Loaded '{loadResult.Manifest?.Id}' from '{loadResult.SourcePath}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
_catalog.Add(new PluginCatalogEntry(
|
||||
candidate.Manifest,
|
||||
candidate.SourcePath,
|
||||
candidate.SourceKind == PluginCatalogSourceKind.Package,
|
||||
true,
|
||||
false,
|
||||
loadResult.Error?.Message,
|
||||
0,
|
||||
0));
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
|
||||
}
|
||||
|
||||
if (_catalog.Count == 0 && discoveryFailures.Count == 0)
|
||||
{
|
||||
Debug.WriteLine($"[PluginRuntime] No .laapp packages or loose plugin manifests found under '{PluginsDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetPluginEnabled(string pluginId, bool isEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
|
||||
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var changed = isEnabled
|
||||
? disabledPluginIds.Remove(pluginId)
|
||||
: disabledPluginIds.Add(pluginId);
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot.DisabledPluginIds = disabledPluginIds
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
_appSettingsService.Save(snapshot);
|
||||
|
||||
for (var i = 0; i < _catalog.Count; i++)
|
||||
{
|
||||
if (string.Equals(_catalog[i].Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_catalog[i] = _catalog[i] with { IsEnabled = isEnabled };
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UnloadInstalledPlugins();
|
||||
}
|
||||
|
||||
private void UnloadInstalledPlugins()
|
||||
{
|
||||
for (var i = _loadedPlugins.Count - 1; i >= 0; i--)
|
||||
{
|
||||
_loadedPlugins[i].Dispose();
|
||||
}
|
||||
|
||||
_loadedPlugins.Clear();
|
||||
_loadResults.Clear();
|
||||
_catalog.Clear();
|
||||
_settingsPages.Clear();
|
||||
_desktopComponents.Clear();
|
||||
}
|
||||
|
||||
private HashSet<string> GetDisabledPluginIds()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
return snapshot.DisabledPluginIds is { Count: > 0 }
|
||||
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private IReadOnlyList<PluginCandidate> DiscoverCandidates(List<PluginLoadResult> failures)
|
||||
{
|
||||
var candidates = new List<PluginCandidate>();
|
||||
|
||||
foreach (var packagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = ReadManifestFromPackage(packagePath);
|
||||
candidates.Add(new PluginCandidate(packagePath, manifest, PluginCatalogSourceKind.Package));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(PluginLoadResult.Failure(packagePath, null, ex));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var manifestPath in EnumerateCandidatePaths("plugin.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = PluginManifest.Load(manifestPath);
|
||||
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.Manifest));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(PluginLoadResult.Failure(manifestPath, null, ex));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderBy(candidate => candidate.SourceKind)
|
||||
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
|
||||
|
||||
return Directory
|
||||
.EnumerateFiles(PluginsDirectory, searchPattern, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static PluginLoaderOptions CreateOptions()
|
||||
{
|
||||
var options = new PluginLoaderOptions();
|
||||
AddSharedAssembly(options, typeof(App).Assembly);
|
||||
AddSharedAssembly(options, typeof(Application).Assembly);
|
||||
AddSharedAssembly(options, typeof(Control).Assembly);
|
||||
AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static void AddSharedAssembly(PluginLoaderOptions options, Assembly assembly)
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||
{
|
||||
options.SharedAssemblyNames.Add(assemblyName);
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectContributions(LoadedPlugin loadedPlugin)
|
||||
{
|
||||
foreach (var settingsPage in loadedPlugin.SettingsPages)
|
||||
{
|
||||
_settingsPages.Add(new PluginSettingsPageContribution(loadedPlugin, settingsPage));
|
||||
}
|
||||
|
||||
foreach (var desktopComponent in loadedPlugin.DesktopComponents)
|
||||
{
|
||||
_desktopComponents.Add(new PluginDesktopComponentContribution(loadedPlugin, desktopComponent));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PluginCandidate(
|
||||
string SourcePath,
|
||||
PluginManifest Manifest,
|
||||
PluginCatalogSourceKind SourceKind);
|
||||
}
|
||||
Reference in New Issue
Block a user