mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
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:
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# External IPC Public API Checklist
|
||||
|
||||
- [x] Host can expose strong-typed public IPC services.
|
||||
- [x] External .NET client can connect and call built-in services.
|
||||
- [x] Host publishes launcher startup and loading-state notifications through routed notify.
|
||||
- [x] Launcher consumes routed notify instead of the old primary custom named-pipe path.
|
||||
- [x] Plugin SDK exposes public IPC contribution primitives.
|
||||
- [x] Plugin runtime can discover and register plugin public IPC services.
|
||||
- [x] Public catalog includes built-in and plugin-contributed services.
|
||||
- [x] `catalog.changed` is emitted when new services are added after startup.
|
||||
- [ ] Add example external client sample.
|
||||
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# External IPC Public API Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Provide a single `dotnetCampus.Ipc` based external integration layer for:
|
||||
|
||||
- Host public APIs
|
||||
- Launcher/OOBE startup progress and loading-state notifications
|
||||
- plugin-contributed public services and live event push
|
||||
|
||||
## Delivered
|
||||
|
||||
- `LanMountainDesktop.Shared.IPC` project
|
||||
- `[IpcPublic]` based built-in public contracts
|
||||
- `PublicIpcHostService` and `LanMountainDesktopIpcClient`
|
||||
- Launcher migrated to Host public IPC notifications
|
||||
- Plugin SDK public IPC contribution API
|
||||
- Host runtime integration for plugin public IPC services
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- plugin process isolation
|
||||
- non-.NET strong-typed public IPC clients
|
||||
- live plugin public service removal without restart
|
||||
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# External IPC Public API Tasks
|
||||
|
||||
- [x] Add `LanMountainDesktop.Shared.IPC`
|
||||
- [x] Expose built-in `[IpcPublic]` services
|
||||
- [x] Add routed notify constants and public IPC client/host wrappers
|
||||
- [x] Start Host public IPC during app startup
|
||||
- [x] Move Launcher startup progress consumption to the new IPC base
|
||||
- [x] Add plugin public IPC registration/contributor SDK
|
||||
- [x] Register plugin-contributed public services into Host catalog
|
||||
- [x] Add integration tests for strong-typed public service access and plugin registration descriptors
|
||||
- [ ] Expand built-in public service surface beyond the first minimal set
|
||||
- [ ] Add non-.NET bridge guidance and samples
|
||||
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Services;
|
||||
|
||||
@@ -83,7 +83,8 @@ internal sealed class LauncherFlowCoordinator
|
||||
var lastStageMessage = "launcher-started";
|
||||
|
||||
var loadingState = new LoadingStateMessage();
|
||||
using var ipcServer = new LauncherIpcServer(message =>
|
||||
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
@@ -121,7 +122,21 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcServer.Start();
|
||||
ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
loadingState = message;
|
||||
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("IPC loading-state callback failed.", ex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
@@ -174,6 +189,12 @@ internal sealed class LauncherFlowCoordinator
|
||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||
}
|
||||
|
||||
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
|
||||
}
|
||||
|
||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||
var completedTask = await Task.WhenAny(
|
||||
visibilityTcs.Task,
|
||||
@@ -900,6 +921,21 @@ internal sealed class LauncherFlowCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryConnectToPublicIpcAsync(
|
||||
LanMountainDesktopIpcClient ipcClient,
|
||||
TimeSpan timeout)
|
||||
{
|
||||
var connectTask = ipcClient.ConnectAsync();
|
||||
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
if (completedTask != connectTask)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await connectTask.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private enum HostStartMode
|
||||
{
|
||||
ShellExecute,
|
||||
|
||||
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPublicIpcBuilder
|
||||
{
|
||||
IPluginPublicIpcBuilder AddService<TContract>(
|
||||
string? objectId = null,
|
||||
IEnumerable<string>? notifyIds = null)
|
||||
where TContract : class;
|
||||
|
||||
IPluginPublicIpcBuilder AddService(
|
||||
Type contractType,
|
||||
object implementation,
|
||||
string? objectId = null,
|
||||
IEnumerable<string>? notifyIds = null);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPublicIpcContributor
|
||||
{
|
||||
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
|
||||
}
|
||||
@@ -25,8 +25,10 @@
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPublicIpcServiceDescriptor(
|
||||
Type ContractType,
|
||||
object Implementation,
|
||||
string? ObjectId,
|
||||
string[] NotifyIds);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPublicIpcServiceRegistration(
|
||||
Type ContractType,
|
||||
string? ObjectId,
|
||||
string[] NotifyIds);
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
@@ -112,6 +113,55 @@ public static class PluginServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginPublicIpc<TContract, TImplementation>(
|
||||
this IServiceCollection services,
|
||||
string? objectId = null,
|
||||
params string[] notifyIds)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
EnsurePublicIpcContract(typeof(TContract));
|
||||
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||
|
||||
if (!services.Any(descriptor =>
|
||||
descriptor.ServiceType == typeof(PluginPublicIpcServiceRegistration) &&
|
||||
descriptor.ImplementationInstance is PluginPublicIpcServiceRegistration existing &&
|
||||
existing.ContractType == typeof(TContract) &&
|
||||
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
|
||||
{
|
||||
services.AddSingleton(new PluginPublicIpcServiceRegistration(
|
||||
typeof(TContract),
|
||||
objectId,
|
||||
notifyIds ?? []));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginPublicIpcContributor<TContributor>(this IServiceCollection services)
|
||||
where TContributor : class, IPluginPublicIpcContributor
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddSingleton<IPluginPublicIpcContributor, TContributor>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsurePublicIpcContract(Type contractType)
|
||||
{
|
||||
if (!contractType.IsInterface)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Public IPC contract '{contractType.FullName}' must be an interface.");
|
||||
}
|
||||
|
||||
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicAppInfoService
|
||||
{
|
||||
PublicAppInfoSnapshot GetAppInfo();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicPluginCatalogService
|
||||
{
|
||||
PublicIpcCatalogSnapshot GetCatalog();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
[IpcPublic(IgnoresIpcException = true)]
|
||||
public interface IPublicShellControlService
|
||||
{
|
||||
Task<bool> ActivateMainWindowAsync();
|
||||
|
||||
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
||||
|
||||
Task<bool> RestartAsync();
|
||||
|
||||
Task<bool> ExitAsync();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
|
||||
|
||||
public sealed record PublicIpcServiceRegistration(
|
||||
Type ContractType,
|
||||
Func<IServiceProvider, object> ImplementationFactory,
|
||||
string? ObjectId,
|
||||
string? PluginId,
|
||||
string[] NotifyIds);
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddLanMountainDesktopIpcHost(
|
||||
this IServiceCollection services,
|
||||
string? pipeName = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
var host = new PublicIpcHostService(pipeName ?? IpcConstants.DefaultPipeName);
|
||||
foreach (var registration in provider.GetServices<PublicIpcServiceRegistration>())
|
||||
{
|
||||
var implementation = registration.ImplementationFactory(provider);
|
||||
host.RegisterPublicService(
|
||||
registration.ContractType,
|
||||
implementation,
|
||||
registration.ObjectId,
|
||||
registration.PluginId,
|
||||
registration.NotifyIds);
|
||||
}
|
||||
|
||||
host.Start();
|
||||
return host;
|
||||
});
|
||||
|
||||
services.AddSingleton<IExternalIpcNotificationPublisher>(provider =>
|
||||
provider.GetRequiredService<PublicIpcHostService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPublicIpcService<TContract, TImplementation>(
|
||||
this IServiceCollection services,
|
||||
string? objectId = null,
|
||||
string? pluginId = null,
|
||||
params string[] notifyIds)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||
|
||||
if (!services.Any(descriptor =>
|
||||
descriptor.ServiceType == typeof(PublicIpcServiceRegistration) &&
|
||||
descriptor.ImplementationInstance is PublicIpcServiceRegistration existing &&
|
||||
existing.ContractType == typeof(TContract) &&
|
||||
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
|
||||
{
|
||||
services.AddSingleton(new PublicIpcServiceRegistration(
|
||||
typeof(TContract),
|
||||
provider => provider.GetRequiredService<TContract>(),
|
||||
objectId,
|
||||
pluginId,
|
||||
notifyIds ?? []));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TContract));
|
||||
if (descriptor is null)
|
||||
{
|
||||
services.AddSingleton<TContract, TImplementation>();
|
||||
return;
|
||||
}
|
||||
|
||||
if (descriptor.Lifetime != ServiceLifetime.Singleton)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Public IPC contract '{typeof(TContract).FullName}' must be registered as Singleton.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public interface IExternalIpcNotificationPublisher
|
||||
{
|
||||
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
|
||||
where TPayload : class;
|
||||
}
|
||||
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public static class IpcConstants
|
||||
{
|
||||
public const string DefaultPipeName = "LanMountainDesktop.IPC.v1.Server";
|
||||
|
||||
public const string ProtocolVersion = "external-ipc-public-api.v1";
|
||||
|
||||
public static class Routes
|
||||
{
|
||||
public const string SessionGetInfo = "lanmountain.session.get-info";
|
||||
public const string CatalogGet = "lanmountain.catalog.get";
|
||||
}
|
||||
}
|
||||
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public static class IpcRoutedNotifyIds
|
||||
{
|
||||
public const string CatalogChanged = "lanmountain.catalog.changed";
|
||||
public const string LauncherStartupProgress = "lanmountain.launcher.startup-progress";
|
||||
public const string LauncherLoadingState = "lanmountain.launcher.loading-state";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Public IPC abstractions and host/client infrastructure for LanMountainDesktop, backed by dotnetCampus.Ipc.</Description>
|
||||
<PackageTags>LanMountainDesktop;IPC;dotnetCampus.Ipc;Integration</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
|
||||
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
|
||||
using dotnetCampus.Ipc.Pipes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed class LanMountainDesktopIpcClient : IDisposable
|
||||
{
|
||||
private bool _started;
|
||||
|
||||
public LanMountainDesktopIpcClient(string? clientPipeName = null)
|
||||
{
|
||||
Provider = string.IsNullOrWhiteSpace(clientPipeName)
|
||||
? new IpcProvider()
|
||||
: new IpcProvider(clientPipeName);
|
||||
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
|
||||
}
|
||||
|
||||
public IpcProvider Provider { get; }
|
||||
|
||||
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
|
||||
|
||||
public PeerProxy? Peer { get; private set; }
|
||||
|
||||
public bool IsConnected => Peer is not null && Peer.IsConnectedFinished;
|
||||
|
||||
public async Task ConnectAsync(string pipeName = IpcConstants.DefaultPipeName)
|
||||
{
|
||||
EnsureStarted();
|
||||
Peer = await Provider.GetAndConnectToPeerAsync(pipeName).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void RegisterNotifyHandler<TPayload>(string notifyId, Action<TPayload> handler)
|
||||
where TPayload : class
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
RoutedProvider.AddNotifyHandler(notifyId, handler);
|
||||
}
|
||||
|
||||
public void RegisterNotifyHandler<TPayload>(string notifyId, Func<TPayload, Task> handler)
|
||||
where TPayload : class
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
RoutedProvider.AddNotifyHandler(notifyId, handler);
|
||||
}
|
||||
|
||||
public TContract CreateProxy<TContract>(string? objectId = null)
|
||||
where TContract : class
|
||||
{
|
||||
var peer = Peer ?? throw new InvalidOperationException("IPC client is not connected.");
|
||||
return Provider.CreateIpcProxy<TContract>(peer, objectId);
|
||||
}
|
||||
|
||||
public async Task<PublicIpcCatalogSnapshot?> GetCatalogAsync()
|
||||
{
|
||||
var client = await GetRoutedClientAsync().ConfigureAwait(false);
|
||||
return await client.GetResponseAsync<PublicIpcCatalogSnapshot>(IpcConstants.Routes.CatalogGet)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PublicIpcSessionInfo?> GetSessionInfoAsync()
|
||||
{
|
||||
var client = await GetRoutedClientAsync().ConfigureAwait(false);
|
||||
return await client.GetResponseAsync<PublicIpcSessionInfo>(IpcConstants.Routes.SessionGetInfo)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<JsonIpcDirectRoutedClientProxy> GetRoutedClientAsync()
|
||||
{
|
||||
if (Peer is null)
|
||||
{
|
||||
throw new InvalidOperationException("IPC client is not connected.");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
return new JsonIpcDirectRoutedClientProxy(Peer);
|
||||
}
|
||||
|
||||
private void EnsureStarted()
|
||||
{
|
||||
if (_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RoutedProvider.StartServer();
|
||||
_started = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Provider.Dispose();
|
||||
}
|
||||
}
|
||||
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicAppInfoSnapshot(
|
||||
string ApplicationName,
|
||||
string Version,
|
||||
string Codename,
|
||||
string PipeName,
|
||||
int ProcessId,
|
||||
DateTimeOffset StartedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicIpcCatalogSnapshot(
|
||||
PublicIpcServiceDescriptor[] Services,
|
||||
PublicPluginDescriptor[] Plugins,
|
||||
DateTimeOffset Timestamp);
|
||||
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System.Reflection;
|
||||
using System.Collections.Concurrent;
|
||||
using dotnetCampus.Ipc.Context;
|
||||
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
|
||||
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
|
||||
using dotnetCampus.Ipc.Pipes;
|
||||
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed class PublicIpcHostService : IDisposable, IExternalIpcNotificationPublisher
|
||||
{
|
||||
private static readonly MethodInfo CreateIpcJointMethod = typeof(GeneratedIpcFactory)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.Single(method =>
|
||||
method.Name == nameof(GeneratedIpcFactory.CreateIpcJoint) &&
|
||||
method.IsGenericMethodDefinition &&
|
||||
method.GetParameters().Length == 3);
|
||||
|
||||
private readonly Dictionary<(Type ContractType, string ObjectId), PublicServiceEntry> _services = new();
|
||||
private readonly ConcurrentDictionary<string, PeerProxy> _connectedPeers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _gate = new();
|
||||
private bool _started;
|
||||
|
||||
public PublicIpcHostService(string pipeName = IpcConstants.DefaultPipeName)
|
||||
{
|
||||
PipeName = pipeName;
|
||||
StartedAt = DateTimeOffset.UtcNow;
|
||||
Provider = new IpcProvider(pipeName);
|
||||
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
|
||||
}
|
||||
|
||||
public string PipeName { get; }
|
||||
|
||||
public DateTimeOffset StartedAt { get; }
|
||||
|
||||
public IpcProvider Provider { get; }
|
||||
|
||||
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
|
||||
|
||||
public Func<IReadOnlyList<PublicPluginDescriptor>> PluginDescriptorProvider { get; set; } =
|
||||
static () => Array.Empty<PublicPluginDescriptor>();
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RoutedProvider.AddRequestHandler(IpcConstants.Routes.SessionGetInfo, () => BuildSessionInfo());
|
||||
RoutedProvider.AddRequestHandler(IpcConstants.Routes.CatalogGet, () => GetCatalogSnapshot());
|
||||
Provider.PeerConnected += OnPeerConnected;
|
||||
RoutedProvider.StartServer();
|
||||
_started = true;
|
||||
}
|
||||
|
||||
public void RegisterPublicService<TContract>(
|
||||
TContract implementation,
|
||||
string? objectId = null,
|
||||
string? pluginId = null,
|
||||
params string[] notifyIds)
|
||||
where TContract : class
|
||||
{
|
||||
RegisterPublicService(typeof(TContract), implementation, objectId, pluginId, notifyIds);
|
||||
}
|
||||
|
||||
public void RegisterPublicService(
|
||||
Type contractType,
|
||||
object implementation,
|
||||
string? objectId = null,
|
||||
string? pluginId = null,
|
||||
IEnumerable<string>? notifyIds = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contractType);
|
||||
ArgumentNullException.ThrowIfNull(implementation);
|
||||
|
||||
var normalizedObjectId = objectId ?? string.Empty;
|
||||
var normalizedNotifyIds = notifyIds?
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? [];
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_services.ContainsKey((contractType, normalizedObjectId)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}' is already registered.");
|
||||
}
|
||||
|
||||
CreateIpcJointMethod
|
||||
.MakeGenericMethod(contractType)
|
||||
.Invoke(null, [Provider, implementation, string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId]);
|
||||
|
||||
_services[(contractType, normalizedObjectId)] = new PublicServiceEntry(
|
||||
contractType,
|
||||
implementation,
|
||||
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
|
||||
pluginId,
|
||||
normalizedNotifyIds);
|
||||
}
|
||||
|
||||
if (_started)
|
||||
{
|
||||
_ = NotifyCatalogChangedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public PublicIpcCatalogSnapshot GetCatalogSnapshot()
|
||||
{
|
||||
PublicIpcServiceDescriptor[] services;
|
||||
lock (_gate)
|
||||
{
|
||||
services = _services.Values
|
||||
.Select(entry => new PublicIpcServiceDescriptor(
|
||||
entry.ContractType.FullName ?? entry.ContractType.Name,
|
||||
entry.ContractType.Assembly.GetName().Name ?? string.Empty,
|
||||
entry.ContractType.AssemblyQualifiedName,
|
||||
entry.ObjectId,
|
||||
entry.PluginId,
|
||||
string.IsNullOrWhiteSpace(entry.PluginId),
|
||||
entry.NotifyIds))
|
||||
.OrderBy(entry => entry.PluginId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(entry => entry.ContractTypeName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var plugins = PluginDescriptorProvider()?.ToArray() ?? Array.Empty<PublicPluginDescriptor>();
|
||||
return new PublicIpcCatalogSnapshot(services, plugins, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public Task PublishStartupProgressAsync(
|
||||
LanMountainDesktop.Shared.Contracts.Launcher.StartupProgressMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
return NotifyAsync(IpcRoutedNotifyIds.LauncherStartupProgress, message, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PublishLoadingStateAsync(
|
||||
LanMountainDesktop.Shared.Contracts.Launcher.LoadingStateMessage message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
return NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, message, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
|
||||
where TPayload : class
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
foreach (var peer in _connectedPeers.Values)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var client = new JsonIpcDirectRoutedClientProxy(peer);
|
||||
await client.NotifyAsync(notifyId, payload).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep notification fan-out best-effort. Broken peers are cleaned by dotnetCampus.Ipc.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task NotifyCatalogChangedAsync()
|
||||
{
|
||||
return NotifyAsync(IpcRoutedNotifyIds.CatalogChanged, GetCatalogSnapshot());
|
||||
}
|
||||
|
||||
private PublicIpcSessionInfo BuildSessionInfo()
|
||||
{
|
||||
return new PublicIpcSessionInfo(
|
||||
PipeName,
|
||||
IpcConstants.ProtocolVersion,
|
||||
[
|
||||
IpcConstants.Routes.SessionGetInfo,
|
||||
IpcConstants.Routes.CatalogGet,
|
||||
IpcRoutedNotifyIds.CatalogChanged,
|
||||
IpcRoutedNotifyIds.LauncherStartupProgress,
|
||||
IpcRoutedNotifyIds.LauncherLoadingState
|
||||
],
|
||||
StartedAt);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Provider.PeerConnected -= OnPeerConnected;
|
||||
Provider.Dispose();
|
||||
}
|
||||
|
||||
private void OnPeerConnected(object? sender, PeerConnectedArgs e)
|
||||
{
|
||||
var peer = e.Peer;
|
||||
_connectedPeers[peer.PeerName] = peer;
|
||||
peer.PeerConnectionBroken -= OnPeerConnectionBroken;
|
||||
peer.PeerConnectionBroken += OnPeerConnectionBroken;
|
||||
}
|
||||
|
||||
private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e)
|
||||
{
|
||||
if (sender is PeerProxy peer)
|
||||
{
|
||||
_connectedPeers.TryRemove(peer.PeerName, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PublicServiceEntry(
|
||||
Type ContractType,
|
||||
object Implementation,
|
||||
string? ObjectId,
|
||||
string? PluginId,
|
||||
string[] NotifyIds);
|
||||
}
|
||||
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicIpcServiceDescriptor(
|
||||
string ContractTypeName,
|
||||
string ContractAssemblyName,
|
||||
string? ContractAssemblyQualifiedName,
|
||||
string? ObjectId,
|
||||
string? PluginId,
|
||||
bool IsBuiltIn,
|
||||
string[] NotifyIds);
|
||||
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicIpcSessionInfo(
|
||||
string PipeName,
|
||||
string ProtocolVersion,
|
||||
string[] Capabilities,
|
||||
DateTimeOffset StartedAt);
|
||||
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.Shared.IPC;
|
||||
|
||||
public sealed record PublicPluginDescriptor(
|
||||
string PluginId,
|
||||
string DisplayName,
|
||||
string? Version,
|
||||
bool IsLoaded,
|
||||
bool IsEnabled);
|
||||
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# LanMountainDesktop.Shared.IPC
|
||||
|
||||
Public IPC abstractions and host/client helpers for LanMountainDesktop.
|
||||
120
LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
Normal file
120
LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ExternalIpcPublicApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublicIpcHost_ExposesStrongTypedServiceAndCatalog()
|
||||
{
|
||||
var pipeName = "LanMountainDesktop.Test." + Guid.NewGuid().ToString("N");
|
||||
using var host = new PublicIpcHostService(pipeName);
|
||||
host.PluginDescriptorProvider = () =>
|
||||
[
|
||||
new PublicPluginDescriptor("sample.plugin", "Sample Plugin", "1.0.0", true, true)
|
||||
];
|
||||
|
||||
var appInfo = new PublicAppInfoSnapshot(
|
||||
"LanMountainDesktop",
|
||||
"1.2.3",
|
||||
"Administrate",
|
||||
pipeName,
|
||||
42,
|
||||
DateTimeOffset.UtcNow);
|
||||
host.RegisterPublicService<IPublicAppInfoService>(new TestPublicAppInfoService(appInfo));
|
||||
host.Start();
|
||||
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
var catalogChanged = new TaskCompletionSource<PublicIpcCatalogSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.RegisterNotifyHandler<PublicIpcCatalogSnapshot>(IpcRoutedNotifyIds.CatalogChanged, snapshot =>
|
||||
{
|
||||
catalogChanged.TrySetResult(snapshot);
|
||||
});
|
||||
|
||||
await client.ConnectAsync(pipeName);
|
||||
|
||||
var proxy = client.CreateProxy<IPublicAppInfoService>();
|
||||
var remoteInfo = proxy.GetAppInfo();
|
||||
Assert.Equal(appInfo.ApplicationName, remoteInfo.ApplicationName);
|
||||
Assert.Equal(appInfo.Version, remoteInfo.Version);
|
||||
Assert.Equal(appInfo.Codename, remoteInfo.Codename);
|
||||
|
||||
var initialCatalog = await client.GetCatalogAsync();
|
||||
Assert.NotNull(initialCatalog);
|
||||
Assert.Contains(initialCatalog!.Services, service => service.ContractTypeName == typeof(IPublicAppInfoService).FullName);
|
||||
Assert.Contains(initialCatalog.Plugins, plugin => plugin.PluginId == "sample.plugin");
|
||||
|
||||
host.RegisterPublicService<IPublicPluginCatalogService>(new TestPublicPluginCatalogService(initialCatalog));
|
||||
var updatedCatalog = await catalogChanged.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Contains(updatedCatalog.Services, service => service.ContractTypeName == typeof(IPublicPluginCatalogService).FullName);
|
||||
|
||||
var sessionInfo = await client.GetSessionInfoAsync();
|
||||
Assert.NotNull(sessionInfo);
|
||||
Assert.Equal(pipeName, sessionInfo!.PipeName);
|
||||
Assert.Equal(IpcConstants.ProtocolVersion, sessionInfo.ProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPluginPublicIpc_RegistersServiceDescriptor()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddPluginPublicIpc<ITestPluginPublicService, TestPluginPublicService>(
|
||||
objectId: "plugin-service",
|
||||
notifyIds: ["lanmountain.plugin.sample.updated"]);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var registration = Assert.Single(provider.GetServices<PluginPublicIpcServiceRegistration>());
|
||||
Assert.Equal(typeof(ITestPluginPublicService), registration.ContractType);
|
||||
Assert.Equal("plugin-service", registration.ObjectId);
|
||||
Assert.Contains("lanmountain.plugin.sample.updated", registration.NotifyIds);
|
||||
}
|
||||
|
||||
private sealed class TestPublicAppInfoService : IPublicAppInfoService
|
||||
{
|
||||
private readonly PublicAppInfoSnapshot _snapshot;
|
||||
|
||||
public TestPublicAppInfoService(PublicAppInfoSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public PublicAppInfoSnapshot GetAppInfo()
|
||||
{
|
||||
return _snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestPublicPluginCatalogService : IPublicPluginCatalogService
|
||||
{
|
||||
private readonly PublicIpcCatalogSnapshot _snapshot;
|
||||
|
||||
public TestPublicPluginCatalogService(PublicIpcCatalogSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public PublicIpcCatalogSnapshot GetCatalog()
|
||||
{
|
||||
return _snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[dotnetCampus.Ipc.CompilerServices.Attributes.IpcPublic]
|
||||
public interface ITestPluginPublicService
|
||||
{
|
||||
string Ping();
|
||||
}
|
||||
|
||||
public sealed class TestPluginPublicService : ITestPluginPublicService
|
||||
{
|
||||
public string Ping()
|
||||
{
|
||||
return "pong";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<Project Path="LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj" />
|
||||
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
|
||||
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
|
||||
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
|
||||
@@ -20,10 +20,13 @@ using LanMountainDesktop.DesktopHost;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.ExternalIpc;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Services.Loading;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
@@ -55,6 +58,7 @@ public partial class App : Application
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
|
||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||
private ISettingsWindowService? _settingsWindowService;
|
||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||
@@ -75,7 +79,7 @@ public partial class App : Application
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
private DesktopShellHost? _desktopShellHost;
|
||||
private LauncherIpcClient? _launcherIpcClient;
|
||||
private PublicIpcHostService? _publicIpcHostService;
|
||||
private LoadingStateManager? _loadingStateManager;
|
||||
private LoadingStateReporter? _loadingStateReporter;
|
||||
private bool _singleInstanceReleased;
|
||||
@@ -160,6 +164,7 @@ public partial class App : Application
|
||||
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePublicIpc();
|
||||
_ = InitializeLauncherIpcAsync();
|
||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||
|
||||
@@ -173,34 +178,24 @@ public partial class App : Application
|
||||
|
||||
private async Task InitializeLauncherIpcAsync()
|
||||
{
|
||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||
if (_loadingStateManager is not null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_launcherIpcClient = new LauncherIpcClient();
|
||||
var connected = await _launcherIpcClient.ConnectAsync();
|
||||
if (!connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
||||
|
||||
bool hadBufferedMessages;
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
|
||||
}
|
||||
|
||||
await FlushPendingLauncherProgressAsync();
|
||||
|
||||
_loadingStateManager = new LoadingStateManager();
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
|
||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _publicIpcHostService);
|
||||
_loadingStateReporter.Start();
|
||||
|
||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
|
||||
_loadingStateManager.StartItem("system.init", "Launcher IPC connected.");
|
||||
_loadingStateManager.StartItem("system.init", "Public IPC host ready.");
|
||||
await FlushPendingLauncherProgressAsync();
|
||||
|
||||
if (!hadBufferedMessages)
|
||||
{
|
||||
@@ -238,8 +233,8 @@ public partial class App : Application
|
||||
|
||||
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
|
||||
{
|
||||
var ipcClient = _launcherIpcClient;
|
||||
if (ipcClient is null || !ipcClient.IsConnected)
|
||||
var publicIpcHostService = _publicIpcHostService;
|
||||
if (publicIpcHostService is null)
|
||||
{
|
||||
lock (_launcherProgressLock)
|
||||
{
|
||||
@@ -250,13 +245,13 @@ public partial class App : Application
|
||||
return;
|
||||
}
|
||||
|
||||
_ = SendLauncherProgressAsync(ipcClient, message, logSuccess);
|
||||
_ = SendLauncherProgressAsync(publicIpcHostService, message, logSuccess);
|
||||
}
|
||||
|
||||
private async Task FlushPendingLauncherProgressAsync()
|
||||
{
|
||||
var ipcClient = _launcherIpcClient;
|
||||
if (ipcClient is null || !ipcClient.IsConnected)
|
||||
var publicIpcHostService = _publicIpcHostService;
|
||||
if (publicIpcHostService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -270,15 +265,15 @@ public partial class App : Application
|
||||
|
||||
foreach (var pendingMessage in pendingMessages)
|
||||
{
|
||||
await SendLauncherProgressAsync(ipcClient, pendingMessage, logSuccess: false);
|
||||
await SendLauncherProgressAsync(publicIpcHostService, pendingMessage, logSuccess: false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendLauncherProgressAsync(LauncherIpcClient ipcClient, StartupProgressMessage message, bool logSuccess)
|
||||
private async Task SendLauncherProgressAsync(PublicIpcHostService publicIpcHostService, StartupProgressMessage message, bool logSuccess)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ipcClient.ReportProgressAsync(message);
|
||||
await publicIpcHostService.PublishStartupProgressAsync(message);
|
||||
if (logSuccess)
|
||||
{
|
||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
|
||||
@@ -463,7 +458,7 @@ public partial class App : Application
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade, _publicIpcHostService);
|
||||
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
@@ -1043,6 +1038,19 @@ public partial class App : Application
|
||||
_pluginRuntimeService = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_publicIpcHostService?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PublicIpc", "Failed to dispose public IPC host during shutdown.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_publicIpcHostService = null;
|
||||
}
|
||||
|
||||
_settingsWindowService?.Close();
|
||||
if (_settingsPageRegistry is IDisposable disposableRegistry)
|
||||
{
|
||||
@@ -1336,6 +1344,56 @@ public partial class App : Application
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
return _localizationService.GetString(languageCode, key, fallback);
|
||||
}
|
||||
|
||||
internal bool TryActivateMainWindowFromExternalIpc(string source)
|
||||
{
|
||||
return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||
}
|
||||
|
||||
private void InitializePublicIpc()
|
||||
{
|
||||
if (_publicIpcHostService is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||
_publicIpcHostService = new PublicIpcHostService();
|
||||
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
|
||||
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
|
||||
new PublicAppInfoService(version, "Administrate", _startupAt));
|
||||
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
|
||||
new PublicShellControlService());
|
||||
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
|
||||
new PublicPluginCatalogService(_publicIpcHostService));
|
||||
_publicIpcHostService.Start();
|
||||
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PublicIpc", "Failed to initialize public IPC host.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<PublicPluginDescriptor> BuildPublicPluginDescriptors()
|
||||
{
|
||||
var runtime = _pluginRuntimeService;
|
||||
if (runtime is null)
|
||||
{
|
||||
return Array.Empty<PublicPluginDescriptor>();
|
||||
}
|
||||
|
||||
return runtime.Catalog
|
||||
.Select(entry => new PublicPluginDescriptor(
|
||||
entry.Manifest.Id,
|
||||
entry.Manifest.Name,
|
||||
entry.Manifest.Version,
|
||||
entry.IsLoaded,
|
||||
entry.IsEnabled))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicAppInfoService : IPublicAppInfoService
|
||||
{
|
||||
private readonly string _version;
|
||||
private readonly string _codename;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
|
||||
public PublicAppInfoService(string version, string codename, DateTimeOffset startedAt)
|
||||
{
|
||||
_version = version;
|
||||
_codename = codename;
|
||||
_startedAt = startedAt;
|
||||
}
|
||||
|
||||
public PublicAppInfoSnapshot GetAppInfo()
|
||||
{
|
||||
return new PublicAppInfoSnapshot(
|
||||
"LanMountainDesktop",
|
||||
_version,
|
||||
_codename,
|
||||
IpcConstants.DefaultPipeName,
|
||||
Environment.ProcessId,
|
||||
_startedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicPluginCatalogService : IPublicPluginCatalogService
|
||||
{
|
||||
private readonly PublicIpcHostService _publicIpcHostService;
|
||||
|
||||
public PublicPluginCatalogService(PublicIpcHostService publicIpcHostService)
|
||||
{
|
||||
_publicIpcHostService = publicIpcHostService;
|
||||
}
|
||||
|
||||
public PublicIpcCatalogSnapshot GetCatalog()
|
||||
{
|
||||
return _publicIpcHostService.GetCatalogSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||
|
||||
internal sealed class PublicShellControlService : IPublicShellControlService
|
||||
{
|
||||
public Task<bool> ActivateMainWindowAsync()
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
return (Application.Current as App)?.TryActivateMainWindowFromExternalIpc("PublicIpc") == true;
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<bool> OpenSettingsAsync(string? pageTag = null)
|
||||
{
|
||||
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (Application.Current is not App app)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
app.OpenIndependentSettingsModule("PublicIpc", pageTag);
|
||||
return true;
|
||||
}).GetTask();
|
||||
}
|
||||
|
||||
public Task<bool> RestartAsync()
|
||||
{
|
||||
var lifecycle = App.CurrentHostApplicationLifecycle;
|
||||
return Task.FromResult(lifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: "PublicIpc",
|
||||
Reason: "External IPC requested restart.")) == true);
|
||||
}
|
||||
|
||||
public Task<bool> ExitAsync()
|
||||
{
|
||||
var lifecycle = App.CurrentHostApplicationLifecycle;
|
||||
return Task.FromResult(lifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "PublicIpc",
|
||||
Reason: "External IPC requested exit.")) == true);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Timers;
|
||||
using LanMountainDesktop.Services.Launcher;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
|
||||
namespace LanMountainDesktop.Services.Loading;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Services.Loading;
|
||||
public class LoadingStateReporter : IDisposable
|
||||
{
|
||||
private readonly LoadingStateManager _manager;
|
||||
private readonly LauncherIpcClient? _ipcClient;
|
||||
private readonly IExternalIpcNotificationPublisher? _notificationPublisher;
|
||||
private readonly System.Timers.Timer _reportTimer;
|
||||
private readonly object _lock = new();
|
||||
private bool _isDisposed;
|
||||
@@ -36,10 +36,10 @@ public class LoadingStateReporter : IDisposable
|
||||
|
||||
public LoadingStateReporter(
|
||||
LoadingStateManager manager,
|
||||
LauncherIpcClient? ipcClient = null)
|
||||
IExternalIpcNotificationPublisher? notificationPublisher = null)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
_ipcClient = ipcClient;
|
||||
_notificationPublisher = notificationPublisher;
|
||||
|
||||
// 创建定时上报定时器
|
||||
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
|
||||
@@ -80,7 +80,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
public async Task ReportImmediatelyAsync()
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == null) return;
|
||||
|
||||
var message = CreateDetailedProgressMessage();
|
||||
await SendMessageAsync(message);
|
||||
@@ -91,7 +91,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == null) return;
|
||||
|
||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||
if (item == null) return;
|
||||
@@ -121,7 +121,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == null) return;
|
||||
|
||||
var progressMessage = new DetailedProgressMessage
|
||||
{
|
||||
@@ -140,7 +140,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
public async Task ReportErrorAsync(string errorMessage, string? details = null)
|
||||
{
|
||||
if (_isDisposed || _ipcClient == null) return;
|
||||
if (_isDisposed || _notificationPublisher == null) return;
|
||||
|
||||
var fullMessage = string.IsNullOrEmpty(details)
|
||||
? errorMessage
|
||||
@@ -280,7 +280,7 @@ public class LoadingStateReporter : IDisposable
|
||||
/// </summary>
|
||||
private async Task SendMessageAsync(DetailedProgressMessage message)
|
||||
{
|
||||
if (_ipcClient == null) return;
|
||||
if (_notificationPublisher == null) return;
|
||||
|
||||
// 检查最小上报间隔
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -293,15 +293,15 @@ public class LoadingStateReporter : IDisposable
|
||||
try
|
||||
{
|
||||
// 转换为 StartupProgressMessage 以保持兼容性
|
||||
var baseMessage = new StartupProgressMessage
|
||||
var loadingStateMessage = _manager.GetLoadingStateMessage() with
|
||||
{
|
||||
Stage = message.Stage,
|
||||
ProgressPercent = message.ProgressPercent,
|
||||
OverallProgressPercent = message.ProgressPercent,
|
||||
Message = FormatMessage(message),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _ipcClient.ReportProgressAsync(baseMessage);
|
||||
await _notificationPublisher.NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, loadingStateMessage);
|
||||
_lastReportTime = DateTimeOffset.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,16 @@ Two new supporting packages define the isolation boundary:
|
||||
|
||||
For the detailed design, migration path, UI strategy, and residual risks, see `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`.
|
||||
|
||||
## External IPC Public API
|
||||
|
||||
- The current IPC mainline is external integration, not plugin process isolation.
|
||||
- `LanMountainDesktop.Shared.IPC` is the unified IPC base for Host public services, Launcher/OOBE startup notifications, and plugin-contributed public services.
|
||||
- Strongly typed command/query access uses `[IpcPublic]` contracts plus `dotnetCampus.Ipc` generated proxy/joint support.
|
||||
- One-way events use `JsonIpcDirectRoutedProvider.NotifyAsync` with fixed top-level notify IDs.
|
||||
- Host remains the single external IPC entry point even when a capability is contributed by a plugin.
|
||||
|
||||
See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration model.
|
||||
|
||||
## Launcher OOBE / Elevation Contract
|
||||
|
||||
- Launcher OOBE state is owned by a per-user JSON file under `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
|
||||
|
||||
125
docs/EXTERNAL_IPC_ARCHITECTURE.md
Normal file
125
docs/EXTERNAL_IPC_ARCHITECTURE.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# External IPC Architecture
|
||||
|
||||
## Scope
|
||||
|
||||
This document defines the current external integration IPC baseline for LanMountainDesktop.
|
||||
|
||||
- The delivery focus is external application integration, not plugin process isolation.
|
||||
- `dotnetCampus.Ipc` is the single IPC foundation for Host public APIs, Launcher/OOBE startup notifications, and plugin-contributed external services.
|
||||
- Process isolation remains a future track and stays documented in `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`.
|
||||
|
||||
## Design Summary
|
||||
|
||||
The public IPC stack is split into two complementary layers:
|
||||
|
||||
1. Strongly typed public services
|
||||
- Contracts are marked with `[IpcPublic]`.
|
||||
- Host exposes service instances through `CreateIpcJoint<TContract>(instance)`.
|
||||
- .NET clients connect once and obtain strong typed proxies through `CreateIpcProxy<TContract>(peer)`.
|
||||
2. Routed notifications
|
||||
- `JsonIpcDirectRoutedProvider.NotifyAsync` is used for one-way event delivery.
|
||||
- Startup progress, loading-state updates, catalog changed events, and plugin live events all use routed notify IDs.
|
||||
|
||||
This keeps command/query calls explicit and strongly typed while still giving plugins and Launcher a lightweight event channel.
|
||||
|
||||
## Projects
|
||||
|
||||
- `LanMountainDesktop.Shared.IPC`
|
||||
- Public IPC constants, routed notify IDs, DTOs, strong-typed public service contracts, host/client helpers, and DI registration helpers.
|
||||
- `LanMountainDesktop`
|
||||
- Runs `PublicIpcHostService`, exposes built-in public services, and folds plugin-contributed services into one external catalog.
|
||||
- `LanMountainDesktop.Launcher`
|
||||
- Connects to the Host public pipe and listens for startup and loading-state notifications instead of running a custom length-prefixed IPC server.
|
||||
- `LanMountainDesktop.PluginSdk`
|
||||
- Adds `IPluginPublicIpcContributor`, `IPluginPublicIpcBuilder`, and `AddPluginPublicIpc(...)`.
|
||||
|
||||
## Built-in Public Services
|
||||
|
||||
Current built-in `[IpcPublic]` contracts:
|
||||
|
||||
- `IPublicAppInfoService`
|
||||
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
|
||||
- `IPublicShellControlService`
|
||||
- Allows external .NET clients to activate the shell, open settings, request restart, and request exit.
|
||||
- `IPublicPluginCatalogService`
|
||||
- Returns the merged public IPC catalog snapshot exposed by Host.
|
||||
|
||||
## Routed Notify IDs
|
||||
|
||||
Current fixed routed notify IDs:
|
||||
|
||||
- `lanmountain.catalog.changed`
|
||||
- `lanmountain.launcher.startup-progress`
|
||||
- `lanmountain.launcher.loading-state`
|
||||
|
||||
The fixed routed surface is intentionally small. Runtime variation happens in the service catalog and in plugin-contributed service instances, not in ad-hoc top-level route registration after startup.
|
||||
|
||||
## Host Lifecycle
|
||||
|
||||
`PublicIpcHostService` is started during Host application startup and remains the single external IPC entry point.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Start a named `dotnetCampus.Ipc` provider.
|
||||
- Register fixed request routes before `StartServer()`.
|
||||
- Expose built-in strong-typed public services.
|
||||
- Maintain the merged service catalog.
|
||||
- Publish startup and loading-state notifications to connected clients.
|
||||
- Accept plugin-contributed public services after plugin load.
|
||||
|
||||
## Launcher / OOBE Migration
|
||||
|
||||
Launcher no longer depends on the previous custom named-pipe length-prefixed protocol as the primary path.
|
||||
|
||||
- Host publishes `StartupProgressMessage` through `lanmountain.launcher.startup-progress`.
|
||||
- Host publishes `LoadingStateMessage` through `lanmountain.launcher.loading-state`.
|
||||
- Launcher connects as a normal public IPC client and subscribes to those routed notifications.
|
||||
|
||||
This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators.
|
||||
|
||||
## Plugin Public IPC Contribution Model
|
||||
|
||||
Plugins can contribute new external IPC services in two ways:
|
||||
|
||||
1. Declarative registration
|
||||
- `services.AddPluginPublicIpc<TContract, TImplementation>(...)`
|
||||
2. Advanced contributor
|
||||
- Register `IPluginPublicIpcContributor`
|
||||
- Use `IPluginPublicIpcBuilder` to contribute services from plugin DI
|
||||
|
||||
At plugin load time the Host runtime:
|
||||
|
||||
- discovers `PluginPublicIpcServiceRegistration`
|
||||
- executes `IPluginPublicIpcContributor`
|
||||
- validates that contributed contracts are `[IpcPublic]` interfaces
|
||||
- registers the resolved instances into `PublicIpcHostService`
|
||||
- emits `lanmountain.catalog.changed`
|
||||
|
||||
Plugins can also inject `IExternalIpcNotificationPublisher` and translate internal DI/message-bus events into routed notifications such as:
|
||||
|
||||
- `lanmountain.plugin.{pluginId}.attendance.updated`
|
||||
- `lanmountain.plugin.{pluginId}.status.changed`
|
||||
|
||||
## Service Catalog
|
||||
|
||||
The public catalog is represented by `PublicIpcCatalogSnapshot` and includes:
|
||||
|
||||
- built-in and plugin-provided public services
|
||||
- contract type metadata
|
||||
- optional object id
|
||||
- owning `pluginId` for plugin services
|
||||
- declared notify IDs
|
||||
- current loaded/enabled plugin list
|
||||
|
||||
This catalog is available through:
|
||||
|
||||
- strong-typed public service `IPublicPluginCatalogService`
|
||||
- fixed request route `lanmountain.catalog.get`
|
||||
- routed notify `lanmountain.catalog.changed`
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- Strong-typed proxy/joint support is .NET-first.
|
||||
- Plugin service removal is still restart-bound. New services can be added at runtime, but service removal is not yet modeled as a live unload contract.
|
||||
- Cross-language clients still need a .NET bridge or sidecar if they want to consume `[IpcPublic]` contracts directly.
|
||||
- Plugin process isolation is not part of this delivery. That remains future work.
|
||||
@@ -559,3 +559,13 @@ var updateCheckService = new UpdateCheckService(
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
|
||||
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||
|
||||
## Public IPC Baseline
|
||||
|
||||
Launcher now consumes Host startup telemetry from the unified public IPC stack:
|
||||
|
||||
- Host publishes `StartupProgressMessage` via `lanmountain.launcher.startup-progress`
|
||||
- Host publishes `LoadingStateMessage` via `lanmountain.launcher.loading-state`
|
||||
- Launcher connects through `LanMountainDesktopIpcClient`
|
||||
|
||||
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.
|
||||
|
||||
@@ -684,3 +684,34 @@ if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
- [组件开发指南](COMPONENT_DEVELOPMENT.md)
|
||||
- [API 参考](API_REFERENCE.md)
|
||||
- [架构文档](ARCHITECTURE.md)
|
||||
## Public IPC Extension
|
||||
|
||||
Plugins can now contribute external IPC capabilities through the Host public IPC entry point.
|
||||
|
||||
Recommended registration styles:
|
||||
|
||||
```csharp
|
||||
services.AddPluginPublicIpc<IMyPluginPublicService, MyPluginPublicService>(
|
||||
objectId: "default",
|
||||
notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
|
||||
```
|
||||
|
||||
Or use the advanced contributor model:
|
||||
|
||||
```csharp
|
||||
public sealed class MyPluginPublicIpcContributor : IPluginPublicIpcContributor
|
||||
{
|
||||
public void ConfigurePublicIpc(IPluginPublicIpcBuilder builder)
|
||||
{
|
||||
builder.AddService<IMyPluginPublicService>(
|
||||
objectId: "default",
|
||||
notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Additional notes:
|
||||
|
||||
- Public IPC contracts must be interfaces marked with `[IpcPublic]`.
|
||||
- External .NET clients can reference the plugin contract assembly and create strong-typed proxies through the Host public pipe.
|
||||
- Plugins can inject `IExternalIpcNotificationPublisher` to push live events outward through routed notifications.
|
||||
|
||||
Reference in New Issue
Block a user