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.