diff --git a/.trae/specs/external-ipc-public-api/checklist.md b/.trae/specs/external-ipc-public-api/checklist.md
new file mode 100644
index 0000000..42528b7
--- /dev/null
+++ b/.trae/specs/external-ipc-public-api/checklist.md
@@ -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.
diff --git a/.trae/specs/external-ipc-public-api/spec.md b/.trae/specs/external-ipc-public-api/spec.md
new file mode 100644
index 0000000..4ef8ddd
--- /dev/null
+++ b/.trae/specs/external-ipc-public-api/spec.md
@@ -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
diff --git a/.trae/specs/external-ipc-public-api/tasks.md b/.trae/specs/external-ipc-public-api/tasks.md
new file mode 100644
index 0000000..981df2a
--- /dev/null
+++ b/.trae/specs/external-ipc-public-api/tasks.md
@@ -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
diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
index 8553be2..01e8e07 100644
--- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
+++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
@@ -18,6 +18,7 @@
+
diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
index f39bcd0..448c0c2 100644
--- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
+++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
@@ -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(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
{
Dispatcher.UIThread.Post(() =>
{
@@ -121,7 +122,21 @@ internal sealed class LauncherFlowCoordinator
}
});
});
- ipcServer.Start();
+ ipcClient.RegisterNotifyHandler(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 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,
diff --git a/LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs b/LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
new file mode 100644
index 0000000..1167944
--- /dev/null
+++ b/LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
@@ -0,0 +1,15 @@
+namespace LanMountainDesktop.PluginSdk;
+
+public interface IPluginPublicIpcBuilder
+{
+ IPluginPublicIpcBuilder AddService(
+ string? objectId = null,
+ IEnumerable? notifyIds = null)
+ where TContract : class;
+
+ IPluginPublicIpcBuilder AddService(
+ Type contractType,
+ object implementation,
+ string? objectId = null,
+ IEnumerable? notifyIds = null);
+}
diff --git a/LanMountainDesktop.PluginSdk/IPluginPublicIpcContributor.cs b/LanMountainDesktop.PluginSdk/IPluginPublicIpcContributor.cs
new file mode 100644
index 0000000..7e06304
--- /dev/null
+++ b/LanMountainDesktop.PluginSdk/IPluginPublicIpcContributor.cs
@@ -0,0 +1,6 @@
+namespace LanMountainDesktop.PluginSdk;
+
+public interface IPluginPublicIpcContributor
+{
+ void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
+}
diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj
index 524773f..76eccd9 100644
--- a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj
+++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj
@@ -25,8 +25,10 @@
+
+
diff --git a/LanMountainDesktop.PluginSdk/PluginPublicIpcServiceDescriptor.cs b/LanMountainDesktop.PluginSdk/PluginPublicIpcServiceDescriptor.cs
new file mode 100644
index 0000000..3a5b983
--- /dev/null
+++ b/LanMountainDesktop.PluginSdk/PluginPublicIpcServiceDescriptor.cs
@@ -0,0 +1,7 @@
+namespace LanMountainDesktop.PluginSdk;
+
+public sealed record PluginPublicIpcServiceDescriptor(
+ Type ContractType,
+ object Implementation,
+ string? ObjectId,
+ string[] NotifyIds);
diff --git a/LanMountainDesktop.PluginSdk/PluginPublicIpcServiceRegistration.cs b/LanMountainDesktop.PluginSdk/PluginPublicIpcServiceRegistration.cs
new file mode 100644
index 0000000..a20525a
--- /dev/null
+++ b/LanMountainDesktop.PluginSdk/PluginPublicIpcServiceRegistration.cs
@@ -0,0 +1,6 @@
+namespace LanMountainDesktop.PluginSdk;
+
+public sealed record PluginPublicIpcServiceRegistration(
+ Type ContractType,
+ string? ObjectId,
+ string[] NotifyIds);
diff --git a/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs b/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs
index d1c872b..8ef9139 100644
--- a/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs
+++ b/LanMountainDesktop.PluginSdk/PluginServiceCollectionExtensions.cs
@@ -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(
+ this IServiceCollection services,
+ string? objectId = null,
+ params string[] notifyIds)
+ where TContract : class
+ where TImplementation : class, TContract
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ EnsurePublicIpcContract(typeof(TContract));
+ EnsureSingletonRegistration(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(this IServiceCollection services)
+ where TContributor : class, IPluginPublicIpcContributor
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ services.AddSingleton();
+ 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(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicAppInfoService.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicAppInfoService.cs
new file mode 100644
index 0000000..f3655ce
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicAppInfoService.cs
@@ -0,0 +1,9 @@
+using dotnetCampus.Ipc.CompilerServices.Attributes;
+
+namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
+
+[IpcPublic(IgnoresIpcException = true)]
+public interface IPublicAppInfoService
+{
+ PublicAppInfoSnapshot GetAppInfo();
+}
diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicPluginCatalogService.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicPluginCatalogService.cs
new file mode 100644
index 0000000..ecde3a2
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicPluginCatalogService.cs
@@ -0,0 +1,9 @@
+using dotnetCampus.Ipc.CompilerServices.Attributes;
+
+namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
+
+[IpcPublic(IgnoresIpcException = true)]
+public interface IPublicPluginCatalogService
+{
+ PublicIpcCatalogSnapshot GetCatalog();
+}
diff --git a/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs
new file mode 100644
index 0000000..6febdfe
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/Abstractions/Services/IPublicShellControlService.cs
@@ -0,0 +1,15 @@
+using dotnetCampus.Ipc.CompilerServices.Attributes;
+
+namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
+
+[IpcPublic(IgnoresIpcException = true)]
+public interface IPublicShellControlService
+{
+ Task ActivateMainWindowAsync();
+
+ Task OpenSettingsAsync(string? pageTag = null);
+
+ Task RestartAsync();
+
+ Task ExitAsync();
+}
diff --git a/LanMountainDesktop.Shared.IPC/DependencyInjection/PublicIpcServiceRegistration.cs b/LanMountainDesktop.Shared.IPC/DependencyInjection/PublicIpcServiceRegistration.cs
new file mode 100644
index 0000000..63e6582
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/DependencyInjection/PublicIpcServiceRegistration.cs
@@ -0,0 +1,8 @@
+namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
+
+public sealed record PublicIpcServiceRegistration(
+ Type ContractType,
+ Func ImplementationFactory,
+ string? ObjectId,
+ string? PluginId,
+ string[] NotifyIds);
diff --git a/LanMountainDesktop.Shared.IPC/DependencyInjection/ServiceCollectionExtensions.cs b/LanMountainDesktop.Shared.IPC/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..482e6d0
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/DependencyInjection/ServiceCollectionExtensions.cs
@@ -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())
+ {
+ var implementation = registration.ImplementationFactory(provider);
+ host.RegisterPublicService(
+ registration.ContractType,
+ implementation,
+ registration.ObjectId,
+ registration.PluginId,
+ registration.NotifyIds);
+ }
+
+ host.Start();
+ return host;
+ });
+
+ services.AddSingleton(provider =>
+ provider.GetRequiredService());
+
+ return services;
+ }
+
+ public static IServiceCollection AddPublicIpcService(
+ this IServiceCollection services,
+ string? objectId = null,
+ string? pluginId = null,
+ params string[] notifyIds)
+ where TContract : class
+ where TImplementation : class, TContract
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ EnsureSingletonRegistration(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(),
+ objectId,
+ pluginId,
+ notifyIds ?? []));
+ }
+
+ return services;
+ }
+
+ private static void EnsureSingletonRegistration(IServiceCollection services)
+ where TContract : class
+ where TImplementation : class, TContract
+ {
+ var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TContract));
+ if (descriptor is null)
+ {
+ services.AddSingleton();
+ return;
+ }
+
+ if (descriptor.Lifetime != ServiceLifetime.Singleton)
+ {
+ throw new InvalidOperationException(
+ $"Public IPC contract '{typeof(TContract).FullName}' must be registered as Singleton.");
+ }
+ }
+}
diff --git a/LanMountainDesktop.Shared.IPC/IExternalIpcNotificationPublisher.cs b/LanMountainDesktop.Shared.IPC/IExternalIpcNotificationPublisher.cs
new file mode 100644
index 0000000..2bde2a1
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/IExternalIpcNotificationPublisher.cs
@@ -0,0 +1,7 @@
+namespace LanMountainDesktop.Shared.IPC;
+
+public interface IExternalIpcNotificationPublisher
+{
+ Task NotifyAsync(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
+ where TPayload : class;
+}
diff --git a/LanMountainDesktop.Shared.IPC/IpcConstants.cs b/LanMountainDesktop.Shared.IPC/IpcConstants.cs
new file mode 100644
index 0000000..338cdc0
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/IpcConstants.cs
@@ -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";
+ }
+}
diff --git a/LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs b/LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
new file mode 100644
index 0000000..ab83624
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
@@ -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";
+}
diff --git a/LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj b/LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj
new file mode 100644
index 0000000..9e2841a
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj
@@ -0,0 +1,28 @@
+
+
+ net10.0
+ enable
+ enable
+ 1.0.0
+ LanMountainDesktop.Shared.IPC
+ true
+ LanMountainDesktop
+ Public IPC abstractions and host/client infrastructure for LanMountainDesktop, backed by dotnetCampus.Ipc.
+ LanMountainDesktop;IPC;dotnetCampus.Ipc;Integration
+ README.md
+ https://github.com/wwiinnddyy/LanMountainDesktop
+ git
+ LGPL-3.0-or-later
+ Copyright (c) LanMountainDesktop Contributors
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs b/LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
new file mode 100644
index 0000000..97427cd
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
@@ -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(string notifyId, Action handler)
+ where TPayload : class
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
+ ArgumentNullException.ThrowIfNull(handler);
+ RoutedProvider.AddNotifyHandler(notifyId, handler);
+ }
+
+ public void RegisterNotifyHandler(string notifyId, Func handler)
+ where TPayload : class
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
+ ArgumentNullException.ThrowIfNull(handler);
+ RoutedProvider.AddNotifyHandler(notifyId, handler);
+ }
+
+ public TContract CreateProxy(string? objectId = null)
+ where TContract : class
+ {
+ var peer = Peer ?? throw new InvalidOperationException("IPC client is not connected.");
+ return Provider.CreateIpcProxy(peer, objectId);
+ }
+
+ public async Task GetCatalogAsync()
+ {
+ var client = await GetRoutedClientAsync().ConfigureAwait(false);
+ return await client.GetResponseAsync(IpcConstants.Routes.CatalogGet)
+ .ConfigureAwait(false);
+ }
+
+ public async Task GetSessionInfoAsync()
+ {
+ var client = await GetRoutedClientAsync().ConfigureAwait(false);
+ return await client.GetResponseAsync(IpcConstants.Routes.SessionGetInfo)
+ .ConfigureAwait(false);
+ }
+
+ private async Task 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();
+ }
+}
diff --git a/LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs b/LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
new file mode 100644
index 0000000..d81e7e2
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
@@ -0,0 +1,9 @@
+namespace LanMountainDesktop.Shared.IPC;
+
+public sealed record PublicAppInfoSnapshot(
+ string ApplicationName,
+ string Version,
+ string Codename,
+ string PipeName,
+ int ProcessId,
+ DateTimeOffset StartedAt);
diff --git a/LanMountainDesktop.Shared.IPC/PublicIpcCatalogSnapshot.cs b/LanMountainDesktop.Shared.IPC/PublicIpcCatalogSnapshot.cs
new file mode 100644
index 0000000..0a7bf3c
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/PublicIpcCatalogSnapshot.cs
@@ -0,0 +1,6 @@
+namespace LanMountainDesktop.Shared.IPC;
+
+public sealed record PublicIpcCatalogSnapshot(
+ PublicIpcServiceDescriptor[] Services,
+ PublicPluginDescriptor[] Plugins,
+ DateTimeOffset Timestamp);
diff --git a/LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs b/LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
new file mode 100644
index 0000000..09c395a
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
@@ -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 _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> PluginDescriptorProvider { get; set; } =
+ static () => Array.Empty();
+
+ 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 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? 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();
+ 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(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);
+}
diff --git a/LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs b/LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
new file mode 100644
index 0000000..9575f49
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
@@ -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);
diff --git a/LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs b/LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
new file mode 100644
index 0000000..e2b7d07
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
@@ -0,0 +1,7 @@
+namespace LanMountainDesktop.Shared.IPC;
+
+public sealed record PublicIpcSessionInfo(
+ string PipeName,
+ string ProtocolVersion,
+ string[] Capabilities,
+ DateTimeOffset StartedAt);
diff --git a/LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs b/LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
new file mode 100644
index 0000000..c120c2b
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
@@ -0,0 +1,8 @@
+namespace LanMountainDesktop.Shared.IPC;
+
+public sealed record PublicPluginDescriptor(
+ string PluginId,
+ string DisplayName,
+ string? Version,
+ bool IsLoaded,
+ bool IsEnabled);
diff --git a/LanMountainDesktop.Shared.IPC/README.md b/LanMountainDesktop.Shared.IPC/README.md
new file mode 100644
index 0000000..5f6e101
--- /dev/null
+++ b/LanMountainDesktop.Shared.IPC/README.md
@@ -0,0 +1,3 @@
+# LanMountainDesktop.Shared.IPC
+
+Public IPC abstractions and host/client helpers for LanMountainDesktop.
diff --git a/LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs b/LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
new file mode 100644
index 0000000..23cf779
--- /dev/null
+++ b/LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
@@ -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(new TestPublicAppInfoService(appInfo));
+ host.Start();
+
+ using var client = new LanMountainDesktopIpcClient();
+ var catalogChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.RegisterNotifyHandler(IpcRoutedNotifyIds.CatalogChanged, snapshot =>
+ {
+ catalogChanged.TrySetResult(snapshot);
+ });
+
+ await client.ConnectAsync(pipeName);
+
+ var proxy = client.CreateProxy();
+ 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(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(
+ objectId: "plugin-service",
+ notifyIds: ["lanmountain.plugin.sample.updated"]);
+
+ using var provider = services.BuildServiceProvider();
+ var registration = Assert.Single(provider.GetServices());
+ 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";
+ }
+}
diff --git a/LanMountainDesktop.slnx b/LanMountainDesktop.slnx
index 1e9d7da..9c9b07f 100644
--- a/LanMountainDesktop.slnx
+++ b/LanMountainDesktop.slnx
@@ -1,6 +1,7 @@
+
diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index 1cbf1fd..f57d214 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -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(
+ new PublicAppInfoService(version, "Administrate", _startupAt));
+ _publicIpcHostService.RegisterPublicService(
+ new PublicShellControlService());
+ _publicIpcHostService.RegisterPublicService(
+ 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 BuildPublicPluginDescriptors()
+ {
+ var runtime = _pluginRuntimeService;
+ if (runtime is null)
+ {
+ return Array.Empty();
+ }
+
+ return runtime.Catalog
+ .Select(entry => new PublicPluginDescriptor(
+ entry.Manifest.Id,
+ entry.Manifest.Name,
+ entry.Manifest.Version,
+ entry.IsLoaded,
+ entry.IsEnabled))
+ .ToArray();
+ }
}
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index 803899b..ab5dee9 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -31,6 +31,7 @@
+
diff --git a/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs b/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs
new file mode 100644
index 0000000..197455c
--- /dev/null
+++ b/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs
@@ -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);
+ }
+}
diff --git a/LanMountainDesktop/Services/ExternalIpc/PublicPluginCatalogService.cs b/LanMountainDesktop/Services/ExternalIpc/PublicPluginCatalogService.cs
new file mode 100644
index 0000000..ef78d61
--- /dev/null
+++ b/LanMountainDesktop/Services/ExternalIpc/PublicPluginCatalogService.cs
@@ -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();
+ }
+}
diff --git a/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs b/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs
new file mode 100644
index 0000000..bad011c
--- /dev/null
+++ b/LanMountainDesktop/Services/ExternalIpc/PublicShellControlService.cs
@@ -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 ActivateMainWindowAsync()
+ {
+ return Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ return (Application.Current as App)?.TryActivateMainWindowFromExternalIpc("PublicIpc") == true;
+ }).GetTask();
+ }
+
+ public Task 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 RestartAsync()
+ {
+ var lifecycle = App.CurrentHostApplicationLifecycle;
+ return Task.FromResult(lifecycle?.TryRestart(new HostApplicationLifecycleRequest(
+ Source: "PublicIpc",
+ Reason: "External IPC requested restart.")) == true);
+ }
+
+ public Task ExitAsync()
+ {
+ var lifecycle = App.CurrentHostApplicationLifecycle;
+ return Task.FromResult(lifecycle?.TryExit(new HostApplicationLifecycleRequest(
+ Source: "PublicIpc",
+ Reason: "External IPC requested exit.")) == true);
+ }
+}
diff --git a/LanMountainDesktop/Services/Loading/LoadingStateReporter.cs b/LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
index d8f817e..7fc4d43 100644
--- a/LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
+++ b/LanMountainDesktop/Services/Loading/LoadingStateReporter.cs
@@ -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
///
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
///
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
///
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
///
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
///
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)
diff --git a/LanMountainDesktop/plugins/LoadedPlugin.cs b/LanMountainDesktop/plugins/LoadedPlugin.cs
index 645bc15..c1bf965 100644
--- a/LanMountainDesktop/plugins/LoadedPlugin.cs
+++ b/LanMountainDesktop/plugins/LoadedPlugin.cs
@@ -25,6 +25,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
IReadOnlyList desktopComponents,
IReadOnlyList desktopComponentEditors,
IReadOnlyList exportedServices,
+ IReadOnlyList publicIpcServices,
IReadOnlyList 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 ExportedServices { get; }
+ public IReadOnlyList PublicIpcServices { get; }
+
public PluginLoadContext LoadContext { get; }
private IReadOnlyList HostedServices { get; }
diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs
index 68a85f2..61c0946 100644
--- a/LanMountainDesktop/plugins/PluginLoader.cs
+++ b/LanMountainDesktop/plugins/PluginLoader.cs
@@ -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().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(services, hostServices);
RegisterHostService(services, hostServices);
RegisterHostService(services, hostServices);
+ RegisterHostService(services, hostServices);
return services;
}
@@ -413,6 +418,68 @@ public sealed class PluginLoader
.ToArray();
}
+ private static IReadOnlyList ResolvePublicIpcServices(
+ PluginManifest manifest,
+ IServiceProvider services)
+ {
+ var descriptors = new List();
+ var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var registration in services.GetServices())
+ {
+ 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())
+ {
+ contributor.ConfigurePublicIpc(builder);
+ }
+
+ return descriptors;
+
+ void AddDescriptor(Type contractType, object implementation, string? objectId, IEnumerable? 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?> _register;
+
+ public RuntimePluginPublicIpcBuilder(
+ IServiceProvider services,
+ Action?> register)
+ {
+ _services = services;
+ _register = register;
+ }
+
+ public IPluginPublicIpcBuilder AddService(
+ string? objectId = null,
+ IEnumerable? 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? notifyIds = null)
+ {
+ ArgumentNullException.ThrowIfNull(contractType);
+ ArgumentNullException.ThrowIfNull(implementation);
+ _register(contractType, implementation, objectId, notifyIds);
+ return this;
+ }
+ }
}
diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs
index 6ae2f9f..2cbaed4 100644
--- a/LanMountainDesktop/plugins/PluginRuntimeService.cs
+++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs
@@ -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 _loadedPlugins = [];
private readonly List _loadResults = [];
private readonly List _catalog = [];
@@ -39,13 +41,16 @@ public sealed class PluginRuntimeService : IDisposable
private readonly List _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;
}
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 74e7587..78b4d06 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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`.
diff --git a/docs/EXTERNAL_IPC_ARCHITECTURE.md b/docs/EXTERNAL_IPC_ARCHITECTURE.md
new file mode 100644
index 0000000..fcdf8fa
--- /dev/null
+++ b/docs/EXTERNAL_IPC_ARCHITECTURE.md
@@ -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(instance)`.
+ - .NET clients connect once and obtain strong typed proxies through `CreateIpcProxy(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(...)`
+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.
diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md
index f884854..db745d0 100644
--- a/docs/LAUNCHER.md
+++ b/docs/LAUNCHER.md
@@ -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.
diff --git a/docs/PLUGIN_DEVELOPMENT.md b/docs/PLUGIN_DEVELOPMENT.md
index cc794e4..e0b71a0 100644
--- a/docs/PLUGIN_DEVELOPMENT.md
+++ b/docs/PLUGIN_DEVELOPMENT.md
@@ -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(
+ 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(
+ 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.