138 lines
5.3 KiB
C#
138 lines
5.3 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|