Compare commits

..

2 Commits

Author SHA1 Message Date
lincube
00747f33b2 Merge branch 'main' into codex/ipc 2026-04-22 10:25:08 +08:00
lincube
2c48b7b846 Add plugin isolation IPC scaffolding and host phase one docs 2026-04-22 10:15:07 +08:00
43 changed files with 49 additions and 1347 deletions

View File

@@ -1,11 +0,0 @@
# 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.

View File

@@ -1,24 +0,0 @@
# 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

View File

@@ -1,12 +0,0 @@
# 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

View File

@@ -18,7 +18,6 @@
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -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,8 +83,7 @@ internal sealed class LauncherFlowCoordinator
var lastStageMessage = "launcher-started";
var loadingState = new LoadingStateMessage();
using var ipcClient = new LanMountainDesktopIpcClient();
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
using var ipcServer = new LauncherIpcServer(message =>
{
Dispatcher.UIThread.Post(() =>
{
@@ -122,21 +121,7 @@ internal sealed class LauncherFlowCoordinator
}
});
});
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);
}
});
});
ipcServer.Start();
try
{
@@ -189,12 +174,6 @@ 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,
@@ -921,21 +900,6 @@ 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,

View File

@@ -1,15 +0,0 @@
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);
}

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPublicIpcContributor
{
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
}

View File

@@ -25,10 +25,8 @@
<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>

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPublicIpcServiceDescriptor(
Type ContractType,
object Implementation,
string? ObjectId,
string[] NotifyIds);

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPublicIpcServiceRegistration(
Type ContractType,
string? ObjectId,
string[] NotifyIds);

View File

@@ -1,5 +1,4 @@
using Avalonia.Controls;
using dotnetCampus.Ipc.CompilerServices.Attributes;
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
@@ -113,55 +112,6 @@ 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

View File

@@ -1,9 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicAppInfoService
{
PublicAppInfoSnapshot GetAppInfo();
}

View File

@@ -1,9 +0,0 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicPluginCatalogService
{
PublicIpcCatalogSnapshot GetCatalog();
}

View File

@@ -1,15 +0,0 @@
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();
}

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
public sealed record PublicIpcServiceRegistration(
Type ContractType,
Func<IServiceProvider, object> ImplementationFactory,
string? ObjectId,
string? PluginId,
string[] NotifyIds);

View File

@@ -1,83 +0,0 @@
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.");
}
}
}

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public interface IExternalIpcNotificationPublisher
{
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class;
}

View File

@@ -1,14 +0,0 @@
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";
}
}

View File

@@ -1,8 +0,0 @@
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";
}

View File

@@ -1,28 +0,0 @@
<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>

View File

@@ -1,96 +0,0 @@
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();
}
}

View File

@@ -1,9 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicAppInfoSnapshot(
string ApplicationName,
string Version,
string Codename,
string PipeName,
int ProcessId,
DateTimeOffset StartedAt);

View File

@@ -1,6 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcCatalogSnapshot(
PublicIpcServiceDescriptor[] Services,
PublicPluginDescriptor[] Plugins,
DateTimeOffset Timestamp);

View File

@@ -1,219 +0,0 @@
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);
}

View File

@@ -1,10 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcServiceDescriptor(
string ContractTypeName,
string ContractAssemblyName,
string? ContractAssemblyQualifiedName,
string? ObjectId,
string? PluginId,
bool IsBuiltIn,
string[] NotifyIds);

View File

@@ -1,7 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicIpcSessionInfo(
string PipeName,
string ProtocolVersion,
string[] Capabilities,
DateTimeOffset StartedAt);

View File

@@ -1,8 +0,0 @@
namespace LanMountainDesktop.Shared.IPC;
public sealed record PublicPluginDescriptor(
string PluginId,
string DisplayName,
string? Version,
bool IsLoaded,
bool IsEnabled);

View File

@@ -1,3 +0,0 @@
# LanMountainDesktop.Shared.IPC
Public IPC abstractions and host/client helpers for LanMountainDesktop.

View File

@@ -1,120 +0,0 @@
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";
}
}

View File

@@ -1,7 +1,6 @@
<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" />

View File

