Status: Draft / proposal
Scope: Native (unmanaged) plugins that contribute services only.
Target: SharpConsoleUI (net8.0 / net9.0 / net10.0, IsAotCompatible=true).
Today every SharpConsoleUI plugin is a managed .NET type implementing IPlugin. This
spec adds a second source of plugin contributions: a native shared library
(.so / .dll / .dylib) written in any language with a C ABI (Rust, C, Zig, …).
A native library is exposed to the framework as a pure logic engine. The managed
framework remains the sole owner of rendering, layout, input, and the window system.
The native side never sees a .NET type, namespace, or the ConsoleWindowSystem. It
implements four extern "C" functions and speaks a small, fixed JSON vocabulary.
The design is additive and non-breaking: no existing interface, signature, or caller
changes. A native library is wrapped in a managed container that is an IPlugin, so the
existing PluginStateService.LoadPlugin(IPlugin) path handles it unchanged.
Goals
- Let unmanaged libraries contribute services (
IPluginService) with zero shared .NET types. - Preserve the existing managed plugin system exactly (themes, controls, windows, actions, services).
- Preserve the public service API:
Execute(string, Dictionary<string, object>?) → object?. - Stay NativeAOT-clean — no reflection-based serialization, no
[RequiresDynamicCode], no new dependency that trips theaotCI gate.
Non-goals (explicitly out of scope)
- Native controls, windows, action providers, or themes. See §11.
- Hot-reload / unloading of native libraries (mirrors the managed limitation).
- A cross-language rendering / draw protocol.
| Principle | Consequence |
|---|---|
| Managed owns the UI; native only "thinks" | Native contributes callable operations, never pixels. |
| Additive, non-breaking | Reuse IPlugin / IPluginService / PluginStateService untouched. |
| Don't probe, don't offer | No ABI export exists for controls/windows/actions/themes, so a native author cannot offer them and the container returns empty for them. |
| AOT is a hard requirement | Marshalling is manual Utf8JsonWriter/Utf8JsonReader over a closed type vocabulary — no reflection, no source-gen context, no Newtonsoft. |
Three layers:
| Layer | Responsibility | Language | Type |
|---|---|---|---|
| Brain | Pure logic | Rust / C / Zig | C-compatible library exporting 4 functions |
| Shim | Bridge / marshalling | C# | NativeServiceShim : IPluginService (generic, one class for all native services) |
| Loader | Registration | C# | NativePluginContainer : PluginBase + a discovery entry point |
NativePluginContainer implements IPlugin (via PluginBase). It loads the native
library, probes it once, and materializes one NativeServiceShim per described service.
GetServicePlugins()→ the native service shims.GetThemes(),GetControls(),GetWindows(),GetActionProviders()→ empty (PluginBasedefaults).
Because the container is an IPlugin, it is loaded through the existing, unmodified path:
windowSystem.PluginStateService.LoadPlugin(new NativePluginContainer("./libmyplugin.so"));and consumed through the existing, unmodified retrieval API:
var svc = windowSystem.PluginStateService.GetService("MyService");
long n = (long)svc!.Execute("Compute", new Dictionary<string, object> { ["x"] = 21L })!;Host (managed)
→ IPluginService.Execute("op", dict) // unchanged public contract
→ NativeServiceShim (managed)
validate dict against probed metadata
Utf8JsonWriter → args JSON // per closed vocabulary, no reflection
→ plugin_invoke("op", argsJson) [P/Invoke]
→ Brain (unmanaged): returns result JSON envelope
→ NativeServiceShim (managed)
copy native string → managed, then plugin_free
Utf8JsonReader → object? per declared ReturnType
→ Host (managed): object?
A native plugin exports exactly four C functions. Names are fixed; symbol lookup is by name.
/* ABI handshake. Return the ABI version this library was built against.
Checked BEFORE plugin_describe; a mismatch aborts the load. */
int32_t plugin_abi_version(void);
/* Self-description. Return a UTF-8 JSON manifest (see §6).
The returned pointer is owned by the library and released via plugin_free. */
const char* plugin_describe(void);
/* Invoke a named operation. `op` and `args_json` are host-owned, valid for the call.
Return a UTF-8 JSON result envelope (see §9), owned by the library. */
const char* plugin_invoke(const char* op, const char* args_json);
/* Release a string previously returned by plugin_describe / plugin_invoke. */
void plugin_free(const char* ptr);Managed side uses [LibraryImport] (source-generated, trim/AOT-clean UTF-8 marshalling).
The repository already ships native interop (Controls/Terminal/PtyNative.cs via [DllImport]);
[LibraryImport] is the AOT-preferred form for this new path.
A single services[] section. The manifest is, in effect, a wire-format
IReadOnlyList<ServiceOperation> per service — it rehydrates directly into the framework's
existing metadata types (ServiceOperation, ServiceParameter).
{
"abiVersion": 1,
"services": [
{
"name": "MathService",
"description": "Arithmetic helpers.",
"operations": [
{
"name": "Add",
"description": "Adds two integers.",
"parameters": [
{ "name": "a", "type": "i64", "required": true },
{ "name": "b", "type": "i64", "required": true }
],
"returnType": "i64"
}
]
}
]
}Mapping to framework types:
- service
name/description→IPluginService.ServiceName/Description operations[]→GetAvailableOperations()returningServiceOperation(Name, Description, Parameters, ReturnType?)parameters[]→ServiceParameter(Name, Type, Required, DefaultValue?, Description?)returnTypeomitted /null→ a void operation (ReturnType == null)
The manifest declares types as strings. ServiceParameter.Type / ServiceOperation.ReturnType
are System.Type, so the shim maps each wire type to a CLR type and a marshalling rule. The set
is closed — this table is the ABI contract. Keep it small; every addition is marshalling
code on both sides.
| Wire type | System.Type |
JSON representation |
|---|---|---|
i64 |
long |
number |
f64 |
double |
number |
bool |
bool |
boolean |
string |
string |
string (UTF-8) |
bytes |
byte[] |
base64 string |
i64[] / f64[] / bool[] / string[] |
array types | JSON array |
json |
System.Text.Json.JsonElement |
arbitrary JSON (escape hatch for nested objects) |
void (or an omitted returnType) denotes no return value.
- The shim never calls
JsonSerializer.Serialize(object)— that is the reflection path that breaks NativeAOT. It walks known-typed values withUtf8JsonWriterand reads results withUtf8JsonReader, dispatching on the declared wire type (from the manifest), not on the runtime type of a boxed value. - No
JsonSerializerContext/ source generator is required, because operation shapes live in data, not in compile-time types. - No
Newtonsoft.Json— it is[RequiresUnreferencedCode]+[RequiresDynamicCode]and would fail theaotCI gate. - Result: the pipeline is analyzer-clean and preserves
IsAotCompatible=true.
plugin_invoke returns a result envelope:
{ "ok": true, "value": 42 }
{ "ok": false, "error": "unknown operation 'Frobnicate'" }ok: false→ the shim throwsInvalidOperationException(error), matching the documented contract ofIPluginService.Execute(unknown operation / invalid parameters).- A null pointer or unparseable envelope →
InvalidOperationExceptionwith a diagnostic message.
Every string returned by plugin_describe / plugin_invoke is native-allocated. The shim:
- Copies the UTF-8 bytes into a managed
string. - Calls
plugin_free(ptr)in afinallyblock — always, including on exception.
The host never allocates strings the native side frees, and vice versa. Args passed into
plugin_invoke are host-owned and valid only for the duration of the call; the library must copy
anything it retains.
Native plugins contribute services only. The ABI has no export for the following, so a native author cannot offer them and the container returns empty:
- Controls / Windows.
IWindowControlis a per-frame render + input + layout + focus surface. Bridging it means a serialized draw/input protocol (a binary command buffer, not JSON-per-frame), which reintroduces the coupling and hot-path costs this design deliberately avoids. Separate, larger effort; not planned here. - Action providers. Structurally identical to services (
ActionDescriptor≅ServiceOperation,ExecuteAction≅Execute), so the same machinery could host them later at low cost — but they typically act on the UI, which would require a host-callback surface. Deferred. - Themes.
IThemeis pure data but ~150 framework-specific, still-growing slots — the opposite of a small stable vocabulary, and a theme has no logic that benefits from running native. If native plugins should ship themes, the right vehicle is a declarative theme file (seed palette + mode + a few overrides, expanded managed-side byPaletteThemeGenerator), loaded from disk — not the P/Invoke ABI. Tracked separately; out of scope here.
- No change to
IPlugin,IPluginService,PluginStateService, or any caller. Execute(string, Dictionary<string, object>?) → object?is unchanged; the dictionary is the compatibility seam. Native services validate boxed dictionary values against declared types (§13).- Managed plugins are entirely unaffected. The change is purely additive.
- Parameter validation — strict vs lenient. When a caller passes a boxed value whose type
doesn't match the declared wire type (e.g.
"42"for ani64): reject withInvalidOperationException(strict, recommended — the manifest gives us the type), or attemptConvert.ChangeType(lenient). Strict cannot be quietly relaxed later without risk, so decide now. - Unified loader facade. Ship a single discovery entry point (point at a folder → load managed
assemblies and native libraries, funnel both through
LoadPlugin(IPlugin)) in v1, or leave native loading as an explicitLoadPlugin(new NativePluginContainer(path))call for now?
// libmath.c — compile to libmath.so
#include <string.h>
#include <stdlib.h>
int32_t plugin_abi_version(void) { return 1; }
const char* plugin_describe(void) {
return strdup(
"{\"abiVersion\":1,\"services\":[{"
"\"name\":\"MathService\",\"description\":\"Arithmetic helpers.\","
"\"operations\":[{\"name\":\"Add\",\"description\":\"Adds two integers.\","
"\"parameters\":[{\"name\":\"a\",\"type\":\"i64\",\"required\":true},"
"{\"name\":\"b\",\"type\":\"i64\",\"required\":true}],\"returnType\":\"i64\"}]"
"}]}");
}
const char* plugin_invoke(const char* op, const char* args_json) {
// parse args_json, compute, return envelope (real code uses a JSON lib)
if (strcmp(op, "Add") == 0)
return strdup("{\"ok\":true,\"value\":42}");
return strdup("{\"ok\":false,\"error\":\"unknown operation\"}");
}
void plugin_free(const char* ptr) { free((void*)ptr); }Managed usage — indistinguishable from a managed service:
windowSystem.PluginStateService.LoadPlugin(new NativePluginContainer("./libmath.so"));
var math = windowSystem.PluginStateService.GetService("MathService");
long sum = (long)math!.Execute("Add", new Dictionary<string, object>
{
["a"] = 21L,
["b"] = 21L
})!;