From f51ec309a642991662e11ccef12445ea8531180f Mon Sep 17 00:00:00 2001 From: lincube Date: Wed, 22 Apr 2026 10:25:46 +0800 Subject: [PATCH] Add plugin isolation IPC scaffolding and host phase one docs (#5) --- .../plugin-process-isolation/checklist.md | 12 + .trae/specs/plugin-process-isolation/spec.md | 41 +++ .trae/specs/plugin-process-isolation/tasks.md | 12 + .../AppearanceContracts.cs | 12 + .../DiagnosticsContracts.cs | 45 +++ ...inDesktop.PluginIsolation.Contracts.csproj | 22 ++ .../LifecycleContracts.cs | 33 +++ .../PluginCapabilityDeclaration.cs | 17 ++ .../PluginIpcErrorCodes.cs | 15 + .../PluginIpcRoutes.cs | 56 ++++ .../PluginIsolationJsonContext.cs | 39 +++ .../PluginIsolationProtocolVersion.cs | 6 + .../README.md | 9 + .../SessionContracts.cs | 21 ++ .../SettingsContracts.cs | 33 +++ .../UiContracts.cs | 52 ++++ ...MountainDesktop.PluginIsolation.Ipc.csproj | 27 ++ .../PluginIpcClient.cs | 90 ++++++ .../PluginIpcClientOptions.cs | 17 ++ .../PluginIpcConstants.cs | 25 ++ .../PluginIpcDelegates.cs | 13 + .../PluginIpcRoutedNotifyIds.cs | 17 ++ .../PluginIpcServer.cs | 113 ++++++++ .../PluginIpcServerOptions.cs | 17 ++ .../README.md | 10 + LanMountainDesktop.PluginSdk/IPluginWorker.cs | 12 + .../IPluginWorkerContext.cs | 26 ++ .../LanMountainDesktop.PluginSdk.csproj | 1 + .../PluginManifest.cs | 10 +- .../PluginRuntimeConfiguration.cs | 15 + .../PluginRuntimeMode.cs | 8 + .../PluginRuntimeModes.cs | 58 ++++ .../PluginWorkerBase.cs | 20 ++ .../PluginWorkerEntranceAttribute.cs | 6 + LanMountainDesktop.PluginSdk/README.md | 2 + .../content/README.md | 1 + .../content/plugin.json | 5 +- .../PluginManifestRuntimeTests.cs | 48 ++++ LanMountainDesktop.slnx | 2 + docs/ARCHITECTURE.md | 19 +- docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md | 263 ++++++++++++++++++ docs/PLUGIN_SDK_V4_MIGRATION.md | 35 +++ 42 files changed, 1281 insertions(+), 4 deletions(-) create mode 100644 .trae/specs/plugin-process-isolation/checklist.md create mode 100644 .trae/specs/plugin-process-isolation/spec.md create mode 100644 .trae/specs/plugin-process-isolation/tasks.md create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/DiagnosticsContracts.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/LifecycleContracts.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/PluginCapabilityDeclaration.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/PluginIpcErrorCodes.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/PluginIpcRoutes.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationProtocolVersion.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/README.md create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/SessionContracts.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/SettingsContracts.cs create mode 100644 LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClientOptions.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcRoutedNotifyIds.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServerOptions.cs create mode 100644 LanMountainDesktop.PluginIsolation.Ipc/README.md create mode 100644 LanMountainDesktop.PluginSdk/IPluginWorker.cs create mode 100644 LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginWorkerBase.cs create mode 100644 LanMountainDesktop.PluginSdk/PluginWorkerEntranceAttribute.cs create mode 100644 LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs create mode 100644 docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md diff --git a/.trae/specs/plugin-process-isolation/checklist.md b/.trae/specs/plugin-process-isolation/checklist.md new file mode 100644 index 0000000..54c7738 --- /dev/null +++ b/.trae/specs/plugin-process-isolation/checklist.md @@ -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 回路 diff --git a/.trae/specs/plugin-process-isolation/spec.md b/.trae/specs/plugin-process-isolation/spec.md new file mode 100644 index 0000000..abe7a36 --- /dev/null +++ b/.trae/specs/plugin-process-isolation/spec.md @@ -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 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。 diff --git a/.trae/specs/plugin-process-isolation/tasks.md b/.trae/specs/plugin-process-isolation/tasks.md new file mode 100644 index 0000000..ee54d84 --- /dev/null +++ b/.trae/specs/plugin-process-isolation/tasks.md @@ -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 壳层适配器 +- [ ] 为故障、心跳、降级与恢复补齐端到端测试 diff --git a/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs new file mode 100644 index 0000000..4bc96ad --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs @@ -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? CornerRadiusTokens = null, + IReadOnlyDictionary? ResourceAliases = null); + +public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot); diff --git a/LanMountainDesktop.PluginIsolation.Contracts/DiagnosticsContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/DiagnosticsContracts.cs new file mode 100644 index 0000000..7a5b9b1 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/DiagnosticsContracts.cs @@ -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"; +} diff --git a/LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj b/LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj new file mode 100644 index 0000000..cb3f84f --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + 1.0.0 + LanMountainDesktop.PluginIsolation.Contracts + true + LanMountainDesktop + Transport-neutral IPC contracts for the LanMountainDesktop plugin isolation architecture. + LanMountainDesktop;Plugin;IPC;Isolation;Contracts + README.md + https://github.com/wwiinnddyy/LanMountainDesktop + git + LGPL-3.0-or-later + Copyright (c) LanMountainDesktop Contributors + + + + + + diff --git a/LanMountainDesktop.PluginIsolation.Contracts/LifecycleContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/LifecycleContracts.cs new file mode 100644 index 0000000..ad821ab --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/LifecycleContracts.cs @@ -0,0 +1,33 @@ +namespace LanMountainDesktop.PluginIsolation.Contracts; + +public sealed record PluginInitializeRequest( + string PluginId, + string SessionId, + string HostPipeName, + string DataDirectory, + IReadOnlyDictionary? 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"; +} diff --git a/LanMountainDesktop.PluginIsolation.Contracts/PluginCapabilityDeclaration.cs b/LanMountainDesktop.PluginIsolation.Contracts/PluginCapabilityDeclaration.cs new file mode 100644 index 0000000..3d12ec3 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/PluginCapabilityDeclaration.cs @@ -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"; +} diff --git a/LanMountainDesktop.PluginIsolation.Contracts/PluginIpcErrorCodes.cs b/LanMountainDesktop.PluginIsolation.Contracts/PluginIpcErrorCodes.cs new file mode 100644 index 0000000..6b1aebe --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/PluginIpcErrorCodes.cs @@ -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"; +} diff --git a/LanMountainDesktop.PluginIsolation.Contracts/PluginIpcRoutes.cs b/LanMountainDesktop.PluginIsolation.Contracts/PluginIpcRoutes.cs new file mode 100644 index 0000000..2c9288b --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/PluginIpcRoutes.cs @@ -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"; + } +} diff --git a/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs b/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs new file mode 100644 index 0000000..83fdbc7 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationJsonContext.cs @@ -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))] +[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))] +[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; diff --git a/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationProtocolVersion.cs b/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationProtocolVersion.cs new file mode 100644 index 0000000..64452b1 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/PluginIsolationProtocolVersion.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.PluginIsolation.Contracts; + +public static class PluginIsolationProtocolVersion +{ + public const string Current = "1.0"; +} diff --git a/LanMountainDesktop.PluginIsolation.Contracts/README.md b/LanMountainDesktop.PluginIsolation.Contracts/README.md new file mode 100644 index 0000000..9085ac1 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/README.md @@ -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 diff --git a/LanMountainDesktop.PluginIsolation.Contracts/SessionContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/SessionContracts.cs new file mode 100644 index 0000000..014a6d8 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/SessionContracts.cs @@ -0,0 +1,21 @@ +namespace LanMountainDesktop.PluginIsolation.Contracts; + +public sealed record PluginSessionHandshakeRequest( + string PluginId, + string SessionId, + string RuntimeMode, + string ProtocolVersion, + IReadOnlyList? RequestedCapabilities = null, + IReadOnlyDictionary? Metadata = null); + +public sealed record PluginSessionHandshakeResponse( + bool Accepted, + string ProtocolVersion, + IReadOnlyList? GrantedCapabilities = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public sealed record PluginReadyNotification( + string PluginId, + string SessionId, + IReadOnlyList? UiSurfaces = null); diff --git a/LanMountainDesktop.PluginIsolation.Contracts/SettingsContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/SettingsContracts.cs new file mode 100644 index 0000000..e8d420b --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/SettingsContracts.cs @@ -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); diff --git a/LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs new file mode 100644 index 0000000..40f4b5c --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Contracts/UiContracts.cs @@ -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); diff --git a/LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj b/LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj new file mode 100644 index 0000000..eefd43c --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + 1.0.0 + LanMountainDesktop.PluginIsolation.Ipc + true + LanMountainDesktop + ClassIsland-style IPC facade for LanMountainDesktop plugin process isolation, backed by dotnetCampus.Ipc. + LanMountainDesktop;Plugin;IPC;Isolation;dotnetCampus.Ipc + README.md + https://github.com/wwiinnddyy/LanMountainDesktop + git + LGPL-3.0-or-later + Copyright (c) LanMountainDesktop Contributors + + + + + + + + + + + diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs new file mode 100644 index 0000000..1d4179e --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClient.cs @@ -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 RequestAsync( + string route, + TRequest payload, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(route); + return RequestCoreAsync(route, payload, cancellationToken); + } + + public Task NotifyAsync( + string route, + TPayload payload, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(route); + return NotifyCoreAsync(route, Serialize(payload), cancellationToken); + } + + private async Task RequestCoreAsync( + 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(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 payload) + { + return JsonSerializer.SerializeToElement(payload, SerializerOptions); + } + + private T? Deserialize(JsonElement? payload) + { + if (payload is null) + { + return default; + } + + return payload.Value.Deserialize(SerializerOptions); + } +} diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClientOptions.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClientOptions.cs new file mode 100644 index 0000000..72bee57 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcClientOptions.cs @@ -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; +} diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs new file mode 100644 index 0000000..ab4a6a1 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcConstants.cs @@ -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; +} diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs new file mode 100644 index 0000000..f3fc57e --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcDelegates.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace LanMountainDesktop.PluginIsolation.Ipc; + +public delegate Task PluginIpcRequestDispatcher( + string route, + JsonElement? payload, + CancellationToken cancellationToken); + +public delegate Task PluginIpcNotificationDispatcher( + string route, + JsonElement? payload, + CancellationToken cancellationToken); diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcRoutedNotifyIds.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcRoutedNotifyIds.cs new file mode 100644 index 0000000..8c4b77d --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcRoutedNotifyIds.cs @@ -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; +} diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs new file mode 100644 index 0000000..bfa8730 --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServer.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LanMountainDesktop.PluginIsolation.Ipc; + +public sealed class PluginIpcServer +{ + private readonly Dictionary>> _requestHandlers = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary> _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( + string route, + Func> handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(route); + ArgumentNullException.ThrowIfNull(handler); + + _requestHandlers[route] = async (payload, cancellationToken) => + { + var request = Deserialize(payload); + var response = await handler(request, cancellationToken).ConfigureAwait(false); + return Serialize(response); + }; + } + + public void MapNotification( + string route, + Func handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(route); + ArgumentNullException.ThrowIfNull(handler); + + _notificationHandlers[route] = (payload, cancellationToken) => + { + var notification = Deserialize(payload); + return handler(notification, cancellationToken); + }; + } + + public async Task 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 payload) + { + return JsonSerializer.SerializeToElement(payload, SerializerOptions); + } + + private T Deserialize(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(SerializerOptions); + if (value is null && default(T) is not null) + { + throw new InvalidOperationException( + $"Failed to deserialize IPC payload to '{typeof(T).FullName}'."); + } + + return value!; + } +} diff --git a/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServerOptions.cs b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServerOptions.cs new file mode 100644 index 0000000..498c0cb --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/PluginIpcServerOptions.cs @@ -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; +} diff --git a/LanMountainDesktop.PluginIsolation.Ipc/README.md b/LanMountainDesktop.PluginIsolation.Ipc/README.md new file mode 100644 index 0000000..4c5318a --- /dev/null +++ b/LanMountainDesktop.PluginIsolation.Ipc/README.md @@ -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 diff --git a/LanMountainDesktop.PluginSdk/IPluginWorker.cs b/LanMountainDesktop.PluginSdk/IPluginWorker.cs new file mode 100644 index 0000000..32fee7b --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginWorker.cs @@ -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); +} diff --git a/LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs b/LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs new file mode 100644 index 0000000..3aa8697 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginWorkerContext.cs @@ -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 GrantedCapabilities { get; } + + IReadOnlyDictionary StartupProperties { get; } +} diff --git a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj index 6089957..524773f 100644 --- a/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj +++ b/LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj @@ -25,6 +25,7 @@ + diff --git a/LanMountainDesktop.PluginSdk/PluginManifest.cs b/LanMountainDesktop.PluginSdk/PluginManifest.cs index 56d1ac7..e90f64c 100644 --- a/LanMountainDesktop.PluginSdk/PluginManifest.cs +++ b/LanMountainDesktop.PluginSdk/PluginManifest.cs @@ -10,7 +10,8 @@ public sealed record PluginManifest( string? Author = null, string? Version = null, string? ApiVersion = null, - IReadOnlyList? SharedContracts = null) + IReadOnlyList? 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)) diff --git a/LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs b/LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs new file mode 100644 index 0000000..4d406f6 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginRuntimeConfiguration.cs @@ -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) + }; + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs b/LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs new file mode 100644 index 0000000..dd93240 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginRuntimeMode.cs @@ -0,0 +1,8 @@ +namespace LanMountainDesktop.PluginSdk; + +public enum PluginRuntimeMode +{ + InProcess = 0, + IsolatedBackground = 1, + IsolatedWindow = 2 +} diff --git a/LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs b/LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs new file mode 100644 index 0000000..8d52ab4 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginRuntimeModes.cs @@ -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) ? "" : 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.") + }; + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginWorkerBase.cs b/LanMountainDesktop.PluginSdk/PluginWorkerBase.cs new file mode 100644 index 0000000..f3c5418 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginWorkerBase.cs @@ -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; + } +} diff --git a/LanMountainDesktop.PluginSdk/PluginWorkerEntranceAttribute.cs b/LanMountainDesktop.PluginSdk/PluginWorkerEntranceAttribute.cs new file mode 100644 index 0000000..9c7972b --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginWorkerEntranceAttribute.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.PluginSdk; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class PluginWorkerEntranceAttribute : Attribute +{ +} diff --git a/LanMountainDesktop.PluginSdk/README.md b/LanMountainDesktop.PluginSdk/README.md index 2198e83..a69adbd 100644 --- a/LanMountainDesktop.PluginSdk/README.md +++ b/LanMountainDesktop.PluginSdk/README.md @@ -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 diff --git a/LanMountainDesktop.PluginTemplate/content/README.md b/LanMountainDesktop.PluginTemplate/content/README.md index 0a9bd45..d900c1a 100644 --- a/LanMountainDesktop.PluginTemplate/content/README.md +++ b/LanMountainDesktop.PluginTemplate/content/README.md @@ -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) diff --git a/LanMountainDesktop.PluginTemplate/content/plugin.json b/LanMountainDesktop.PluginTemplate/content/plugin.json index a1a4f03..6ca9f8b 100644 --- a/LanMountainDesktop.PluginTemplate/content/plugin.json +++ b/LanMountainDesktop.PluginTemplate/content/plugin.json @@ -6,5 +6,8 @@ "version": "1.0.0", "apiVersion": "4.0.2", "entranceAssembly": "LanMountainDesktop.PluginTemplate.dll", - "sharedContracts": [] + "sharedContracts": [], + "runtime": { + "mode": "in-proc" + } } diff --git a/LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs b/LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs new file mode 100644 index 0000000..82113af --- /dev/null +++ b/LanMountainDesktop.Tests/PluginManifestRuntimeTests.cs @@ -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(() => PluginManifest.Load(stream, "plugin.json")); + + Assert.Contains("runtime.mode", ex.Message); + Assert.Contains("shared-worker", ex.Message); + } +} diff --git a/LanMountainDesktop.slnx b/LanMountainDesktop.slnx index 636d71c..1e9d7da 100644 --- a/LanMountainDesktop.slnx +++ b/LanMountainDesktop.slnx @@ -5,6 +5,8 @@ + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36b0744..74e7587 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -197,9 +197,26 @@ The runtime flow starts with the Launcher selecting the best version, then proce ## VeloPack Integration Note -- Incremental package build/publish has moved to VeloPack native assets ( eleases.win.json + *.nupkg). +- 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 + +For the detailed design, migration path, UI strategy, and residual risks, see `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`. ## Launcher OOBE / Elevation Contract diff --git a/docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md b/docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md new file mode 100644 index 0000000..36a3a46 --- /dev/null +++ b/docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md @@ -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`。 diff --git a/docs/PLUGIN_SDK_V4_MIGRATION.md b/docs/PLUGIN_SDK_V4_MIGRATION.md index 906c89b..0a9ad91 100644 --- a/docs/PLUGIN_SDK_V4_MIGRATION.md +++ b/docs/PLUGIN_SDK_V4_MIGRATION.md @@ -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.