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? MessageJsonReceived; event Action? 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? MessageJsonReceived; public event Action? 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 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 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); }