Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
aa7c118d13 Add external public IPC host/client and plugin SDK
Introduce a new LanMountainDesktop.Shared.IPC project implementing a public IPC host and client (LanMountainDesktopIpcClient, PublicIpcHostService), IPC constants and routed notify IDs, DTOs and DI helpers for registering public services. Update Plugin SDK to allow plugins to contribute public IPC services and registrations, add related descriptors/records and extension helpers. Migrate Launcher/App to use the new public IPC for startup/loading notifications and wiring (including TryConnect helper), switch LoadingStateReporter to use the external notification publisher, and add host-side public services (app info, shell control, plugin catalog). Include integration tests and spec/checklist/docs for the external IPC public API.
2026-04-22 14:55:30 +08:00
lincube
f51ec309a6 Add plugin isolation IPC scaffolding and host phase one docs (#5) 2026-04-22 10:25:46 +08:00
82 changed files with 2628 additions and 53 deletions

View File

@@ -0,0 +1,11 @@
# External IPC Public API Checklist
- [x] Host can expose strong-typed public IPC services.
- [x] External .NET client can connect and call built-in services.
- [x] Host publishes launcher startup and loading-state notifications through routed notify.
- [x] Launcher consumes routed notify instead of the old primary custom named-pipe path.
- [x] Plugin SDK exposes public IPC contribution primitives.
- [x] Plugin runtime can discover and register plugin public IPC services.
- [x] Public catalog includes built-in and plugin-contributed services.
- [x] `catalog.changed` is emitted when new services are added after startup.
- [ ] Add example external client sample.

View File

@@ -0,0 +1,24 @@
# External IPC Public API Spec
## Goal
Provide a single `dotnetCampus.Ipc` based external integration layer for:
- Host public APIs
- Launcher/OOBE startup progress and loading-state notifications
- plugin-contributed public services and live event push
## Delivered
- `LanMountainDesktop.Shared.IPC` project
- `[IpcPublic]` based built-in public contracts
- `PublicIpcHostService` and `LanMountainDesktopIpcClient`
- Launcher migrated to Host public IPC notifications
- Plugin SDK public IPC contribution API
- Host runtime integration for plugin public IPC services
## Out of Scope
- plugin process isolation
- non-.NET strong-typed public IPC clients
- live plugin public service removal without restart

View File

@@ -0,0 +1,12 @@
# External IPC Public API Tasks
- [x] Add `LanMountainDesktop.Shared.IPC`
- [x] Expose built-in `[IpcPublic]` services
- [x] Add routed notify constants and public IPC client/host wrappers
- [x] Start Host public IPC during app startup
- [x] Move Launcher startup progress consumption to the new IPC base
- [x] Add plugin public IPC registration/contributor SDK
- [x] Register plugin-contributed public services into Host catalog
- [x] Add integration tests for strong-typed public service access and plugin registration descriptors
- [ ] Expand built-in public service surface beyond the first minimal set
- [ ] Add non-.NET bridge guidance and samples

View File

@@ -0,0 +1,12 @@
# Checklist
- [x] `plugin.json` 缺省时仍默认为 `in-proc`
- [x] 非法 `runtime.mode` 会给出清晰错误
- [x] SDK 中已有 Worker 入口和隔离运行模式的公共接口
- [x] IPC 契约已拆到独立工程,且不引用 Avalonia
- [x] IPC 封装层已集中环境变量、启动参数和通知路由常量
- [x] 架构文档已写明一期 `isolated-background`、二期 `isolated-window`
- [x] 架构文档已写明 `IPluginExportRegistry` / `IPluginMessageBus` 不再作为隔离插件主边界
- [x] 文档已写明 ClassIsland 的借鉴点与取舍
- [ ] Host 在 Worker 崩溃时仅降级插件且不中断主程序
- [ ] `isolated-background` 的组件、编辑器、设置页完成真实 IPC 回路

View File

@@ -0,0 +1,41 @@
# Plugin Process Isolation
## Why
现有插件体系仍是“同进程 + AssemblyLoadContext 隔离”,无法阻止插件 fatal crash 拖垮 Host也无法阻止插件直接访问 Host 进程内对象和内存。
## What Changes
- 增加插件运行模式概念:`in-proc``isolated-background``isolated-window`
- 一期落地 `isolated-background`
- 新建独立 IPC 契约包和 IPC 封装包
-`PluginSdk` 中新增 Worker 入口与 `runtime.mode`
- 明确隔离模式下不再兼容对象实例共享型 API
- 新增正式架构文档说明 UI 方案、迁移策略、残余风险和 ClassIsland 借鉴
## Impact
- `LanMountainDesktop.PluginSdk/`
- `LanMountainDesktop.PluginTemplate/`
- 新增 `LanMountainDesktop.PluginIsolation.Contracts/`
- 新增 `LanMountainDesktop.PluginIsolation.Ipc/`
- `docs/ARCHITECTURE.md`
- `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`
## Requirements
### Requirement 1
宿主必须同时支持存量 `in-proc` 插件与未来的隔离插件,不得以本次改造打断旧插件加载。
### Requirement 2
隔离插件的 Host/Worker 通信必须基于显式 IPC 路由和 DTO而不是 Host 服务对象实例共享。
### Requirement 3
一期必须把后台逻辑隔离为独立 Worker 进程,并显式记录 Host UI 壳层的残余风险。
### Requirement 4
仓库文档必须把 ClassIsland IPC 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。

View File

@@ -0,0 +1,12 @@
# Tasks
- [x] 梳理现有插件运行时、组件注册、设置页和共享对象边界
- [x] 形成插件进程隔离架构文档
- [x]`.trae/specs/plugin-process-isolation/` 下补齐 spec、tasks、checklist
- [x]`PluginSdk` 中增加 `runtime.mode`、Worker 入口接口和运行模式枚举
- [x] 新建 `LanMountainDesktop.PluginIsolation.Contracts`,沉淀纯 DTO、路由常量、错误码与 JSON context
- [x] 新建 `LanMountainDesktop.PluginIsolation.Ipc`,沉淀 ClassIsland 风格的 IPC 包装外壳
- [x] 更新插件模板 `plugin.json`,让新插件默认显式声明 `in-proc`
- [ ] 在 Host 侧接入真实 Worker 进程拉起与 dotnetCampus.Ipc 传输绑定
- [ ]`isolated-background` 构建 Host UI 壳层适配器
- [ ] 为故障、心跳、降级与恢复补齐端到端测试

View File

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

View File

@@ -1,9 +1,9 @@
using System.Diagnostics; using System.Diagnostics;
using Avalonia.Threading; using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views; using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Services;
@@ -83,7 +83,8 @@ internal sealed class LauncherFlowCoordinator
var lastStageMessage = "launcher-started"; var lastStageMessage = "launcher-started";
var loadingState = new LoadingStateMessage(); var loadingState = new LoadingStateMessage();
using var ipcServer = new LauncherIpcServer(message => using var ipcClient = new LanMountainDesktopIpcClient();
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
@@ -121,7 +122,21 @@ internal sealed class LauncherFlowCoordinator
} }
}); });
}); });
ipcServer.Start(); ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
{
Dispatcher.UIThread.Post(() =>
{
try
{
loadingState = message;
loadingDetailsWindow?.UpdateLoadingState(loadingState);
}
catch (Exception ex)
{
Logger.Error("IPC loading-state callback failed.", ex);
}
});
});
try try
{ {
@@ -174,6 +189,12 @@ internal sealed class LauncherFlowCoordinator
details: MergeDetails(launcherContextDetails, launchOutcome.Details)); 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 processExitTask = launchOutcome.Process.WaitForExitAsync();
var completedTask = await Task.WhenAny( var completedTask = await Task.WhenAny(
visibilityTcs.Task, visibilityTcs.Task,
@@ -900,6 +921,21 @@ internal sealed class LauncherFlowCoordinator
} }
} }
private static async Task<bool> TryConnectToPublicIpcAsync(
LanMountainDesktopIpcClient ipcClient,
TimeSpan timeout)
{
var connectTask = ipcClient.ConnectAsync();
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != connectTask)
{
return false;
}
await connectTask.ConfigureAwait(false);
return true;
}
private enum HostStartMode private enum HostStartMode
{ {
ShellExecute, ShellExecute,

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginAppearanceSnapshotRequest(string SessionId);
public sealed record PluginAppearanceSnapshot(
string ThemeVariant,
string? AccentColor = null,
double CornerRadiusScale = 1.0,
IReadOnlyDictionary<string, double>? CornerRadiusTokens = null,
IReadOnlyDictionary<string, string>? ResourceAliases = null);
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);

View File

@@ -0,0 +1,45 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginHeartbeatPing(
string SessionId,
DateTimeOffset SentAtUtc);
public sealed record PluginHeartbeatPong(
string SessionId,
DateTimeOffset ReceivedAtUtc);
public sealed record PluginLogEntry(
string Level,
string Category,
string Message,
DateTimeOffset TimestampUtc,
string? Exception = null);
public static class PluginLogLevels
{
public const string Trace = "trace";
public const string Debug = "debug";
public const string Information = "information";
public const string Warning = "warning";
public const string Error = "error";
public const string Critical = "critical";
}
public sealed record PluginFaultReport(
string SessionId,
string FaultKind,
bool IsFatal,
string Message,
string? StackTrace = null,
int? WorkerProcessId = null,
int? ExitCode = null,
DateTimeOffset? OccurredAtUtc = null);
public static class PluginFaultKinds
{
public const string ManagedException = "managed-exception";
public const string NativeCrash = "native-crash";
public const string WatchdogTimeout = "watchdog-timeout";
public const string StartupFailure = "startup-failure";
public const string ForcedTermination = "forced-termination";
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.PluginIsolation.Contracts</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Transport-neutral IPC contracts for the LanMountainDesktop plugin isolation architecture.</Description>
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;Contracts</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginInitializeRequest(
string PluginId,
string SessionId,
string HostPipeName,
string DataDirectory,
IReadOnlyDictionary<string, string>? StartupProperties = null);
public sealed record PluginInitializeResponse(
bool Succeeded,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginStopRequest(
string Reason,
bool RestartRequested = false);
public sealed record PluginRestartRequest(string Reason);
public sealed record PluginLifecycleStateChanged(
string State,
string? Detail = null);
public static class PluginLifecycleStates
{
public const string Starting = "starting";
public const string Ready = "ready";
public const string Degraded = "degraded";
public const string Stopping = "stopping";
public const string Stopped = "stopped";
public const string Faulted = "faulted";
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginCapabilityDeclaration(
string Name,
string Version,
string? Description = null);
public static class PluginCapabilityNames
{
public const string Settings = "settings";
public const string Appearance = "appearance";
public const string DesktopComponentUi = "ui.desktop-component";
public const string ComponentEditorUi = "ui.component-editor";
public const string SettingsPageUi = "ui.settings-page";
public const string Logging = "diagnostics.log";
public const string FaultReporting = "diagnostics.fault";
}

View File

@@ -0,0 +1,15 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIpcErrorCodes
{
public const string ProtocolMismatch = "protocol_mismatch";
public const string SessionRejected = "session_rejected";
public const string CapabilityDenied = "capability_denied";
public const string InvalidRequest = "invalid_request";
public const string UnsupportedRoute = "unsupported_route";
public const string SettingsConflict = "settings_conflict";
public const string UiAttachRejected = "ui_attach_rejected";
public const string WorkerFaulted = "worker_faulted";
public const string WorkerExited = "worker_exited";
public const string HeartbeatTimeout = "heartbeat_timeout";
}

View File

@@ -0,0 +1,56 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIpcRoutes
{
public static class Session
{
public const string Handshake = "session/handshake";
public const string Capabilities = "session/capabilities";
public const string Ready = "session/ready";
}
public static class Lifecycle
{
public const string Initialize = "lifecycle/initialize";
public const string Stop = "lifecycle/stop";
public const string RestartRequest = "lifecycle/restart-request";
public const string StateChanged = "lifecycle/state-changed";
}
public static class Settings
{
public const string GetSnapshot = "settings/get-snapshot";
public const string Write = "settings/write";
public const string Changed = "settings/changed";
}
public static class Appearance
{
public const string GetSnapshot = "appearance/get-snapshot";
public const string Changed = "appearance/changed";
}
public static class Ui
{
public const string Attach = "ui/attach";
public const string Detach = "ui/detach";
public const string Command = "ui/command";
public const string StateChanged = "ui/state-changed";
}
public static class Heartbeat
{
public const string Ping = "heartbeat/ping";
public const string Pong = "heartbeat/pong";
}
public static class Log
{
public const string Write = "log/write";
}
public static class Fault
{
public const string Report = "fault/report";
}
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginIsolation.Contracts;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(PluginCapabilityDeclaration))]
[JsonSerializable(typeof(List<PluginCapabilityDeclaration>))]
[JsonSerializable(typeof(PluginSessionHandshakeRequest))]
[JsonSerializable(typeof(PluginSessionHandshakeResponse))]
[JsonSerializable(typeof(PluginReadyNotification))]
[JsonSerializable(typeof(PluginInitializeRequest))]
[JsonSerializable(typeof(PluginInitializeResponse))]
[JsonSerializable(typeof(PluginStopRequest))]
[JsonSerializable(typeof(PluginRestartRequest))]
[JsonSerializable(typeof(PluginLifecycleStateChanged))]
[JsonSerializable(typeof(PluginSettingsSnapshotRequest))]
[JsonSerializable(typeof(PluginSettingsSnapshotResponse))]
[JsonSerializable(typeof(PluginSettingsWriteRequest))]
[JsonSerializable(typeof(PluginSettingsWriteResponse))]
[JsonSerializable(typeof(PluginSettingsChangedNotification))]
[JsonSerializable(typeof(PluginAppearanceSnapshotRequest))]
[JsonSerializable(typeof(PluginAppearanceSnapshot))]
[JsonSerializable(typeof(PluginAppearanceChangedNotification))]
[JsonSerializable(typeof(PluginUiSurfaceDescriptor))]
[JsonSerializable(typeof(List<PluginUiSurfaceDescriptor>))]
[JsonSerializable(typeof(PluginUiAttachRequest))]
[JsonSerializable(typeof(PluginUiAttachResponse))]
[JsonSerializable(typeof(PluginUiDetachNotification))]
[JsonSerializable(typeof(PluginUiCommandRequest))]
[JsonSerializable(typeof(PluginUiCommandResponse))]
[JsonSerializable(typeof(PluginUiStateChangedNotification))]
[JsonSerializable(typeof(PluginHeartbeatPing))]
[JsonSerializable(typeof(PluginHeartbeatPong))]
[JsonSerializable(typeof(PluginLogEntry))]
[JsonSerializable(typeof(PluginFaultReport))]
public partial class PluginIsolationJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public static class PluginIsolationProtocolVersion
{
public const string Current = "1.0";
}

View File

@@ -0,0 +1,9 @@
# LanMountainDesktop.PluginIsolation.Contracts
Transport-neutral DTOs, route constants, protocol versioning, and JSON serialization context for plugin process isolation.
## Includes
- route groups for session, lifecycle, settings, appearance, UI, heartbeat, log, and fault
- explicit DTOs for routed request and notification payloads
- source-generated `System.Text.Json` context for the IPC protocol

View File

@@ -0,0 +1,21 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginSessionHandshakeRequest(
string PluginId,
string SessionId,
string RuntimeMode,
string ProtocolVersion,
IReadOnlyList<PluginCapabilityDeclaration>? RequestedCapabilities = null,
IReadOnlyDictionary<string, string>? Metadata = null);
public sealed record PluginSessionHandshakeResponse(
bool Accepted,
string ProtocolVersion,
IReadOnlyList<PluginCapabilityDeclaration>? GrantedCapabilities = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginReadyNotification(
string PluginId,
string SessionId,
IReadOnlyList<PluginUiSurfaceDescriptor>? UiSurfaces = null);

View File

@@ -0,0 +1,33 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginSettingsSnapshotRequest(
string Scope,
string? SectionId = null,
string? ComponentInstanceId = null);
public sealed record PluginSettingsSnapshotResponse(
string Scope,
JsonElement Snapshot,
string? ETag = null);
public sealed record PluginSettingsWriteRequest(
string Scope,
JsonElement Value,
string? SectionId = null,
string? ComponentInstanceId = null,
string? ETag = null);
public sealed record PluginSettingsWriteResponse(
bool Accepted,
string? ETag = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginSettingsChangedNotification(
string Scope,
JsonElement Value,
string? SectionId = null,
string? ComponentInstanceId = null,
string? ETag = null);

View File

@@ -0,0 +1,52 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginIsolation.Contracts;
public sealed record PluginUiSurfaceDescriptor(
string SurfaceId,
string SurfaceKind,
string Title,
string? ComponentId = null);
public static class PluginUiSurfaceKinds
{
public const string DesktopComponent = "desktop-component";
public const string ComponentEditor = "component-editor";
public const string SettingsPage = "settings-page";
public const string Window = "window";
}
public sealed record PluginUiAttachRequest(
string SurfaceId,
string SurfaceKind,
string? InstanceId = null,
JsonElement? InitialState = null);
public sealed record PluginUiAttachResponse(
bool Accepted,
JsonElement? InitialState = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginUiDetachNotification(
string SurfaceId,
string SurfaceKind,
string? InstanceId = null);
public sealed record PluginUiCommandRequest(
string SurfaceId,
string CommandName,
string? InstanceId = null,
JsonElement? Payload = null);
public sealed record PluginUiCommandResponse(
bool Accepted,
JsonElement? Payload = null,
string? ErrorCode = null,
string? ErrorMessage = null);
public sealed record PluginUiStateChangedNotification(
string SurfaceId,
string SurfaceKind,
string? InstanceId = null,
JsonElement? State = null);

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.PluginIsolation.Ipc</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>ClassIsland-style IPC facade for LanMountainDesktop plugin process isolation, backed by dotnetCampus.Ipc.</Description>
<PackageTags>LanMountainDesktop;Plugin;IPC;Isolation;dotnetCampus.Ipc</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,90 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed class PluginIpcClient
{
public PluginIpcClient(PluginIpcClientOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
SerializerOptions = SerializerContext.Options;
}
public PluginIpcClientOptions Options { get; }
public JsonSerializerContext SerializerContext { get; }
public JsonSerializerOptions SerializerOptions { get; }
public PluginIpcRequestDispatcher? RequestDispatcher { get; set; }
public PluginIpcNotificationDispatcher? NotificationDispatcher { get; set; }
public Task<TResponse?> RequestAsync<TRequest, TResponse>(
string route,
TRequest payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
return RequestCoreAsync<TRequest, TResponse>(route, payload, cancellationToken);
}
public Task NotifyAsync<TPayload>(
string route,
TPayload payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
return NotifyCoreAsync(route, Serialize(payload), cancellationToken);
}
private async Task<TResponse?> RequestCoreAsync<TRequest, TResponse>(
string route,
TRequest payload,
CancellationToken cancellationToken)
{
if (RequestDispatcher is null)
{
throw new NotSupportedException(
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
"Wire RequestDispatcher during host/worker transport integration.");
}
var response = await RequestDispatcher(route, Serialize(payload), cancellationToken).ConfigureAwait(false);
if (response is null)
{
return default;
}
return Deserialize<TResponse>(response);
}
private async Task NotifyCoreAsync(string route, JsonElement? payload, CancellationToken cancellationToken)
{
if (NotificationDispatcher is null)
{
throw new NotSupportedException(
"PluginIpcClient is not yet bound to a dotnetCampus.Ipc transport dispatcher. " +
"Wire NotificationDispatcher during host/worker transport integration.");
}
await NotificationDispatcher(route, payload, cancellationToken).ConfigureAwait(false);
}
private JsonElement Serialize<T>(T payload)
{
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
}
private T? Deserialize<T>(JsonElement? payload)
{
if (payload is null)
{
return default;
}
return payload.Value.Deserialize<T>(SerializerOptions);
}
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed record PluginIpcClientOptions
{
public required string PipeName { get; init; }
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
public TimeSpan ConnectTimeout { get; init; } = PluginIpcConstants.DefaultConnectTimeout;
public TimeSpan RequestTimeout { get; init; } = PluginIpcConstants.DefaultRequestTimeout;
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
}

View File

@@ -0,0 +1,25 @@
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public static class PluginIpcConstants
{
public const string EnvironmentPluginId = "LANMOUNTAIN_PLUGIN_ID";
public const string EnvironmentSessionId = "LANMOUNTAIN_PLUGIN_SESSION_ID";
public const string EnvironmentHostPipeName = "LANMOUNTAIN_PLUGIN_HOST_PIPE";
public const string EnvironmentProtocolVersion = "LANMOUNTAIN_PLUGIN_PROTOCOL_VERSION";
public const string EnvironmentRuntimeMode = "LANMOUNTAIN_PLUGIN_RUNTIME_MODE";
public const string CommandLinePluginId = "--plugin-id";
public const string CommandLineSessionId = "--session-id";
public const string CommandLineHostPipeName = "--host-pipe-name";
public const string CommandLineProtocolVersion = "--protocol-version";
public const string CommandLineRuntimeMode = "--runtime-mode";
public static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(10);
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5);
public static readonly TimeSpan DefaultHeartbeatTimeout = TimeSpan.FromSeconds(15);
public const string DefaultProtocolVersion = PluginIsolationProtocolVersion.Current;
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public delegate Task<JsonElement?> PluginIpcRequestDispatcher(
string route,
JsonElement? payload,
CancellationToken cancellationToken);
public delegate Task PluginIpcNotificationDispatcher(
string route,
JsonElement? payload,
CancellationToken cancellationToken);

View File

@@ -0,0 +1,17 @@
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public static class PluginIpcRoutedNotifyIds
{
public const string SessionReady = PluginIpcRoutes.Session.Ready;
public const string LifecycleStateChanged = PluginIpcRoutes.Lifecycle.StateChanged;
public const string SettingsChanged = PluginIpcRoutes.Settings.Changed;
public const string AppearanceChanged = PluginIpcRoutes.Appearance.Changed;
public const string UiDetach = PluginIpcRoutes.Ui.Detach;
public const string UiStateChanged = PluginIpcRoutes.Ui.StateChanged;
public const string HeartbeatPing = PluginIpcRoutes.Heartbeat.Ping;
public const string HeartbeatPong = PluginIpcRoutes.Heartbeat.Pong;
public const string LogWrite = PluginIpcRoutes.Log.Write;
public const string FaultReport = PluginIpcRoutes.Fault.Report;
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed class PluginIpcServer
{
private readonly Dictionary<string, Func<JsonElement?, CancellationToken, Task<JsonElement?>>> _requestHandlers =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Func<JsonElement?, CancellationToken, Task>> _notificationHandlers =
new(StringComparer.OrdinalIgnoreCase);
public PluginIpcServer(PluginIpcServerOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
SerializerContext = options.SerializerContext ?? throw new ArgumentNullException(nameof(options.SerializerContext));
SerializerOptions = SerializerContext.Options;
}
public PluginIpcServerOptions Options { get; }
public JsonSerializerContext SerializerContext { get; }
public JsonSerializerOptions SerializerOptions { get; }
public void MapRequest<TRequest, TResponse>(
string route,
Func<TRequest, CancellationToken, Task<TResponse>> handler)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
ArgumentNullException.ThrowIfNull(handler);
_requestHandlers[route] = async (payload, cancellationToken) =>
{
var request = Deserialize<TRequest>(payload);
var response = await handler(request, cancellationToken).ConfigureAwait(false);
return Serialize(response);
};
}
public void MapNotification<TPayload>(
string route,
Func<TPayload, CancellationToken, Task> handler)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
ArgumentNullException.ThrowIfNull(handler);
_notificationHandlers[route] = (payload, cancellationToken) =>
{
var notification = Deserialize<TPayload>(payload);
return handler(notification, cancellationToken);
};
}
public async Task<JsonElement?> HandleRequestAsync(
string route,
JsonElement? payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
if (!_requestHandlers.TryGetValue(route, out var handler))
{
throw new InvalidOperationException($"No IPC request handler is registered for route '{route}'.");
}
return await handler(payload, cancellationToken).ConfigureAwait(false);
}
public Task HandleNotificationAsync(
string route,
JsonElement? payload,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(route);
if (!_notificationHandlers.TryGetValue(route, out var handler))
{
throw new InvalidOperationException($"No IPC notification handler is registered for route '{route}'.");
}
return handler(payload, cancellationToken);
}
private JsonElement Serialize<T>(T payload)
{
return JsonSerializer.SerializeToElement(payload, SerializerOptions);
}
private T Deserialize<T>(JsonElement? payload)
{
if (payload is null)
{
if (default(T) is null)
{
return default!;
}
throw new InvalidOperationException(
$"IPC payload is required for '{typeof(T).FullName}', but the caller provided no payload.");
}
var value = payload.Value.Deserialize<T>(SerializerOptions);
if (value is null && default(T) is not null)
{
throw new InvalidOperationException(
$"Failed to deserialize IPC payload to '{typeof(T).FullName}'.");
}
return value!;
}
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginIsolation.Ipc;
public sealed record PluginIpcServerOptions
{
public required string PipeName { get; init; }
public string ProtocolVersion { get; init; } = PluginIsolationProtocolVersion.Current;
public TimeSpan HeartbeatInterval { get; init; } = PluginIpcConstants.DefaultHeartbeatInterval;
public TimeSpan HeartbeatTimeout { get; init; } = PluginIpcConstants.DefaultHeartbeatTimeout;
public JsonSerializerContext SerializerContext { get; init; } = PluginIsolationJsonContext.Default;
}

View File

@@ -0,0 +1,10 @@
# LanMountainDesktop.PluginIsolation.Ipc
ClassIsland-inspired IPC facade for LanMountainDesktop plugin isolation.
## Includes
- host and worker startup constants
- centralized routed notification IDs
- transport-neutral routed client and server wrappers
- explicit dependency on `dotnetCampus.Ipc` for the eventual pipe transport binding

View File

@@ -0,0 +1,15 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPublicIpcBuilder
{
IPluginPublicIpcBuilder AddService<TContract>(
string? objectId = null,
IEnumerable<string>? notifyIds = null)
where TContract : class;
IPluginPublicIpcBuilder AddService(
Type contractType,
object implementation,
string? objectId = null,
IEnumerable<string>? notifyIds = null);
}

View File

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

View File

@@ -0,0 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginWorker
{
void ConfigureServices(IPluginWorkerContext context, IServiceCollection services);
Task StartAsync(IPluginWorkerContext context, IServiceProvider services, CancellationToken cancellationToken = default);
Task StopAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using LanMountainDesktop.PluginIsolation.Contracts;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginWorkerContext
{
string PluginId { get; }
PluginManifest Manifest { get; }
PluginRuntimeMode RuntimeMode { get; }
string SessionId { get; }
string HostPipeName { get; }
string ProtocolVersion { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IReadOnlyList<PluginCapabilityDeclaration> GrantedCapabilities { get; }
IReadOnlyDictionary<string, string> StartupProperties { get; }
}

View File

@@ -25,7 +25,10 @@
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" /> <PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -10,7 +10,8 @@ public sealed record PluginManifest(
string? Author = null, string? Author = null,
string? Version = null, string? Version = null,
string? ApiVersion = null, string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null) IReadOnlyList<PluginSharedContractReference>? SharedContracts = null,
PluginRuntimeConfiguration? Runtime = null)
{ {
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@@ -56,9 +57,13 @@ public sealed record PluginManifest(
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly)); return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
} }
public PluginRuntimeMode RuntimeMode =>
PluginRuntimeModes.TryParse(Runtime?.Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
private PluginManifest NormalizeAndValidate(string manifestPath) private PluginManifest NormalizeAndValidate(string manifestPath)
{ {
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts); var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
var normalizedRuntime = (Runtime ?? new PluginRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
var normalized = this with var normalized = this with
{ {
Id = RequireValue(Id, nameof(Id), manifestPath), Id = RequireValue(Id, nameof(Id), manifestPath),
@@ -68,7 +73,8 @@ public sealed record PluginManifest(
Author = NormalizeOptionalValue(Author), Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version), Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion, ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
SharedContracts = normalizedSharedContracts SharedContracts = normalizedSharedContracts,
Runtime = normalizedRuntime
}; };
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion)) if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginRuntimeConfiguration(string Mode = PluginRuntimeModes.InProcess)
{
public PluginRuntimeMode RuntimeMode =>
PluginRuntimeModes.TryParse(Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
internal PluginRuntimeConfiguration NormalizeAndValidate(string manifestPath)
{
return this with
{
Mode = PluginRuntimeModes.NormalizeManifestValue(Mode, manifestPath)
};
}
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginRuntimeMode
{
InProcess = 0,
IsolatedBackground = 1,
IsolatedWindow = 2
}

View File

@@ -0,0 +1,58 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginRuntimeModes
{
public const string InProcess = "in-proc";
public const string IsolatedBackground = "isolated-background";
public const string IsolatedWindow = "isolated-window";
public static bool TryParse(string? value, out PluginRuntimeMode mode)
{
switch (value?.Trim().ToLowerInvariant())
{
case null:
case "":
case InProcess:
mode = PluginRuntimeMode.InProcess;
return true;
case IsolatedBackground:
mode = PluginRuntimeMode.IsolatedBackground;
return true;
case IsolatedWindow:
mode = PluginRuntimeMode.IsolatedWindow;
return true;
default:
mode = default;
return false;
}
}
public static PluginRuntimeMode Parse(string? value, string sourceName, string propertyName = "runtime.mode")
{
if (TryParse(value, out var mode))
{
return mode;
}
var candidate = string.IsNullOrWhiteSpace(value) ? "<empty>" : value.Trim();
throw new InvalidOperationException(
$"Plugin manifest '{sourceName}' declares unsupported runtime mode '{candidate}' in '{propertyName}'. " +
$"Supported values: '{InProcess}', '{IsolatedBackground}', '{IsolatedWindow}'.");
}
public static string NormalizeManifestValue(string? value, string sourceName, string propertyName = "runtime.mode")
{
return ToManifestValue(Parse(value, sourceName, propertyName));
}
public static string ToManifestValue(PluginRuntimeMode mode)
{
return mode switch
{
PluginRuntimeMode.InProcess => InProcess,
PluginRuntimeMode.IsolatedBackground => IsolatedBackground,
PluginRuntimeMode.IsolatedWindow => IsolatedWindow,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported plugin runtime mode.")
};
}
}

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using dotnetCampus.Ipc.CompilerServices.Attributes;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk; namespace LanMountainDesktop.PluginSdk;
@@ -112,6 +113,55 @@ public static class PluginServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddPluginPublicIpc<TContract, TImplementation>(
this IServiceCollection services,
string? objectId = null,
params string[] notifyIds)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsurePublicIpcContract(typeof(TContract));
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PluginPublicIpcServiceRegistration) &&
descriptor.ImplementationInstance is PluginPublicIpcServiceRegistration existing &&
existing.ContractType == typeof(TContract) &&
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
{
services.AddSingleton(new PluginPublicIpcServiceRegistration(
typeof(TContract),
objectId,
notifyIds ?? []));
}
return services;
}
public static IServiceCollection AddPluginPublicIpcContributor<TContributor>(this IServiceCollection services)
where TContributor : class, IPluginPublicIpcContributor
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<IPluginPublicIpcContributor, TContributor>();
return services;
}
private static void EnsurePublicIpcContract(Type contractType)
{
if (!contractType.IsInterface)
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' must be an interface.");
}
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
}
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services) private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class where TContract : class
where TImplementation : class, TContract where TImplementation : class, TContract

View File

@@ -0,0 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public abstract class PluginWorkerBase : IPluginWorker
{
public virtual void ConfigureServices(IPluginWorkerContext context, IServiceCollection services)
{
}
public virtual Task StartAsync(IPluginWorkerContext context, IServiceProvider services, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public virtual Task StopAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginWorkerEntranceAttribute : Attribute
{
}

View File

@@ -5,7 +5,9 @@ Official SDK package for LanMountainDesktop plugins.
## Includes ## Includes
- `IPlugin`/`PluginBase` entry abstractions - `IPlugin`/`PluginBase` entry abstractions
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
- `PluginManifest` and shared contract declarations - `PluginManifest` and shared contract declarations
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
- desktop component registration extensions - desktop component registration extensions
- plugin runtime context and host service abstractions - plugin runtime context and host service abstractions
- build-transitive packaging targets for `.laapp` output - build-transitive packaging targets for `.laapp` output

View File

@@ -22,3 +22,4 @@ Update `plugin.json` fields as needed before release:
- `description` - `description`
- `author` - `author`
- `version` - `version`
- `runtime.mode` (`in-proc` by default, `isolated-background` for phase-1 worker mode)

View File

@@ -6,5 +6,8 @@
"version": "1.0.0", "version": "1.0.0",
"apiVersion": "4.0.2", "apiVersion": "4.0.2",
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll", "entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
"sharedContracts": [] "sharedContracts": [],
"runtime": {
"mode": "in-proc"
}
} }

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
using dotnetCampus.Ipc.CompilerServices.Attributes;
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
[IpcPublic(IgnoresIpcException = true)]
public interface IPublicShellControlService
{
Task<bool> ActivateMainWindowAsync();
Task<bool> OpenSettingsAsync(string? pageTag = null);
Task<bool> RestartAsync();
Task<bool> ExitAsync();
}

View File

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

View File

@@ -0,0 +1,83 @@
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddLanMountainDesktopIpcHost(
this IServiceCollection services,
string? pipeName = null)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(provider =>
{
var host = new PublicIpcHostService(pipeName ?? IpcConstants.DefaultPipeName);
foreach (var registration in provider.GetServices<PublicIpcServiceRegistration>())
{
var implementation = registration.ImplementationFactory(provider);
host.RegisterPublicService(
registration.ContractType,
implementation,
registration.ObjectId,
registration.PluginId,
registration.NotifyIds);
}
host.Start();
return host;
});
services.AddSingleton<IExternalIpcNotificationPublisher>(provider =>
provider.GetRequiredService<PublicIpcHostService>());
return services;
}
public static IServiceCollection AddPublicIpcService<TContract, TImplementation>(
this IServiceCollection services,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PublicIpcServiceRegistration) &&
descriptor.ImplementationInstance is PublicIpcServiceRegistration existing &&
existing.ContractType == typeof(TContract) &&
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
{
services.AddSingleton(new PublicIpcServiceRegistration(
typeof(TContract),
provider => provider.GetRequiredService<TContract>(),
objectId,
pluginId,
notifyIds ?? []));
}
return services;
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TContract));
if (descriptor is null)
{
services.AddSingleton<TContract, TImplementation>();
return;
}
if (descriptor.Lifetime != ServiceLifetime.Singleton)
{
throw new InvalidOperationException(
$"Public IPC contract '{typeof(TContract).FullName}' must be registered as Singleton.");
}
}
}

