Retire legacy implementations and flatten managed layout
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using Voicecat.V1;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public sealed partial class VoiceCatClient
|
||||
{
|
||||
public Permissions Permissions => Authentication?.Permissions?.Clone() ?? new Permissions();
|
||||
|
||||
public Task<GenericResult> KickUserAsync(uint userId, string reason = "", CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { Kick = new() { UserId = userId, Reason = reason } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> BanUserAsync(uint userId, string reason = "", ulong expiresUnixMs = 0, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { Ban = new() { UserId = userId, Reason = reason, ExpiresUnixMs = expiresUnixMs } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> MoveUserAsync(uint userId, uint channelId, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { MoveUser = new() { UserId = userId, ChannelId = channelId } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> SetServerMuteAsync(uint userId, bool muted, bool deafened, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { ServerMute = new() { UserId = userId, Muted = muted, Deafened = deafened } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> SetPermissionsAsync(uint userId, Permissions permissions, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { SetPermission = new() { UserId = userId, Permissions = permissions.Clone() } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> CreateChannelAsync(Channel channel, string password = "", CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { CreateChannel = new() { Channel = channel.Clone(), Password = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> EditChannelAsync(Channel channel, string password = "", CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { EditChannel = new() { Channel = channel.Clone(), Password = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> DeleteChannelAsync(uint channelId, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { DeleteChannel = new() { ChannelId = channelId } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> CreateAccountAsync(string username, string password, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { CreateAccount = new() { Username = username, Password = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> ResetPasswordAsync(string username, string password, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { ResetPassword = new() { Username = username, NewPassword = password } }, cancellationToken);
|
||||
|
||||
public Task<GenericResult> DeleteAccountAsync(string username, CancellationToken cancellationToken = default) =>
|
||||
RequestGenericAsync(new() { DeleteAccount = new() { Username = username } }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<AccountEntry>> ListAccountsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Envelope response = await RequestAsync(new() { ListAccounts = new() }, cancellationToken).ConfigureAwait(false);
|
||||
if (response.ListAccountsResult is null) throw new IOException("Unexpected account-list response.");
|
||||
return response.ListAccountsResult.Accounts.Select(account => account.Clone()).ToArray();
|
||||
}
|
||||
|
||||
public void SetSelfAudioState(bool microphoneMuted, bool deafened)
|
||||
{
|
||||
Audio.MicMuted = microphoneMuted;
|
||||
Audio.Deafened = deafened;
|
||||
foreach (StreamInfo stream in LocalStreams)
|
||||
{
|
||||
(float _, bool talking) = Audio.GetLocalLevel(stream.StreamId);
|
||||
Send(new() { StreamState = new() { StreamId = stream.StreamId, Muted = microphoneMuted, Talking = talking } });
|
||||
}
|
||||
}
|
||||
|
||||
public void PublishStreamState(uint streamId, bool talking)
|
||||
{
|
||||
if (!LocalStreams.Any(stream => stream.StreamId == streamId)) return;
|
||||
Send(new() { StreamState = new() { StreamId = streamId, Muted = Audio.MicMuted, Talking = talking } });
|
||||
}
|
||||
|
||||
private async Task<GenericResult> RequestGenericAsync(Envelope request, CancellationToken cancellationToken)
|
||||
{
|
||||
Envelope response = await RequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return response.GenericResult?.Clone() ?? throw new IOException("Unexpected administration response.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Transport;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public delegate void EncodedVoiceHandler(VoiceFrameHeader header, ReadOnlySpan<byte> payload);
|
||||
|
||||
internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket socket;
|
||||
private readonly MediaSessionCrypto crypto;
|
||||
private readonly CancellationTokenSource stop;
|
||||
private readonly byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
private readonly byte[] keepalive = new byte[VoiceFrameHeader.Size];
|
||||
private readonly PacketQueue packets = new();
|
||||
private readonly Task sending;
|
||||
private readonly Task receiving;
|
||||
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal event EncodedVoiceHandler? Received;
|
||||
internal Task Bound => bound.Task;
|
||||
|
||||
internal ClientMediaTransport(IPEndPoint endpoint, ReadOnlySpan<byte> token, MediaSessionCrypto crypto, CancellationToken cancellationToken)
|
||||
{
|
||||
if (token.Length != 16) throw new IOException("Invalid UDP binding token.");
|
||||
this.crypto = crypto;
|
||||
stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { socket.Connect(endpoint); }
|
||||
catch { socket.Dispose(); stop.Dispose(); throw; }
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
token.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
receiving = ReceiveAsync();
|
||||
sending = SendAsync();
|
||||
}
|
||||
|
||||
internal bool TrySend(VoiceFrameHeader header, ReadOnlySpan<byte> payload) => packets.TryWrite(header, payload);
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
byte[] plain = new byte[1275], packet = new byte[1275 + VoiceFrameHeader.Size + MediaEncryptor.TagSize];
|
||||
long nextKeepalive = 0;
|
||||
try
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
if (Environment.TickCount64 >= nextKeepalive)
|
||||
{
|
||||
if (!bound.Task.IsCompleted) await socket.SendAsync(binding, SocketFlags.None, stop.Token).ConfigureAwait(false);
|
||||
await socket.SendAsync(keepalive, SocketFlags.None, stop.Token).ConfigureAwait(false);
|
||||
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250);
|
||||
}
|
||||
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
|
||||
{
|
||||
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
|
||||
await socket.SendAsync(packet.AsMemory(0, size), SocketFlags.None, stop.Token).ConfigureAwait(false);
|
||||
}
|
||||
await Task.Delay(5, stop.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{ if (!stop.IsCancellationRequested) bound.TrySetException(exception); }
|
||||
finally { stop.Cancel(); }
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
byte[] packet = new byte[65535], plain = new byte[65535];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int length;
|
||||
try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; }
|
||||
if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue;
|
||||
if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; }
|
||||
if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
||||
!crypto.Decryptor.TryDecrypt(packet.AsSpan(0, length), plain, out var header, out int size)) continue;
|
||||
Received?.Invoke(header, plain.AsSpan(0, size));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{ if (!stop.IsCancellationRequested) bound.TrySetException(exception); }
|
||||
finally { bound.TrySetCanceled(); stop.Cancel(); }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); socket.Dispose();
|
||||
try { await Task.WhenAll(sending, receiving).ConfigureAwait(false); }
|
||||
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); }
|
||||
}
|
||||
|
||||
// A bounded, allocation-free packet handoff. A contending producer drops instead of
|
||||
// waiting; the network owner alone consumes and encrypts. Audio never enters a Channel lock.
|
||||
private sealed class PacketQueue
|
||||
{
|
||||
private readonly byte[][] payloads = Enumerable.Range(0, 64).Select(_ => new byte[1275]).ToArray();
|
||||
private readonly VoiceFrameHeader[] headers = new VoiceFrameHeader[64];
|
||||
private readonly int[] lengths = new int[64];
|
||||
private int read, written, producer;
|
||||
internal bool TryWrite(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length is < 1 or > 1275 || Interlocked.CompareExchange(ref producer, 1, 0) != 0) return false;
|
||||
try
|
||||
{
|
||||
int index = written;
|
||||
if (unchecked(index - Volatile.Read(ref read)) >= 64) return false;
|
||||
int slot = index & 63;
|
||||
payload.CopyTo(payloads[slot]); headers[slot] = header; lengths[slot] = payload.Length;
|
||||
Volatile.Write(ref written, unchecked(index + 1)); return true;
|
||||
}
|
||||
finally { Volatile.Write(ref producer, 0); }
|
||||
}
|
||||
internal bool TryRead(Span<byte> payload, out VoiceFrameHeader header, out int length)
|
||||
{
|
||||
int index = read; header = default; length = 0;
|
||||
if (index == Volatile.Read(ref written)) return false;
|
||||
int slot = index & 63; header = headers[slot]; length = lengths[slot];
|
||||
payloads[slot].AsSpan(0, length).CopyTo(payload);
|
||||
Volatile.Write(ref read, unchecked(index + 1)); return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public enum ServerAuthentication { Guest, Account }
|
||||
|
||||
public sealed record ServerProfile(Guid Id, string Host, ushort Port, ServerAuthentication Authentication, string? Username, string? Nickname,
|
||||
[property: JsonIgnore] string? LegacyKeychainTag = null)
|
||||
{
|
||||
public static ServerProfile Create(string host, ushort port, ServerAuthentication authentication, string? username = null, string? nickname = null, Guid? id = null)
|
||||
{
|
||||
host = host.Trim(); username = Normalize(username); nickname = Normalize(nickname);
|
||||
if (host.Length == 0) throw new ArgumentException("Server host is required.", nameof(host));
|
||||
if (port == 0) throw new ArgumentOutOfRangeException(nameof(port));
|
||||
if (authentication == ServerAuthentication.Account && username is null) throw new ArgumentException("Username is required for account authentication.", nameof(username));
|
||||
return new(id.GetValueOrDefault(Guid.NewGuid()), host, port, authentication,
|
||||
authentication == ServerAuthentication.Account ? username : null,
|
||||
authentication == ServerAuthentication.Guest ? nickname : null);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string DisplayName => Authentication == ServerAuthentication.Account
|
||||
? $"{Username}@{Host}:{Port}"
|
||||
: $"{Host}:{Port} (Guest{(Nickname is null ? "" : $": {Nickname}")})";
|
||||
|
||||
internal bool IsValid => Id != Guid.Empty && !string.IsNullOrWhiteSpace(Host) && Port != 0 &&
|
||||
(Authentication == ServerAuthentication.Guest || !string.IsNullOrWhiteSpace(Username));
|
||||
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed class ServerProfileStore(string path)
|
||||
{
|
||||
public IReadOnlyList<ServerProfile> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return [];
|
||||
byte[] contents = File.ReadAllBytes(path);
|
||||
using JsonDocument document = JsonDocument.Parse(contents);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Array && document.RootElement.EnumerateArray().Any(LooksLegacy))
|
||||
return LoadLegacy(document.RootElement);
|
||||
return (JsonSerializer.Deserialize(contents, ServerProfileJsonContext.Default.ServerProfileArray) ?? [])
|
||||
.Where(profile => profile.IsValid).ToArray();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { return []; }
|
||||
}
|
||||
|
||||
public void Save(IEnumerable<ServerProfile> profiles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profiles);
|
||||
ServerProfile[] valid = profiles.Where(profile => profile is not null && profile.IsValid).ToArray();
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
PreserveLegacyBackup(fullPath);
|
||||
string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(valid, ServerProfileJsonContext.Default.ServerProfileArray));
|
||||
File.Move(temporary, fullPath, true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
private static bool LooksLegacy(JsonElement item) => item.ValueKind == JsonValueKind.Object && item.TryGetProperty("authMode", out _);
|
||||
|
||||
private static IReadOnlyList<ServerProfile> LoadLegacy(JsonElement root)
|
||||
{
|
||||
var profiles = new List<ServerProfile>();
|
||||
foreach (JsonElement item in root.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("id", out JsonElement idValue) || !Guid.TryParse(idValue.GetString(), out Guid id) ||
|
||||
!item.TryGetProperty("host", out JsonElement hostValue) || !item.TryGetProperty("port", out JsonElement portValue) ||
|
||||
!portValue.TryGetUInt16(out ushort port)) continue;
|
||||
string? mode = item.TryGetProperty("authMode", out JsonElement modeValue) ? modeValue.GetString() : null;
|
||||
string? username = item.TryGetProperty("savedUsername", out JsonElement usernameValue) ? usernameValue.GetString() : null;
|
||||
string? nickname = item.TryGetProperty("nickname", out JsonElement nicknameValue) ? nicknameValue.GetString() : null;
|
||||
string? keychainTag = item.TryGetProperty("keychainTag", out JsonElement tagValue) ? tagValue.GetString() : null;
|
||||
ServerAuthentication authentication = mode == "password" ? ServerAuthentication.Account : ServerAuthentication.Guest;
|
||||
try { profiles.Add(ServerProfile.Create(hostValue.GetString() ?? "", port, authentication, username, nickname, id) with { LegacyKeychainTag = keychainTag }); }
|
||||
catch (ArgumentException) { }
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
private static void PreserveLegacyBackup(string fullPath)
|
||||
{
|
||||
if (!File.Exists(fullPath)) return;
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(fullPath));
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array || !document.RootElement.EnumerateArray().Any(LooksLegacy)) return;
|
||||
string backup = fullPath + ".swift-backup.json";
|
||||
if (!File.Exists(backup)) File.Copy(fullPath, backup);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true, UseStringEnumConverter = true)]
|
||||
[JsonSerializable(typeof(ServerProfile[]))]
|
||||
internal sealed partial class ServerProfileJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../VoiceCat.Crypto/VoiceCat.Crypto.csproj" />
|
||||
<ProjectReference Include="../VoiceCat.Audio/VoiceCat.Audio.csproj" />
|
||||
<InternalsVisibleTo Include="VoiceCat.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Transport;
|
||||
using VoiceCat.Protocol;
|
||||
using VoiceCat.Audio;
|
||||
using Voicecat.V1;
|
||||
using Channel = Voicecat.V1.Channel;
|
||||
|
||||
namespace VoiceCat.Core;
|
||||
|
||||
public enum ClientConnectionState { Disconnected, Connecting, VerifyingIdentity, Authenticating, Connected }
|
||||
public sealed record ServerIdentityChallenge(string Host, ushort Port, string CertificateFingerprint, TofuStatus Status);
|
||||
|
||||
public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
{
|
||||
private readonly string clientName;
|
||||
private readonly string clientVersion;
|
||||
private readonly TofuStore pins;
|
||||
private readonly SemaphoreSlim lifecycle = new(1);
|
||||
private readonly CancellationTokenSource disposed = new();
|
||||
private readonly object stateGate = new();
|
||||
private readonly ConcurrentDictionary<ulong, TaskCompletionSource<Envelope>> pending = new();
|
||||
private readonly System.Threading.Channels.Channel<Envelope> events = System.Threading.Channels.Channel.CreateBounded<Envelope>(128);
|
||||
private readonly Dictionary<uint, Channel> channels = [];
|
||||
private readonly Dictionary<uint, User> users = [];
|
||||
private readonly Dictionary<uint, StreamInfo> localStreams = [];
|
||||
public AudioEngine Audio { get; }
|
||||
public IReadOnlyList<StreamInfo> LocalStreams { get { lock (stateGate) return localStreams.Values.Select(s => s.Clone()).ToArray(); } }
|
||||
private TlsControlConnection? control;
|
||||
private MediaSessionCrypto? mediaCrypto;
|
||||
private ClientMediaTransport? media;
|
||||
private Task keepalive = Task.CompletedTask;
|
||||
public event EncodedVoiceHandler? VoiceReceived;
|
||||
private CancellationTokenSource? connectionLifetime;
|
||||
private Task reader = Task.CompletedTask;
|
||||
private long nextRequest;
|
||||
private AuthResult? authentication;
|
||||
private ServerHello? hello;
|
||||
private ClientConnectionState state;
|
||||
|
||||
public event Action<ClientConnectionState>? ConnectionStateChanged;
|
||||
public ClientConnectionState State { get { lock (stateGate) return state; } }
|
||||
public Exception? ConnectionFailure { get; private set; }
|
||||
public Task Completion => reader;
|
||||
public AuthResult? Authentication { get { lock (stateGate) return authentication?.Clone(); } }
|
||||
public ServerHello? ServerHello { get { lock (stateGate) return hello?.Clone(); } }
|
||||
public IReadOnlyList<Channel> Channels { get { lock (stateGate) return channels.Values.Select(c => c.Clone()).ToArray(); } }
|
||||
public IReadOnlyList<User> Users { get { lock (stateGate) return users.Values.Select(u => u.Clone()).ToArray(); } }
|
||||
public bool TryReadEvent(out Envelope? envelope) => events.Reader.TryRead(out envelope);
|
||||
public IAsyncEnumerable<Envelope> ReadEventsAsync(CancellationToken cancellationToken = default) => events.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public VoiceCatClient(string clientName = "VoiceCat .NET", string clientVersion = "0.1.0", string? tofuStorePath = null)
|
||||
{
|
||||
this.clientName = clientName;
|
||||
this.clientVersion = clientVersion;
|
||||
pins = new(tofuStorePath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VoiceCat", "tofu.txt"));
|
||||
Audio = new(TrySendEncodedVoice);
|
||||
VoiceReceived += Audio.Receive;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(string host, ushort port, Func<ServerIdentityChallenge, CancellationToken, ValueTask<bool>>? confirmIdentity = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(port);
|
||||
await lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
Socket? socket = null;
|
||||
bool started = false;
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed.IsCancellationRequested, this);
|
||||
if (control is not null) throw new InvalidOperationException("Disconnect before reconnecting.");
|
||||
started = true;
|
||||
ConnectionFailure = null;
|
||||
connectionLifetime = CancellationTokenSource.CreateLinkedTokenSource(disposed.Token);
|
||||
using var connecting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, connectionLifetime.Token);
|
||||
CancellationToken token = connecting.Token;
|
||||
SetState(ClientConnectionState.Connecting);
|
||||
socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
|
||||
await socket.ConnectAsync(host, port, token).ConfigureAwait(false);
|
||||
string? fingerprint = null;
|
||||
control = new(socket, TlsSession.CreateClient(value => { fingerprint = value; return true; }), connectionLifetime.Token);
|
||||
socket = null; // Transport owns it from here.
|
||||
mediaCrypto = await control.TakeMediaCryptoAsync(token).ConfigureAwait(false);
|
||||
string certificatePin = fingerprint ?? throw new IOException("TLS did not report a certificate fingerprint.");
|
||||
TofuStatus pinStatus = pins.Check(host, port, certificatePin);
|
||||
if (pinStatus != TofuStatus.Matched)
|
||||
{
|
||||
SetState(ClientConnectionState.VerifyingIdentity);
|
||||
if (confirmIdentity is null || !await confirmIdentity(new(host, port, certificatePin, pinStatus), token).ConfigureAwait(false))
|
||||
throw new System.Security.Authentication.AuthenticationException("Server identity was rejected.");
|
||||
pins.Pin(host, port, certificatePin);
|
||||
}
|
||||
reader = ReadAsync(control, connectionLifetime.Token);
|
||||
Envelope response = await RequestAsync(new() { ClientHello = new() { ProtoVersion = 2, ClientName = clientName, ClientVersion = clientVersion } }, token).ConfigureAwait(false);
|
||||
if (response.ServerHello?.ProtoVersion != 2) throw new IOException("Unsupported server protocol.");
|
||||
lock (stateGate) hello = response.ServerHello.Clone();
|
||||
keepalive = KeepaliveAsync(connectionLifetime.Token);
|
||||
SetState(ClientConnectionState.Authenticating);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket?.Dispose();
|
||||
if (started) await CloseAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
public Task<AuthResult> AuthenticateGuestAsync(string nickname, CancellationToken cancellationToken = default) =>
|
||||
AuthenticateAsync(new() { Guest = new() { Nickname = nickname } }, cancellationToken);
|
||||
public Task<AuthResult> AuthenticateUserAsync(string username, string password, CancellationToken cancellationToken = default) =>
|
||||
AuthenticateAsync(new() { Password = new() { Username = username, Password = password } }, cancellationToken);
|
||||
|
||||
private async Task<AuthResult> AuthenticateAsync(AuthRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (State != ClientConnectionState.Authenticating) throw new InvalidOperationException("Authentication requires a connected TLS session.");
|
||||
Envelope response = await RequestAsync(new() { AuthRequest = request }, cancellationToken).ConfigureAwait(false);
|
||||
AuthResult result = response.AuthResult ?? throw new IOException("Unexpected authentication response.");
|
||||
if (result.Ok)
|
||||
{
|
||||
var endpoint = (IPEndPoint)control!.RemoteEndPoint;
|
||||
IPAddress address = endpoint.Address.IsIPv4MappedToIPv6 ? endpoint.Address.MapToIPv4() : endpoint.Address;
|
||||
media = new(new(address, checked((int)ServerHello!.UdpPort)), result.UdpToken.Span, mediaCrypto!, connectionLifetime!.Token);
|
||||
media.Received += (header, packet) => VoiceReceived?.Invoke(header, packet);
|
||||
SetState(ClientConnectionState.Connected);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<VoiceSubscriptionResult> SubscribeVoiceAsync(bool subscribe = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (subscribe && media is not null) await media.Bound.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||
return (await RequestAsync(subscribe ? new() { SubscribeVoice = new() } : new() { UnsubscribeVoice = new() }, cancellationToken).ConfigureAwait(false)).VoiceSubscriptionResult;
|
||||
}
|
||||
|
||||
public bool TrySendEncodedVoice(uint ssrc, uint timestamp, ReadOnlySpan<byte> payload, VoiceFrameFlags flags = VoiceFrameFlags.None) =>
|
||||
media?.TrySend(new(MediaFrameType.Voice, flags, 0, ssrc, 0, timestamp), payload) == true;
|
||||
|
||||
public async Task<StreamInfo> StartStreamAsync(StreamKind kind, string label = "", int captureChannels = 1, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client is disconnected.");
|
||||
var response = (await RequestAsync(new() { StreamAnnounce = new() { Kind = kind, Label = label } }, cancellationToken).ConfigureAwait(false)).StreamAnnounceResult;
|
||||
if (!response.Ok) throw new InvalidOperationException(response.Error);
|
||||
var info = new StreamInfo { StreamId = response.StreamId, Ssrc = response.Ssrc, Kind = kind, Audio = response.EffectiveAudio.Clone(), Label = label };
|
||||
try
|
||||
{
|
||||
lock (stateGate) { if (State != ClientConnectionState.Connected) throw new InvalidOperationException("Client disconnected during stream negotiation."); Audio.AddLocalStream(info, captureChannels); localStreams[info.StreamId] = info; }
|
||||
return info.Clone();
|
||||
}
|
||||
catch { if (State == ClientConnectionState.Connected) Send(new() { StreamStop = new() { StreamId = info.StreamId } }); throw; }
|
||||
}
|
||||
|
||||
public void StopStream(uint streamId)
|
||||
{
|
||||
lock (stateGate) { localStreams.Remove(streamId); Audio.RemoveLocalStream(streamId); }
|
||||
Send(new() { StreamStop = new() { StreamId = streamId } });
|
||||
}
|
||||
|
||||
private async Task KeepaliveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or IOException or InvalidOperationException) { }
|
||||
}
|
||||
|
||||
public async Task<Envelope> RequestAsync(Envelope request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
TlsControlConnection connection = control ?? throw new InvalidOperationException("Client is disconnected.");
|
||||
var completion = new TaskCompletionSource<Envelope>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ulong id = checked((ulong)Interlocked.Increment(ref nextRequest));
|
||||
Envelope outbound = request.Clone(); outbound.RequestId = id;
|
||||
if (!pending.TryAdd(id, completion)) throw new InvalidOperationException("Request ids exhausted.");
|
||||
try
|
||||
{
|
||||
if (!connection.TrySend(outbound)) throw new IOException("Control queue is full or closed.");
|
||||
return await completion.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { pending.TryRemove(id, out _); }
|
||||
}
|
||||
|
||||
public void Send(Envelope message)
|
||||
{
|
||||
TlsControlConnection connection = control ?? throw new InvalidOperationException("Client is disconnected.");
|
||||
if (!connection.TrySend(message.Clone())) throw new IOException("Control queue is full or closed.");
|
||||
}
|
||||
|
||||
private async Task ReadAsync(TlsControlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
await foreach (Envelope message in connection.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
Apply(message);
|
||||
if (message.RequestId != 0 && pending.TryRemove(message.RequestId, out var completion)) completion.TrySetResult(message.Clone());
|
||||
if (!events.Writer.TryWrite(message.Clone())) throw new IOException("Client event queue exhausted; consume events regularly.");
|
||||
if (message.Disconnect is not null) { connection.CompleteWrites(); break; }
|
||||
}
|
||||
}
|
||||
catch (Exception exception) { failure = exception; ConnectionFailure = exception; }
|
||||
finally
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
foreach (var operation in pending.Values) operation.TrySetException(failure ?? new IOException("Connection closed."));
|
||||
SetState(ClientConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
private void Apply(Envelope message)
|
||||
{
|
||||
lock (stateGate)
|
||||
{
|
||||
if (message.AuthResult?.Ok == true) authentication = message.AuthResult.Clone();
|
||||
if (message.ServerState is not null)
|
||||
{
|
||||
channels.Clear(); users.Clear();
|
||||
foreach (var channel in message.ServerState.Channels) channels[channel.Id] = channel.Clone();
|
||||
foreach (var user in message.ServerState.Users) users[user.Id] = user.Clone();
|
||||
}
|
||||
if (message.ChannelEvent is not null)
|
||||
{
|
||||
if (message.ChannelEvent.Kind == ChannelEvent.Types.Kind.Deleted) channels.Remove(message.ChannelEvent.DeletedId);
|
||||
else if (message.ChannelEvent.Channel is not null) channels[message.ChannelEvent.Channel.Id] = message.ChannelEvent.Channel.Clone();
|
||||
}
|
||||
if (message.UserEvent is not null)
|
||||
{
|
||||
if (message.UserEvent.Kind == UserEvent.Types.Kind.Left) users.Remove(message.UserEvent.LeftId);
|
||||
else if (message.UserEvent.User is not null) users[message.UserEvent.User.Id] = message.UserEvent.User.Clone();
|
||||
}
|
||||
if (authentication is not null && (message.ServerState is not null || message.UserEvent is not null))
|
||||
{
|
||||
User self = users.GetValueOrDefault(authentication.Self.Id, authentication.Self);
|
||||
Audio.SetRemoteStreams(users.Values.ToArray(), self.Id, self.ChannelId);
|
||||
foreach (var id in localStreams.Keys.Where(id => !self.Streams.Any(s => s.StreamId == id)).ToArray())
|
||||
{ Audio.RemoveLocalStream(id); localStreams.Remove(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetState(ClientConnectionState value)
|
||||
{
|
||||
lock (stateGate) state = value;
|
||||
ConnectionStateChanged?.Invoke(value);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
await lifecycle.WaitAsync().ConfigureAwait(false);
|
||||
try { await CloseAsync().ConfigureAwait(false); }
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
private async Task CloseAsync()
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
try { await reader.ConfigureAwait(false); }
|
||||
finally
|
||||
{
|
||||
try { await keepalive.ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
try { if (media is not null) await media.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
try { if (control is not null) await control.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception exception) when (exception is IOException or OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
control = null;
|
||||
media = null;
|
||||
mediaCrypto?.Dispose(); mediaCrypto = null;
|
||||
connectionLifetime?.Dispose(); connectionLifetime = null;
|
||||
lock (stateGate) { authentication = null; hello = null; channels.Clear(); users.Clear(); }
|
||||
lock (stateGate)
|
||||
{
|
||||
foreach (var id in localStreams.Keys) Audio.RemoveLocalStream(id);
|
||||
localStreams.Clear(); Audio.SetRemoteStreams([], 0, 0);
|
||||
}
|
||||
SetState(ClientConnectionState.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed.IsCancellationRequested) return;
|
||||
disposed.Cancel();
|
||||
await DisconnectAsync().ConfigureAwait(false);
|
||||
events.Writer.TryComplete();
|
||||
Audio.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/ios-arm64": {},
|
||||
"net10.0/iossimulator-arm64": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.7, )",
|
||||
"resolved": "10.0.7",
|
||||
"contentHash": "AA/yhzFHNtQZXLdqjzujPy25G8EWwGWsAnxOE2zYSBoT/8QHP6ketN3CToD3DFreO653ipUwnKHo22B8AlBMCw=="
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"Google.Protobuf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.36.1",
|
||||
"contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ=="
|
||||
},
|
||||
"voicecat.audio": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"VoiceCat.Codec": "[1.0.0, )",
|
||||
"VoiceCat.Dsp": "[1.0.0, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.codec": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "[2.6.2, )",
|
||||
"VoiceCat.Protocol": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"voicecat.dsp": {
|
||||
"type": "Project"
|
||||
},
|
||||
"voicecat.protocol": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Google.Protobuf": "[3.36.1, )"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user