Files

140 lines
4.9 KiB
C#
Raw Permalink Normal View History

2026-08-04 02:46:53 -07:00
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);
}