View File

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

View File

@@ -0,0 +1,14 @@
namespace LanMountainDesktop.Shared.IPC;
public static class IpcConstants
{
public const string DefaultPipeName = "LanMountainDesktop.IPC.v1.Server";
public const string ProtocolVersion = "external-ipc-public-api.v1";
public static class Routes
{
public const string SessionGetInfo = "lanmountain.session.get-info";
public const string CatalogGet = "lanmountain.catalog.get";
}
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.Shared.IPC;
public static class IpcRoutedNotifyIds
{
public const string CatalogChanged = "lanmountain.catalog.changed";
public const string LauncherStartupProgress = "lanmountain.launcher.startup-progress";
public const string LauncherLoadingState = "lanmountain.launcher.loading-state";
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Public IPC abstractions and host/client infrastructure for LanMountainDesktop, backed by dotnetCampus.Ipc.</Description>
<PackageTags>LanMountainDesktop;IPC;dotnetCampus.Ipc;Integration</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,96 @@
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class LanMountainDesktopIpcClient : IDisposable
{
private bool _started;
public LanMountainDesktopIpcClient(string? clientPipeName = null)
{
Provider = string.IsNullOrWhiteSpace(clientPipeName)
? new IpcProvider()
: new IpcProvider(clientPipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public PeerProxy? Peer { get; private set; }
public bool IsConnected => Peer is not null && Peer.IsConnectedFinished;
public async Task ConnectAsync(string pipeName = IpcConstants.DefaultPipeName)
{
EnsureStarted();
Peer = await Provider.GetAndConnectToPeerAsync(pipeName).ConfigureAwait(false);
}
public void RegisterNotifyHandler<TPayload>(string notifyId, Action<TPayload> handler)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(handler);
RoutedProvider.AddNotifyHandler(notifyId, handler);
}
public void RegisterNotifyHandler<TPayload>(string notifyId, Func<TPayload, Task> handler)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(handler);
RoutedProvider.AddNotifyHandler(notifyId, handler);
}
public TContract CreateProxy<TContract>(string? objectId = null)
where TContract : class
{
var peer = Peer ?? throw new InvalidOperationException("IPC client is not connected.");
return Provider.CreateIpcProxy<TContract>(peer, objectId);
}
public async Task<PublicIpcCatalogSnapshot?> GetCatalogAsync()
{
var client = await GetRoutedClientAsync().ConfigureAwait(false);
return await client.GetResponseAsync<PublicIpcCatalogSnapshot>(IpcConstants.Routes.CatalogGet)
.ConfigureAwait(false);
}
public async Task<PublicIpcSessionInfo?> GetSessionInfoAsync()
{
var client = await GetRoutedClientAsync().ConfigureAwait(false);
return await client.GetResponseAsync<PublicIpcSessionInfo>(IpcConstants.Routes.SessionGetInfo)
.ConfigureAwait(false);
}
private async Task<JsonIpcDirectRoutedClientProxy> GetRoutedClientAsync()
{
if (Peer is null)
{
throw new InvalidOperationException("IPC client is not connected.");
}
await Task.CompletedTask;
return new JsonIpcDirectRoutedClientProxy(Peer);
}
private void EnsureStarted()
{
if (_started)
{
return;
}
RoutedProvider.StartServer();
_started = true;
}
public void Dispose()
{
Provider.Dispose();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,219 @@
using System.Reflection;
using System.Collections.Concurrent;
using dotnetCampus.Ipc.Context;
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
using dotnetCampus.Ipc.Pipes;
namespace LanMountainDesktop.Shared.IPC;
public sealed class PublicIpcHostService : IDisposable, IExternalIpcNotificationPublisher
{
private static readonly MethodInfo CreateIpcJointMethod = typeof(GeneratedIpcFactory)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Single(method =>
method.Name == nameof(GeneratedIpcFactory.CreateIpcJoint) &&
method.IsGenericMethodDefinition &&
method.GetParameters().Length == 3);
private readonly Dictionary<(Type ContractType, string ObjectId), PublicServiceEntry> _services = new();
private readonly ConcurrentDictionary<string, PeerProxy> _connectedPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly object _gate = new();
private bool _started;
public PublicIpcHostService(string pipeName = IpcConstants.DefaultPipeName)
{
PipeName = pipeName;
StartedAt = DateTimeOffset.UtcNow;
Provider = new IpcProvider(pipeName);
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
}
public string PipeName { get; }
public DateTimeOffset StartedAt { get; }
public IpcProvider Provider { get; }
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
public Func<IReadOnlyList<PublicPluginDescriptor>> PluginDescriptorProvider { get; set; } =
static () => Array.Empty<PublicPluginDescriptor>();
public void Start()
{
if (_started)
{
return;
}
RoutedProvider.AddRequestHandler(IpcConstants.Routes.SessionGetInfo, () => BuildSessionInfo());
RoutedProvider.AddRequestHandler(IpcConstants.Routes.CatalogGet, () => GetCatalogSnapshot());
Provider.PeerConnected += OnPeerConnected;
RoutedProvider.StartServer();
_started = true;
}
public void RegisterPublicService<TContract>(
TContract implementation,
string? objectId = null,
string? pluginId = null,
params string[] notifyIds)
where TContract : class
{
RegisterPublicService(typeof(TContract), implementation, objectId, pluginId, notifyIds);
}
public void RegisterPublicService(
Type contractType,
object implementation,
string? objectId = null,
string? pluginId = null,
IEnumerable<string>? notifyIds = null)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementation);
var normalizedObjectId = objectId ?? string.Empty;
var normalizedNotifyIds = notifyIds?
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
lock (_gate)
{
if (_services.ContainsKey((contractType, normalizedObjectId)))
{
throw new InvalidOperationException(
$"Public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}' is already registered.");
}
CreateIpcJointMethod
.MakeGenericMethod(contractType)
.Invoke(null, [Provider, implementation, string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId]);
_services[(contractType, normalizedObjectId)] = new PublicServiceEntry(
contractType,
implementation,
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
pluginId,
normalizedNotifyIds);
}
if (_started)
{
_ = NotifyCatalogChangedAsync();
}
}
public PublicIpcCatalogSnapshot GetCatalogSnapshot()
{
PublicIpcServiceDescriptor[] services;
lock (_gate)
{
services = _services.Values
.Select(entry => new PublicIpcServiceDescriptor(
entry.ContractType.FullName ?? entry.ContractType.Name,
entry.ContractType.Assembly.GetName().Name ?? string.Empty,
entry.ContractType.AssemblyQualifiedName,
entry.ObjectId,
entry.PluginId,
string.IsNullOrWhiteSpace(entry.PluginId),
entry.NotifyIds))
.OrderBy(entry => entry.PluginId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.ContractTypeName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
var plugins = PluginDescriptorProvider()?.ToArray() ?? Array.Empty<PublicPluginDescriptor>();
return new PublicIpcCatalogSnapshot(services, plugins, DateTimeOffset.UtcNow);
}
public Task PublishStartupProgressAsync(
LanMountainDesktop.Shared.Contracts.Launcher.StartupProgressMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherStartupProgress, message, cancellationToken);
}
public Task PublishLoadingStateAsync(
LanMountainDesktop.Shared.Contracts.Launcher.LoadingStateMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
return NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, message, cancellationToken);
}
public async Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
where TPayload : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
ArgumentNullException.ThrowIfNull(payload);
cancellationToken.ThrowIfCancellationRequested();
foreach (var peer in _connectedPeers.Values)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var client = new JsonIpcDirectRoutedClientProxy(peer);
await client.NotifyAsync(notifyId, payload).ConfigureAwait(false);
}
catch
{
// Keep notification fan-out best-effort. Broken peers are cleaned by dotnetCampus.Ipc.
}
}
}
private Task NotifyCatalogChangedAsync()
{
return NotifyAsync(IpcRoutedNotifyIds.CatalogChanged, GetCatalogSnapshot());
}
private PublicIpcSessionInfo BuildSessionInfo()
{
return new PublicIpcSessionInfo(
PipeName,
IpcConstants.ProtocolVersion,
[
IpcConstants.Routes.SessionGetInfo,
IpcConstants.Routes.CatalogGet,
IpcRoutedNotifyIds.CatalogChanged,
IpcRoutedNotifyIds.LauncherStartupProgress,
IpcRoutedNotifyIds.LauncherLoadingState
],
StartedAt);
}
public void Dispose()
{
Provider.PeerConnected -= OnPeerConnected;
Provider.Dispose();
}
private void OnPeerConnected(object? sender, PeerConnectedArgs e)
{
var peer = e.Peer;
_connectedPeers[peer.PeerName] = peer;
peer.PeerConnectionBroken -= OnPeerConnectionBroken;
peer.PeerConnectionBroken += OnPeerConnectionBroken;
}
private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e)
{
if (sender is PeerProxy peer)
{
_connectedPeers.TryRemove(peer.PeerName, out _);
}
}
private sealed record PublicServiceEntry(
Type ContractType,
object Implementation,
string? ObjectId,
string? PluginId,
string[] NotifyIds);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ExternalIpcPublicApiTests
{
[Fact]
public async Task PublicIpcHost_ExposesStrongTypedServiceAndCatalog()
{
var pipeName = "LanMountainDesktop.Test." + Guid.NewGuid().ToString("N");
using var host = new PublicIpcHostService(pipeName);
host.PluginDescriptorProvider = () =>
[
new PublicPluginDescriptor("sample.plugin", "Sample Plugin", "1.0.0", true, true)
];
var appInfo = new PublicAppInfoSnapshot(
"LanMountainDesktop",
"1.2.3",
"Administrate",
pipeName,
42,
DateTimeOffset.UtcNow);
host.RegisterPublicService<IPublicAppInfoService>(new TestPublicAppInfoService(appInfo));
host.Start();
using var client = new LanMountainDesktopIpcClient();
var catalogChanged = new TaskCompletionSource<PublicIpcCatalogSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
client.RegisterNotifyHandler<PublicIpcCatalogSnapshot>(IpcRoutedNotifyIds.CatalogChanged, snapshot =>
{
catalogChanged.TrySetResult(snapshot);
});
await client.ConnectAsync(pipeName);
var proxy = client.CreateProxy<IPublicAppInfoService>();
var remoteInfo = proxy.GetAppInfo();
Assert.Equal(appInfo.ApplicationName, remoteInfo.ApplicationName);
Assert.Equal(appInfo.Version, remoteInfo.Version);
Assert.Equal(appInfo.Codename, remoteInfo.Codename);
var initialCatalog = await client.GetCatalogAsync();
Assert.NotNull(initialCatalog);
Assert.Contains(initialCatalog!.Services, service => service.ContractTypeName == typeof(IPublicAppInfoService).FullName);
Assert.Contains(initialCatalog.Plugins, plugin => plugin.PluginId == "sample.plugin");
host.RegisterPublicService<IPublicPluginCatalogService>(new TestPublicPluginCatalogService(initialCatalog));
var updatedCatalog = await catalogChanged.Task.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Contains(updatedCatalog.Services, service => service.ContractTypeName == typeof(IPublicPluginCatalogService).FullName);
var sessionInfo = await client.GetSessionInfoAsync();
Assert.NotNull(sessionInfo);
Assert.Equal(pipeName, sessionInfo!.PipeName);
Assert.Equal(IpcConstants.ProtocolVersion, sessionInfo.ProtocolVersion);
}
[Fact]
public void AddPluginPublicIpc_RegistersServiceDescriptor()
{
var services = new ServiceCollection();
services.AddPluginPublicIpc<ITestPluginPublicService, TestPluginPublicService>(
objectId: "plugin-service",
notifyIds: ["lanmountain.plugin.sample.updated"]);
using var provider = services.BuildServiceProvider();
var registration = Assert.Single(provider.GetServices<PluginPublicIpcServiceRegistration>());
Assert.Equal(typeof(ITestPluginPublicService), registration.ContractType);
Assert.Equal("plugin-service", registration.ObjectId);
Assert.Contains("lanmountain.plugin.sample.updated", registration.NotifyIds);
}
private sealed class TestPublicAppInfoService : IPublicAppInfoService
{
private readonly PublicAppInfoSnapshot _snapshot;
public TestPublicAppInfoService(PublicAppInfoSnapshot snapshot)
{
_snapshot = snapshot;
}
public PublicAppInfoSnapshot GetAppInfo()
{
return _snapshot;
}
}
private sealed class TestPublicPluginCatalogService : IPublicPluginCatalogService
{
private readonly PublicIpcCatalogSnapshot _snapshot;
public TestPublicPluginCatalogService(PublicIpcCatalogSnapshot snapshot)
{
_snapshot = snapshot;
}
public PublicIpcCatalogSnapshot GetCatalog()
{
return _snapshot;
}
}
}
[dotnetCampus.Ipc.CompilerServices.Attributes.IpcPublic]
public interface ITestPluginPublicService
{
string Ping();
}
public sealed class TestPluginPublicService : ITestPluginPublicService
{
public string Ping()
{
return "pong";
}
}

