Add external public IPC host/client and plugin SDK

Introduce a new LanMountainDesktop.Shared.IPC project implementing a public IPC host and client (LanMountainDesktopIpcClient, PublicIpcHostService), IPC constants and routed notify IDs, DTOs and DI helpers for registering public services. Update Plugin SDK to allow plugins to contribute public IPC services and registrations, add related descriptors/records and extension helpers. Migrate Launcher/App to use the new public IPC for startup/loading notifications and wiring (including TryConnect helper), switch LoadingStateReporter to use the external notification publisher, and add host-side public services (app info, shell control, plugin catalog). Include integration tests and spec/checklist/docs for the external IPC public API.
This commit is contained in:
lincube
2026-04-22 14:55:30 +08:00
parent f51ec309a6
commit aa7c118d13
43 changed files with 1347 additions and 49 deletions

View File

@@ -25,6 +25,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
IReadOnlyList<PluginPublicIpcServiceDescriptor> publicIpcServices,
IReadOnlyList<IHostedService> hostedServices,
PluginLoadContext loadContext)
{
@@ -39,6 +40,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
DesktopComponents = desktopComponents;
DesktopComponentEditors = desktopComponentEditors;
ExportedServices = exportedServices;
PublicIpcServices = publicIpcServices;
HostedServices = hostedServices;
LoadContext = loadContext;
}
@@ -67,6 +69,8 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
public IReadOnlyList<PluginPublicIpcServiceDescriptor> PublicIpcServices { get; }
public PluginLoadContext LoadContext { get; }
private IReadOnlyList<IHostedService> HostedServices { get; }

View File

@@ -14,8 +14,10 @@ using System.Threading.Tasks;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.IPC;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Plugins;
@@ -187,9 +189,10 @@ public sealed class PluginLoader
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
.ToArray();
var exportedServices = ResolveExports(manifest, pluginServices);
var publicIpcServices = ResolvePublicIpcServices(manifest, pluginServices);
AppLogger.Info(
"PluginLoader",
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}.");
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}; PublicIpcServices={publicIpcServices.Count}.");
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
StartHostedServices(hostedServices);
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
@@ -206,6 +209,7 @@ public sealed class PluginLoader
desktopComponents,
desktopComponentEditors,
exportedServices,
publicIpcServices,
hostedServices,
loadContext);
@@ -332,6 +336,7 @@ public sealed class PluginLoader
RegisterHostService<ISettingsService>(services, hostServices);
RegisterHostService<ISettingsCatalog>(services, hostServices);
RegisterHostService<IAppearanceThemeService>(services, hostServices);
RegisterHostService<IExternalIpcNotificationPublisher>(services, hostServices);
return services;
}
@@ -413,6 +418,68 @@ public sealed class PluginLoader
.ToArray();
}
private static IReadOnlyList<PluginPublicIpcServiceDescriptor> ResolvePublicIpcServices(
PluginManifest manifest,
IServiceProvider services)
{
var descriptors = new List<PluginPublicIpcServiceDescriptor>();
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var registration in services.GetServices<PluginPublicIpcServiceRegistration>())
{
var implementation = services.GetService(registration.ContractType)
?? throw new InvalidOperationException(
$"Plugin '{manifest.Id}' registered public IPC contract '{registration.ContractType.FullName}', but no singleton service instance was found.");
AddDescriptor(registration.ContractType, implementation, registration.ObjectId, registration.NotifyIds);
}
var builder = new RuntimePluginPublicIpcBuilder(services, AddDescriptor);
foreach (var contributor in services.GetServices<IPluginPublicIpcContributor>())
{
contributor.ConfigurePublicIpc(builder);
}
return descriptors;
void AddDescriptor(Type contractType, object implementation, string? objectId, IEnumerable<string>? notifyIds)
{
EnsurePublicIpcContract(manifest, contractType);
var normalizedObjectId = objectId ?? string.Empty;
var dedupeKey = $"{contractType.AssemblyQualifiedName}::{normalizedObjectId}";
if (!seenKeys.Add(dedupeKey))
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' registered duplicate public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}'.");
}
descriptors.Add(new PluginPublicIpcServiceDescriptor(
contractType,
implementation,
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
notifyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? []));
}
}
private static void EnsurePublicIpcContract(PluginManifest manifest, Type contractType)
{
if (!contractType.IsInterface)
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' public IPC contract '{contractType.FullName}' must be an interface.");
}
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
}
}
private static bool IsSupportedExportContract(PluginManifest manifest, Type contractType)
{
if (contractType.Assembly == typeof(IPlugin).Assembly)
@@ -1074,4 +1141,42 @@ public sealed class PluginLoader
string SourcePath,
PluginManifest Manifest,
PluginSourceKind SourceKind);
private sealed class RuntimePluginPublicIpcBuilder : IPluginPublicIpcBuilder
{
private readonly IServiceProvider _services;
private readonly Action<Type, object, string?, IEnumerable<string>?> _register;
public RuntimePluginPublicIpcBuilder(
IServiceProvider services,
Action<Type, object, string?, IEnumerable<string>?> register)
{
_services = services;
_register = register;
}
public IPluginPublicIpcBuilder AddService<TContract>(
string? objectId = null,
IEnumerable<string>? notifyIds = null)
where TContract : class
{
var implementation = _services.GetService(typeof(TContract))
?? throw new InvalidOperationException(
$"Plugin public IPC contributor requested contract '{typeof(TContract).FullName}', but no singleton service was registered.");
_register(typeof(TContract), implementation, objectId, notifyIds);
return this;
}
public IPluginPublicIpcBuilder AddService(
Type contractType,
object implementation,
string? objectId = null,
IEnumerable<string>? notifyIds = null)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementation);
_register(contractType, implementation, objectId, notifyIds);
return this;
}
}
}

