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? 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; } }