mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7c118d13 | ||
|
|
f51ec309a6 |
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
11
.trae/specs/external-ipc-public-api/checklist.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# External IPC Public API Checklist
|
||||||
|
|
||||||
|
- [x] Host can expose strong-typed public IPC services.
|
||||||
|
- [x] External .NET client can connect and call built-in services.
|
||||||
|
- [x] Host publishes launcher startup and loading-state notifications through routed notify.
|
||||||
|
- [x] Launcher consumes routed notify instead of the old primary custom named-pipe path.
|
||||||
|
- [x] Plugin SDK exposes public IPC contribution primitives.
|
||||||
|
- [x] Plugin runtime can discover and register plugin public IPC services.
|
||||||
|
- [x] Public catalog includes built-in and plugin-contributed services.
|
||||||
|
- [x] `catalog.changed` is emitted when new services are added after startup.
|
||||||
|
- [ ] Add example external client sample.
|
||||||
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
24
.trae/specs/external-ipc-public-api/spec.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# External IPC Public API Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Provide a single `dotnetCampus.Ipc` based external integration layer for:
|
||||||
|
|
||||||
|
- Host public APIs
|
||||||
|
- Launcher/OOBE startup progress and loading-state notifications
|
||||||
|
- plugin-contributed public services and live event push
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- `LanMountainDesktop.Shared.IPC` project
|
||||||
|
- `[IpcPublic]` based built-in public contracts
|
||||||
|
- `PublicIpcHostService` and `LanMountainDesktopIpcClient`
|
||||||
|
- Launcher migrated to Host public IPC notifications
|
||||||
|
- Plugin SDK public IPC contribution API
|
||||||
|
- Host runtime integration for plugin public IPC services
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- plugin process isolation
|
||||||
|
- non-.NET strong-typed public IPC clients
|
||||||
|
- live plugin public service removal without restart
|
||||||
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
12
.trae/specs/external-ipc-public-api/tasks.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# External IPC Public API Tasks
|
||||||
|
|
||||||
|
- [x] Add `LanMountainDesktop.Shared.IPC`
|
||||||
|
- [x] Expose built-in `[IpcPublic]` services
|
||||||
|
- [x] Add routed notify constants and public IPC client/host wrappers
|
||||||
|
- [x] Start Host public IPC during app startup
|
||||||
|
- [x] Move Launcher startup progress consumption to the new IPC base
|
||||||
|
- [x] Add plugin public IPC registration/contributor SDK
|
||||||
|
- [x] Register plugin-contributed public services into Host catalog
|
||||||
|
- [x] Add integration tests for strong-typed public service access and plugin registration descriptors
|
||||||
|
- [ ] Expand built-in public service surface beyond the first minimal set
|
||||||
|
- [ ] Add non-.NET bridge guidance and samples
|
||||||
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 壳层适配器
|
||||||
|
- [ ] 为故障、心跳、降级与恢复补齐端到端测试
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
using LanMountainDesktop.Launcher.Services.Ipc;
|
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
@@ -83,7 +83,8 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
var lastStageMessage = "launcher-started";
|
var lastStageMessage = "launcher-started";
|
||||||
|
|
||||||
var loadingState = new LoadingStateMessage();
|
var loadingState = new LoadingStateMessage();
|
||||||
using var ipcServer = new LauncherIpcServer(message =>
|
using var ipcClient = new LanMountainDesktopIpcClient();
|
||||||
|
ipcClient.RegisterNotifyHandler<StartupProgressMessage>(IpcRoutedNotifyIds.LauncherStartupProgress, message =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
@@ -121,7 +122,21 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ipcServer.Start();
|
ipcClient.RegisterNotifyHandler<LoadingStateMessage>(IpcRoutedNotifyIds.LauncherLoadingState, message =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loadingState = message;
|
||||||
|
loadingDetailsWindow?.UpdateLoadingState(loadingState);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error("IPC loading-state callback failed.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -174,6 +189,12 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var connected = await TryConnectToPublicIpcAsync(ipcClient, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||||||
|
if (!connected)
|
||||||
|
{
|
||||||
|
Logger.Warn("Timed out waiting for host public IPC. Launcher will continue without live startup notifications.");
|
||||||
|
}
|
||||||
|
|
||||||
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
var processExitTask = launchOutcome.Process.WaitForExitAsync();
|
||||||
var completedTask = await Task.WhenAny(
|
var completedTask = await Task.WhenAny(
|
||||||
visibilityTcs.Task,
|
visibilityTcs.Task,
|
||||||
@@ -900,6 +921,21 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> TryConnectToPublicIpcAsync(
|
||||||
|
LanMountainDesktopIpcClient ipcClient,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var connectTask = ipcClient.ConnectAsync();
|
||||||
|
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
|
||||||
|
if (completedTask != connectTask)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectTask.ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private enum HostStartMode
|
private enum HostStartMode
|
||||||
{
|
{
|
||||||
ShellExecute,
|
ShellExecute,
|
||||||
|
|||||||
@@ -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
|
||||||
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
15
LanMountainDesktop.PluginSdk/IPluginPublicIpcBuilder.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginPublicIpcBuilder
|
||||||
|
{
|
||||||
|
IPluginPublicIpcBuilder AddService<TContract>(
|
||||||
|
string? objectId = null,
|
||||||
|
IEnumerable<string>? notifyIds = null)
|
||||||
|
where TContract : class;
|
||||||
|
|
||||||
|
IPluginPublicIpcBuilder AddService(
|
||||||
|
Type contractType,
|
||||||
|
object implementation,
|
||||||
|
string? objectId = null,
|
||||||
|
IEnumerable<string>? notifyIds = null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginPublicIpcContributor
|
||||||
|
{
|
||||||
|
void ConfigurePublicIpc(IPluginPublicIpcBuilder builder);
|
||||||
|
}
|
||||||
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,7 +25,10 @@
|
|||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public sealed record PluginManifest(
|
|||||||
string? Author = null,
|
string? Author = null,
|
||||||
string? Version = null,
|
string? Version = null,
|
||||||
string? ApiVersion = null,
|
string? ApiVersion = null,
|
||||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null,
|
||||||
|
PluginRuntimeConfiguration? Runtime = null)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
@@ -56,9 +57,13 @@ public sealed record PluginManifest(
|
|||||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PluginRuntimeMode RuntimeMode =>
|
||||||
|
PluginRuntimeModes.TryParse(Runtime?.Mode, out var mode) ? mode : PluginRuntimeMode.InProcess;
|
||||||
|
|
||||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||||
{
|
{
|
||||||
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||||
|
var normalizedRuntime = (Runtime ?? new PluginRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
|
||||||
var normalized = this with
|
var normalized = this with
|
||||||
{
|
{
|
||||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||||
@@ -68,7 +73,8 @@ public sealed record PluginManifest(
|
|||||||
Author = NormalizeOptionalValue(Author),
|
Author = NormalizeOptionalValue(Author),
|
||||||
Version = NormalizeOptionalValue(Version),
|
Version = NormalizeOptionalValue(Version),
|
||||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||||
SharedContracts = normalizedSharedContracts
|
SharedContracts = normalizedSharedContracts,
|
||||||
|
Runtime = normalizedRuntime
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginPublicIpcServiceDescriptor(
|
||||||
|
Type ContractType,
|
||||||
|
object Implementation,
|
||||||
|
string? ObjectId,
|
||||||
|
string[] NotifyIds);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginPublicIpcServiceRegistration(
|
||||||
|
Type ContractType,
|
||||||
|
string? ObjectId,
|
||||||
|
string[] NotifyIds);
|
||||||
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.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
@@ -112,6 +113,55 @@ public static class PluginServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPluginPublicIpc<TContract, TImplementation>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string? objectId = null,
|
||||||
|
params string[] notifyIds)
|
||||||
|
where TContract : class
|
||||||
|
where TImplementation : class, TContract
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
EnsurePublicIpcContract(typeof(TContract));
|
||||||
|
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||||
|
|
||||||
|
if (!services.Any(descriptor =>
|
||||||
|
descriptor.ServiceType == typeof(PluginPublicIpcServiceRegistration) &&
|
||||||
|
descriptor.ImplementationInstance is PluginPublicIpcServiceRegistration existing &&
|
||||||
|
existing.ContractType == typeof(TContract) &&
|
||||||
|
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
services.AddSingleton(new PluginPublicIpcServiceRegistration(
|
||||||
|
typeof(TContract),
|
||||||
|
objectId,
|
||||||
|
notifyIds ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPluginPublicIpcContributor<TContributor>(this IServiceCollection services)
|
||||||
|
where TContributor : class, IPluginPublicIpcContributor
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
services.AddSingleton<IPluginPublicIpcContributor, TContributor>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsurePublicIpcContract(Type contractType)
|
||||||
|
{
|
||||||
|
if (!contractType.IsInterface)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Public IPC contract '{contractType.FullName}' must be an interface.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||||
where TContract : class
|
where TContract : class
|
||||||
where TImplementation : class, TContract
|
where TImplementation : class, TContract
|
||||||
|
|||||||
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
|
## Includes
|
||||||
|
|
||||||
- `IPlugin`/`PluginBase` entry abstractions
|
- `IPlugin`/`PluginBase` entry abstractions
|
||||||
|
- `IPluginWorker`/`PluginWorkerBase` worker-side entry abstractions for isolated background mode
|
||||||
- `PluginManifest` and shared contract declarations
|
- `PluginManifest` and shared contract declarations
|
||||||
|
- `runtime.mode` manifest support for `in-proc`, `isolated-background`, and `isolated-window`
|
||||||
- desktop component registration extensions
|
- desktop component registration extensions
|
||||||
- plugin runtime context and host service abstractions
|
- plugin runtime context and host service abstractions
|
||||||
- build-transitive packaging targets for `.laapp` output
|
- build-transitive packaging targets for `.laapp` output
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ Update `plugin.json` fields as needed before release:
|
|||||||
- `description`
|
- `description`
|
||||||
- `author`
|
- `author`
|
||||||
- `version`
|
- `version`
|
||||||
|
- `runtime.mode` (`in-proc` by default, `isolated-background` for phase-1 worker mode)
|
||||||
|
|||||||
@@ -6,5 +6,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"apiVersion": "4.0.2",
|
"apiVersion": "4.0.2",
|
||||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||||
"sharedContracts": []
|
"sharedContracts": [],
|
||||||
|
"runtime": {
|
||||||
|
"mode": "in-proc"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
|
public interface IPublicAppInfoService
|
||||||
|
{
|
||||||
|
PublicAppInfoSnapshot GetAppInfo();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
|
public interface IPublicPluginCatalogService
|
||||||
|
{
|
||||||
|
PublicIpcCatalogSnapshot GetCatalog();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
[IpcPublic(IgnoresIpcException = true)]
|
||||||
|
public interface IPublicShellControlService
|
||||||
|
{
|
||||||
|
Task<bool> ActivateMainWindowAsync();
|
||||||
|
|
||||||
|
Task<bool> OpenSettingsAsync(string? pageTag = null);
|
||||||
|
|
||||||
|
Task<bool> RestartAsync();
|
||||||
|
|
||||||
|
Task<bool> ExitAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
|
||||||
|
|
||||||
|
public sealed record PublicIpcServiceRegistration(
|
||||||
|
Type ContractType,
|
||||||
|
Func<IServiceProvider, object> ImplementationFactory,
|
||||||
|
string? ObjectId,
|
||||||
|
string? PluginId,
|
||||||
|
string[] NotifyIds);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC.DependencyInjection;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddLanMountainDesktopIpcHost(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string? pipeName = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddSingleton(provider =>
|
||||||
|
{
|
||||||
|
var host = new PublicIpcHostService(pipeName ?? IpcConstants.DefaultPipeName);
|
||||||
|
foreach (var registration in provider.GetServices<PublicIpcServiceRegistration>())
|
||||||
|
{
|
||||||
|
var implementation = registration.ImplementationFactory(provider);
|
||||||
|
host.RegisterPublicService(
|
||||||
|
registration.ContractType,
|
||||||
|
implementation,
|
||||||
|
registration.ObjectId,
|
||||||
|
registration.PluginId,
|
||||||
|
registration.NotifyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
host.Start();
|
||||||
|
return host;
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<IExternalIpcNotificationPublisher>(provider =>
|
||||||
|
provider.GetRequiredService<PublicIpcHostService>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPublicIpcService<TContract, TImplementation>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string? objectId = null,
|
||||||
|
string? pluginId = null,
|
||||||
|
params string[] notifyIds)
|
||||||
|
where TContract : class
|
||||||
|
where TImplementation : class, TContract
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||||
|
|
||||||
|
if (!services.Any(descriptor =>
|
||||||
|
descriptor.ServiceType == typeof(PublicIpcServiceRegistration) &&
|
||||||
|
descriptor.ImplementationInstance is PublicIpcServiceRegistration existing &&
|
||||||
|
existing.ContractType == typeof(TContract) &&
|
||||||
|
string.Equals(existing.ObjectId, objectId, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
services.AddSingleton(new PublicIpcServiceRegistration(
|
||||||
|
typeof(TContract),
|
||||||
|
provider => provider.GetRequiredService<TContract>(),
|
||||||
|
objectId,
|
||||||
|
pluginId,
|
||||||
|
notifyIds ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||||
|
where TContract : class
|
||||||
|
where TImplementation : class, TContract
|
||||||
|
{
|
||||||
|
var descriptor = services.LastOrDefault(item => item.ServiceType == typeof(TContract));
|
||||||
|
if (descriptor is null)
|
||||||
|
{
|
||||||
|
services.AddSingleton<TContract, TImplementation>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.Lifetime != ServiceLifetime.Singleton)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Public IPC contract '{typeof(TContract).FullName}' must be registered as Singleton.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public interface IExternalIpcNotificationPublisher
|
||||||
|
{
|
||||||
|
Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
|
||||||
|
where TPayload : class;
|
||||||
|
}
|
||||||
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
14
LanMountainDesktop.Shared.IPC/IpcConstants.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public static class IpcConstants
|
||||||
|
{
|
||||||
|
public const string DefaultPipeName = "LanMountainDesktop.IPC.v1.Server";
|
||||||
|
|
||||||
|
public const string ProtocolVersion = "external-ipc-public-api.v1";
|
||||||
|
|
||||||
|
public static class Routes
|
||||||
|
{
|
||||||
|
public const string SessionGetInfo = "lanmountain.session.get-info";
|
||||||
|
public const string CatalogGet = "lanmountain.catalog.get";
|
||||||
|
}
|
||||||
|
}
|
||||||
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
8
LanMountainDesktop.Shared.IPC/IpcRoutedNotifyIds.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public static class IpcRoutedNotifyIds
|
||||||
|
{
|
||||||
|
public const string CatalogChanged = "lanmountain.catalog.changed";
|
||||||
|
public const string LauncherStartupProgress = "lanmountain.launcher.startup-progress";
|
||||||
|
public const string LauncherLoadingState = "lanmountain.launcher.loading-state";
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageId>LanMountainDesktop.Shared.IPC</PackageId>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
<Description>Public IPC abstractions and host/client infrastructure for LanMountainDesktop, backed by dotnetCampus.Ipc.</Description>
|
||||||
|
<PackageTags>LanMountainDesktop;IPC;dotnetCampus.Ipc;Integration</PackageTags>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||||
|
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="dotnetCampus.Ipc" Version="2.0.0-alpha434" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
96
LanMountainDesktop.Shared.IPC/LanMountainDesktopIpcClient.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
|
||||||
|
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
|
||||||
|
using dotnetCampus.Ipc.Pipes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed class LanMountainDesktopIpcClient : IDisposable
|
||||||
|
{
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
public LanMountainDesktopIpcClient(string? clientPipeName = null)
|
||||||
|
{
|
||||||
|
Provider = string.IsNullOrWhiteSpace(clientPipeName)
|
||||||
|
? new IpcProvider()
|
||||||
|
: new IpcProvider(clientPipeName);
|
||||||
|
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IpcProvider Provider { get; }
|
||||||
|
|
||||||
|
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
|
||||||
|
|
||||||
|
public PeerProxy? Peer { get; private set; }
|
||||||
|
|
||||||
|
public bool IsConnected => Peer is not null && Peer.IsConnectedFinished;
|
||||||
|
|
||||||
|
public async Task ConnectAsync(string pipeName = IpcConstants.DefaultPipeName)
|
||||||
|
{
|
||||||
|
EnsureStarted();
|
||||||
|
Peer = await Provider.GetAndConnectToPeerAsync(pipeName).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterNotifyHandler<TPayload>(string notifyId, Action<TPayload> handler)
|
||||||
|
where TPayload : class
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
|
||||||
|
ArgumentNullException.ThrowIfNull(handler);
|
||||||
|
RoutedProvider.AddNotifyHandler(notifyId, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterNotifyHandler<TPayload>(string notifyId, Func<TPayload, Task> handler)
|
||||||
|
where TPayload : class
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
|
||||||
|
ArgumentNullException.ThrowIfNull(handler);
|
||||||
|
RoutedProvider.AddNotifyHandler(notifyId, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TContract CreateProxy<TContract>(string? objectId = null)
|
||||||
|
where TContract : class
|
||||||
|
{
|
||||||
|
var peer = Peer ?? throw new InvalidOperationException("IPC client is not connected.");
|
||||||
|
return Provider.CreateIpcProxy<TContract>(peer, objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PublicIpcCatalogSnapshot?> GetCatalogAsync()
|
||||||
|
{
|
||||||
|
var client = await GetRoutedClientAsync().ConfigureAwait(false);
|
||||||
|
return await client.GetResponseAsync<PublicIpcCatalogSnapshot>(IpcConstants.Routes.CatalogGet)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PublicIpcSessionInfo?> GetSessionInfoAsync()
|
||||||
|
{
|
||||||
|
var client = await GetRoutedClientAsync().ConfigureAwait(false);
|
||||||
|
return await client.GetResponseAsync<PublicIpcSessionInfo>(IpcConstants.Routes.SessionGetInfo)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonIpcDirectRoutedClientProxy> GetRoutedClientAsync()
|
||||||
|
{
|
||||||
|
if (Peer is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("IPC client is not connected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
return new JsonIpcDirectRoutedClientProxy(Peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureStarted()
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RoutedProvider.StartServer();
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Provider.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
9
LanMountainDesktop.Shared.IPC/PublicAppInfoSnapshot.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicAppInfoSnapshot(
|
||||||
|
string ApplicationName,
|
||||||
|
string Version,
|
||||||
|
string Codename,
|
||||||
|
string PipeName,
|
||||||
|
int ProcessId,
|
||||||
|
DateTimeOffset StartedAt);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicIpcCatalogSnapshot(
|
||||||
|
PublicIpcServiceDescriptor[] Services,
|
||||||
|
PublicPluginDescriptor[] Plugins,
|
||||||
|
DateTimeOffset Timestamp);
|
||||||
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
219
LanMountainDesktop.Shared.IPC/PublicIpcHostService.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using dotnetCampus.Ipc.Context;
|
||||||
|
using dotnetCampus.Ipc.CompilerServices.GeneratedProxies;
|
||||||
|
using dotnetCampus.Ipc.IpcRouteds.DirectRouteds;
|
||||||
|
using dotnetCampus.Ipc.Pipes;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed class PublicIpcHostService : IDisposable, IExternalIpcNotificationPublisher
|
||||||
|
{
|
||||||
|
private static readonly MethodInfo CreateIpcJointMethod = typeof(GeneratedIpcFactory)
|
||||||
|
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||||
|
.Single(method =>
|
||||||
|
method.Name == nameof(GeneratedIpcFactory.CreateIpcJoint) &&
|
||||||
|
method.IsGenericMethodDefinition &&
|
||||||
|
method.GetParameters().Length == 3);
|
||||||
|
|
||||||
|
private readonly Dictionary<(Type ContractType, string ObjectId), PublicServiceEntry> _services = new();
|
||||||
|
private readonly ConcurrentDictionary<string, PeerProxy> _connectedPeers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
public PublicIpcHostService(string pipeName = IpcConstants.DefaultPipeName)
|
||||||
|
{
|
||||||
|
PipeName = pipeName;
|
||||||
|
StartedAt = DateTimeOffset.UtcNow;
|
||||||
|
Provider = new IpcProvider(pipeName);
|
||||||
|
RoutedProvider = new JsonIpcDirectRoutedProvider(Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PipeName { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset StartedAt { get; }
|
||||||
|
|
||||||
|
public IpcProvider Provider { get; }
|
||||||
|
|
||||||
|
public JsonIpcDirectRoutedProvider RoutedProvider { get; }
|
||||||
|
|
||||||
|
public Func<IReadOnlyList<PublicPluginDescriptor>> PluginDescriptorProvider { get; set; } =
|
||||||
|
static () => Array.Empty<PublicPluginDescriptor>();
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RoutedProvider.AddRequestHandler(IpcConstants.Routes.SessionGetInfo, () => BuildSessionInfo());
|
||||||
|
RoutedProvider.AddRequestHandler(IpcConstants.Routes.CatalogGet, () => GetCatalogSnapshot());
|
||||||
|
Provider.PeerConnected += OnPeerConnected;
|
||||||
|
RoutedProvider.StartServer();
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterPublicService<TContract>(
|
||||||
|
TContract implementation,
|
||||||
|
string? objectId = null,
|
||||||
|
string? pluginId = null,
|
||||||
|
params string[] notifyIds)
|
||||||
|
where TContract : class
|
||||||
|
{
|
||||||
|
RegisterPublicService(typeof(TContract), implementation, objectId, pluginId, notifyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterPublicService(
|
||||||
|
Type contractType,
|
||||||
|
object implementation,
|
||||||
|
string? objectId = null,
|
||||||
|
string? pluginId = null,
|
||||||
|
IEnumerable<string>? notifyIds = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(contractType);
|
||||||
|
ArgumentNullException.ThrowIfNull(implementation);
|
||||||
|
|
||||||
|
var normalizedObjectId = objectId ?? string.Empty;
|
||||||
|
var normalizedNotifyIds = notifyIds?
|
||||||
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray() ?? [];
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_services.ContainsKey((contractType, normalizedObjectId)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}' is already registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateIpcJointMethod
|
||||||
|
.MakeGenericMethod(contractType)
|
||||||
|
.Invoke(null, [Provider, implementation, string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId]);
|
||||||
|
|
||||||
|
_services[(contractType, normalizedObjectId)] = new PublicServiceEntry(
|
||||||
|
contractType,
|
||||||
|
implementation,
|
||||||
|
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
|
||||||
|
pluginId,
|
||||||
|
normalizedNotifyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
_ = NotifyCatalogChangedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicIpcCatalogSnapshot GetCatalogSnapshot()
|
||||||
|
{
|
||||||
|
PublicIpcServiceDescriptor[] services;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
services = _services.Values
|
||||||
|
.Select(entry => new PublicIpcServiceDescriptor(
|
||||||
|
entry.ContractType.FullName ?? entry.ContractType.Name,
|
||||||
|
entry.ContractType.Assembly.GetName().Name ?? string.Empty,
|
||||||
|
entry.ContractType.AssemblyQualifiedName,
|
||||||
|
entry.ObjectId,
|
||||||
|
entry.PluginId,
|
||||||
|
string.IsNullOrWhiteSpace(entry.PluginId),
|
||||||
|
entry.NotifyIds))
|
||||||
|
.OrderBy(entry => entry.PluginId ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(entry => entry.ContractTypeName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var plugins = PluginDescriptorProvider()?.ToArray() ?? Array.Empty<PublicPluginDescriptor>();
|
||||||
|
return new PublicIpcCatalogSnapshot(services, plugins, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PublishStartupProgressAsync(
|
||||||
|
LanMountainDesktop.Shared.Contracts.Launcher.StartupProgressMessage message,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
return NotifyAsync(IpcRoutedNotifyIds.LauncherStartupProgress, message, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PublishLoadingStateAsync(
|
||||||
|
LanMountainDesktop.Shared.Contracts.Launcher.LoadingStateMessage message,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
return NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, message, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyAsync<TPayload>(string notifyId, TPayload payload, CancellationToken cancellationToken = default)
|
||||||
|
where TPayload : class
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(notifyId);
|
||||||
|
ArgumentNullException.ThrowIfNull(payload);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
foreach (var peer in _connectedPeers.Values)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new JsonIpcDirectRoutedClientProxy(peer);
|
||||||
|
await client.NotifyAsync(notifyId, payload).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep notification fan-out best-effort. Broken peers are cleaned by dotnetCampus.Ipc.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task NotifyCatalogChangedAsync()
|
||||||
|
{
|
||||||
|
return NotifyAsync(IpcRoutedNotifyIds.CatalogChanged, GetCatalogSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PublicIpcSessionInfo BuildSessionInfo()
|
||||||
|
{
|
||||||
|
return new PublicIpcSessionInfo(
|
||||||
|
PipeName,
|
||||||
|
IpcConstants.ProtocolVersion,
|
||||||
|
[
|
||||||
|
IpcConstants.Routes.SessionGetInfo,
|
||||||
|
IpcConstants.Routes.CatalogGet,
|
||||||
|
IpcRoutedNotifyIds.CatalogChanged,
|
||||||
|
IpcRoutedNotifyIds.LauncherStartupProgress,
|
||||||
|
IpcRoutedNotifyIds.LauncherLoadingState
|
||||||
|
],
|
||||||
|
StartedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Provider.PeerConnected -= OnPeerConnected;
|
||||||
|
Provider.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(object? sender, PeerConnectedArgs e)
|
||||||
|
{
|
||||||
|
var peer = e.Peer;
|
||||||
|
_connectedPeers[peer.PeerName] = peer;
|
||||||
|
peer.PeerConnectionBroken -= OnPeerConnectionBroken;
|
||||||
|
peer.PeerConnectionBroken += OnPeerConnectionBroken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnectionBroken(object? sender, IPeerConnectionBrokenArgs e)
|
||||||
|
{
|
||||||
|
if (sender is PeerProxy peer)
|
||||||
|
{
|
||||||
|
_connectedPeers.TryRemove(peer.PeerName, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PublicServiceEntry(
|
||||||
|
Type ContractType,
|
||||||
|
object Implementation,
|
||||||
|
string? ObjectId,
|
||||||
|
string? PluginId,
|
||||||
|
string[] NotifyIds);
|
||||||
|
}
|
||||||
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
10
LanMountainDesktop.Shared.IPC/PublicIpcServiceDescriptor.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicIpcServiceDescriptor(
|
||||||
|
string ContractTypeName,
|
||||||
|
string ContractAssemblyName,
|
||||||
|
string? ContractAssemblyQualifiedName,
|
||||||
|
string? ObjectId,
|
||||||
|
string? PluginId,
|
||||||
|
bool IsBuiltIn,
|
||||||
|
string[] NotifyIds);
|
||||||
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
7
LanMountainDesktop.Shared.IPC/PublicIpcSessionInfo.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicIpcSessionInfo(
|
||||||
|
string PipeName,
|
||||||
|
string ProtocolVersion,
|
||||||
|
string[] Capabilities,
|
||||||
|
DateTimeOffset StartedAt);
|
||||||
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
8
LanMountainDesktop.Shared.IPC/PublicPluginDescriptor.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
|
public sealed record PublicPluginDescriptor(
|
||||||
|
string PluginId,
|
||||||
|
string DisplayName,
|
||||||
|
string? Version,
|
||||||
|
bool IsLoaded,
|
||||||
|
bool IsEnabled);
|
||||||
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
3
LanMountainDesktop.Shared.IPC/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# LanMountainDesktop.Shared.IPC
|
||||||
|
|
||||||
|
Public IPC abstractions and host/client helpers for LanMountainDesktop.
|
||||||
120
LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
Normal file
120
LanMountainDesktop.Tests/ExternalIpcPublicApiTests.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ExternalIpcPublicApiTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PublicIpcHost_ExposesStrongTypedServiceAndCatalog()
|
||||||
|
{
|
||||||
|
var pipeName = "LanMountainDesktop.Test." + Guid.NewGuid().ToString("N");
|
||||||
|
using var host = new PublicIpcHostService(pipeName);
|
||||||
|
host.PluginDescriptorProvider = () =>
|
||||||
|
[
|
||||||
|
new PublicPluginDescriptor("sample.plugin", "Sample Plugin", "1.0.0", true, true)
|
||||||
|
];
|
||||||
|
|
||||||
|
var appInfo = new PublicAppInfoSnapshot(
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"1.2.3",
|
||||||
|
"Administrate",
|
||||||
|
pipeName,
|
||||||
|
42,
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
host.RegisterPublicService<IPublicAppInfoService>(new TestPublicAppInfoService(appInfo));
|
||||||
|
host.Start();
|
||||||
|
|
||||||
|
using var client = new LanMountainDesktopIpcClient();
|
||||||
|
var catalogChanged = new TaskCompletionSource<PublicIpcCatalogSnapshot>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
client.RegisterNotifyHandler<PublicIpcCatalogSnapshot>(IpcRoutedNotifyIds.CatalogChanged, snapshot =>
|
||||||
|
{
|
||||||
|
catalogChanged.TrySetResult(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.ConnectAsync(pipeName);
|
||||||
|
|
||||||
|
var proxy = client.CreateProxy<IPublicAppInfoService>();
|
||||||
|
var remoteInfo = proxy.GetAppInfo();
|
||||||
|
Assert.Equal(appInfo.ApplicationName, remoteInfo.ApplicationName);
|
||||||
|
Assert.Equal(appInfo.Version, remoteInfo.Version);
|
||||||
|
Assert.Equal(appInfo.Codename, remoteInfo.Codename);
|
||||||
|
|
||||||
|
var initialCatalog = await client.GetCatalogAsync();
|
||||||
|
Assert.NotNull(initialCatalog);
|
||||||
|
Assert.Contains(initialCatalog!.Services, service => service.ContractTypeName == typeof(IPublicAppInfoService).FullName);
|
||||||
|
Assert.Contains(initialCatalog.Plugins, plugin => plugin.PluginId == "sample.plugin");
|
||||||
|
|
||||||
|
host.RegisterPublicService<IPublicPluginCatalogService>(new TestPublicPluginCatalogService(initialCatalog));
|
||||||
|
var updatedCatalog = await catalogChanged.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Contains(updatedCatalog.Services, service => service.ContractTypeName == typeof(IPublicPluginCatalogService).FullName);
|
||||||
|
|
||||||
|
var sessionInfo = await client.GetSessionInfoAsync();
|
||||||
|
Assert.NotNull(sessionInfo);
|
||||||
|
Assert.Equal(pipeName, sessionInfo!.PipeName);
|
||||||
|
Assert.Equal(IpcConstants.ProtocolVersion, sessionInfo.ProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddPluginPublicIpc_RegistersServiceDescriptor()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddPluginPublicIpc<ITestPluginPublicService, TestPluginPublicService>(
|
||||||
|
objectId: "plugin-service",
|
||||||
|
notifyIds: ["lanmountain.plugin.sample.updated"]);
|
||||||
|
|
||||||
|
using var provider = services.BuildServiceProvider();
|
||||||
|
var registration = Assert.Single(provider.GetServices<PluginPublicIpcServiceRegistration>());
|
||||||
|
Assert.Equal(typeof(ITestPluginPublicService), registration.ContractType);
|
||||||
|
Assert.Equal("plugin-service", registration.ObjectId);
|
||||||
|
Assert.Contains("lanmountain.plugin.sample.updated", registration.NotifyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestPublicAppInfoService : IPublicAppInfoService
|
||||||
|
{
|
||||||
|
private readonly PublicAppInfoSnapshot _snapshot;
|
||||||
|
|
||||||
|
public TestPublicAppInfoService(PublicAppInfoSnapshot snapshot)
|
||||||
|
{
|
||||||
|
_snapshot = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicAppInfoSnapshot GetAppInfo()
|
||||||
|
{
|
||||||
|
return _snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestPublicPluginCatalogService : IPublicPluginCatalogService
|
||||||
|
{
|
||||||
|
private readonly PublicIpcCatalogSnapshot _snapshot;
|
||||||
|
|
||||||
|
public TestPublicPluginCatalogService(PublicIpcCatalogSnapshot snapshot)
|
||||||
|
{
|
||||||
|
_snapshot = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicIpcCatalogSnapshot GetCatalog()
|
||||||
|
{
|
||||||
|
return _snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[dotnetCampus.Ipc.CompilerServices.Attributes.IpcPublic]
|
||||||
|
public interface ITestPluginPublicService
|
||||||
|
{
|
||||||
|
string Ping();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TestPluginPublicService : ITestPluginPublicService
|
||||||
|
{
|
||||||
|
public string Ping()
|
||||||
|
{
|
||||||
|
return "pong";
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
|
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
|
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.Shared.IPC/LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
|
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
|
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
|
||||||
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ using LanMountainDesktop.DesktopHost;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.ExternalIpc;
|
||||||
using LanMountainDesktop.Services.Launcher;
|
using LanMountainDesktop.Services.Launcher;
|
||||||
using LanMountainDesktop.Services.Loading;
|
using LanMountainDesktop.Services.Loading;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views;
|
using LanMountainDesktop.Views;
|
||||||
@@ -55,6 +58,7 @@ public partial class App : Application
|
|||||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
|
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
|
||||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||||
private ISettingsWindowService? _settingsWindowService;
|
private ISettingsWindowService? _settingsWindowService;
|
||||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||||
@@ -75,7 +79,7 @@ public partial class App : Application
|
|||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private bool _uiUnhandledExceptionHooked;
|
private bool _uiUnhandledExceptionHooked;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
private LauncherIpcClient? _launcherIpcClient;
|
private PublicIpcHostService? _publicIpcHostService;
|
||||||
private LoadingStateManager? _loadingStateManager;
|
private LoadingStateManager? _loadingStateManager;
|
||||||
private LoadingStateReporter? _loadingStateReporter;
|
private LoadingStateReporter? _loadingStateReporter;
|
||||||
private bool _singleInstanceReleased;
|
private bool _singleInstanceReleased;
|
||||||
@@ -160,6 +164,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
RegisterUiUnhandledExceptionGuard();
|
RegisterUiUnhandledExceptionGuard();
|
||||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||||
|
InitializePublicIpc();
|
||||||
_ = InitializeLauncherIpcAsync();
|
_ = InitializeLauncherIpcAsync();
|
||||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||||
|
|
||||||
@@ -173,34 +178,24 @@ public partial class App : Application
|
|||||||
|
|
||||||
private async Task InitializeLauncherIpcAsync()
|
private async Task InitializeLauncherIpcAsync()
|
||||||
{
|
{
|
||||||
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
if (_loadingStateManager is not null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_launcherIpcClient = new LauncherIpcClient();
|
|
||||||
var connected = await _launcherIpcClient.ConnectAsync();
|
|
||||||
if (!connected)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
|
||||||
|
|
||||||
bool hadBufferedMessages;
|
bool hadBufferedMessages;
|
||||||
lock (_launcherProgressLock)
|
lock (_launcherProgressLock)
|
||||||
{
|
{
|
||||||
hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
|
hadBufferedMessages = _pendingLauncherProgressMessages.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await FlushPendingLauncherProgressAsync();
|
|
||||||
|
|
||||||
_loadingStateManager = new LoadingStateManager();
|
_loadingStateManager = new LoadingStateManager();
|
||||||
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _launcherIpcClient);
|
_loadingStateReporter = new LoadingStateReporter(_loadingStateManager, _publicIpcHostService);
|
||||||
_loadingStateReporter.Start();
|
_loadingStateReporter.Start();
|
||||||
|
|
||||||
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
|
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "System Initialization", "Initialize core application services.");
|
||||||
_loadingStateManager.StartItem("system.init", "Launcher IPC connected.");
|
_loadingStateManager.StartItem("system.init", "Public IPC host ready.");
|
||||||
|
await FlushPendingLauncherProgressAsync();
|
||||||
|
|
||||||
if (!hadBufferedMessages)
|
if (!hadBufferedMessages)
|
||||||
{
|
{
|
||||||
@@ -238,8 +233,8 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
|
private void QueueOrSendLauncherProgress(StartupProgressMessage message, bool logSuccess)
|
||||||
{
|
{
|
||||||
var ipcClient = _launcherIpcClient;
|
var publicIpcHostService = _publicIpcHostService;
|
||||||
if (ipcClient is null || !ipcClient.IsConnected)
|
if (publicIpcHostService is null)
|
||||||
{
|
{
|
||||||
lock (_launcherProgressLock)
|
lock (_launcherProgressLock)
|
||||||
{
|
{
|
||||||
@@ -250,13 +245,13 @@ public partial class App : Application
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = SendLauncherProgressAsync(ipcClient, message, logSuccess);
|
_ = SendLauncherProgressAsync(publicIpcHostService, message, logSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task FlushPendingLauncherProgressAsync()
|
private async Task FlushPendingLauncherProgressAsync()
|
||||||
{
|
{
|
||||||
var ipcClient = _launcherIpcClient;
|
var publicIpcHostService = _publicIpcHostService;
|
||||||
if (ipcClient is null || !ipcClient.IsConnected)
|
if (publicIpcHostService is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -270,15 +265,15 @@ public partial class App : Application
|
|||||||
|
|
||||||
foreach (var pendingMessage in pendingMessages)
|
foreach (var pendingMessage in pendingMessages)
|
||||||
{
|
{
|
||||||
await SendLauncherProgressAsync(ipcClient, pendingMessage, logSuccess: false);
|
await SendLauncherProgressAsync(publicIpcHostService, pendingMessage, logSuccess: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendLauncherProgressAsync(LauncherIpcClient ipcClient, StartupProgressMessage message, bool logSuccess)
|
private async Task SendLauncherProgressAsync(PublicIpcHostService publicIpcHostService, StartupProgressMessage message, bool logSuccess)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ipcClient.ReportProgressAsync(message);
|
await publicIpcHostService.PublishStartupProgressAsync(message);
|
||||||
if (logSuccess)
|
if (logSuccess)
|
||||||
{
|
{
|
||||||
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
|
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {message.Stage}");
|
||||||
@@ -463,7 +458,7 @@ public partial class App : Application
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_pluginRuntimeService?.Dispose();
|
_pluginRuntimeService?.Dispose();
|
||||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
|
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade, _publicIpcHostService);
|
||||||
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
||||||
_pluginRuntimeService.LoadInstalledPlugins();
|
_pluginRuntimeService.LoadInstalledPlugins();
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1038,19 @@ public partial class App : Application
|
|||||||
_pluginRuntimeService = null;
|
_pluginRuntimeService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_publicIpcHostService?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PublicIpc", "Failed to dispose public IPC host during shutdown.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_publicIpcHostService = null;
|
||||||
|
}
|
||||||
|
|
||||||
_settingsWindowService?.Close();
|
_settingsWindowService?.Close();
|
||||||
if (_settingsPageRegistry is IDisposable disposableRegistry)
|
if (_settingsPageRegistry is IDisposable disposableRegistry)
|
||||||
{
|
{
|
||||||
@@ -1336,6 +1344,56 @@ public partial class App : Application
|
|||||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
return _localizationService.GetString(languageCode, key, fallback);
|
return _localizationService.GetString(languageCode, key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool TryActivateMainWindowFromExternalIpc(string source)
|
||||||
|
{
|
||||||
|
return RestoreOrCreateMainWindowCore(showSingleInstanceNotice: false, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializePublicIpc()
|
||||||
|
{
|
||||||
|
if (_publicIpcHostService is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||||
|
_publicIpcHostService = new PublicIpcHostService();
|
||||||
|
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
|
||||||
|
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
|
||||||
|
new PublicAppInfoService(version, "Administrate", _startupAt));
|
||||||
|
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
|
||||||
|
new PublicShellControlService());
|
||||||
|
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
|
||||||
|
new PublicPluginCatalogService(_publicIpcHostService));
|
||||||
|
_publicIpcHostService.Start();
|
||||||
|
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PublicIpc", "Failed to initialize public IPC host.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<PublicPluginDescriptor> BuildPublicPluginDescriptors()
|
||||||
|
{
|
||||||
|
var runtime = _pluginRuntimeService;
|
||||||
|
if (runtime is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<PublicPluginDescriptor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return runtime.Catalog
|
||||||
|
.Select(entry => new PublicPluginDescriptor(
|
||||||
|
entry.Manifest.Id,
|
||||||
|
entry.Manifest.Name,
|
||||||
|
entry.Manifest.Version,
|
||||||
|
entry.IsLoaded,
|
||||||
|
entry.IsEnabled))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||||
|
|
||||||
|
internal sealed class PublicAppInfoService : IPublicAppInfoService
|
||||||
|
{
|
||||||
|
private readonly string _version;
|
||||||
|
private readonly string _codename;
|
||||||
|
private readonly DateTimeOffset _startedAt;
|
||||||
|
|
||||||
|
public PublicAppInfoService(string version, string codename, DateTimeOffset startedAt)
|
||||||
|
{
|
||||||
|
_version = version;
|
||||||
|
_codename = codename;
|
||||||
|
_startedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicAppInfoSnapshot GetAppInfo()
|
||||||
|
{
|
||||||
|
return new PublicAppInfoSnapshot(
|
||||||
|
"LanMountainDesktop",
|
||||||
|
_version,
|
||||||
|
_codename,
|
||||||
|
IpcConstants.DefaultPipeName,
|
||||||
|
Environment.ProcessId,
|
||||||
|
_startedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||||
|
|
||||||
|
internal sealed class PublicPluginCatalogService : IPublicPluginCatalogService
|
||||||
|
{
|
||||||
|
private readonly PublicIpcHostService _publicIpcHostService;
|
||||||
|
|
||||||
|
public PublicPluginCatalogService(PublicIpcHostService publicIpcHostService)
|
||||||
|
{
|
||||||
|
_publicIpcHostService = publicIpcHostService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PublicIpcCatalogSnapshot GetCatalog()
|
||||||
|
{
|
||||||
|
return _publicIpcHostService.GetCatalogSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.ExternalIpc;
|
||||||
|
|
||||||
|
internal sealed class PublicShellControlService : IPublicShellControlService
|
||||||
|
{
|
||||||
|
public Task<bool> ActivateMainWindowAsync()
|
||||||
|
{
|
||||||
|
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
return (Application.Current as App)?.TryActivateMainWindowFromExternalIpc("PublicIpc") == true;
|
||||||
|
}).GetTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> OpenSettingsAsync(string? pageTag = null)
|
||||||
|
{
|
||||||
|
return Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (Application.Current is not App app)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.OpenIndependentSettingsModule("PublicIpc", pageTag);
|
||||||
|
return true;
|
||||||
|
}).GetTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> RestartAsync()
|
||||||
|
{
|
||||||
|
var lifecycle = App.CurrentHostApplicationLifecycle;
|
||||||
|
return Task.FromResult(lifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
|
Source: "PublicIpc",
|
||||||
|
Reason: "External IPC requested restart.")) == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> ExitAsync()
|
||||||
|
{
|
||||||
|
var lifecycle = App.CurrentHostApplicationLifecycle;
|
||||||
|
return Task.FromResult(lifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||||
|
Source: "PublicIpc",
|
||||||
|
Reason: "External IPC requested exit.")) == true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Timers;
|
using System.Timers;
|
||||||
using LanMountainDesktop.Services.Launcher;
|
|
||||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Services.Loading;
|
namespace LanMountainDesktop.Services.Loading;
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ namespace LanMountainDesktop.Services.Loading;
|
|||||||
public class LoadingStateReporter : IDisposable
|
public class LoadingStateReporter : IDisposable
|
||||||
{
|
{
|
||||||
private readonly LoadingStateManager _manager;
|
private readonly LoadingStateManager _manager;
|
||||||
private readonly LauncherIpcClient? _ipcClient;
|
private readonly IExternalIpcNotificationPublisher? _notificationPublisher;
|
||||||
private readonly System.Timers.Timer _reportTimer;
|
private readonly System.Timers.Timer _reportTimer;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private bool _isDisposed;
|
private bool _isDisposed;
|
||||||
@@ -36,10 +36,10 @@ public class LoadingStateReporter : IDisposable
|
|||||||
|
|
||||||
public LoadingStateReporter(
|
public LoadingStateReporter(
|
||||||
LoadingStateManager manager,
|
LoadingStateManager manager,
|
||||||
LauncherIpcClient? ipcClient = null)
|
IExternalIpcNotificationPublisher? notificationPublisher = null)
|
||||||
{
|
{
|
||||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
_ipcClient = ipcClient;
|
_notificationPublisher = notificationPublisher;
|
||||||
|
|
||||||
// 创建定时上报定时器
|
// 创建定时上报定时器
|
||||||
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
|
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
|
||||||
@@ -80,7 +80,7 @@ public class LoadingStateReporter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ReportImmediatelyAsync()
|
public async Task ReportImmediatelyAsync()
|
||||||
{
|
{
|
||||||
if (_isDisposed || _ipcClient == null) return;
|
if (_isDisposed || _notificationPublisher == null) return;
|
||||||
|
|
||||||
var message = CreateDetailedProgressMessage();
|
var message = CreateDetailedProgressMessage();
|
||||||
await SendMessageAsync(message);
|
await SendMessageAsync(message);
|
||||||
@@ -91,7 +91,7 @@ public class LoadingStateReporter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
|
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
|
||||||
{
|
{
|
||||||
if (_isDisposed || _ipcClient == null) return;
|
if (_isDisposed || _notificationPublisher == null) return;
|
||||||
|
|
||||||
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
|
||||||
if (item == null) return;
|
if (item == null) return;
|
||||||
@@ -121,7 +121,7 @@ public class LoadingStateReporter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
|
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
|
||||||
{
|
{
|
||||||
if (_isDisposed || _ipcClient == null) return;
|
if (_isDisposed || _notificationPublisher == null) return;
|
||||||
|
|
||||||
var progressMessage = new DetailedProgressMessage
|
var progressMessage = new DetailedProgressMessage
|
||||||
{
|
{
|
||||||
@@ -140,7 +140,7 @@ public class LoadingStateReporter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ReportErrorAsync(string errorMessage, string? details = null)
|
public async Task ReportErrorAsync(string errorMessage, string? details = null)
|
||||||
{
|
{
|
||||||
if (_isDisposed || _ipcClient == null) return;
|
if (_isDisposed || _notificationPublisher == null) return;
|
||||||
|
|
||||||
var fullMessage = string.IsNullOrEmpty(details)
|
var fullMessage = string.IsNullOrEmpty(details)
|
||||||
? errorMessage
|
? errorMessage
|
||||||
@@ -280,7 +280,7 @@ public class LoadingStateReporter : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SendMessageAsync(DetailedProgressMessage message)
|
private async Task SendMessageAsync(DetailedProgressMessage message)
|
||||||
{
|
{
|
||||||
if (_ipcClient == null) return;
|
if (_notificationPublisher == null) return;
|
||||||
|
|
||||||
// 检查最小上报间隔
|
// 检查最小上报间隔
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
@@ -293,15 +293,15 @@ public class LoadingStateReporter : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 转换为 StartupProgressMessage 以保持兼容性
|
// 转换为 StartupProgressMessage 以保持兼容性
|
||||||
var baseMessage = new StartupProgressMessage
|
var loadingStateMessage = _manager.GetLoadingStateMessage() with
|
||||||
{
|
{
|
||||||
Stage = message.Stage,
|
Stage = message.Stage,
|
||||||
ProgressPercent = message.ProgressPercent,
|
OverallProgressPercent = message.ProgressPercent,
|
||||||
Message = FormatMessage(message),
|
Message = FormatMessage(message),
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
await _ipcClient.ReportProgressAsync(baseMessage);
|
await _notificationPublisher.NotifyAsync(IpcRoutedNotifyIds.LauncherLoadingState, loadingStateMessage);
|
||||||
_lastReportTime = DateTimeOffset.UtcNow;
|
_lastReportTime = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
|||||||
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
IReadOnlyList<PluginDesktopComponentRegistration> desktopComponents,
|
||||||
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
|
IReadOnlyList<PluginDesktopComponentEditorRegistration> desktopComponentEditors,
|
||||||
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
|
IReadOnlyList<PluginServiceExportDescriptor> exportedServices,
|
||||||
|
IReadOnlyList<PluginPublicIpcServiceDescriptor> publicIpcServices,
|
||||||
IReadOnlyList<IHostedService> hostedServices,
|
IReadOnlyList<IHostedService> hostedServices,
|
||||||
PluginLoadContext loadContext)
|
PluginLoadContext loadContext)
|
||||||
{
|
{
|
||||||
@@ -39,6 +40,7 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
|||||||
DesktopComponents = desktopComponents;
|
DesktopComponents = desktopComponents;
|
||||||
DesktopComponentEditors = desktopComponentEditors;
|
DesktopComponentEditors = desktopComponentEditors;
|
||||||
ExportedServices = exportedServices;
|
ExportedServices = exportedServices;
|
||||||
|
PublicIpcServices = publicIpcServices;
|
||||||
HostedServices = hostedServices;
|
HostedServices = hostedServices;
|
||||||
LoadContext = loadContext;
|
LoadContext = loadContext;
|
||||||
}
|
}
|
||||||
@@ -67,6 +69,8 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
|||||||
|
|
||||||
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
|
public IReadOnlyList<PluginServiceExportDescriptor> ExportedServices { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<PluginPublicIpcServiceDescriptor> PublicIpcServices { get; }
|
||||||
|
|
||||||
public PluginLoadContext LoadContext { get; }
|
public PluginLoadContext LoadContext { get; }
|
||||||
|
|
||||||
private IReadOnlyList<IHostedService> HostedServices { get; }
|
private IReadOnlyList<IHostedService> HostedServices { get; }
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ using System.Threading.Tasks;
|
|||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Plugins;
|
namespace LanMountainDesktop.Plugins;
|
||||||
|
|
||||||
@@ -187,9 +189,10 @@ public sealed class PluginLoader
|
|||||||
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(editor => editor.ComponentId, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var exportedServices = ResolveExports(manifest, pluginServices);
|
var exportedServices = ResolveExports(manifest, pluginServices);
|
||||||
|
var publicIpcServices = ResolvePublicIpcServices(manifest, pluginServices);
|
||||||
AppLogger.Info(
|
AppLogger.Info(
|
||||||
"PluginLoader",
|
"PluginLoader",
|
||||||
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}.");
|
$"Plugin contributions resolved. PluginId='{manifest.Id}'; SettingsSections={settingsSections.Length}; Widgets={desktopComponents.Length}; Editors={desktopComponentEditors.Length}; Exports={exportedServices.Count}; PublicIpcServices={publicIpcServices.Count}.");
|
||||||
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
|
hostedServices = pluginServices.GetServices<IHostedService>().ToArray();
|
||||||
StartHostedServices(hostedServices);
|
StartHostedServices(hostedServices);
|
||||||
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
|
AppLogger.Info("PluginLoader", $"Hosted services started. PluginId='{manifest.Id}'; HostedServices={hostedServices.Count}.");
|
||||||
@@ -206,6 +209,7 @@ public sealed class PluginLoader
|
|||||||
desktopComponents,
|
desktopComponents,
|
||||||
desktopComponentEditors,
|
desktopComponentEditors,
|
||||||
exportedServices,
|
exportedServices,
|
||||||
|
publicIpcServices,
|
||||||
hostedServices,
|
hostedServices,
|
||||||
loadContext);
|
loadContext);
|
||||||
|
|
||||||
@@ -332,6 +336,7 @@ public sealed class PluginLoader
|
|||||||
RegisterHostService<ISettingsService>(services, hostServices);
|
RegisterHostService<ISettingsService>(services, hostServices);
|
||||||
RegisterHostService<ISettingsCatalog>(services, hostServices);
|
RegisterHostService<ISettingsCatalog>(services, hostServices);
|
||||||
RegisterHostService<IAppearanceThemeService>(services, hostServices);
|
RegisterHostService<IAppearanceThemeService>(services, hostServices);
|
||||||
|
RegisterHostService<IExternalIpcNotificationPublisher>(services, hostServices);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
@@ -413,6 +418,68 @@ public sealed class PluginLoader
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginPublicIpcServiceDescriptor> ResolvePublicIpcServices(
|
||||||
|
PluginManifest manifest,
|
||||||
|
IServiceProvider services)
|
||||||
|
{
|
||||||
|
var descriptors = new List<PluginPublicIpcServiceDescriptor>();
|
||||||
|
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var registration in services.GetServices<PluginPublicIpcServiceRegistration>())
|
||||||
|
{
|
||||||
|
var implementation = services.GetService(registration.ContractType)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Plugin '{manifest.Id}' registered public IPC contract '{registration.ContractType.FullName}', but no singleton service instance was found.");
|
||||||
|
|
||||||
|
AddDescriptor(registration.ContractType, implementation, registration.ObjectId, registration.NotifyIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new RuntimePluginPublicIpcBuilder(services, AddDescriptor);
|
||||||
|
foreach (var contributor in services.GetServices<IPluginPublicIpcContributor>())
|
||||||
|
{
|
||||||
|
contributor.ConfigurePublicIpc(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptors;
|
||||||
|
|
||||||
|
void AddDescriptor(Type contractType, object implementation, string? objectId, IEnumerable<string>? notifyIds)
|
||||||
|
{
|
||||||
|
EnsurePublicIpcContract(manifest, contractType);
|
||||||
|
|
||||||
|
var normalizedObjectId = objectId ?? string.Empty;
|
||||||
|
var dedupeKey = $"{contractType.AssemblyQualifiedName}::{normalizedObjectId}";
|
||||||
|
if (!seenKeys.Add(dedupeKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{manifest.Id}' registered duplicate public IPC contract '{contractType.FullName}' with object id '{normalizedObjectId}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptors.Add(new PluginPublicIpcServiceDescriptor(
|
||||||
|
contractType,
|
||||||
|
implementation,
|
||||||
|
string.IsNullOrEmpty(normalizedObjectId) ? null : normalizedObjectId,
|
||||||
|
notifyIds?
|
||||||
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray() ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsurePublicIpcContract(PluginManifest manifest, Type contractType)
|
||||||
|
{
|
||||||
|
if (!contractType.IsInterface)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{manifest.Id}' public IPC contract '{contractType.FullName}' must be an interface.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Attribute.IsDefined(contractType, typeof(IpcPublicAttribute), inherit: false))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin '{manifest.Id}' public IPC contract '{contractType.FullName}' must be marked with '{nameof(IpcPublicAttribute)}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsSupportedExportContract(PluginManifest manifest, Type contractType)
|
private static bool IsSupportedExportContract(PluginManifest manifest, Type contractType)
|
||||||
{
|
{
|
||||||
if (contractType.Assembly == typeof(IPlugin).Assembly)
|
if (contractType.Assembly == typeof(IPlugin).Assembly)
|
||||||
@@ -1074,4 +1141,42 @@ public sealed class PluginLoader
|
|||||||
string SourcePath,
|
string SourcePath,
|
||||||
PluginManifest Manifest,
|
PluginManifest Manifest,
|
||||||
PluginSourceKind SourceKind);
|
PluginSourceKind SourceKind);
|
||||||
|
|
||||||
|
private sealed class RuntimePluginPublicIpcBuilder : IPluginPublicIpcBuilder
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly Action<Type, object, string?, IEnumerable<string>?> _register;
|
||||||
|
|
||||||
|
public RuntimePluginPublicIpcBuilder(
|
||||||
|
IServiceProvider services,
|
||||||
|
Action<Type, object, string?, IEnumerable<string>?> register)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_register = register;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IPluginPublicIpcBuilder AddService<TContract>(
|
||||||
|
string? objectId = null,
|
||||||
|
IEnumerable<string>? notifyIds = null)
|
||||||
|
where TContract : class
|
||||||
|
{
|
||||||
|
var implementation = _services.GetService(typeof(TContract))
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Plugin public IPC contributor requested contract '{typeof(TContract).FullName}', but no singleton service was registered.");
|
||||||
|
_register(typeof(TContract), implementation, objectId, notifyIds);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IPluginPublicIpcBuilder AddService(
|
||||||
|
Type contractType,
|
||||||
|
object implementation,
|
||||||
|
string? objectId = null,
|
||||||
|
IEnumerable<string>? notifyIds = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(contractType);
|
||||||
|
ArgumentNullException.ThrowIfNull(implementation);
|
||||||
|
_register(contractType, implementation, objectId, notifyIds);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using LanMountainDesktop.Models;
|
|||||||
using LanMountainDesktop.Plugins;
|
using LanMountainDesktop.Plugins;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Shared.IPC;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
private readonly IPluginPackageManager _packageManager;
|
private readonly IPluginPackageManager _packageManager;
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
private readonly SettingsCatalogService _settingsCatalogService;
|
private readonly SettingsCatalogService _settingsCatalogService;
|
||||||
|
private readonly PublicIpcHostService? _publicIpcHostService;
|
||||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||||
private readonly List<PluginLoadResult> _loadResults = [];
|
private readonly List<PluginLoadResult> _loadResults = [];
|
||||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||||
@@ -39,13 +41,16 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
|
private readonly List<PluginDesktopComponentEditorContribution> _desktopComponentEditors = [];
|
||||||
private readonly object _packageMutationGate = new();
|
private readonly object _packageMutationGate = new();
|
||||||
|
|
||||||
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
|
public PluginRuntimeService(
|
||||||
|
ISettingsFacadeService? settingsFacade = null,
|
||||||
|
PublicIpcHostService? publicIpcHostService = null)
|
||||||
{
|
{
|
||||||
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
|
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
|
||||||
_sharedContractManager = new PluginSharedContractManager(
|
_sharedContractManager = new PluginSharedContractManager(
|
||||||
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
||||||
_packageManager = new PluginRuntimePackageManager(this);
|
_packageManager = new PluginRuntimePackageManager(this);
|
||||||
_settingsFacade = settingsFacade ?? new SettingsFacadeService();
|
_settingsFacade = settingsFacade ?? new SettingsFacadeService();
|
||||||
|
_publicIpcHostService = publicIpcHostService;
|
||||||
_settingsCatalogService = _settingsFacade.Catalog as SettingsCatalogService
|
_settingsCatalogService = _settingsFacade.Catalog as SettingsCatalogService
|
||||||
?? new SettingsCatalogService();
|
?? new SettingsCatalogService();
|
||||||
if (_settingsFacade is SettingsFacadeService concreteFacade)
|
if (_settingsFacade is SettingsFacadeService concreteFacade)
|
||||||
@@ -58,7 +63,8 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
_exportRegistry,
|
_exportRegistry,
|
||||||
_settingsFacade,
|
_settingsFacade,
|
||||||
_settingsFacade.Settings,
|
_settingsFacade.Settings,
|
||||||
_settingsFacade.Catalog);
|
_settingsFacade.Catalog,
|
||||||
|
_publicIpcHostService);
|
||||||
_loaderOptions = CreateOptions();
|
_loaderOptions = CreateOptions();
|
||||||
_loader = new PluginLoader(_loaderOptions);
|
_loader = new PluginLoader(_loaderOptions);
|
||||||
}
|
}
|
||||||
@@ -675,6 +681,8 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
AddSharedAssembly(options, typeof(App).Assembly);
|
AddSharedAssembly(options, typeof(App).Assembly);
|
||||||
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
|
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
|
||||||
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
|
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
|
||||||
|
AddSharedAssembly(options, typeof(IExternalIpcNotificationPublisher).Assembly);
|
||||||
|
AddSharedAssembly(options, typeof(dotnetCampus.Ipc.Pipes.IpcProvider).Assembly);
|
||||||
|
|
||||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
{
|
{
|
||||||
@@ -761,6 +769,19 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
{
|
{
|
||||||
_desktopComponentEditors.Add(new PluginDesktopComponentEditorContribution(loadedPlugin, desktopComponentEditor));
|
_desktopComponentEditors.Add(new PluginDesktopComponentEditorContribution(loadedPlugin, desktopComponentEditor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_publicIpcHostService is not null)
|
||||||
|
{
|
||||||
|
foreach (var publicIpcService in loadedPlugin.PublicIpcServices)
|
||||||
|
{
|
||||||
|
_publicIpcHostService.RegisterPublicService(
|
||||||
|
publicIpcService.ContractType,
|
||||||
|
publicIpcService.Implementation,
|
||||||
|
publicIpcService.ObjectId,
|
||||||
|
loadedPlugin.Manifest.Id,
|
||||||
|
publicIpcService.NotifyIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterSharedContractsForLoad(PluginManifest manifest)
|
private void RegisterSharedContractsForLoad(PluginManifest manifest)
|
||||||
@@ -990,11 +1011,12 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IPluginPackageManager _packageManager;
|
private readonly IPluginPackageManager _packageManager;
|
||||||
private readonly IHostApplicationLifecycle _applicationLifecycle;
|
private readonly IHostApplicationLifecycle _applicationLifecycle;
|
||||||
private readonly IPluginExportRegistry _exportRegistry;
|
private readonly IPluginExportRegistry _exportRegistry;
|
||||||
private readonly ISettingsFacadeService _settingsFacade;
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
private readonly ISettingsCatalog _settingsCatalog;
|
private readonly ISettingsCatalog _settingsCatalog;
|
||||||
private readonly IAppearanceThemeService _appearanceThemeService;
|
private readonly IAppearanceThemeService _appearanceThemeService;
|
||||||
|
private readonly IExternalIpcNotificationPublisher? _externalIpcNotificationPublisher;
|
||||||
|
|
||||||
public PluginHostServiceProvider(
|
public PluginHostServiceProvider(
|
||||||
IPluginPackageManager packageManager,
|
IPluginPackageManager packageManager,
|
||||||
@@ -1002,7 +1024,8 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
IPluginExportRegistry exportRegistry,
|
IPluginExportRegistry exportRegistry,
|
||||||
ISettingsFacadeService settingsFacade,
|
ISettingsFacadeService settingsFacade,
|
||||||
ISettingsService settingsService,
|
ISettingsService settingsService,
|
||||||
ISettingsCatalog settingsCatalog)
|
ISettingsCatalog settingsCatalog,
|
||||||
|
IExternalIpcNotificationPublisher? externalIpcNotificationPublisher)
|
||||||
{
|
{
|
||||||
_packageManager = packageManager;
|
_packageManager = packageManager;
|
||||||
_applicationLifecycle = applicationLifecycle;
|
_applicationLifecycle = applicationLifecycle;
|
||||||
@@ -1011,6 +1034,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_settingsCatalog = settingsCatalog;
|
_settingsCatalog = settingsCatalog;
|
||||||
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||||
|
_externalIpcNotificationPublisher = externalIpcNotificationPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public object? GetService(Type serviceType)
|
public object? GetService(Type serviceType)
|
||||||
@@ -1050,6 +1074,11 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
return _appearanceThemeService;
|
return _appearanceThemeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serviceType == typeof(IExternalIpcNotificationPublisher))
|
||||||
|
{
|
||||||
|
return _externalIpcNotificationPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,9 +197,36 @@ The runtime flow starts with the Launcher selecting the best version, then proce
|
|||||||
|
|
||||||
## VeloPack Integration Note
|
## VeloPack Integration Note
|
||||||
|
|
||||||
- Incremental package build/publish has moved to VeloPack native assets (
|
- Incremental package build/publish has moved to VeloPack native assets (
|
||||||
|
eleases.win.json + *.nupkg).
|
||||||
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
- Launcher runtime responsibilities are unchanged: OOBE, startup orchestration, update apply, and rollback.
|
||||||
|
|
||||||
|
## Plugin Isolation Modes
|
||||||
|
|
||||||
|
The current plugin runtime is still in-process. `PluginRuntimeService` and `PluginLoader` load plugin code inside the Host process, while `PluginLoadContext` only provides assembly isolation, not process isolation.
|
||||||
|
|
||||||
|
The repository now reserves three runtime modes:
|
||||||
|
|
||||||
|
- `in-proc`: current default and compatibility mode
|
||||||
|
- `isolated-background`: phase-1 mode, where background logic moves into a dedicated worker process and Host UI becomes a thin IPC-driven shell
|
||||||
|
- `isolated-window`: phase-2 mode, where plugin UI renders out of process and Host embeds a platform window handle
|
||||||
|
|
||||||
|
Two new supporting packages define the isolation boundary:
|
||||||
|
|
||||||
|
- `LanMountainDesktop.PluginIsolation.Contracts/`: transport-neutral DTOs, route constants, error codes, capabilities, and JSON context
|
||||||
|
- `LanMountainDesktop.PluginIsolation.Ipc/`: ClassIsland-inspired IPC facade that centralizes startup constants, routed notify IDs, and client/server wrappers over the future `dotnetCampus.Ipc` transport binding
|
||||||
|
|
||||||
|
For the detailed design, migration path, UI strategy, and residual risks, see `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
## External IPC Public API
|
||||||
|
|
||||||
|
- The current IPC mainline is external integration, not plugin process isolation.
|
||||||
|
- `LanMountainDesktop.Shared.IPC` is the unified IPC base for Host public services, Launcher/OOBE startup notifications, and plugin-contributed public services.
|
||||||
|
- Strongly typed command/query access uses `[IpcPublic]` contracts plus `dotnetCampus.Ipc` generated proxy/joint support.
|
||||||
|
- One-way events use `JsonIpcDirectRoutedProvider.NotifyAsync` with fixed top-level notify IDs.
|
||||||
|
- Host remains the single external IPC entry point even when a capability is contributed by a plugin.
|
||||||
|
|
||||||
|
See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration model.
|
||||||
|
|
||||||
## Launcher OOBE / Elevation Contract
|
## Launcher OOBE / Elevation Contract
|
||||||
|
|
||||||
|
|||||||
125
docs/EXTERNAL_IPC_ARCHITECTURE.md
Normal file
125
docs/EXTERNAL_IPC_ARCHITECTURE.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# External IPC Architecture
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This document defines the current external integration IPC baseline for LanMountainDesktop.
|
||||||
|
|
||||||
|
- The delivery focus is external application integration, not plugin process isolation.
|
||||||
|
- `dotnetCampus.Ipc` is the single IPC foundation for Host public APIs, Launcher/OOBE startup notifications, and plugin-contributed external services.
|
||||||
|
- Process isolation remains a future track and stays documented in `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
## Design Summary
|
||||||
|
|
||||||
|
The public IPC stack is split into two complementary layers:
|
||||||
|
|
||||||
|
1. Strongly typed public services
|
||||||
|
- Contracts are marked with `[IpcPublic]`.
|
||||||
|
- Host exposes service instances through `CreateIpcJoint<TContract>(instance)`.
|
||||||
|
- .NET clients connect once and obtain strong typed proxies through `CreateIpcProxy<TContract>(peer)`.
|
||||||
|
2. Routed notifications
|
||||||
|
- `JsonIpcDirectRoutedProvider.NotifyAsync` is used for one-way event delivery.
|
||||||
|
- Startup progress, loading-state updates, catalog changed events, and plugin live events all use routed notify IDs.
|
||||||
|
|
||||||
|
This keeps command/query calls explicit and strongly typed while still giving plugins and Launcher a lightweight event channel.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
- `LanMountainDesktop.Shared.IPC`
|
||||||
|
- Public IPC constants, routed notify IDs, DTOs, strong-typed public service contracts, host/client helpers, and DI registration helpers.
|
||||||
|
- `LanMountainDesktop`
|
||||||
|
- Runs `PublicIpcHostService`, exposes built-in public services, and folds plugin-contributed services into one external catalog.
|
||||||
|
- `LanMountainDesktop.Launcher`
|
||||||
|
- Connects to the Host public pipe and listens for startup and loading-state notifications instead of running a custom length-prefixed IPC server.
|
||||||
|
- `LanMountainDesktop.PluginSdk`
|
||||||
|
- Adds `IPluginPublicIpcContributor`, `IPluginPublicIpcBuilder`, and `AddPluginPublicIpc(...)`.
|
||||||
|
|
||||||
|
## Built-in Public Services
|
||||||
|
|
||||||
|
Current built-in `[IpcPublic]` contracts:
|
||||||
|
|
||||||
|
- `IPublicAppInfoService`
|
||||||
|
- Returns application metadata such as version, codename, process id, pipe name, and startup time.
|
||||||
|
- `IPublicShellControlService`
|
||||||
|
- Allows external .NET clients to activate the shell, open settings, request restart, and request exit.
|
||||||
|
- `IPublicPluginCatalogService`
|
||||||
|
- Returns the merged public IPC catalog snapshot exposed by Host.
|
||||||
|
|
||||||
|
## Routed Notify IDs
|
||||||
|
|
||||||
|
Current fixed routed notify IDs:
|
||||||
|
|
||||||
|
- `lanmountain.catalog.changed`
|
||||||
|
- `lanmountain.launcher.startup-progress`
|
||||||
|
- `lanmountain.launcher.loading-state`
|
||||||
|
|
||||||
|
The fixed routed surface is intentionally small. Runtime variation happens in the service catalog and in plugin-contributed service instances, not in ad-hoc top-level route registration after startup.
|
||||||
|
|
||||||
|
## Host Lifecycle
|
||||||
|
|
||||||
|
`PublicIpcHostService` is started during Host application startup and remains the single external IPC entry point.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Start a named `dotnetCampus.Ipc` provider.
|
||||||
|
- Register fixed request routes before `StartServer()`.
|
||||||
|
- Expose built-in strong-typed public services.
|
||||||
|
- Maintain the merged service catalog.
|
||||||
|
- Publish startup and loading-state notifications to connected clients.
|
||||||
|
- Accept plugin-contributed public services after plugin load.
|
||||||
|
|
||||||
|
## Launcher / OOBE Migration
|
||||||
|
|
||||||
|
Launcher no longer depends on the previous custom named-pipe length-prefixed protocol as the primary path.
|
||||||
|
|
||||||
|
- Host publishes `StartupProgressMessage` through `lanmountain.launcher.startup-progress`.
|
||||||
|
- Host publishes `LoadingStateMessage` through `lanmountain.launcher.loading-state`.
|
||||||
|
- Launcher connects as a normal public IPC client and subscribes to those routed notifications.
|
||||||
|
|
||||||
|
This means Splash/OOBE is now just another IPC consumer on the same base transport used by external integrators.
|
||||||
|
|
||||||
|
## Plugin Public IPC Contribution Model
|
||||||
|
|
||||||
|
Plugins can contribute new external IPC services in two ways:
|
||||||
|
|
||||||
|
1. Declarative registration
|
||||||
|
- `services.AddPluginPublicIpc<TContract, TImplementation>(...)`
|
||||||
|
2. Advanced contributor
|
||||||
|
- Register `IPluginPublicIpcContributor`
|
||||||
|
- Use `IPluginPublicIpcBuilder` to contribute services from plugin DI
|
||||||
|
|
||||||
|
At plugin load time the Host runtime:
|
||||||
|
|
||||||
|
- discovers `PluginPublicIpcServiceRegistration`
|
||||||
|
- executes `IPluginPublicIpcContributor`
|
||||||
|
- validates that contributed contracts are `[IpcPublic]` interfaces
|
||||||
|
- registers the resolved instances into `PublicIpcHostService`
|
||||||
|
- emits `lanmountain.catalog.changed`
|
||||||
|
|
||||||
|
Plugins can also inject `IExternalIpcNotificationPublisher` and translate internal DI/message-bus events into routed notifications such as:
|
||||||
|
|
||||||
|
- `lanmountain.plugin.{pluginId}.attendance.updated`
|
||||||
|
- `lanmountain.plugin.{pluginId}.status.changed`
|
||||||
|
|
||||||
|
## Service Catalog
|
||||||
|
|
||||||
|
The public catalog is represented by `PublicIpcCatalogSnapshot` and includes:
|
||||||
|
|
||||||
|
- built-in and plugin-provided public services
|
||||||
|
- contract type metadata
|
||||||
|
- optional object id
|
||||||
|
- owning `pluginId` for plugin services
|
||||||
|
- declared notify IDs
|
||||||
|
- current loaded/enabled plugin list
|
||||||
|
|
||||||
|
This catalog is available through:
|
||||||
|
|
||||||
|
- strong-typed public service `IPublicPluginCatalogService`
|
||||||
|
- fixed request route `lanmountain.catalog.get`
|
||||||
|
- routed notify `lanmountain.catalog.changed`
|
||||||
|
|
||||||
|
## Current Limitations
|
||||||
|
|
||||||
|
- Strong-typed proxy/joint support is .NET-first.
|
||||||
|
- Plugin service removal is still restart-bound. New services can be added at runtime, but service removal is not yet modeled as a live unload contract.
|
||||||
|
- Cross-language clients still need a .NET bridge or sidecar if they want to consume `[IpcPublic]` contracts directly.
|
||||||
|
- Plugin process isolation is not part of this delivery. That remains future work.
|
||||||
@@ -559,3 +559,13 @@ var updateCheckService = new UpdateCheckService(
|
|||||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||||
- Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
|
- Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
|
||||||
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
|
||||||
|
|
||||||
|
## Public IPC Baseline
|
||||||
|
|
||||||
|
Launcher now consumes Host startup telemetry from the unified public IPC stack:
|
||||||
|
|
||||||
|
- Host publishes `StartupProgressMessage` via `lanmountain.launcher.startup-progress`
|
||||||
|
- Host publishes `LoadingStateMessage` via `lanmountain.launcher.loading-state`
|
||||||
|
- Launcher connects through `LanMountainDesktopIpcClient`
|
||||||
|
|
||||||
|
The previous custom length-prefixed named-pipe transport is no longer the primary startup communication path.
|
||||||
|
|||||||
@@ -684,3 +684,34 @@ if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
|||||||
- [组件开发指南](COMPONENT_DEVELOPMENT.md)
|
- [组件开发指南](COMPONENT_DEVELOPMENT.md)
|
||||||
- [API 参考](API_REFERENCE.md)
|
- [API 参考](API_REFERENCE.md)
|
||||||
- [架构文档](ARCHITECTURE.md)
|
- [架构文档](ARCHITECTURE.md)
|
||||||
|
## Public IPC Extension
|
||||||
|
|
||||||
|
Plugins can now contribute external IPC capabilities through the Host public IPC entry point.
|
||||||
|
|
||||||
|
Recommended registration styles:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddPluginPublicIpc<IMyPluginPublicService, MyPluginPublicService>(
|
||||||
|
objectId: "default",
|
||||||
|
notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the advanced contributor model:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class MyPluginPublicIpcContributor : IPluginPublicIpcContributor
|
||||||
|
{
|
||||||
|
public void ConfigurePublicIpc(IPluginPublicIpcBuilder builder)
|
||||||
|
{
|
||||||
|
builder.AddService<IMyPluginPublicService>(
|
||||||
|
objectId: "default",
|
||||||
|
notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional notes:
|
||||||
|
|
||||||
|
- Public IPC contracts must be interfaces marked with `[IpcPublic]`.
|
||||||
|
- External .NET clients can reference the plugin contract assembly and create strong-typed proxies through the Host public pipe.
|
||||||
|
- Plugins can inject `IExternalIpcNotificationPublisher` to push live events outward through routed notifications.
|
||||||
|
|||||||
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
|
- component registration migrated to options model
|
||||||
- runtime appearance access uses `IPluginAppearanceContext`
|
- runtime appearance access uses `IPluginAppearanceContext`
|
||||||
- plugin package rebuilt and republished as `.laapp`
|
- plugin package rebuilt and republished as `.laapp`
|
||||||
|
|
||||||
|
## Process Isolation Additions
|
||||||
|
|
||||||
|
SDK `4.x` now also reserves manifest and API surface for process isolation without breaking existing plugins.
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
|
||||||
|
`plugin.json` can declare the desired runtime mode:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runtime": {
|
||||||
|
"mode": "in-proc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported values:
|
||||||
|
|
||||||
|
- `in-proc`
|
||||||
|
- `isolated-background`
|
||||||
|
- `isolated-window`
|
||||||
|
|
||||||
|
If `runtime` is omitted, the host normalizes it to `in-proc` for backward compatibility.
|
||||||
|
|
||||||
|
### Worker Entry
|
||||||
|
|
||||||
|
Plugins that opt into isolated execution can prepare a worker-side entry by implementing:
|
||||||
|
|
||||||
|
- `IPluginWorker`
|
||||||
|
- `PluginWorkerBase`
|
||||||
|
- `IPluginWorkerContext`
|
||||||
|
- `[PluginWorkerEntrance]`
|
||||||
|
|
||||||
|
The first phase only targets `isolated-background`: background services, timers, network calls, and risky native integrations move into the worker process, while UI remains a host-side shell driven over IPC.
|
||||||
|
|||||||
Reference in New Issue
Block a user