View File

@@ -13,6 +13,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.IPC;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -31,6 +32,7 @@ public sealed class PluginRuntimeService : IDisposable
private readonly IPluginPackageManager _packageManager;
private readonly ISettingsFacadeService _settingsFacade;
private readonly SettingsCatalogService _settingsCatalogService;
private readonly PublicIpcHostService? _publicIpcHostService;
private readonly List<LoadedPlugin> _loadedPlugins = [];
private readonly List<PluginLoadResult> _loadResults = [];
private readonly List<PluginCatalogEntry> _catalog = [];
@@ -39,13 +41,16 @@ public sealed class PluginRuntimeService : IDisposable
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
private readonly object _packageMutationGate = new();
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
public PluginRuntimeService(
ISettingsFacadeService? settingsFacade = null,
PublicIpcHostService? publicIpcHostService = null)
{
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
_sharedContractManager = new PluginSharedContractManager(
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
_packageManager = new PluginRuntimePackageManager(this);
_settingsFacade = settingsFacade ?? new SettingsFacadeService();
_publicIpcHostService = publicIpcHostService;
_settingsCatalogService = _settingsFacade.Catalog as SettingsCatalogService
?? new SettingsCatalogService();
if (_settingsFacade is SettingsFacadeService concreteFacade)
@@ -58,7 +63,8 @@ public sealed class PluginRuntimeService : IDisposable
_exportRegistry,
_settingsFacade,
_settingsFacade.Settings,
_settingsFacade.Catalog);
_settingsFacade.Catalog,
_publicIpcHostService);
_loaderOptions = CreateOptions();
_loader = new PluginLoader(_loaderOptions);
}
@@ -675,6 +681,8 @@ public sealed class PluginRuntimeService : IDisposable
AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
AddSharedAssembly(options, typeof(IExternalIpcNotificationPublisher).Assembly);
AddSharedAssembly(options, typeof(dotnetCampus.Ipc.Pipes.IpcProvider).Assembly);
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
@@ -761,6 +769,19 @@ public sealed class PluginRuntimeService : IDisposable
{
_desktopComponentEditors.Add(new PluginDesktopComponentEditorContribution(loadedPlugin, desktopComponentEditor));
}
if (_publicIpcHostService is not null)
{
foreach (var publicIpcService in loadedPlugin.PublicIpcServices)
{
_publicIpcHostService.RegisterPublicService(
publicIpcService.ContractType,
publicIpcService.Implementation,
publicIpcService.ObjectId,
loadedPlugin.Manifest.Id,
publicIpcService.NotifyIds);
}
}
}
private void RegisterSharedContractsForLoad(PluginManifest manifest)
@@ -990,11 +1011,12 @@ public sealed class PluginRuntimeService : IDisposable
{
private readonly IPluginPackageManager _packageManager;
private readonly IHostApplicationLifecycle _applicationLifecycle;
private readonly IPluginExportRegistry _exportRegistry;
private readonly ISettingsFacadeService _settingsFacade;
private readonly ISettingsService _settingsService;
private readonly ISettingsCatalog _settingsCatalog;
private readonly IAppearanceThemeService _appearanceThemeService;
private readonly IPluginExportRegistry _exportRegistry;
private readonly ISettingsFacadeService _settingsFacade;
private readonly ISettingsService _settingsService;
private readonly ISettingsCatalog _settingsCatalog;
private readonly IAppearanceThemeService _appearanceThemeService;
private readonly IExternalIpcNotificationPublisher? _externalIpcNotificationPublisher;
public PluginHostServiceProvider(
IPluginPackageManager packageManager,
@@ -1002,7 +1024,8 @@ public sealed class PluginRuntimeService : IDisposable
IPluginExportRegistry exportRegistry,
ISettingsFacadeService settingsFacade,
ISettingsService settingsService,
ISettingsCatalog settingsCatalog)
ISettingsCatalog settingsCatalog,
IExternalIpcNotificationPublisher? externalIpcNotificationPublisher)
{
_packageManager = packageManager;
_applicationLifecycle = applicationLifecycle;
@@ -1011,6 +1034,7 @@ public sealed class PluginRuntimeService : IDisposable
_settingsService = settingsService;
_settingsCatalog = settingsCatalog;
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
_externalIpcNotificationPublisher = externalIpcNotificationPublisher;
}
public object? GetService(Type serviceType)
@@ -1050,6 +1074,11 @@ public sealed class PluginRuntimeService : IDisposable
return _appearanceThemeService;
}
if (serviceType == typeof(IExternalIpcNotificationPublisher))
{
return _externalIpcNotificationPublisher;
}
return null;
}
}