using System.Runtime.InteropServices; using System.Threading.Channels; using System.Collections.Concurrent; using VoiceCat.Audio; using VoiceCat.Core; using VoiceCat.Crypto; using Voicecat.V1; using CoreClient = VoiceCat.Core.VoiceCatClient; namespace VoiceCat.Interop; // Preserves the shipped WinForms call/event surface while its implementation moves to // the async managed core. Events still reach controls only through PumpEvents on the UI thread. public sealed partial class VoiceCatClient : IDisposable { private readonly CoreClient core; private readonly IAudioDeviceBackend? backend; private readonly System.Threading.Channels.Channel events = System.Threading.Channels.Channel.CreateUnbounded(); private readonly Dictionary captures = []; private readonly Dictionary devices = []; private readonly Dictionary remoteStreams = []; private readonly Dictionary talkState = []; private sealed class LocalStream(StreamInfo info, bool external) { internal readonly uint Alias = info.StreamId; internal StreamInfo Info = info; internal readonly bool External = external; internal int CaptureChannels = 1; internal int Restarting; } private readonly ConcurrentDictionary local = new(); private IAudioPlayback? playback; private Task connecting = Task.CompletedTask; private TaskCompletionSource? identity; private int disposed; private List accounts = []; private bool audioFailureReported; public event Action? EventReceived; public event Action? LevelChanged; public CoreClient ManagedClient => core; public VoiceCatClient(string clientName, string clientVersion, VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null, IAudioDeviceBackend? audioBackend = null) { backend = audioBackend; core = new(clientName, clientVersion, tofuStorePath); core.ConnectionStateChanged += state => { VcConnectionState mapped = state switch { ClientConnectionState.Connecting => VcConnectionState.Connecting, ClientConnectionState.VerifyingIdentity => VcConnectionState.VerifyingIdentity, ClientConnectionState.Authenticating => VcConnectionState.Authenticating, ClientConnectionState.Connected => VcConnectionState.Connected, _ => VcConnectionState.Disconnected }; Queue(new(VcEventType.ConnectionState, ConnectionState: mapped)); if (mapped == VcConnectionState.Disconnected) Queue(new(VcEventType.Disconnected)); }; core.Audio.MixedPcm += pcm => Volatile.Read(ref playback)?.Write(pcm); core.Audio.StreamPcm += ForwardPcm; } private void Queue(VoiceCatEvent message) => events.Writer.TryWrite(message); public VcResult Connect(string host, ushort port) { if (!connecting.IsCompleted || core.State != ClientConnectionState.Disconnected) return VcResult.Already; connecting = ConnectAsync(host, port); return VcResult.Ok; } private async Task ConnectAsync(string host, ushort port) { try { await core.ConnectAsync(host, port, async (challenge, token) => { identity = new(TaskCreationOptions.RunContinuationsAsynchronously); Queue(new(VcEventType.ServerIdentity, U32a: (uint)challenge.Status, Text: challenge.CertificateFingerprint)); return await identity.Task.WaitAsync(token).ConfigureAwait(false); }).ConfigureAwait(false); } catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Io, Text: exception.Message)); } } public VcResult ConfirmServerIdentity(bool accept) { identity?.TrySetResult(accept); return VcResult.Ok; } public VcResult AuthenticateGuest(string nickname) { _ = AuthenticateAsync(() => core.AuthenticateGuestAsync(nickname)); return VcResult.Ok; } public VcResult AuthenticateUser(string username, string password) { _ = AuthenticateAsync(() => core.AuthenticateUserAsync(username, password)); return VcResult.Ok; } private async Task AuthenticateAsync(Func> authenticate) { try { await connecting.ConfigureAwait(false); await authenticate().ConfigureAwait(false); } catch (Exception exception) { Queue(new(VcEventType.AuthResult, Result: VcResult.AuthFailed, Text: exception.Message)); } } public VcResult Disconnect() { StopDevices(); core.DisconnectAsync().GetAwaiter().GetResult(); return VcResult.Ok; } public string GetServerIdentityDisplay() => core.ServerHello is { } hello ? Convert.ToHexString(hello.ServerIdentityFingerprint.Span) : ""; public void PumpEvents() { if (!audioFailureReported && core.Audio.Failure is { } failure) { audioFailureReported = true; Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: failure.Message)); } while (core.TryReadEvent(out Envelope? message)) Translate(message!); while (events.Reader.TryRead(out VoiceCatEvent? message)) EventReceived?.Invoke(message); foreach (LocalStream stream in local.Values) { var info = Volatile.Read(ref stream.Info); var level = core.Audio.GetLocalLevel(info.StreamId); LevelChanged?.Invoke(stream.Alias, level.Level); if (talkState.GetValueOrDefault(stream.Alias) != level.Talking) { talkState[stream.Alias] = level.Talking; if (core.State == ClientConnectionState.Connected) core.Send(new() { StreamState = new() { StreamId = info.StreamId, Talking = level.Talking, Muted = core.Audio.MicMuted } }); EventReceived?.Invoke(new(VcEventType.TalkState, UserId: core.Authentication?.Self.Id ?? 0, StreamId: stream.Alias, U32a: level.Talking ? 1U : 0)); } } } private void Translate(Envelope message) { if (message.AuthResult is not null) Queue(new(VcEventType.AuthResult, Result: message.AuthResult.Ok ? VcResult.Ok : VcResult.AuthFailed, UserId: message.AuthResult.Self?.Id ?? 0, Text: message.AuthResult.Error)); if (message.ServerState is not null) { remoteStreams.Clear(); foreach (User user in message.ServerState.Users) UpdateStreams(user); Queue(new(VcEventType.ChannelList)); } if (message.ChannelEvent is not null) Queue(new(VcEventType.ChannelList)); if (message.UserEvent is not null) { var change = message.UserEvent; if (change.User is { VoiceSubscribed: true } self && self.Id == core.Authentication?.Self.Id) ReconcileStreams(); if (change.User is not null) UpdateStreams(change.User); if (change.Kind == UserEvent.Types.Kind.Left) remoteStreams.Remove(change.LeftId); Queue(new(change.Kind switch { UserEvent.Types.Kind.Joined => VcEventType.UserJoined, UserEvent.Types.Kind.Left => VcEventType.UserLeft, _ => VcEventType.UserUpdated }, UserId: change.User?.Id ?? change.LeftId, ChannelId: change.User?.ChannelId ?? 0, Text: change.Kind == UserEvent.Types.Kind.Joined ? change.User?.Nickname : change.Reason)); } if (message.JoinChannelResult is not null) Queue(new(VcEventType.JoinResult, Result: message.JoinChannelResult.Ok ? VcResult.Ok : VcResult.InvalidArg, ChannelId: message.JoinChannelResult.ChannelId, Text: message.JoinChannelResult.Error)); if (message.VoiceSubscriptionResult is not null) Queue(new(VcEventType.VoiceState, U32a: message.VoiceSubscriptionResult.Subscribed ? 1U : 0)); if (message.TextMessage is not null) Queue(new(VcEventType.TextMessage, UserId: message.TextMessage.SenderId, ChannelId: message.TextMessage.TargetId, TextScope: (VcTextScope)message.TextMessage.Scope, Text: message.TextMessage.Body, TimestampUnixMs: message.TextMessage.SentAtUnixMs)); if (message.StreamState is not null) Queue(new(VcEventType.TalkState, UserId: message.StreamState.UserId, StreamId: message.StreamState.UserId == core.Authentication?.Self.Id ? local.Values.FirstOrDefault(s => s.Info.StreamId == message.StreamState.StreamId)?.Alias ?? message.StreamState.StreamId : message.StreamState.StreamId, U32a: message.StreamState.Talking ? 1U : 0)); if (message.GenericResult is not null) Queue(new(VcEventType.GenericResult, Result: message.GenericResult.Ok ? VcResult.Ok : message.GenericResult.Code == 6 ? VcResult.PermissionDenied : VcResult.InvalidArg, U32a: message.GenericResult.Code, Text: message.GenericResult.Message)); if (message.ListAccountsResult is not null) { accounts = message.ListAccountsResult.Accounts.Select(a => new AccountInfo(a.Username, a.IsAdmin, a.CreatedAtUnixMs, a.LastLoginUnixMs)).ToList(); Queue(new(VcEventType.AccountList)); } if (message.Disconnect is not null) Queue(new(VcEventType.Error, Result: VcResult.Io, Text: message.Disconnect.Reason)); } private void UpdateStreams(User user) { StreamSummary[] previous = remoteStreams.GetValueOrDefault(user.Id, []); StreamSummary[] next = user.Id == core.Authentication?.Self.Id ? ListUserStreams(user.Id).ToArray() : user.Streams.Select(s => new StreamSummary(s.StreamId, (VcStreamKind)s.Kind, s.Label)).ToArray(); foreach (var stream in previous) if (!next.Any(s => s.StreamId == stream.StreamId)) Queue(new(VcEventType.StreamStopped, UserId: user.Id, StreamId: stream.StreamId)); foreach (var stream in next) if (!previous.Any(s => s.StreamId == stream.StreamId)) Queue(new(VcEventType.StreamStarted, UserId: user.Id, StreamId: stream.StreamId, U32a: (uint)stream.Kind, Text: stream.Label)); remoteStreams[user.Id] = next; } private VcResult Request(Envelope request) { if (core.State != ClientConnectionState.Connected) return VcResult.NotConnected; try { _ = RequestAsync(request); return VcResult.Ok; } catch { return VcResult.NotConnected; } } private async Task RequestAsync(Envelope request) { try { await core.RequestAsync(request).ConfigureAwait(false); } catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Io, Text: exception.Message)); } } public VcResult JoinChannel(uint id, string? password = null) => Request(new() { JoinChannel = new() { ChannelId = id, Password = password ?? "" } }); public VcResult LeaveChannel() => Request(new() { LeaveChannel = new() }); public VcResult JoinVoice() { try { if (backend is not null && playback is null) { playback = backend.OpenPlayback(); playback.BufferMilliseconds = core.Audio.DeviceBufferMilliseconds; } var result = core.SubscribeVoiceAsync().GetAwaiter().GetResult(); if (!result.Ok) { Interlocked.Exchange(ref playback, null)?.Dispose(); } return result.Ok ? VcResult.Ok : VcResult.Audio; } catch (Exception exception) { Queue(new(VcEventType.Error, Result: VcResult.Audio, Text: exception.Message)); return VcResult.Audio; } } public VcResult LeaveVoice() { StopDevices(); local.Clear(); return Request(new() { UnsubscribeVoice = new() }); } public List ListChannels() => core.Channels.Select(c => new ChannelInfo(c.Id, c.ParentId, c.Name, c.Topic, c.PasswordProtected, c.MaxUsers, unchecked((uint)c.Order), Audio(c.Audio))).ToList(); public List ListUsers() => core.Users.Select(u => new UserInfo(u.Id, u.Nickname, u.IsGuest, u.ChannelId, u.SelfMicMuted, u.SelfDeafened, u.ServerMuted, u.ServerDeafened, u.VoiceSubscribed)).ToList(); public List ListUserStreams(uint id) => id == core.Authentication?.Self.Id ? local.Values.Select(s => new StreamSummary(s.Alias, (VcStreamKind)s.Info.Kind, s.Info.Label)).ToList() : core.Users.FirstOrDefault(u => u.Id == id)?.Streams.Select(s => new StreamSummary(s.StreamId, (VcStreamKind)s.Kind, s.Label)).ToList() ?? []; public PermissionsInfo GetPermissions() { Permissions p = core.Authentication?.Permissions ?? new(); return new(p.CanCreateTempChannel, p.CanKick, p.CanBan, p.CanMoveUsers, p.CanAdminAccounts, p.IsAdmin); } public List ListAccounts() => accounts.ToList(); public List ListDevices(VcDeviceKind kind) => backend?.Enumerate(kind == VcDeviceKind.Input).Select(d => new DeviceInfo(d.Id, d.Name, d.IsDefault)).ToList() ?? []; public static string VersionString => "VoiceCat managed core 0.1.0 (protocol v2)"; public static string ResultString(VcResult result) => result.ToString(); private static AudioConfigInfo Audio(AudioConfig a) => new(a.Codec, a.Mode == ChannelMode.ModeStereo, a.SampleRate, a.BitrateBps, a.FrameMs, (uint)a.Application, a.Fec, a.ExpectedPacketLoss, a.Dtx, a.Complexity, a.Dred); private void StopDevices() { foreach (var capture in captures.Values) capture.Dispose(); captures.Clear(); local.Clear(); IAudioPlayback? previous = Interlocked.Exchange(ref playback, null); previous?.Dispose(); } public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; identity?.TrySetResult(false); StopDevices(); core.DisposeAsync().AsTask().GetAwaiter().GetResult(); } }