mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Add plugin isolation IPC scaffolding and host phase one docs
This commit is contained in:
12
.trae/specs/plugin-process-isolation/checklist.md
Normal file
12
.trae/specs/plugin-process-isolation/checklist.md
Normal 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 回路
|
||||
41
.trae/specs/plugin-process-isolation/spec.md
Normal file
41
.trae/specs/plugin-process-isolation/spec.md
Normal 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 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。
|
||||
12
.trae/specs/plugin-process-isolation/tasks.md
Normal file
12
.trae/specs/plugin-process-isolation/tasks.md
Normal 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 壳层适配器
|
||||
- [ ] 为故障、心跳、降级与恢复补齐端到端测试
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginIsolation.Contracts;
|
||||
|
||||
public static class PluginIsolationProtocolVersion
|
||||
{
|
||||
public const string Current = "1.0";
|
||||
}
|
||||
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal file
9
LanMountainDesktop.PluginIsolation.Contracts/README.md
Normal 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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal file
52
LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs
Normal 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);
|
||||
@@ -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>
|
||||
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal file
90
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
25
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs
Normal file
25
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs
Normal 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;
|
||||
}
|
||||
13
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs
Normal file
13
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
113
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs
Normal file
113
LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs
Normal 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!;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
10
LanMountainDesktop.PluginIsolation.Ipc/README.md
Normal file
10
LanMountainDesktop.PluginIsolation.Ipc/README.md
Normal 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
|
||||
12
LanMountainDesktop.PluginSdk/IPluginWorker.cs
Normal file
12
LanMountainDesktop.PluginSdk/IPluginWorker.cs
Normal 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);
|
||||
}
|
||||
26
LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs
Normal file
26
LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs
Normal 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; }
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ public sealed record PluginManifest(
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null,
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null,
|
||||
PluginRuntimeConfiguration? Runtime = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
@@ -56,9 +57,13 @@ public sealed record PluginManifest(
|
||||
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)
|
||||
{
|
||||
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||
var normalizedRuntime = (Runtime ?? new PluginRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
@@ -68,7 +73,8 @@ public sealed record PluginManifest(
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||
SharedContracts = normalizedSharedContracts
|
||||
SharedContracts = normalizedSharedContracts,
|
||||
Runtime = normalizedRuntime
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
|
||||
15
LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs
Normal file
15
LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginRuntimeMode
|
||||
{
|
||||
InProcess = 0,
|
||||
IsolatedBackground = 1,
|
||||
IsolatedWindow = 2
|
||||
}
|
||||
58
LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs
Normal file
58
LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs
Normal 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.")
|
||||
};
|
||||
}
|
||||
}
|
||||
20
LanMountainDesktop.PluginSdk/PluginWorkerBase.cs
Normal file
20
LanMountainDesktop.PluginSdk/PluginWorkerBase.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginWorkerEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -5,7 +5,9 @@ Official SDK package for LanMountainDesktop plugins.
|
||||
## Includes
|
||||
|
||||
- `IPlugin`/`PluginBase` entry abstractions
|
||||
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
|
||||
- `PluginManifest` and shared contract declarations
|
||||
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
|
||||
- desktop component registration extensions
|
||||
- plugin runtime context and host service abstractions
|
||||
- build-transitive packaging targets for `.laapp` output
|
||||
|
||||
@@ -22,3 +22,4 @@ Update `plugin.json` fields as needed before release:
|
||||
- `description`
|
||||
- `author`
|
||||
- `version`
|
||||
- `runtime.mode` (`in-proc` by default, `isolated-background` for phase-1 worker mode)
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.2",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||
"sharedContracts": []
|
||||
"sharedContracts": [],
|
||||
"runtime": {
|
||||
"mode": "in-proc"
|
||||
}
|
||||
}
|
||||
|
||||
48
LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs
Normal file
48
LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
|
||||
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.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.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||
|
||||
@@ -200,3 +200,19 @@ The runtime flow starts with the Launcher selecting the best version, then proce
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
263
docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md
Normal file
263
docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md
Normal 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`。
|
||||
@@ -145,3 +145,38 @@ Update plugin manifests to API `4.x`:
|
||||
- component registration migrated to options model
|
||||
- runtime appearance access uses `IPluginAppearanceContext`
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user