Files
nucleic-windows/NucleicProtocol.Interop/ProtocolModels.cs
T

138 lines
5.3 KiB
C#
Raw Normal View History

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