@@ -20,13 +20,10 @@ 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;
@@ -58,7 +55,6 @@ 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;
@@ -79,7 +75,7 @@ public partial class App : Application
private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost;
private PublicIpcHostService? _publicIpcHostService;
private LauncherIpcClient? _launcherIpcClient;
private LoadingStateManager? _loadingStateManager;
private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased;
@@ -164,7 +160,6 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePublicIpc();
_ = InitializeLauncherIpcAsync();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
@@ -178,24 +173,34 @@ public partial class App : Application
private async Task InitializeLauncherIpcAsync()
{
if (_loadingStateManager is not null)
if (!LauncherIpcClient.IsLaunchedByLauncher())
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, _publicIpcHostService);
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
_loadingStateReporter.Start();
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
_loadingStateManager.StartItem("system.init", "Public IPC host ready.");
await FlushPendingLauncherProgressAsync();
_loadingStateManager.StartItem("system.init", "Launcher IPC connected.");
if (!hadBufferedMessages)
{
@@ -233,8 +238,8 @@ public partial class App : Application
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
{
var publicIpcHostService = _publicIpcHostService;
if (publicIpcHostService is null)
var ipcClient = _launcherIpcClient;
if (ipcClient is null || !ipcClient.IsConnected)
{
lock (_launcherProgressLock)
{
@@ -245,13 +250,13 @@ public partial class App : Application
return;
}
_ = SendLauncherProgressAsync(publicIpcHostService, message, logSuccess);
_ = SendLauncherProgressAsync(ipcClient, message, logSuccess);
}
private async Task FlushPendingLauncherProgressAsync()
{
var publicIpcHostService = _publicIpcHostService;
if (publicIpcHostService is null)
var ipcClient = _launcherIpcClient;
if (ipcClient is null || !ipcClient.IsConnected)
{
return;
}
@@ -265,15 +270,15 @@ public partial class App : Application
foreach (var pendingMessage in pendingMessages)
{
await SendLauncherProgressAsync(publicIpcHostService, pendingMessage, logSuccess: false);
await SendLauncherProgressAsync(ipcClient, pendingMessage, logSuccess: false);
}
}
private async Task SendLauncherProgressAsync(PublicIpcHostService publicIpcHostService, StartupProgressMessage message, bool logSuccess)
private async Task SendLauncherProgressAsync(LauncherIpcClient ipcClient, StartupProgressMessage message, bool logSuccess)
{
try
{
await publicIpcHostService.PublishStartupProgressAsync(message);
await ipcClient.ReportProgressAsync(message);
if (logSuccess)
{
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
@@ -458,7 +463,7 @@ public partial class App : Application
try
{
_pluginRuntimeService?.Dispose();
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade, _publicIpcHostService);
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
_pluginRuntimeService.LoadInstalledPlugins();
}
@@ -1038,19 +1043,6 @@ 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)
{
@@ -1344,56 +1336,6 @@ 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();
}
}

View File

@@ -31,7 +31,6 @@
<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" />

View File

@@ -1,29 +0,0 @@
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);
}
}

View File

@@ -1,19 +0,0 @@
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();
}
}

View File

@@ -1,47 +0,0 @@
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);
}
}

View File

