Merge nucleic/warm-north-vole-pf37 into dev
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NucleicProtocol.Interop;
|
||||
|
||||
/// Small typed constructors for the renderer's first intents. Swift remains the decoder and
|
||||
/// source of truth; these helpers only keep hand-written UI JSON out of call sites.
|
||||
public static class ClientIntents
|
||||
{
|
||||
public static string ListSessions() => TagOnly("listSessions");
|
||||
public static string ListDashboard() => TagOnly("listDashboard");
|
||||
public static string ListPeers() => TagOnly("listPeers");
|
||||
public static string Ping() => TagOnly("ping");
|
||||
|
||||
public static string Subscribe(string sessionId, ulong? sinceSequence = null, string verbosity = "full") =>
|
||||
Write(writer =>
|
||||
{
|
||||
writer.WriteString("sessionID", sessionId);
|
||||
if (sinceSequence is { } sequence) writer.WriteNumber("sinceSeq", sequence);
|
||||
writer.WriteString("t", "subscribe");
|
||||
writer.WriteString("verbosity", verbosity);
|
||||
});
|
||||
|
||||
public static string Unsubscribe(string sessionId) => Write(writer =>
|
||||
{
|
||||
writer.WriteString("sessionID", sessionId);
|
||||
writer.WriteString("t", "unsubscribe");
|
||||
});
|
||||
|
||||
public static string ApprovalRespond(
|
||||
string approvalId, string decisionType, JsonElement? updatedInput = null) =>
|
||||
Write(writer =>
|
||||
{
|
||||
writer.WriteString("approvalID", approvalId);
|
||||
writer.WritePropertyName("decision");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", decisionType);
|
||||
if (updatedInput is { } input)
|
||||
{
|
||||
writer.WritePropertyName("updatedInput");
|
||||
input.WriteTo(writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
writer.WriteString("t", "approvalRespond");
|
||||
});
|
||||
|
||||
public static string SendInput(string sessionId, string text) => Write(writer =>
|
||||
{
|
||||
writer.WritePropertyName("input");
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("parts");
|
||||
writer.WriteStartArray();
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("text", text);
|
||||
writer.WriteString("type", "text");
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
writer.WriteString("sessionID", sessionId);
|
||||
writer.WriteString("t", "sendInput");
|
||||
});
|
||||
|
||||
private static string TagOnly(string tag) => JsonSerializer.Serialize(
|
||||
new TagOnlyIntent(tag), ProtocolJsonContext.Default.TagOnlyIntent);
|
||||
|
||||
private static string Write(Action<Utf8JsonWriter> body)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
}))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
body(writer);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
return System.Text.Encoding.UTF8.GetString(stream.GetBuffer(), 0, checked((int)stream.Length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace NucleicProtocol.Interop;
|
||||
|
||||
public enum ProtocolResult
|
||||
{
|
||||
Ok = 0,
|
||||
InvalidArgument = -1,
|
||||
InvalidState = -2,
|
||||
DecodeError = -3,
|
||||
Unsupported = -4,
|
||||
}
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate void NativeProtocolCallback(nint context, nint utf8Json);
|
||||
|
||||
/// Injectable native surface: production delegates to the Swift DLL; tests use an in-memory
|
||||
/// implementation and exercise callback lifetime without loading a platform binary.
|
||||
public interface INativeProtocol
|
||||
{
|
||||
nint ClientCreate(string configurationJson);
|
||||
void ClientSetCallbacks(
|
||||
nint handle, NativeProtocolCallback? eventCallback,
|
||||
NativeProtocolCallback? stateCallback, nint context);
|
||||
int ClientConnectLocal(nint handle, string rendezvousJson);
|
||||
int ClientConnectRemote(nint handle, string endpointJson);
|
||||
int ClientPair(nint handle, string pairingCode);
|
||||
int ClientSendIntent(nint handle, string clientMessageJson);
|
||||
void ClientClose(nint handle);
|
||||
nint ProjectionCreate();
|
||||
nint ProjectionApply(nint projection, string hostMessageJson);
|
||||
void ProjectionFree(nint projection);
|
||||
string CopyAndFreeString(nint value);
|
||||
}
|
||||
|
||||
internal sealed class PInvokeNativeProtocol : INativeProtocol
|
||||
{
|
||||
public static PInvokeNativeProtocol Instance { get; } = new();
|
||||
private PInvokeNativeProtocol() { }
|
||||
|
||||
public nint ClientCreate(string json) => NativeMethods.np_client_create(json);
|
||||
public void ClientSetCallbacks(
|
||||
nint handle, NativeProtocolCallback? eventCallback,
|
||||
NativeProtocolCallback? stateCallback, nint context) =>
|
||||
NativeMethods.np_client_set_callbacks(handle, eventCallback, stateCallback, context);
|
||||
public int ClientConnectLocal(nint handle, string json) =>
|
||||
NativeMethods.np_client_connect_local(handle, json);
|
||||
public int ClientConnectRemote(nint handle, string json) =>
|
||||
NativeMethods.np_client_connect_remote(handle, json);
|
||||
public int ClientPair(nint handle, string code) => NativeMethods.np_client_pair(handle, code);
|
||||
public int ClientSendIntent(nint handle, string json) =>
|
||||
NativeMethods.np_client_send_intent(handle, json);
|
||||
public void ClientClose(nint handle) => NativeMethods.np_client_close(handle);
|
||||
public nint ProjectionCreate() => NativeMethods.np_projection_create();
|
||||
public nint ProjectionApply(nint projection, string json) =>
|
||||
NativeMethods.np_projection_apply(projection, json);
|
||||
public void ProjectionFree(nint projection) => NativeMethods.np_projection_free(projection);
|
||||
|
||||
public string CopyAndFreeString(nint value)
|
||||
{
|
||||
if (value == 0) throw new InvalidOperationException("native protocol returned NULL");
|
||||
try { return Marshal.PtrToStringUTF8(value) ?? string.Empty; }
|
||||
finally { NativeMethods.np_free(value); }
|
||||
}
|
||||
|
||||
private static class NativeMethods
|
||||
{
|
||||
private const string Dll = "NucleicProtocolC";
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern nint np_client_create(
|
||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string configurationJson);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern void np_client_set_callbacks(
|
||||
nint handle, NativeProtocolCallback? eventCallback,
|
||||
NativeProtocolCallback? stateCallback, nint context);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern int np_client_connect_local(
|
||||
nint handle, [MarshalAs(UnmanagedType.LPUTF8Str)] string rendezvousJson);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern int np_client_connect_remote(
|
||||
nint handle, [MarshalAs(UnmanagedType.LPUTF8Str)] string endpointJson);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern int np_client_pair(
|
||||
nint handle, [MarshalAs(UnmanagedType.LPUTF8Str)] string pairingCode);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern int np_client_send_intent(
|
||||
nint handle, [MarshalAs(UnmanagedType.LPUTF8Str)] string clientMessageJson);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern void np_client_close(nint handle);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern nint np_projection_create();
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern nint np_projection_apply(
|
||||
nint projection, [MarshalAs(UnmanagedType.LPUTF8Str)] string hostMessageJson);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern void np_projection_free(nint projection);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern void np_free(nint value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- Managed ownership/lifetime layer over NucleicProtocolC.dll (WINDOWS_PORT §6/§7).
|
||||
Kept free of WinUI so its ABI and golden-fixture tests run on Linux too. -->
|
||||
<PropertyGroup>
|
||||
<RootNamespace>NucleicProtocol.Interop</RootNamespace>
|
||||
<AssemblyName>NucleicProtocol.Interop</AssemblyName>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NucleicProtocol.Interop;
|
||||
|
||||
/// Managed lifetime and callback barrier over one `np_handle`.
|
||||
public interface IProtocolClient : IDisposable
|
||||
{
|
||||
event Action<string>? MessageJsonReceived;
|
||||
event Action<string>? StateJsonReceived;
|
||||
ProtocolResult ConnectLocal(HostdRendezvous rendezvous);
|
||||
ProtocolResult ConnectRemote(string endpointJson);
|
||||
ProtocolResult Pair(string pairingCode);
|
||||
ProtocolResult SendIntent(string clientMessageJson);
|
||||
}
|
||||
|
||||
public sealed class ProtocolClient : IProtocolClient
|
||||
{
|
||||
private static readonly NativeProtocolCallback EventThunk = OnNativeEvent;
|
||||
private static readonly NativeProtocolCallback StateThunk = OnNativeState;
|
||||
|
||||
private readonly object gate = new();
|
||||
private readonly INativeProtocol native;
|
||||
private nint handle;
|
||||
private GCHandle callbackContext;
|
||||
private bool disposed;
|
||||
|
||||
public event Action<string>? MessageJsonReceived;
|
||||
public event Action<string>? StateJsonReceived;
|
||||
|
||||
public ProtocolClient(
|
||||
ProtocolClientConfiguration configuration, INativeProtocol? native = null)
|
||||
{
|
||||
this.native = native ?? PInvokeNativeProtocol.Instance;
|
||||
var json = JsonSerializer.Serialize(
|
||||
configuration, ProtocolJsonContext.Default.ProtocolClientConfiguration);
|
||||
handle = this.native.ClientCreate(json);
|
||||
if (handle == 0) throw new InvalidOperationException("NucleicProtocolC rejected client configuration");
|
||||
|
||||
callbackContext = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||
try
|
||||
{
|
||||
this.native.ClientSetCallbacks(
|
||||
handle, EventThunk, StateThunk, GCHandle.ToIntPtr(callbackContext));
|
||||
}
|
||||
catch
|
||||
{
|
||||
this.native.ClientClose(handle);
|
||||
handle = 0;
|
||||
callbackContext.Free();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public ProtocolResult ConnectLocal(HostdRendezvous rendezvous)
|
||||
{
|
||||
rendezvous.Validate();
|
||||
return Invoke(handle => native.ClientConnectLocal(
|
||||
handle, JsonSerializer.Serialize(rendezvous, ProtocolJsonContext.Default.HostdRendezvous)));
|
||||
}
|
||||
|
||||
public ProtocolResult ConnectRemote(string endpointJson) =>
|
||||
Invoke(handle => native.ClientConnectRemote(handle, endpointJson));
|
||||
|
||||
public ProtocolResult Pair(string pairingCode) =>
|
||||
Invoke(handle => native.ClientPair(handle, pairingCode));
|
||||
|
||||
public ProtocolResult SendIntent(string clientMessageJson)
|
||||
{
|
||||
// Fail malformed UI output before it reaches the ABI. The Swift decoder remains the
|
||||
// authority for the actual ClientMsg shape and returns DecodeError for a valid-but-wrong
|
||||
// document.
|
||||
using (JsonDocument.Parse(clientMessageJson)) { }
|
||||
return Invoke(handle => native.ClientSendIntent(handle, clientMessageJson));
|
||||
}
|
||||
|
||||
private ProtocolResult Invoke(Func<nint, int> operation)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
return (ProtocolResult)operation(handle);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
nint closing;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
closing = handle;
|
||||
handle = 0;
|
||||
}
|
||||
|
||||
// The Swift contract makes close a callback barrier. Only after it returns is it safe to
|
||||
// free the GCHandle that native callbacks carry as their opaque context.
|
||||
native.ClientClose(closing);
|
||||
MessageJsonReceived = null;
|
||||
StateJsonReceived = null;
|
||||
if (callbackContext.IsAllocated) callbackContext.Free();
|
||||
}
|
||||
|
||||
private static void OnNativeEvent(nint context, nint utf8Json) =>
|
||||
Dispatch(context, utf8Json, static (client, json) => client.MessageJsonReceived?.Invoke(json));
|
||||
|
||||
private static void OnNativeState(nint context, nint utf8Json) =>
|
||||
Dispatch(context, utf8Json, static (client, json) => client.StateJsonReceived?.Invoke(json));
|
||||
|
||||
private static void Dispatch(
|
||||
nint context, nint utf8Json, Action<ProtocolClient, string> deliver)
|
||||
{
|
||||
if (context == 0 || utf8Json == 0) return;
|
||||
try
|
||||
{
|
||||
if (GCHandle.FromIntPtr(context).Target is ProtocolClient client)
|
||||
{
|
||||
var json = Marshal.PtrToStringUTF8(utf8Json);
|
||||
if (json is not null) deliver(client, json);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing may unwind through the C callback boundary. Renderer event subscribers own
|
||||
// their diagnostics; a bad observer must not tear down Swift's callback thread.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IProtocolClientFactory
|
||||
{
|
||||
IProtocolClient Create(ProtocolClientConfiguration configuration);
|
||||
}
|
||||
|
||||
public sealed class ProtocolClientFactory : IProtocolClientFactory
|
||||
{
|
||||
public IProtocolClient Create(ProtocolClientConfiguration configuration) => new ProtocolClient(configuration);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NucleicProtocol.Interop;
|
||||
|
||||
public sealed record ProtocolClientConfiguration
|
||||
{
|
||||
[JsonPropertyName("identityDir")]
|
||||
public required string IdentityDirectory { get; init; }
|
||||
|
||||
[JsonPropertyName("hostKeyPins")]
|
||||
public IReadOnlyDictionary<string, string>? HostKeyPins { get; init; }
|
||||
|
||||
[JsonPropertyName("deviceID")]
|
||||
public string? DeviceId { get; init; }
|
||||
|
||||
[JsonPropertyName("deviceLabel")]
|
||||
public string DeviceLabel { get; init; } = "Nucleic for Windows";
|
||||
|
||||
[JsonPropertyName("scopeClaim")]
|
||||
public string ScopeClaim { get; init; } = "control";
|
||||
|
||||
[JsonPropertyName("channel")]
|
||||
public string? Channel { get; init; }
|
||||
}
|
||||
|
||||
/// The owner-only `run/hostd.json` written by nucleic-hostd.
|
||||
public sealed record HostdRendezvous
|
||||
{
|
||||
[JsonPropertyName("pid")]
|
||||
public required int ProcessId { get; init; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; init; } = "127.0.0.1";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public required int Port { get; init; }
|
||||
|
||||
[JsonPropertyName("localPSK")]
|
||||
public required string LocalPsk { get; init; }
|
||||
|
||||
/// Selects the DLL's persisted pin on renderer relaunch. A fresh identity has no matching
|
||||
/// pin and falls back to the one-time local PSK.
|
||||
[JsonPropertyName("hostID")]
|
||||
public required string HostId { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ProcessId <= 0) throw new JsonException("rendezvous pid must be positive");
|
||||
if (Host is not ("127.0.0.1" or "::1" or "localhost"))
|
||||
throw new JsonException("renderer rendezvous must be loopback-only");
|
||||
if (Port is <= 0 or > 65_535) throw new JsonException("rendezvous port is invalid");
|
||||
if (string.IsNullOrWhiteSpace(HostId)) throw new JsonException("rendezvous hostID is missing");
|
||||
byte[] secret;
|
||||
try { secret = Convert.FromBase64String(LocalPsk); }
|
||||
catch (FormatException error) { throw new JsonException("rendezvous localPSK is invalid", error); }
|
||||
if (secret.Length != 32) throw new JsonException("rendezvous localPSK must be 32 bytes");
|
||||
}
|
||||
}
|
||||
|
||||
/// A forward-compatible HostMsg view. The tagged enum remains owned by Swift; C# reads the
|
||||
/// discriminator and keeps the complete JSON value so a newer field or message never gets lost.
|
||||
public sealed record ProtocolMessage(string Tag, JsonElement Root, string Json)
|
||||
{
|
||||
public static ProtocolMessage Parse(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object ||
|
||||
!document.RootElement.TryGetProperty("t", out var tagValue) ||
|
||||
tagValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("protocol message has no string 't' discriminator");
|
||||
}
|
||||
return new ProtocolMessage(tagValue.GetString()!, document.RootElement.Clone(), json);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ProtocolState(
|
||||
string State, string? Message, string? Transport, string? Peer,
|
||||
string? HostId, JsonElement? Welcome, string Json)
|
||||
{
|
||||
public static ProtocolState Parse(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("state", out var state) || state.ValueKind != JsonValueKind.String)
|
||||
throw new JsonException("protocol state callback has no state");
|
||||
return new ProtocolState(
|
||||
state.GetString()!, OptionalString(root, "message"),
|
||||
OptionalString(root, "transport"), OptionalString(root, "peer"),
|
||||
OptionalString(root, "hostID"),
|
||||
root.TryGetProperty("welcome", out var welcome) ? welcome.Clone() : null,
|
||||
json);
|
||||
}
|
||||
|
||||
private static string? OptionalString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() : null;
|
||||
}
|
||||
|
||||
public sealed record ProjectionDiff(JsonElement Root, string Json)
|
||||
{
|
||||
public static ProjectionDiff Parse(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!root.TryGetProperty("version", out var version) || version.GetInt32() != 1 ||
|
||||
!root.TryGetProperty("splices", out var splices) ||
|
||||
splices.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new JsonException("invalid transcript projection result");
|
||||
}
|
||||
return new ProjectionDiff(root.Clone(), json);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TagOnlyIntent([property: JsonPropertyName("t")] string Tag);
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonSerializable(typeof(ProtocolClientConfiguration))]
|
||||
[JsonSerializable(typeof(HostdRendezvous))]
|
||||
[JsonSerializable(typeof(TagOnlyIntent))]
|
||||
internal partial class ProtocolJsonContext : JsonSerializerContext;
|
||||
|
||||
public static class ProtocolJson
|
||||
{
|
||||
public static HostdRendezvous DeserializeRendezvous(string json)
|
||||
{
|
||||
var value = JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.HostdRendezvous)
|
||||
?? throw new JsonException("empty hostd rendezvous");
|
||||
value.Validate();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace NucleicProtocol.Interop;
|
||||
|
||||
/// Managed ownership wrapper for the shared Swift transcript projector.
|
||||
public interface ITranscriptProjector : IDisposable
|
||||
{
|
||||
ProjectionDiff Apply(string hostMessageJson);
|
||||
}
|
||||
|
||||
public sealed class ProtocolProjection : ITranscriptProjector
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly INativeProtocol native;
|
||||
private nint handle;
|
||||
|
||||
public ProtocolProjection(INativeProtocol? native = null)
|
||||
{
|
||||
this.native = native ?? PInvokeNativeProtocol.Instance;
|
||||
handle = this.native.ProjectionCreate();
|
||||
if (handle == 0) throw new InvalidOperationException("could not create transcript projection");
|
||||
}
|
||||
|
||||
public ProjectionDiff Apply(string hostMessageJson)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(handle == 0, this);
|
||||
var value = native.ProjectionApply(handle, hostMessageJson);
|
||||
return ProjectionDiff.Parse(native.CopyAndFreeString(value));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (handle == 0) return;
|
||||
native.ProjectionFree(handle);
|
||||
handle = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user