Merge nucleic/warm-north-vole-pf37 into dev

This commit is contained in:
2026-08-04 02:46:53 -07:00
parent c34eb1ff61
commit f5ee895d3f
18 changed files with 1515 additions and 0 deletions
+80
View File
@@ -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));
}
}
+111
View File
@@ -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>
+139
View File
@@ -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);
}
+137
View File
@@ -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;
}
}
}