mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.5.1
插件系统试验
This commit is contained in:
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
}
|
||||
24
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
24
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginContext
|
||||
{
|
||||
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 RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||
|
||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
74
LanMountainDesktop.PluginSdk/LoadedPlugin.cs
Normal file
74
LanMountainDesktop.PluginSdk/LoadedPlugin.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
internal LoadedPlugin(
|
||||
PluginManifest manifest,
|
||||
string sourcePath,
|
||||
string assemblyPath,
|
||||
Assembly assembly,
|
||||
IPlugin plugin,
|
||||
IPluginContext context,
|
||||
IReadOnlyList<PluginSettingsPageRegistration> settingsPages,
|
||||
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||
PluginLoadContext loadContext)
|
||||
{
|
||||
Manifest = manifest;
|
||||
SourcePath = sourcePath;
|
||||
AssemblyPath = assemblyPath;
|
||||
Assembly = assembly;
|
||||
Plugin = plugin;
|
||||
Context = context;
|
||||
SettingsPages = settingsPages;
|
||||
DesktopComponents = desktopComponents;
|
||||
LoadContext = loadContext;
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string SourcePath { get; }
|
||||
|
||||
public string AssemblyPath { get; }
|
||||
|
||||
public Assembly Assembly { get; }
|
||||
|
||||
public IPlugin Plugin { get; }
|
||||
|
||||
public IPluginContext Context { get; }
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageRegistration> SettingsPages { get; }
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> DesktopComponents { get; }
|
||||
|
||||
public PluginLoadContext LoadContext { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Plugin is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else if (Plugin is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
LoadContext.Unload();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(IPluginContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentContext
|
||||
{
|
||||
public PluginDesktopComponentContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
double cellSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
Services = services;
|
||||
Properties = properties;
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public double CellSize { 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentRegistration
|
||||
{
|
||||
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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayName = displayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
IconKey = iconKey.Trim();
|
||||
Category = category.Trim();
|
||||
MinWidthCells = Math.Max(1, minWidthCells);
|
||||
MinHeightCells = Math.Max(1, minHeightCells);
|
||||
AllowDesktopPlacement = allowDesktopPlacement;
|
||||
AllowStatusBarPlacement = allowStatusBarPlacement;
|
||||
ResizeMode = resizeMode;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public int MinWidthCells { get; }
|
||||
|
||||
public int MinHeightCells { get; }
|
||||
|
||||
public bool AllowDesktopPlacement { get; }
|
||||
|
||||
public bool AllowStatusBarPlacement { get; }
|
||||
|
||||
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginDesktopComponentResizeMode
|
||||
{
|
||||
Proportional = 0,
|
||||
Free = 1
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
66
LanMountainDesktop.PluginSdk/PluginLoadContext.cs
Normal file
66
LanMountainDesktop.PluginSdk/PluginLoadContext.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
private readonly AssemblyDependencyResolver _resolver;
|
||||
private readonly HashSet<string> _sharedAssemblyNames;
|
||||
|
||||
public PluginLoadContext(string mainAssemblyPath, IEnumerable<string>? sharedAssemblyNames = null)
|
||||
: base($"{Path.GetFileNameWithoutExtension(mainAssemblyPath)}_{Guid.NewGuid():N}", isCollectible: true)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mainAssemblyPath);
|
||||
|
||||
MainAssemblyPath = Path.GetFullPath(mainAssemblyPath);
|
||||
_resolver = new AssemblyDependencyResolver(MainAssemblyPath);
|
||||
_sharedAssemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
typeof(IPlugin).Assembly.GetName().Name!
|
||||
};
|
||||
|
||||
if (sharedAssemblyNames is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var assemblyName in sharedAssemblyNames)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(assemblyName))
|
||||
{
|
||||
_sharedAssemblyNames.Add(assemblyName.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string MainAssemblyPath { get; }
|
||||
|
||||
protected override Assembly? Load(AssemblyName assemblyName)
|
||||
{
|
||||
var simpleName = assemblyName.Name;
|
||||
if (string.IsNullOrWhiteSpace(simpleName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_sharedAssemblyNames.Contains(simpleName))
|
||||
{
|
||||
return Default.Assemblies.FirstOrDefault(
|
||||
assembly => string.Equals(
|
||||
assembly.GetName().Name,
|
||||
simpleName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
?? null;
|
||||
}
|
||||
|
||||
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
|
||||
return assemblyPath is null ? null : LoadFromAssemblyPath(assemblyPath);
|
||||
}
|
||||
|
||||
protected override nint LoadUnmanagedDll(string unmanagedDllName)
|
||||
{
|
||||
var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
|
||||
return libraryPath is null ? nint.Zero : LoadUnmanagedDllFromPath(libraryPath);
|
||||
}
|
||||
}
|
||||
20
LanMountainDesktop.PluginSdk/PluginLoadResult.cs
Normal file
20
LanMountainDesktop.PluginSdk/PluginLoadResult.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginLoadResult(
|
||||
string SourcePath,
|
||||
PluginManifest? Manifest,
|
||||
LoadedPlugin? LoadedPlugin,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool IsSuccess => LoadedPlugin is not null && Error is null;
|
||||
|
||||
public static PluginLoadResult Success(string sourcePath, PluginManifest manifest, LoadedPlugin loadedPlugin)
|
||||
{
|
||||
return new PluginLoadResult(sourcePath, manifest, loadedPlugin, null);
|
||||
}
|
||||
|
||||
public static PluginLoadResult Failure(string sourcePath, PluginManifest? manifest, Exception error)
|
||||
{
|
||||
return new PluginLoadResult(sourcePath, manifest, null, error);
|
||||
}
|
||||
}
|
||||
616
LanMountainDesktop.PluginSdk/PluginLoader.cs
Normal file
616
LanMountainDesktop.PluginSdk/PluginLoader.cs
Normal file
@@ -0,0 +1,616 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
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;
|
||||
|
||||
try
|
||||
{
|
||||
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
var pluginType = ResolvePluginType(assembly);
|
||||
var plugin = CreatePluginInstance(pluginType);
|
||||
var context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
|
||||
|
||||
plugin.Initialize(context);
|
||||
var settingsPages = context.GetSettingsPagesSnapshot();
|
||||
var desktopComponents = context.GetDesktopComponentsSnapshot();
|
||||
|
||||
var loadedPlugin = new LoadedPlugin(
|
||||
manifest,
|
||||
sourcePath,
|
||||
assemblyPath,
|
||||
assembly,
|
||||
plugin,
|
||||
context,
|
||||
settingsPages,
|
||||
desktopComponents,
|
||||
loadContext);
|
||||
|
||||
return PluginLoadResult.Success(sourcePath, manifest, loadedPlugin);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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 PluginContext CreateContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider? services,
|
||||
IReadOnlyDictionary<string, object?>? properties)
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
return new PluginContext(
|
||||
manifest,
|
||||
pluginDirectory,
|
||||
dataDirectory,
|
||||
services ?? NullServiceProvider.Instance,
|
||||
CreateReadOnlyProperties(properties));
|
||||
}
|
||||
|
||||
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);
|
||||
RecreateDirectory(extractionDirectory);
|
||||
ZipFile.ExtractToDirectory(packagePath, extractionDirectory, overwriteFiles: true);
|
||||
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))
|
||||
{
|
||||
Directory.Delete(directoryPath, recursive: true);
|
||||
}
|
||||
|
||||
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 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 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 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 PluginContext : IPluginContext
|
||||
{
|
||||
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
Services = services;
|
||||
Properties = properties;
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { 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 RegisterSettingsPage(PluginSettingsPageRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
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);
|
||||
|
||||
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a desktop component with id '{registration.ComponentId}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
|
||||
{
|
||||
return _settingsPages.Values
|
||||
.OrderBy(page => page.SortOrder)
|
||||
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
|
||||
{
|
||||
return _desktopComponents.Values
|
||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
21
LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs
Normal file
21
LanMountainDesktop.PluginSdk/PluginLoaderOptions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLoaderOptions
|
||||
{
|
||||
public string ManifestFileName { get; init; } = PluginSdkInfo.ManifestFileName;
|
||||
|
||||
public string PackageFileExtension { get; init; } = PluginSdkInfo.PackageFileExtension;
|
||||
|
||||
public string DataDirectoryName { get; init; } = PluginSdkInfo.DataDirectoryName;
|
||||
|
||||
public string RuntimeDirectoryName { get; init; } = PluginSdkInfo.RuntimeDirectoryName;
|
||||
|
||||
public string ExtractedPackagesDirectoryName { get; init; } = PluginSdkInfo.ExtractedPackagesDirectoryName;
|
||||
|
||||
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
|
||||
|
||||
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
typeof(IPlugin).Assembly.GetName().Name!
|
||||
};
|
||||
}
|
||||
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
string EntranceAssembly,
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static PluginManifest Load(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
return Load(stream, manifestPath);
|
||||
}
|
||||
|
||||
public static PluginManifest Load(Stream stream, string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
||||
}
|
||||
|
||||
return manifest.NormalizeAndValidate(sourceName);
|
||||
}
|
||||
|
||||
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
if (Path.IsPathRooted(EntranceAssembly))
|
||||
{
|
||||
return Path.GetFullPath(EntranceAssembly);
|
||||
}
|
||||
|
||||
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
||||
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
||||
}
|
||||
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "1.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
public const string RuntimeDirectoryName = ".runtime";
|
||||
public const string ExtractedPackagesDirectoryName = "packages";
|
||||
public const string PackagedDataDirectoryName = "data";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginSettingsPageRegistration
|
||||
{
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentNullException.ThrowIfNull(contentFactory);
|
||||
|
||||
Id = id.Trim();
|
||||
Title = title.Trim();
|
||||
ContentFactory = contentFactory;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public int SortOrder { get; }
|
||||
|
||||
public Func<Control> ContentFactory { get; }
|
||||
}
|
||||
Reference in New Issue
Block a user