mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-23 18:04:26 +08:00
0.5.6
插件系统再进化
This commit is contained in:
@@ -307,6 +307,15 @@
|
||||
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||
"settings.plugins.toggle_state_enabled": "enabled",
|
||||
"settings.plugins.toggle_state_disabled": "disabled",
|
||||
"settings.plugins.install_button": "Open .laapp package",
|
||||
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
||||
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
||||
"settings.plugins.install_picker_title": "Select plugin package",
|
||||
"settings.plugins.install_file_type": ".laapp plugin package",
|
||||
"settings.plugins.install_picker_unavailable": "Storage provider is unavailable.",
|
||||
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
||||
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
|
||||
"settings.plugins.source_package": ".laapp package",
|
||||
"settings.plugins.source_manifest": "Loose manifest",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
|
||||
@@ -307,6 +307,15 @@
|
||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||
"settings.plugins.toggle_state_enabled": "启用",
|
||||
"settings.plugins.toggle_state_disabled": "禁用",
|
||||
"settings.plugins.install_button": "打开 .laapp 插件包",
|
||||
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
||||
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
||||
"settings.plugins.install_picker_title": "选择插件安装包",
|
||||
"settings.plugins.install_file_type": ".laapp 插件包",
|
||||
"settings.plugins.install_picker_unavailable": "文件存储提供程序不可用。",
|
||||
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
||||
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
||||
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
|
||||
"settings.plugins.source_package": ".laapp 包",
|
||||
"settings.plugins.source_manifest": "散装清单",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace LanMountainDesktop.Services;
|
||||
public static class PendingRestartStateService
|
||||
{
|
||||
public const string RenderModeReason = "RenderMode";
|
||||
public const string PluginCatalogReason = "PluginCatalog";
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -278,26 +278,7 @@ public partial class MainWindow
|
||||
"Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L(
|
||||
"settings.plugins.runtime_desc",
|
||||
"Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.runtime_hint",
|
||||
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L(
|
||||
"settings.plugins.runtime_status",
|
||||
"Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L(
|
||||
"settings.plugins.installed_desc",
|
||||
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L(
|
||||
"settings.plugins.restart_hint",
|
||||
"Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
ApplyPluginSettingsLocalization();
|
||||
|
||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
|
||||
@@ -2741,14 +2741,6 @@ public partial class MainWindow
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
// --- PluginSettingsPage (Added for completeness) ---
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -206,13 +206,5 @@ public partial class SettingsWindow
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
// --- PluginSettingsPage (Added for completeness) ---
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,16 +110,7 @@ public partial class SettingsWindow
|
||||
LauncherHiddenItemsDescriptionTextBlock.Text = L("settings.launcher.hidden_hint", "Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
ApplyPluginSettingsLocalization();
|
||||
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
||||
|
||||
88
LanMountainDesktop/plugins/LoadedPlugin.cs
Normal file
88
LanMountainDesktop/plugins/LoadedPlugin.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (Context is IAsyncDisposable asyncContext)
|
||||
{
|
||||
await asyncContext.DisposeAsync();
|
||||
}
|
||||
else if (Context is IDisposable disposableContext)
|
||||
{
|
||||
disposableContext.Dispose();
|
||||
}
|
||||
|
||||
LoadContext.Unload();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
@@ -139,6 +139,30 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
internal void RefreshPluginSettingsNavigation()
|
||||
{
|
||||
if (SettingsNavView?.MenuItems is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||
{
|
||||
var navItem = SettingsNavView.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (navItem is not null)
|
||||
{
|
||||
SettingsNavView.MenuItems.Remove(navItem);
|
||||
}
|
||||
|
||||
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||
}
|
||||
|
||||
_pluginSettingsPageHosts.Clear();
|
||||
InitializePluginSettingsNavigation();
|
||||
}
|
||||
|
||||
private string? GetSelectedSettingsTabTag()
|
||||
{
|
||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void ApplyPluginSettingsLocalization()
|
||||
{
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L(
|
||||
"settings.plugins.runtime_desc",
|
||||
"Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.runtime_hint",
|
||||
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L(
|
||||
"settings.plugins.runtime_status",
|
||||
"Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L(
|
||||
"settings.plugins.installed_desc",
|
||||
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L(
|
||||
"settings.plugins.restart_hint",
|
||||
"Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
72
LanMountainDesktop/plugins/PluginLoadContext.cs
Normal file
72
LanMountainDesktop/plugins/PluginLoadContext.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
24
LanMountainDesktop/plugins/PluginLoadResult.cs
Normal file
24
LanMountainDesktop/plugins/PluginLoadResult.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
932
LanMountainDesktop/plugins/PluginLoader.cs
Normal file
932
LanMountainDesktop/plugins/PluginLoader.cs
Normal file
@@ -0,0 +1,932 @@
|
||||
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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
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;
|
||||
PluginContext? context = null;
|
||||
|
||||
try
|
||||
{
|
||||
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
var pluginType = ResolvePluginType(assembly);
|
||||
plugin = CreatePluginInstance(pluginType);
|
||||
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)
|
||||
{
|
||||
DisposeInstance(plugin);
|
||||
DisposeInstance(context);
|
||||
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 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 PluginContext : IPluginContext, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<Type, object> _registeredServices = [];
|
||||
private readonly List<object> _serviceRegistrationOrder = [];
|
||||
private readonly object _serviceGate = new();
|
||||
private readonly IServiceProvider _hostServices;
|
||||
private int _disposed;
|
||||
|
||||
public PluginContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
_hostServices = services;
|
||||
Services = new PluginCompositeServiceProvider(this);
|
||||
Properties = properties;
|
||||
|
||||
RegisterBuiltInService<IPluginContext>(this);
|
||||
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
|
||||
}
|
||||
|
||||
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 RegisterService<TService>(TService service)
|
||||
where TService : class
|
||||
{
|
||||
RegisterServiceCore(typeof(TService), service, allowOverride: false);
|
||||
}
|
||||
|
||||
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
ThrowIfDisposed();
|
||||
|
||||
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);
|
||||
ThrowIfDisposed();
|
||||
|
||||
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()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _settingsPages.Values
|
||||
.OrderBy(page => page.SortOrder)
|
||||
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _desktopComponents.Values
|
||||
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal object? ResolveService(Type serviceType)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serviceType == typeof(IServiceProvider))
|
||||
{
|
||||
return Services;
|
||||
}
|
||||
|
||||
lock (_serviceGate)
|
||||
{
|
||||
if (_registeredServices.TryGetValue(serviceType, out var service))
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
foreach (var registeredService in _registeredServices.Values)
|
||||
{
|
||||
if (serviceType.IsInstanceOfType(registeredService))
|
||||
{
|
||||
return registeredService;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _hostServices.GetService(serviceType);
|
||||
}
|
||||
|
||||
private void RegisterBuiltInService<TService>(TService service)
|
||||
where TService : class
|
||||
{
|
||||
RegisterServiceCore(typeof(TService), service, allowOverride: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object[] services;
|
||||
lock (_serviceGate)
|
||||
{
|
||||
services = _serviceRegistrationOrder.ToArray();
|
||||
_registeredServices.Clear();
|
||||
_serviceRegistrationOrder.Clear();
|
||||
}
|
||||
|
||||
_settingsPages.Clear();
|
||||
_desktopComponents.Clear();
|
||||
|
||||
var disposedServices = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||||
for (var i = services.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var service = services[i];
|
||||
if (ReferenceEquals(service, this) || !disposedServices.Add(service))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (service is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync();
|
||||
}
|
||||
else if (service is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterServiceCore(Type serviceType, object service, bool allowOverride)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
ArgumentNullException.ThrowIfNull(service);
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (!serviceType.IsInstanceOfType(service))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'.");
|
||||
}
|
||||
|
||||
lock (_serviceGate)
|
||||
{
|
||||
if (!allowOverride && _registeredServices.ContainsKey(serviceType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'.");
|
||||
}
|
||||
|
||||
_registeredServices[serviceType] = service;
|
||||
_serviceRegistrationOrder.Add(service);
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(PluginContext));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginCompositeServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly PluginContext _context;
|
||||
|
||||
public PluginCompositeServiceProvider(PluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceType);
|
||||
return _context.ResolveService(serviceType);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
26
LanMountainDesktop/plugins/PluginLoaderOptions.cs
Normal file
26
LanMountainDesktop/plugins/PluginLoaderOptions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
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!
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
@@ -174,6 +175,36 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
public PluginManifest InstallPluginPackage(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
|
||||
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||
if (!File.Exists(fullPackagePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(fullPackagePath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package must use the '{PluginSdkInfo.PackageFileExtension}' extension.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||
RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Copy(fullPackagePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UnloadInstalledPlugins();
|
||||
@@ -269,6 +300,42 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||
{
|
||||
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||
{
|
||||
if (string.Equals(
|
||||
Path.GetFullPath(existingPackagePath),
|
||||
Path.GetFullPath(packagePathToKeep),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Delete(existingPackagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or invalid packages during replacement.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
@@ -1,19 +1,28 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class PluginSettingsPage : UserControl
|
||||
{
|
||||
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
|
||||
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private string? _packageImportStatusMessage;
|
||||
private bool _packageImportStatusIsError;
|
||||
|
||||
public PluginSettingsPage()
|
||||
{
|
||||
@@ -24,6 +33,7 @@ public partial class PluginSettingsPage : UserControl
|
||||
public void RefreshFromRuntime()
|
||||
{
|
||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||
UpdateInstallerUi(runtime);
|
||||
if (runtime is null)
|
||||
{
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
||||
@@ -37,6 +47,24 @@ public partial class PluginSettingsPage : UserControl
|
||||
BuildPluginCatalog(runtime);
|
||||
}
|
||||
|
||||
private void UpdateInstallerUi(PluginRuntimeService? runtime)
|
||||
{
|
||||
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
|
||||
InstallPluginPackageButton.IsEnabled = runtime is not null;
|
||||
PluginPackageImportHintTextBlock.Text = runtime is null
|
||||
? L(
|
||||
"settings.plugins.install_unavailable",
|
||||
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now.")
|
||||
: F(
|
||||
"settings.plugins.install_hint_format",
|
||||
"Open a .laapp package to install it into: {0}",
|
||||
runtime.PluginsDirectory);
|
||||
|
||||
PluginPackageImportStatusTextBlock.IsVisible = !string.IsNullOrWhiteSpace(_packageImportStatusMessage);
|
||||
PluginPackageImportStatusTextBlock.Text = _packageImportStatusMessage ?? string.Empty;
|
||||
PluginPackageImportStatusTextBlock.Foreground = _packageImportStatusIsError ? ErrorBrush : SuccessBrush;
|
||||
}
|
||||
|
||||
private void BuildRuntimeSummary(PluginRuntimeService runtime)
|
||||
{
|
||||
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
|
||||
@@ -165,6 +193,116 @@ public partial class PluginSettingsPage : UserControl
|
||||
: L("settings.plugins.toggle_state_disabled", "disabled"));
|
||||
}
|
||||
|
||||
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||
if (runtime is null)
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
L(
|
||||
"settings.plugins.install_unavailable",
|
||||
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now."),
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
var storageProvider = topLevel?.StorageProvider;
|
||||
if (storageProvider is null)
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
L("settings.plugins.install_picker_unavailable", "Storage provider is unavailable."),
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = L("settings.plugins.install_picker_title", "Select plugin package"),
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType(L("settings.plugins.install_file_type", ".laapp plugin package"))
|
||||
{
|
||||
Patterns = [$"*{PluginSdkInfo.PackageFileExtension}"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? temporaryPackagePath = null;
|
||||
try
|
||||
{
|
||||
temporaryPackagePath = await CopyPackageToTemporaryFileAsync(files[0]);
|
||||
if (string.IsNullOrWhiteSpace(temporaryPackagePath))
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
L("settings.plugins.install_copy_failed", "Failed to copy the selected .laapp package."),
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
||||
runtime.LoadInstalledPlugins();
|
||||
RefreshPluginNavigation(topLevel);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
RefreshFromRuntime();
|
||||
SetPackageImportStatus(
|
||||
F(
|
||||
"settings.plugins.install_success_format",
|
||||
"Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||
manifest.Name),
|
||||
isError: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
F(
|
||||
"settings.plugins.install_failed_format",
|
||||
"Failed to install plugin package: {0}",
|
||||
ex.Message),
|
||||
isError: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(temporaryPackagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temporaryPackagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore temporary file cleanup errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPluginNavigation(TopLevel? topLevel)
|
||||
{
|
||||
switch (topLevel)
|
||||
{
|
||||
case MainWindow mainWindow:
|
||||
mainWindow.RefreshPluginSettingsNavigation();
|
||||
break;
|
||||
case SettingsWindow settingsWindow:
|
||||
settingsWindow.RefreshPluginSettingsNavigation();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPackageImportStatus(string message, bool isError)
|
||||
{
|
||||
_packageImportStatusMessage = string.IsNullOrWhiteSpace(message) ? null : message;
|
||||
_packageImportStatusIsError = isError;
|
||||
UpdateInstallerUi((Application.Current as App)?.PluginRuntimeService);
|
||||
}
|
||||
|
||||
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
||||
{
|
||||
var source = entry.IsPackage
|
||||
@@ -215,8 +353,35 @@ public partial class PluginSettingsPage : UserControl
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
||||
}
|
||||
|
||||
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(file.Name);
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
extension = PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
var temporaryDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop",
|
||||
"PluginImports");
|
||||
Directory.CreateDirectory(temporaryDirectory);
|
||||
|
||||
var temporaryPackagePath = Path.Combine(
|
||||
temporaryDirectory,
|
||||
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
|
||||
|
||||
await using var sourceStream = await file.OpenReadAsync();
|
||||
await using var destinationStream = File.Create(temporaryPackagePath);
|
||||
await sourceStream.CopyToAsync(destinationStream);
|
||||
return temporaryPackagePath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,24 @@
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="14">
|
||||
<StackPanel Spacing="10">
|
||||
<Button x:Name="InstallPluginPackageButton"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnInstallPluginPackageClick"
|
||||
Content="Open .laapp package" />
|
||||
<TextBlock x:Name="PluginPackageImportHintTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Open a .laapp package to install it into the local plugin directory." />
|
||||
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock x:Name="PluginRestartHintTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
33
LanMountainDesktop/plugins/README.md
Normal file
33
LanMountainDesktop/plugins/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 宿主侧插件运行时
|
||||
|
||||
这个目录用于归档阑山桌面宿主侧的插件相关实现。
|
||||
|
||||
职责范围:
|
||||
- 已安装插件的发现
|
||||
- `.laapp` 安装包安装与替换
|
||||
- 插件运行时加载
|
||||
- 插件贡献的设置页与桌面组件接入
|
||||
- 宿主侧插件设置页的安装、显示与刷新
|
||||
|
||||
当前宿主侧核心文件:
|
||||
- `PluginLoader.cs`
|
||||
- `PluginLoadContext.cs`
|
||||
- `PluginLoaderOptions.cs`
|
||||
- `PluginLoadResult.cs`
|
||||
- `LoadedPlugin.cs`
|
||||
- `PluginRuntimeService.cs`
|
||||
- `PluginContributions.cs`
|
||||
- `PluginCatalogEntry.cs`
|
||||
- `PluginSettingsPage.axaml`
|
||||
- `PluginSettingsPage.Host.cs`
|
||||
- `MainWindow.PluginSettingsHost.cs`
|
||||
- `SettingsWindow.PluginSettingsHost.cs`
|
||||
- `MainWindow.PluginSettingsLocalization.cs`
|
||||
- `SettingsWindow.PluginSettingsLocalization.cs`
|
||||
- `MainWindow.PluginSettingsControls.cs`
|
||||
- `SettingsWindow.PluginSettingsControls.cs`
|
||||
|
||||
说明:
|
||||
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
|
||||
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
|
||||
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口
|
||||
@@ -0,0 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
@@ -139,6 +139,30 @@ public partial class SettingsWindow
|
||||
}
|
||||
}
|
||||
|
||||
internal void RefreshPluginSettingsNavigation()
|
||||
{
|
||||
if (SettingsNavView?.MenuItems is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||
{
|
||||
var navItem = SettingsNavView.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (navItem is not null)
|
||||
{
|
||||
SettingsNavView.MenuItems.Remove(navItem);
|
||||
}
|
||||
|
||||
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||
}
|
||||
|
||||
_pluginSettingsPageHosts.Clear();
|
||||
InitializePluginSettingsNavigation();
|
||||
}
|
||||
|
||||
private string? GetSelectedSettingsTabTag()
|
||||
{
|
||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
private void ApplyPluginSettingsLocalization()
|
||||
{
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user