@@ -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 IExternalIpcNotificationPublisher? _notificationPublisher;
private readonly LauncherIpcClient? _ipcClient;
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,
IExternalIpcNotificationPublisher? notificationPublisher = null)
LauncherIpcClient? ipcClient = null)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_notificationPublisher = notificationPublisher;
_ipcClient = ipcClient;
// 创建定时上报定时器
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
@@ -80,7 +80,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
public async Task ReportImmediatelyAsync()
{
if (_isDisposed || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == 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 || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == 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 || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == 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 || _notificationPublisher == null) return;
if (_isDisposed || _ipcClient == null) return;
var fullMessage = string.IsNullOrEmpty(details)
? errorMessage
@@ -280,7 +280,7 @@ public class LoadingStateReporter : IDisposable
/// </summary>
private async Task SendMessageAsync(DetailedProgressMessage message)
{
if (_notificationPublisher == null) return;
if (_ipcClient == null) return;
// 检查最小上报间隔
var now = DateTimeOffset.UtcNow;
@@ -293,15 +293,15 @@ public class LoadingStateReporter : IDisposable
try
{
// 转换为 StartupProgressMessage 以保持兼容性
var loadingStateMessage = _manager.GetLoadingStateMessage() with
var baseMessage = new StartupProgressMessage
{
Stage = message.Stage,
OverallProgressPercent = message.ProgressPercent,
ProgressPercent = message.ProgressPercent,
Message = FormatMessage(message),
Timestamp = DateTimeOffset.UtcNow
};
await _notificationPublisher.NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, loadingStateMessage);
await _ipcClient.ReportProgressAsync(baseMessage);
_lastReportTime = DateTimeOffset.UtcNow;
}
catch (Exception ex)

View File

@@ -25,7 +25,6 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
IReadOnlyList<PluginPublicIpcServiceDescriptor> publicIpcServices,
IReadOnlyList<IHostedService> hostedServices,
PluginLoadContext loadContext)
{
@@ -40,7 +39,6 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
DesktopComponents = desktopComponents;
DesktopComponentEditors = desktopComponentEditors;
ExportedServices = exportedServices;
PublicIpcServices = publicIpcServices;
HostedServices = hostedServices;
LoadContext = loadContext;
}
@@ -69,8 +67,6 @@ 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,10 +14,8 @@ 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;
@@ -189,10 +187,9 @@ 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}; PublicIpcServices={publicIpcServices.Count}.");
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}.");
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
StartHostedServices(hostedServices);
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
@@ -209,7 +206,6 @@ public sealed class PluginLoader
desktopComponents,
desktopComponentEditors,
exportedServices,
publicIpcServices,
hostedServices,
loadContext);
@@ -336,7 +332,6 @@ public sealed class PluginLoader
RegisterHostService<ISettingsService>(services, hostServices);
RegisterHostService<ISettingsCatalog>(services, hostServices);
RegisterHostService<IAppearanceThemeService>(services, hostServices);
RegisterHostService<IExternalIpcNotificationPublisher>(services, hostServices);
return services;
}
@@ -418,68 +413,6 @@ 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)
@@ -1141,42 +1074,4 @@ 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,7 +13,6 @@ 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;
@@ -32,7 +31,6 @@ 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 = [];
@@ -41,16 +39,13 @@ public sealed class PluginRuntimeService : IDisposable
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
private readonly object _packageMutationGate = new();
public PluginRuntimeService(
ISettingsFacadeService? settingsFacade = null,
PublicIpcHostService? publicIpcHostService = null)
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = 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)
@@ -63,8 +58,7 @@ public sealed class PluginRuntimeService : IDisposable
_exportRegistry,
_settingsFacade,
_settingsFacade.Settings,
_settingsFacade.Catalog,
_publicIpcHostService);
_settingsFacade.Catalog);
_loaderOptions = CreateOptions();
_loader = new PluginLoader(_loaderOptions);
}
@@ -681,8 +675,6 @@ 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())
{
@@ -769,19 +761,6 @@ 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)
@@ -1011,12 +990,11 @@ 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 IExternalIpcNotificationPublisher? _externalIpcNotificationPublisher;
private readonly IPluginExportRegistry _exportRegistry;
private readonly ISettingsFacadeService _settingsFacade;
private readonly ISettingsService _settingsService;
private readonly ISettingsCatalog _settingsCatalog;
private readonly IAppearanceThemeService _appearanceThemeService;
public PluginHostServiceProvider(
IPluginPackageManager packageManager,
@@ -1024,8 +1002,7 @@ public sealed class PluginRuntimeService : IDisposable
IPluginExportRegistry exportRegistry,
ISettingsFacadeService settingsFacade,
ISettingsService settingsService,
ISettingsCatalog settingsCatalog,
IExternalIpcNotificationPublisher? externalIpcNotificationPublisher)
ISettingsCatalog settingsCatalog)
{
_packageManager = packageManager;
_applicationLifecycle = applicationLifecycle;
@@ -1034,7 +1011,6 @@ public sealed class PluginRuntimeService : IDisposable
_settingsService = settingsService;
_settingsCatalog = settingsCatalog;
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
_externalIpcNotificationPublisher = externalIpcNotificationPublisher;
}
public object? GetService(Type serviceType)
@@ -1074,11 +1050,6 @@ public sealed class PluginRuntimeService : IDisposable
return _appearanceThemeService;
}
if (serviceType == typeof(IExternalIpcNotificationPublisher))
{
return _externalIpcNotificationPublisher;
}
return null;
}
}

View File

@@ -218,16 +218,6 @@ 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`.

View File

@@ -1,125 +0,0 @@
# 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.

View File

@@ -559,13 +559,3 @@ 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.

View File

@@ -684,34 +684,3 @@ 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.