Add plugin isolation IPC scaffolding and host phase one docs

This commit is contained in:
lincube
2026-04-22 10:15:07 +08:00
parent 703ed7b48a
commit 2c48b7b846
42 changed files with 1279 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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