feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode
Core ABI extensions (voicecat.h): - vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads - VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password - VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_ until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519) - vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only - VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate - vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores it atomically so the audio RT path reads without a lock C++ implementation: - SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id, password_protected, and max_users (were permanently zeroed) - TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS - TofuStore split into peek (read-only) + pin (write) so first-connect only persists after user approval; tofu_store_path in vc_config for per-user pin file location - TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows) - windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests - New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green) Windows client (clients/windows/ — .NET 10 WinForms): - VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks, Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer - VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity- Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode, per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar) - PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation) - PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams - Accessibility: explicit AccessibleName/Description on every control, & mnemonics, Activity log ListBox as durable screen-reader record, AutomationNotification for curated live announcements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
253
clients/windows/VoiceCat.Interop/VoiceCatClient.cs
Normal file
253
clients/windows/VoiceCat.Interop/VoiceCatClient.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
// The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around
|
||||
// NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates").
|
||||
namespace VoiceCat.Interop;
|
||||
|
||||
public sealed class VoiceCatClient : IDisposable
|
||||
{
|
||||
private readonly VoiceCatClientHandle _handle = new();
|
||||
private readonly GCHandle _selfHandle;
|
||||
|
||||
// Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not
|
||||
// just vc_client_create(): client_name/client_version are read later, whenever connect()
|
||||
// actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by
|
||||
// value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy
|
||||
// has returned (which synchronously joins every internal thread, so nothing can still be
|
||||
// reading these pointers by then).
|
||||
private nint _clientNamePtr;
|
||||
private nint _clientVersionPtr;
|
||||
private nint _tofuStorePathPtr;
|
||||
|
||||
private readonly Channel<VoiceCatEvent> _events =
|
||||
Channel.CreateUnbounded<VoiceCatEvent>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
});
|
||||
|
||||
// on_level fires far more often than on_event and intermediate values are visually
|
||||
// irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, float> _latestLevels = new();
|
||||
|
||||
/// <summary>Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's
|
||||
/// doc comment) for every event, in order, never coalesced.</summary>
|
||||
public event Action<VoiceCatEvent>? EventReceived;
|
||||
|
||||
/// <summary>Raised from PumpEvents() with the latest RMS level per stream_id since the
|
||||
/// last pump.</summary>
|
||||
public event Action<uint, float>? LevelChanged;
|
||||
|
||||
public unsafe VoiceCatClient(string clientName, string clientVersion,
|
||||
VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null)
|
||||
{
|
||||
_selfHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||
|
||||
_clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName);
|
||||
_clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion);
|
||||
_tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath);
|
||||
|
||||
var cfg = new VcConfigNative
|
||||
{
|
||||
ClientName = _clientNamePtr,
|
||||
ClientVersion = _clientVersionPtr,
|
||||
LogLevel = logLevel,
|
||||
TofuStorePath = _tofuStorePathPtr,
|
||||
};
|
||||
|
||||
var cb = new VcCallbacksNative
|
||||
{
|
||||
OnEvent = (nint)(delegate* unmanaged<nint, VcEventNative*, void>)&NativeCallbacks.OnEvent,
|
||||
OnLevel = (nint)(delegate* unmanaged<nint, uint, float, void>)&NativeCallbacks.OnLevel,
|
||||
User = GCHandle.ToIntPtr(_selfHandle),
|
||||
};
|
||||
|
||||
nint native = NativeMethods.vc_client_create(in cfg, cb);
|
||||
_handle.SetHandle(native);
|
||||
if (_handle.IsInvalid)
|
||||
{
|
||||
FreeConfigStrings();
|
||||
_selfHandle.Free();
|
||||
throw new InvalidOperationException("vc_client_create failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains every event/level sample queued since the last call. Call this from a
|
||||
/// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary
|
||||
/// where the core's own event-delivery thread hands off to the UI thread; see
|
||||
/// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen
|
||||
/// over a message-only window + PostMessage.
|
||||
/// </summary>
|
||||
public void PumpEvents()
|
||||
{
|
||||
while (_events.Reader.TryRead(out var ev))
|
||||
EventReceived?.Invoke(ev);
|
||||
|
||||
if (!_latestLevels.IsEmpty)
|
||||
{
|
||||
foreach (var (streamId, rms) in _latestLevels)
|
||||
LevelChanged?.Invoke(streamId, rms);
|
||||
_latestLevels.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
internal void EnqueueEvent(VoiceCatEvent ev)
|
||||
{
|
||||
// Temporary diagnostic (manual debugging session) — confirms the native callback
|
||||
// chain (UnmanagedCallersOnly -> GCHandle resolve -> here) actually fires, independent
|
||||
// of whether the UI-thread drain (PumpEvents) ever sees it.
|
||||
Console.WriteLine($"[VoiceCatClient] EnqueueEvent (native thread): {ev}");
|
||||
_events.Writer.TryWrite(ev);
|
||||
}
|
||||
internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms;
|
||||
|
||||
// ── Connection & auth ────────────────────────────────────────────────────────────────
|
||||
public VcResult Connect(string host, ushort port) =>
|
||||
NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port);
|
||||
|
||||
public VcResult Disconnect() =>
|
||||
NativeMethods.vc_disconnect(_handle.DangerousGetHandle());
|
||||
|
||||
public VcResult AuthenticateGuest(string nickname) =>
|
||||
NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname);
|
||||
|
||||
public VcResult AuthenticateUser(string username, string password) =>
|
||||
NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password);
|
||||
|
||||
// ── TOFU server-identity gate (M4) ──────────────────────────────────────────────────────
|
||||
public VcResult ConfirmServerIdentity(bool accept) =>
|
||||
NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0);
|
||||
|
||||
/// <summary>The Ed25519 identity fingerprint from ServerHello, hex-formatted — display
|
||||
/// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty
|
||||
/// string if not yet available.</summary>
|
||||
public string GetServerIdentityDisplay()
|
||||
{
|
||||
nint c = _handle.DangerousGetHandle();
|
||||
NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len);
|
||||
if (len == 0) return string.Empty;
|
||||
|
||||
nint buf = Marshal.AllocHGlobal((int)len + 1);
|
||||
try
|
||||
{
|
||||
NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _);
|
||||
return Marshal.PtrToStringUTF8(buf) ?? string.Empty;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
||||
/// <summary>Result arrives as a VcEventType.JoinResult event, not via this return value
|
||||
/// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment).
|
||||
/// NOTE: no in-tree channel has a server-side password to check yet (M5+ feature) — this
|
||||
/// path is wired but not yet exercisable end-to-end.</summary>
|
||||
public VcResult JoinChannel(uint channelId, string? password = null) =>
|
||||
NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password);
|
||||
|
||||
public VcResult LeaveChannel() =>
|
||||
NativeMethods.vc_leave_channel(_handle.DangerousGetHandle());
|
||||
|
||||
public List<ChannelInfo> ListChannels()
|
||||
{
|
||||
NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
public List<UserInfo> ListUsers()
|
||||
{
|
||||
NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
public List<StreamSummary> ListUserStreams(uint userId)
|
||||
{
|
||||
var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native);
|
||||
return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List<StreamSummary>();
|
||||
}
|
||||
|
||||
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
||||
public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label)
|
||||
{
|
||||
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
|
||||
try
|
||||
{
|
||||
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr };
|
||||
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
|
||||
return (r, streamId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(labelPtr);
|
||||
}
|
||||
}
|
||||
|
||||
public VcResult StopStream(uint streamId) =>
|
||||
NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId);
|
||||
|
||||
public VcResult SetInputDevice(uint streamId, string? deviceId) =>
|
||||
NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId);
|
||||
|
||||
public VcResult SetInputMode(VcInputMode mode) =>
|
||||
NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode);
|
||||
|
||||
public VcResult SetVadThreshold(float threshold) =>
|
||||
NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold);
|
||||
|
||||
public VcResult SetPushToTalk(bool active) =>
|
||||
NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0);
|
||||
|
||||
public VcResult SetSelfMute(bool micMuted, bool deafened) =>
|
||||
NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0);
|
||||
|
||||
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
||||
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||
|
||||
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
|
||||
{
|
||||
var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId,
|
||||
streamId, out var native);
|
||||
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
||||
}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────────────────────
|
||||
public VcResult SendText(VcTextScope scope, uint targetId, string utf8) =>
|
||||
NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8);
|
||||
|
||||
// ── Device enumeration (works pre-connect) ──────────────────────────────────────────────
|
||||
public List<DeviceInfo> ListDevices(VcDeviceKind kind)
|
||||
{
|
||||
NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native);
|
||||
return Marshaling.ToManaged(ref native);
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
|
||||
// NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never-
|
||||
// freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport
|
||||
// auto-marshal these as `string` (it would try to free a static literal and corrupt the
|
||||
// heap). Marshal.PtrToStringUTF8 just reads; it never frees.
|
||||
public static string VersionString =>
|
||||
Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty;
|
||||
|
||||
public static string ResultString(VcResult code) =>
|
||||
Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously
|
||||
FreeConfigStrings();
|
||||
if (_selfHandle.IsAllocated) _selfHandle.Free();
|
||||
}
|
||||
|
||||
private void FreeConfigStrings()
|
||||
{
|
||||
if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; }
|
||||
if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; }
|
||||
if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user