View File

@@ -0,0 +1,48 @@
using System.Text;
using LanMountainDesktop.PluginSdk;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class PluginManifestRuntimeTests
{
[Fact]
public void Load_WhenRuntimeIsMissing_DefaultsToInProcess()
{
const string json = """
{
"id": "plugin.runtime.default",
"name": "Runtime Default",
"entranceAssembly": "Plugin.dll"
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var manifest = PluginManifest.Load(stream, "plugin.json");
Assert.NotNull(manifest.Runtime);
Assert.Equal(PluginRuntimeModes.InProcess, manifest.Runtime!.Mode);
Assert.Equal(PluginRuntimeMode.InProcess, manifest.RuntimeMode);
}
[Fact]
public void Load_WhenRuntimeModeIsInvalid_ThrowsHelpfulError()
{
const string json = """
{
"id": "plugin.runtime.invalid",
"name": "Runtime Invalid",
"entranceAssembly": "Plugin.dll",
"runtime": {
"mode": "shared-worker"
}
}
""";
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
var ex = Assert.Throws<InvalidOperationException>(() => PluginManifest.Load(stream, "plugin.json"));
Assert.Contains("runtime.mode", ex.Message);
Assert.Contains("shared-worker", ex.Message);
}
}

View File

@@ -1,10 +1,13 @@
<Solution> <Solution>
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" /> <Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" /> <Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
<Project Path="LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj" />
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" /> <Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" /> <Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" /> <Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" /> <Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
<Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" /> <Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" /> <Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" /> <Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />

View File

@@ -20,10 +20,13 @@ using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.ExternalIpc;
using LanMountainDesktop.Services.Launcher; using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Services.Loading; using LanMountainDesktop.Services.Loading;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using LanMountainDesktop.Theme; using LanMountainDesktop.Theme;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views; using LanMountainDesktop.Views;
@@ -55,6 +58,7 @@ public partial class App : Application
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService(); private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate(); private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
private ISettingsPageRegistry? _settingsPageRegistry; private ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService; private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService; private WeatherLocationRefreshService? _weatherLocationRefreshService;
@@ -75,7 +79,7 @@ public partial class App : Application
private bool _mainWindowClosed; private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked; private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost; private DesktopShellHost? _desktopShellHost;
private LauncherIpcClient? _launcherIpcClient; private PublicIpcHostService? _publicIpcHostService;
private LoadingStateManager? _loadingStateManager; private LoadingStateManager? _loadingStateManager;
private LoadingStateReporter? _loadingStateReporter; private LoadingStateReporter? _loadingStateReporter;
private bool _singleInstanceReleased; private bool _singleInstanceReleased;
@@ -160,6 +164,7 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard(); RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled(); LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePublicIpc();
_ = InitializeLauncherIpcAsync(); _ = InitializeLauncherIpcAsync();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell); DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
@@ -173,34 +178,24 @@ public partial class App : Application
private async Task InitializeLauncherIpcAsync() private async Task InitializeLauncherIpcAsync()
{ {
if (!LauncherIpcClient.IsLaunchedByLauncher()) if (_loadingStateManager is not null)
return; return;
try try
{ {
_launcherIpcClient = new LauncherIpcClient();
var connected = await _launcherIpcClient.ConnectAsync();
if (!connected)
{
return;
}
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
bool hadBufferedMessages; bool hadBufferedMessages;
lock (_launcherProgressLock) lock (_launcherProgressLock)
{ {
hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0; hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
} }
await FlushPendingLauncherProgressAsync();
_loadingStateManager = new LoadingStateManager(); _loadingStateManager = new LoadingStateManager();
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient); _loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _publicIpcHostService);
_loadingStateReporter.Start(); _loadingStateReporter.Start();
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services."); _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) if (!hadBufferedMessages)
{ {
@@ -238,8 +233,8 @@ public partial class App : Application
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess) private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
{ {
var ipcClient = _launcherIpcClient; var publicIpcHostService = _publicIpcHostService;
if (ipcClient is null || !ipcClient.IsConnected) if (publicIpcHostService is null)
{ {
lock (_launcherProgressLock) lock (_launcherProgressLock)
{ {
@@ -250,13 +245,13 @@ public partial class App : Application
return; return;
} }
_ = SendLauncherProgressAsync(ipcClient, message, logSuccess); _ = SendLauncherProgressAsync(publicIpcHostService, message, logSuccess);
} }
private async Task FlushPendingLauncherProgressAsync() private async Task FlushPendingLauncherProgressAsync()
{ {
var ipcClient = _launcherIpcClient; var publicIpcHostService = _publicIpcHostService;
if (ipcClient is null || !ipcClient.IsConnected) if (publicIpcHostService is null)
{ {
return; return;
} }
@@ -270,15 +265,15 @@ public partial class App : Application
foreach (var pendingMessage in pendingMessages) 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 try
{ {
await ipcClient.ReportProgressAsync(message); await publicIpcHostService.PublishStartupProgressAsync(message);
if (logSuccess) if (logSuccess)
{ {
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}"); AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
@@ -463,7 +458,7 @@ public partial class App : Application
try try
{ {
_pluginRuntimeService?.Dispose(); _pluginRuntimeService?.Dispose();
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade); _pluginRuntimeService = new PluginRuntimeService(_settingsFacade, _publicIpcHostService);
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService); HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
_pluginRuntimeService.LoadInstalledPlugins(); _pluginRuntimeService.LoadInstalledPlugins();
} }
@@ -1043,6 +1038,19 @@ public partial class App : Application
_pluginRuntimeService = null; _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(); _settingsWindowService?.Close();
if (_settingsPageRegistry is IDisposable disposableRegistry) if (_settingsPageRegistry is IDisposable disposableRegistry)
{ {
@@ -1336,6 +1344,56 @@ public partial class App : Application
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
return _localizationService.GetString(languageCode, key, fallback); return _localizationService.GetString(languageCode, key, fallback);
} }
internal bool TryActivateMainWindowFromExternalIpc(string source)
{
return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
}
private void InitializePublicIpc()
{
if (_publicIpcHostService is not null)
{
return;
}
try
{
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0";
_publicIpcHostService = new PublicIpcHostService();
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
new PublicAppInfoService(version, "Administrate", _startupAt));
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
new PublicShellControlService());
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
new PublicPluginCatalogService(_publicIpcHostService));
_publicIpcHostService.Start();
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'.");
}
catch (Exception ex)
{
AppLogger.Warn("PublicIpc", "Failed to initialize public IPC host.", ex);
}
}
private IReadOnlyList<PublicPluginDescriptor> BuildPublicPluginDescriptors()
{
var runtime = _pluginRuntimeService;
if (runtime is null)
{
return Array.Empty<PublicPluginDescriptor>();
}
return runtime.Catalog
.Select(entry => new PublicPluginDescriptor(
entry.Manifest.Id,
entry.Manifest.Name,
entry.Manifest.Version,
entry.IsLoaded,
entry.IsEnabled))
.ToArray();
}
} }

View File

@@ -31,6 +31,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" /> <ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,47 @@
using Avalonia;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Services.ExternalIpc;
internal sealed class PublicShellControlService : IPublicShellControlService
{
public Task<bool> ActivateMainWindowAsync()
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
return (Application.Current as App)?.TryActivateMainWindowFromExternalIpc("PublicIpc") == true;
}).GetTask();
}
public Task<bool> OpenSettingsAsync(string? pageTag = null)
{
return Dispatcher.UIThread.InvokeAsync(() =>
{
if (Application.Current is not App app)
{
return false;
}
app.OpenIndependentSettingsModule("PublicIpc", pageTag);
return true;
}).GetTask();
}
public Task<bool> RestartAsync()
{
var lifecycle = App.CurrentHostApplicationLifecycle;
return Task.FromResult(lifecycle?.TryRestart(new HostApplicationLifecycleRequest(
Source: "PublicIpc",
Reason: "External IPC requested restart.")) == true);
}
public Task<bool> ExitAsync()
{
var lifecycle = App.CurrentHostApplicationLifecycle;
return Task.FromResult(lifecycle?.TryExit(new HostApplicationLifecycleRequest(
Source: "PublicIpc",
Reason: "External IPC requested exit.")) == true);
}
}

View File

@@ -1,6 +1,6 @@
using System.Timers; using System.Timers;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
namespace LanMountainDesktop.Services.Loading; namespace LanMountainDesktop.Services.Loading;
@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Services.Loading;
public class LoadingStateReporter : IDisposable public class LoadingStateReporter : IDisposable
{ {
private readonly LoadingStateManager _manager; private readonly LoadingStateManager _manager;
private readonly LauncherIpcClient? _ipcClient; private readonly IExternalIpcNotificationPublisher? _notificationPublisher;
private readonly System.Timers.Timer _reportTimer; private readonly System.Timers.Timer _reportTimer;
private readonly object _lock = new(); private readonly object _lock = new();
private bool _isDisposed; private bool _isDisposed;
@@ -36,10 +36,10 @@ public class LoadingStateReporter : IDisposable
public LoadingStateReporter( public LoadingStateReporter(
LoadingStateManager manager, LoadingStateManager manager,
LauncherIpcClient? ipcClient = null) IExternalIpcNotificationPublisher? notificationPublisher = null)
{ {
_manager = manager ?? throw new ArgumentNullException(nameof(manager)); _manager = manager ?? throw new ArgumentNullException(nameof(manager));
_ipcClient = ipcClient; _notificationPublisher = notificationPublisher;
// 创建定时上报定时器 // 创建定时上报定时器
_reportTimer = new System.Timers.Timer(ReportIntervalMs); _reportTimer = new System.Timers.Timer(ReportIntervalMs);
@@ -80,7 +80,7 @@ public class LoadingStateReporter : IDisposable
/// </summary> /// </summary>
public async Task ReportImmediatelyAsync() public async Task ReportImmediatelyAsync()
{ {
if (_isDisposed || _ipcClient == null) return; if (_isDisposed || _notificationPublisher == null) return;
var message = CreateDetailedProgressMessage(); var message = CreateDetailedProgressMessage();
await SendMessageAsync(message); await SendMessageAsync(message);
@@ -91,7 +91,7 @@ public class LoadingStateReporter : IDisposable
/// </summary> /// </summary>
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null) 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); var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item == null) return; if (item == null) return;
@@ -121,7 +121,7 @@ public class LoadingStateReporter : IDisposable
/// </summary> /// </summary>
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null) public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
{ {
if (_isDisposed || _ipcClient == null) return; if (_isDisposed || _notificationPublisher == null) return;
var progressMessage = new DetailedProgressMessage var progressMessage = new DetailedProgressMessage
{ {
@@ -140,7 +140,7 @@ public class LoadingStateReporter : IDisposable
/// </summary> /// </summary>
public async Task ReportErrorAsync(string errorMessage, string? details = null) public async Task ReportErrorAsync(string errorMessage, string? details = null)
{ {
if (_isDisposed || _ipcClient == null) return; if (_isDisposed || _notificationPublisher == null) return;
var fullMessage = string.IsNullOrEmpty(details) var fullMessage = string.IsNullOrEmpty(details)
? errorMessage ? errorMessage
@@ -280,7 +280,7 @@ public class LoadingStateReporter : IDisposable
/// </summary> /// </summary>
private async Task SendMessageAsync(DetailedProgressMessage message) private async Task SendMessageAsync(DetailedProgressMessage message)
{ {
if (_ipcClient == null) return; if (_notificationPublisher == null) return;
// 检查最小上报间隔 // 检查最小上报间隔
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
@@ -293,15 +293,15 @@ public class LoadingStateReporter : IDisposable
try try
{ {
// 转换为 StartupProgressMessage 以保持兼容性 // 转换为 StartupProgressMessage 以保持兼容性
var baseMessage = new StartupProgressMessage var loadingStateMessage = _manager.GetLoadingStateMessage() with
{ {
Stage = message.Stage, Stage = message.Stage,
ProgressPercent = message.ProgressPercent, OverallProgressPercent = message.ProgressPercent,
Message = FormatMessage(message), Message = FormatMessage(message),
Timestamp = DateTimeOffset.UtcNow Timestamp = DateTimeOffset.UtcNow
}; };
await _ipcClient.ReportProgressAsync(baseMessage); await _notificationPublisher.NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, loadingStateMessage);
_lastReportTime = DateTimeOffset.UtcNow; _lastReportTime = DateTimeOffset.UtcNow;
} }
catch (Exception ex) catch (Exception ex)

View File

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

View File

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

View File

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

View File

@@ -197,9 +197,36 @@ The runtime flow starts with the Launcher selecting the best version, then proce
## VeloPack Integration Note ## VeloPack Integration Note
- Incremental package build/publish has moved to VeloPack native assets ( - Incremental package build/publish has moved to VeloPack native assets (
eleases.win.json + *.nupkg).
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback. - Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
## Plugin Isolation Modes
The current plugin runtime is still in-process. `PluginRuntimeService` and `PluginLoader` load plugin code inside the Host process, while `PluginLoadContext` only provides assembly isolation, not process isolation.
The repository now reserves three runtime modes:
- `in-proc`: current default and compatibility mode
- `isolated-background`: phase-1 mode, where background logic moves into a dedicated worker process and Host UI becomes a thin IPC-driven shell
- `isolated-window`: phase-2 mode, where plugin UI renders out of process and Host embeds a platform window handle
Two new supporting packages define the isolation boundary:
- `LanMountainDesktop.PluginIsolation.Contracts/`: transport-neutral DTOs, route constants, error codes, capabilities, and JSON context
- `LanMountainDesktop.PluginIsolation.Ipc/`: ClassIsland-inspired IPC facade that centralizes startup constants, routed notify IDs, and client/server wrappers over the future `dotnetCampus.Ipc` transport binding
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 / Elevation Contract

View File

@@ -0,0 +1,125 @@
# External IPC Architecture
## Scope
This document defines the current external integration IPC baseline for LanMountainDesktop.
- The delivery focus is external application integration, not plugin process isolation.
- `dotnetCampus.Ipc` is the single IPC foundation for Host public APIs, Launcher/OOBE startup notifications, and plugin-contributed external services.
- Process isolation remains a future track and stays documented in `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`.
## Design Summary
The public IPC stack is split into two complementary layers:
1. Strongly typed public services
- Contracts are marked with `[IpcPublic]`.
- Host exposes service instances through `CreateIpcJoint<TContract>(instance)`.
- .NET clients connect once and obtain strong typed proxies through `CreateIpcProxy<TContract>(peer)`.
2. Routed notifications
- `JsonIpcDirectRoutedProvider.NotifyAsync` is used for one-way event delivery.
- Startup progress, loading-state updates, catalog changed events, and plugin live events all use routed notify IDs.
This keeps command/query calls explicit and strongly typed while still giving plugins and Launcher a lightweight event channel.
## Projects
- `LanMountainDesktop.Shared.IPC`
- Public IPC constants, routed notify IDs, DTOs, strong-typed public service contracts, host/client helpers, and DI registration helpers.
- `LanMountainDesktop`
- Runs `PublicIpcHostService`, exposes built-in public services, and folds plugin-contributed services into one external catalog.
- `LanMountainDesktop.Launcher`
- Connects to the Host public pipe and listens for startup and loading-state notifications instead of running a custom length-prefixed IPC server.
- `LanMountainDesktop.PluginSdk`
- Adds `IPluginPublicIpcContributor`, `IPluginPublicIpcBuilder`, and `AddPluginPublicIpc(...)`.
## Built-in Public Services
Current built-in `[IpcPublic]` contracts:
- `IPublicAppInfoService`
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
- `IPublicShellControlService`
- Allows external .NET clients to activate the shell, open settings, request restart, and request exit.
- `IPublicPluginCatalogService`
- Returns the merged public IPC catalog snapshot exposed by Host.
## Routed Notify IDs
Current fixed routed notify IDs:
- `lanmountain.catalog.changed`
- `lanmountain.launcher.startup-progress`
- `lanmountain.launcher.loading-state`
The fixed routed surface is intentionally small. Runtime variation happens in the service catalog and in plugin-contributed service instances, not in ad-hoc top-level route registration after startup.
## Host Lifecycle
`PublicIpcHostService` is started during Host application startup and remains the single external IPC entry point.
Responsibilities:
- Start a named `dotnetCampus.Ipc` provider.
- Register fixed request routes before `StartServer()`.
- Expose built-in strong-typed public services.
- Maintain the merged service catalog.
- Publish startup and loading-state notifications to connected clients.
- Accept plugin-contributed public services after plugin load.
## Launcher / OOBE Migration
Launcher no longer depends on the previous custom named-pipe length-prefixed protocol as the primary path.
- Host publishes `StartupProgressMessage` through `lanmountain.launcher.startup-progress`.
- Host publishes `LoadingStateMessage` through `lanmountain.launcher.loading-state`.
- Launcher connects as a normal public IPC client and subscribes to those routed notifications.
This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators.
## Plugin Public IPC Contribution Model
Plugins can contribute new external IPC services in two ways:
1. Declarative registration
- `services.AddPluginPublicIpc<TContract, TImplementation>(...)`
2. Advanced contributor
- Register `IPluginPublicIpcContributor`
- Use `IPluginPublicIpcBuilder` to contribute services from plugin DI
At plugin load time the Host runtime:
- discovers `PluginPublicIpcServiceRegistration`
- executes `IPluginPublicIpcContributor`
- validates that contributed contracts are `[IpcPublic]` interfaces
- registers the resolved instances into `PublicIpcHostService`
- emits `lanmountain.catalog.changed`
Plugins can also inject `IExternalIpcNotificationPublisher` and translate internal DI/message-bus events into routed notifications such as:
- `lanmountain.plugin.{pluginId}.attendance.updated`
- `lanmountain.plugin.{pluginId}.status.changed`
## Service Catalog
The public catalog is represented by `PublicIpcCatalogSnapshot` and includes:
- built-in and plugin-provided public services
- contract type metadata
- optional object id
- owning `pluginId` for plugin services
- declared notify IDs
- current loaded/enabled plugin list
This catalog is available through:
- strong-typed public service `IPublicPluginCatalogService`
- fixed request route `lanmountain.catalog.get`
- routed notify `lanmountain.catalog.changed`
## Current Limitations
- Strong-typed proxy/joint support is .NET-first.
- Plugin service removal is still restart-bound. New services can be added at runtime, but service removal is not yet modeled as a live unload contract.
- Cross-language clients still need a .NET bridge or sidecar if they want to consume `[IpcPublic]` contracts directly.
- Plugin process isolation is not part of this delivery. That remains future work.

View File

@@ -559,3 +559,13 @@ var updateCheckService = new UpdateCheckService(
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE. - `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. - 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. - Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
## Public IPC Baseline
Launcher now consumes Host startup telemetry from the unified public IPC stack:
- Host publishes `StartupProgressMessage` via `lanmountain.launcher.startup-progress`
- Host publishes `LoadingStateMessage` via `lanmountain.launcher.loading-state`
- Launcher connects through `LanMountainDesktopIpcClient`
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.

View File

@@ -684,3 +684,34 @@ if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
- [组件开发指南](COMPONENT_DEVELOPMENT.md) - [组件开发指南](COMPONENT_DEVELOPMENT.md)
- [API 参考](API_REFERENCE.md) - [API 参考](API_REFERENCE.md)
- [架构文档](ARCHITECTURE.md) - [架构文档](ARCHITECTURE.md)
## Public IPC Extension
Plugins can now contribute external IPC capabilities through the Host public IPC entry point.
Recommended registration styles:
```csharp
services.AddPluginPublicIpc<IMyPluginPublicService, MyPluginPublicService>(
objectId: "default",
notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
```
Or use the advanced contributor model:
```csharp
public sealed class MyPluginPublicIpcContributor : IPluginPublicIpcContributor
{
public void ConfigurePublicIpc(IPluginPublicIpcBuilder builder)
{
builder.AddService<IMyPluginPublicService>(
objectId: "default",
notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
}
}
```
Additional notes:
- Public IPC contracts must be interfaces marked with `[IpcPublic]`.
- External .NET clients can reference the plugin contract assembly and create strong-typed proxies through the Host public pipe.
- Plugins can inject `IExternalIpcNotificationPublisher` to push live events outward through routed notifications.

View File

@@ -0,0 +1,263 @@
# 插件进程隔离架构
## 1. 背景与问题
当前插件系统只做了程序集隔离,没有做进程隔离。
- 宿主通过 `LanMountainDesktop/plugins/PluginRuntimeService.cs``LanMountainDesktop/plugins/PluginLoader.cs` 在 Host 进程内发现、加载并初始化插件。
- 插件依赖 `PluginLoadContext` 获得 `AssemblyLoadContext` 级别的依赖隔离,但代码、线程、托管堆和原生句柄仍与 Host 共处同一进程。
- 插件 `IHostedService` 也由 Host 直接构造并启动,所以插件后台逻辑和 Host 生命周期强耦合。
- 桌面组件、组件编辑器、设置页当前都直接返回 `Avalonia Control``SettingsPageBase`,并由 Host 直接插入视觉树。
这带来三个核心风险:
1. 插件崩溃会拖垮 Host典型场景包括 `StackOverflowException``AccessViolationException`、原生依赖崩溃。
2. 插件可直接访问 Host 进程中的服务实例与内存对象,缺少安全边界与权限审计点。
3. 现有“对象实例共享”模型难以迁移到跨进程,因为它默认调用成本近似于内存内方法调用。
## 2. 目标与非目标
### 2.1 一期目标
- 保持增量兼容,未声明新运行模式的插件继续走 `in-proc`
- 新增 `isolated-background` 运行模式,为每个隔离插件启动独立 Worker 进程。
- 把后台逻辑、定时任务、网络调用、原生高风险代码迁移到 Worker。
- UI 仍保留 Host 侧薄壳,通过 IPC 获取状态并发送命令。
- 新建独立 IPC 契约与封装层,为后续实际接线和插件升级提供稳定边界。
### 2.2 二期预留
- 预留 `isolated-window` 模式。
- 插件 UI 在进程外窗口中渲染Host 通过平台能力嵌入窗口句柄。
- Windows 侧可评估 `SetParent`Linux 侧可评估 `XEmbed` 或等价方案。
### 2.3 非目标
- 一期不强制所有插件升级。
- 一期不把现有 `IPluginExportRegistry``IPluginMessageBus` 直接升级成跨进程远程对象模型。
- 一期不实现完整的窗口嵌入渲染。
## 3. 运行模式设计
### 3.1 `in-proc`
- 默认模式。
- 继续使用当前 `PluginRuntimeService` + `PluginLoader` + `PluginLoadContext` 路径。
- 适合存量插件和仍依赖直接控件构造的插件。
### 3.2 `isolated-background`
- 一期目标模式。
- Host 为每个插件启动独立 Worker 进程。
- 启动时通过环境变量或命令行参数下发:
- `pluginId`
- `sessionId`
- `hostPipeName`
- `protocolVersion`
- `runtimeMode`
- Worker 内承载后台逻辑和 IPC 端点。
- Host 只保留 UI 壳层与状态同步逻辑。
### 3.3 `isolated-window`
- 二期预留模式。
- Worker 自己创建窗口并负责 UI 渲染。
- Host 负责窗口嵌入、生命周期协调、焦点与尺寸同步。
- 这是彻底切断插件 UI 崩溃影响 Host 的最终方案。
## 4. UI 方案取舍
### 4.1 方案一:进程外窗口
优点:
- 最强崩溃隔离。
- 插件 UI 不再进入 Host 视觉树。
- 安全边界更清晰。
缺点:
- 跨平台复杂度高。
- 窗口句柄嵌入、焦点管理、输入法、缩放、多屏和无障碍都需要额外设计。
- Avalonia 与平台窗口宿主的交互验证成本高。
### 4.2 方案二Host 薄 UI 壳层
优点:
- 与现有组件系统、编辑器系统、设置页系统的迁移成本最低。
- 可以先隔离最危险的后台与原生逻辑。
- 适合做增量兼容与插件生态迁移。
缺点:
- 如果 Host 仍执行插件提供的 UI 代码,仍有残余稳定性风险。
- 无法从根本上解决所有 UI 级崩溃。
### 4.3 一期结论
一期采用方案二,也就是 `isolated-background`
这意味着:
- 后台逻辑先隔离。
- UI 交互先代理。
- 文档必须明确残余风险。
- `isolated-window` 的架构接口要预留,但不进入一期实现。
## 5. IPC 协议设计
底层 IPC 继续基于 [dotnetCampus.Ipc](https://github.com/dotnet-campus/dotnetCampus.Ipc),但插件协议采用“显式路由 + DTO + 会话/心跳/故障管理”的方式,而不是把 Host 服务对象直接远程化。
### 5.1 路由分组
- `session/*`
- `session/handshake`
- `session/capabilities`
- `session/ready`
- `lifecycle/*`
- `lifecycle/initialize`
- `lifecycle/stop`
- `lifecycle/restart-request`
- `lifecycle/state-changed`
- `settings/*`
- `settings/get-snapshot`
- `settings/write`
- `settings/changed`
- `appearance/*`
- `appearance/get-snapshot`
- `appearance/changed`
- `ui/*`
- `ui/attach`
- `ui/detach`
- `ui/command`
- `ui/state-changed`
- `heartbeat/*`
- `heartbeat/ping`
- `heartbeat/pong`
- `log/*`
- `log/write`
- `fault/*`
- `fault/report`
### 5.2 契约原则
- 只传 DTO不传 Host 内存对象。
- 所有 handler 必须在 `StartServer()` 前注册完成。
- 使用 source-generated `System.Text.Json` 上下文统一序列化。
- 协议版本通过 `session/handshake` 协商。
- 能力通过显式 capability 列表声明和授予,不做隐式远程对象暴露。
### 5.3 明确不兼容的旧能力
- `IPluginExportRegistry` 的对象实例共享不延续到隔离模式。
- 现有 `IPluginMessageBus` 不作为隔离插件主通信通道。
- Worker 不直接创建 `Avalonia Control` 并返回给 Host。
## 6. 工程拆分
### 6.1 `LanMountainDesktop.PluginIsolation.Contracts`
职责:
- 纯 DTO
- 协议版本
- 路由常量
- 错误码
- capability 声明
- source-generated JSON context
约束:
- 不引用 Avalonia
- 不依赖 Host 服务实现
- 作为 Host、Worker、SDK 共享的传输边界
### 6.2 `LanMountainDesktop.PluginIsolation.Ipc`
职责:
- 对标 ClassIsland 的轻量 IPC 封装外壳
- 统一 `PluginIpcClient`
- 统一 `PluginIpcServer`
- 统一启动参数、环境变量、通知路由常量
约束:
- 借鉴 ClassIsland 的“包装层 + 常量集中 + 客户端低接入成本”
- 但不把插件系统主协议设计成大面积远程属性模型
### 6.3 `LanMountainDesktop.PluginSdk`
新增内容:
- `runtime.mode` Manifest 支持
- `PluginRuntimeMode`
- `IPluginWorker`
- `IPluginWorkerContext`
- `PluginWorkerBase`
- `[PluginWorkerEntrance]`
## 7. ClassIsland IPC 借鉴与取舍
参考资料:
- [ClassIsland 仓库](https://github.com/ClassIsland/ClassIsland)
- [ClassIsland.Shared.IPC/IpcClient.cs](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland.Shared.IPC/IpcClient.cs)
- [ClassIsland.Shared.IPC/IpcRoutedNotifyIds.cs](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland.Shared.IPC/IpcRoutedNotifyIds.cs)
- [ClassIsland.Shared.IPC Abstractions](https://github.com/ClassIsland/ClassIsland/tree/master/ClassIsland.Shared.IPC/Abstractions/Services)
借鉴点:
- IPC 能力独立成包,边界清晰。
- `IpcClient` 对底层库做轻量封装,接入成本低。
- 通知路由有集中定义,事件名稳定。
- 通过公共接口暴露很小的可用面,减少耦合。
不照搬的部分:
- 不把插件隔离主协议做成“远程对象/远程属性”模型。
- 不隐藏跨进程调用成本。
- 不让 UI 状态同步变成一串隐式属性访问。
最终结论:
- 采用“ClassIsland 风格的封装外壳”。
- 协议主线仍是显式路由和明确 DTO。
## 8. 迁移策略
### 8.1 Manifest
`plugin.json` 新增:
```json
{
"runtime": {
"mode": "in-proc"
}
}
```
默认值为 `in-proc`
### 8.2 插件迁移顺序
1. 保持现有 UI 注册方式不变。
2. 把后台任务和风险代码收敛到 Worker。
3. 让 Host UI 通过 `ui/*``settings/*` 路由访问 Worker 状态。
4. 在二期再评估 `isolated-window` 迁移。
## 9. 故障模型与残余风险
一期必须满足以下行为:
- Worker 启动失败时Host 仅禁用该插件并记录诊断。
- Worker 心跳超时或被强杀时Host 不崩溃。
- Worker 上报 `fault/report`Host 将插件标记为 degraded 或 faulted。
一期残余风险也必须明确写出:
- 如果 Host 仍执行插件提供的 UI 代码UI 级崩溃仍可能影响 Host。
- 因此 `isolated-background` 不是最终隔离形态,只是第一阶段收益最高的落点。
- 完整 UI 崩溃隔离依赖二期 `isolated-window`

View File

@@ -145,3 +145,38 @@ Update plugin manifests to API `4.x`:
- component registration migrated to options model - component registration migrated to options model
- runtime appearance access uses `IPluginAppearanceContext` - runtime appearance access uses `IPluginAppearanceContext`
- plugin package rebuilt and republished as `.laapp` - plugin package rebuilt and republished as `.laapp`
## Process Isolation Additions
SDK `4.x` now also reserves manifest and API surface for process isolation without breaking existing plugins.
### Manifest
`plugin.json` can declare the desired runtime mode:
```json
{
"runtime": {
"mode": "in-proc"
}
}
```
Supported values:
- `in-proc`
- `isolated-background`
- `isolated-window`
If `runtime` is omitted, the host normalizes it to `in-proc` for backward compatibility.
### Worker Entry
Plugins that opt into isolated execution can prepare a worker-side entry by implementing:
- `IPluginWorker`
- `PluginWorkerBase`
- `IPluginWorkerContext`
- `[PluginWorkerEntrance]`
The first phase only targets `isolated-background`: background services, timers, network calls, and risky native integrations move into the worker process, while UI remains a host-side shell driven over IPC.