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>
2026-06-17 00:35:16 +02:00
|
|
|
|
using System.Diagnostics;
|
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
|
using VoiceCat.Interop;
|
|
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.Interop.Tests;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow
|
|
|
|
|
|
/// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real
|
|
|
|
|
|
/// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow
|
|
|
|
|
|
/// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through
|
|
|
|
|
|
/// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention
|
|
|
|
|
|
/// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public sealed class VoiceCatClientSmokeTests : IDisposable
|
|
|
|
|
|
{
|
|
|
|
|
|
private readonly string _tempDir;
|
|
|
|
|
|
private readonly Process _server;
|
|
|
|
|
|
private readonly ushort _port;
|
|
|
|
|
|
|
|
|
|
|
|
public VoiceCatClientSmokeTests()
|
|
|
|
|
|
{
|
|
|
|
|
|
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
|
|
|
|
|
Directory.CreateDirectory(_tempDir);
|
|
|
|
|
|
|
|
|
|
|
|
string serverExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-server.exe");
|
|
|
|
|
|
Assert.True(File.Exists(serverExe),
|
|
|
|
|
|
$"voicecat-server.exe not found at '{serverExe}' — build the m1-dev preset first " +
|
|
|
|
|
|
"(cmake --preset m1-dev && cmake --build --preset m1-dev).");
|
|
|
|
|
|
|
|
|
|
|
|
var psi = new ProcessStartInfo(serverExe)
|
|
|
|
|
|
{
|
|
|
|
|
|
Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest",
|
|
|
|
|
|
RedirectStandardOutput = true,
|
|
|
|
|
|
RedirectStandardError = true,
|
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
|
};
|
|
|
|
|
|
_server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe");
|
|
|
|
|
|
|
|
|
|
|
|
// "[voicecat-server] <name> — TCP :<port> UDP :<port>" — see server/src/server.cpp.
|
|
|
|
|
|
// ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a
|
|
|
|
|
|
// server that never prints anything (crash, hang) can't hang this constructor forever
|
|
|
|
|
|
// — the deadline check must apply to the read itself, not just the loop around it.
|
|
|
|
|
|
ushort? port = null;
|
|
|
|
|
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
|
|
|
|
|
while (port is null && DateTime.UtcNow < deadline)
|
|
|
|
|
|
{
|
|
|
|
|
|
var readTask = _server.StandardOutput.ReadLineAsync();
|
|
|
|
|
|
var remaining = deadline - DateTime.UtcNow;
|
|
|
|
|
|
if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break;
|
|
|
|
|
|
string? line = readTask.Result;
|
|
|
|
|
|
if (line is null) break;
|
|
|
|
|
|
var m = Regex.Match(line, @"TCP :(\d+)");
|
|
|
|
|
|
if (m.Success) port = ushort.Parse(m.Groups[1].Value);
|
|
|
|
|
|
}
|
|
|
|
|
|
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
|
|
|
|
|
_port = port!.Value;
|
2026-06-17 16:31:29 +02:00
|
|
|
|
|
|
|
|
|
|
// M5: provision a known admin account so we can exercise moderation wrappers end-to-end.
|
|
|
|
|
|
string adminExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-admin.exe");
|
|
|
|
|
|
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the m1-dev preset.");
|
|
|
|
|
|
var adminPsi = new ProcessStartInfo(adminExe)
|
|
|
|
|
|
{
|
|
|
|
|
|
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
|
|
|
|
|
|
RedirectStandardOutput = true,
|
|
|
|
|
|
RedirectStandardError = true,
|
|
|
|
|
|
UseShellExecute = false,
|
|
|
|
|
|
};
|
|
|
|
|
|
using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe"))
|
|
|
|
|
|
{
|
|
|
|
|
|
Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s.");
|
|
|
|
|
|
Assert.Equal(0, adminProc.ExitCode);
|
|
|
|
|
|
}
|
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>
2026-06-17 00:35:16 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
|
{
|
|
|
|
|
|
try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ }
|
|
|
|
|
|
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static string FindRepoRoot()
|
|
|
|
|
|
{
|
|
|
|
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
|
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json")))
|
|
|
|
|
|
dir = dir.Parent;
|
|
|
|
|
|
return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static bool PumpUntil(VoiceCatClient client, Func<bool> predicate, int timeoutMs)
|
|
|
|
|
|
{
|
|
|
|
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
|
|
|
|
|
while (DateTime.UtcNow < deadline)
|
|
|
|
|
|
{
|
|
|
|
|
|
client.PumpEvents();
|
|
|
|
|
|
if (predicate()) return true;
|
|
|
|
|
|
Thread.Sleep(20);
|
|
|
|
|
|
}
|
|
|
|
|
|
client.PumpEvents();
|
|
|
|
|
|
return predicate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void VersionString_IsNonEmpty()
|
|
|
|
|
|
{
|
|
|
|
|
|
Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Connect_Tofu_Auth_ListChannels_RoundTrips()
|
|
|
|
|
|
{
|
|
|
|
|
|
var events = new List<VoiceCatEvent>();
|
|
|
|
|
|
using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off,
|
|
|
|
|
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt"));
|
|
|
|
|
|
client.EventReceived += events.Add;
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke"));
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000),
|
|
|
|
|
|
"did not receive VC_EVENT_SERVER_IDENTITY");
|
|
|
|
|
|
var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity);
|
|
|
|
|
|
Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a);
|
|
|
|
|
|
Assert.NotNull(identityEvent.Text);
|
|
|
|
|
|
Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators
|
|
|
|
|
|
|
|
|
|
|
|
// Auth must NOT complete before the identity is confirmed.
|
|
|
|
|
|
Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800));
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000),
|
|
|
|
|
|
"did not receive VC_EVENT_AUTH_RESULT after confirming identity");
|
|
|
|
|
|
var authEvent = events.First(e => e.Type == VcEventType.AuthResult);
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, authEvent.Result);
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000),
|
|
|
|
|
|
"did not receive VC_EVENT_CHANNEL_LIST");
|
|
|
|
|
|
|
|
|
|
|
|
var channels = client.ListChannels();
|
|
|
|
|
|
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
|
|
|
|
|
|
2026-06-17 16:31:29 +02:00
|
|
|
|
// M5: permissions getter round-trip.
|
|
|
|
|
|
var perms = client.GetPermissions();
|
|
|
|
|
|
Assert.False(perms.IsAdmin);
|
|
|
|
|
|
Assert.False(perms.CanKick);
|
|
|
|
|
|
|
|
|
|
|
|
// M5: moderation request wrappers queue without error. As a guest, account listing
|
|
|
|
|
|
// is rejected by the server with a GenericResult, which proves the wrapper path works
|
|
|
|
|
|
// end-to-end and that the new event type is delivered through P/Invoke.
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
|
|
|
|
|
|
"did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts");
|
|
|
|
|
|
var generic = events.First(e => e.Type == VcEventType.GenericResult);
|
|
|
|
|
|
Assert.Equal(VcResult.PermissionDenied, generic.Result);
|
|
|
|
|
|
|
|
|
|
|
|
client.Disconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void Admin_ChannelCrud_AccountCrud_RoundTrips()
|
|
|
|
|
|
{
|
|
|
|
|
|
var events = new List<VoiceCatEvent>();
|
|
|
|
|
|
using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off,
|
|
|
|
|
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt"));
|
|
|
|
|
|
client.EventReceived += events.Add;
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123"));
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
|
|
|
|
|
|
|
|
|
|
|
var perms = client.GetPermissions();
|
|
|
|
|
|
Assert.True(perms.IsAdmin || perms.CanAdminAccounts);
|
|
|
|
|
|
|
|
|
|
|
|
// Channel CRUD
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo(
|
|
|
|
|
|
Id: 0,
|
|
|
|
|
|
ParentId: 0,
|
|
|
|
|
|
Name: "CSharp Test Channel",
|
|
|
|
|
|
Topic: "Created by C# smoke test",
|
|
|
|
|
|
PasswordProtected: false,
|
|
|
|
|
|
Password: null,
|
|
|
|
|
|
MaxUsers: 42,
|
|
|
|
|
|
SortOrder: 0,
|
|
|
|
|
|
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10))));
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
|
|
|
|
|
"CreateChannel did not succeed");
|
|
|
|
|
|
|
|
|
|
|
|
var channels = client.ListChannels();
|
|
|
|
|
|
var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel");
|
|
|
|
|
|
Assert.NotNull(created);
|
|
|
|
|
|
Assert.Equal("Created by C# smoke test", created.Topic);
|
|
|
|
|
|
Assert.True(created.PasswordProtected == false);
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo(
|
|
|
|
|
|
created.Id,
|
|
|
|
|
|
created.ParentId,
|
|
|
|
|
|
created.Name,
|
|
|
|
|
|
"Updated topic",
|
|
|
|
|
|
created.PasswordProtected,
|
|
|
|
|
|
null,
|
|
|
|
|
|
100,
|
|
|
|
|
|
0,
|
|
|
|
|
|
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10))));
|
|
|
|
|
|
events.Clear();
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
|
|
|
|
|
"EditChannel did not succeed");
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id));
|
|
|
|
|
|
events.Clear();
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
|
|
|
|
|
"DeleteChannel did not succeed");
|
|
|
|
|
|
|
|
|
|
|
|
// Account CRUD
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw"));
|
|
|
|
|
|
events.Clear();
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
|
|
|
|
|
"CreateAccount did not succeed");
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000));
|
|
|
|
|
|
var accounts = client.ListAccounts();
|
|
|
|
|
|
Assert.Contains(accounts, a => a.Username == "csharp_smoke_user");
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123"));
|
|
|
|
|
|
events.Clear();
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
|
|
|
|
|
"ResetPassword did not succeed");
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user"));
|
|
|
|
|
|
events.Clear();
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
|
|
|
|
|
|
"DeleteAccount did not succeed");
|
|
|
|
|
|
|
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>
2026-06-17 00:35:16 +02:00
|
|
|
|
client.Disconnect();
|
|
|
|
|
|
}
|
2026-06-17 22:47:28 +02:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's
|
|
|
|
|
|
/// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the
|
|
|
|
|
|
/// StreamAnnounce succeeds regardless of whether the loopback device actually initializes
|
|
|
|
|
|
/// on a headless box, so this test passes in CI while still exercising the full
|
|
|
|
|
|
/// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke.
|
|
|
|
|
|
/// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void ScreenAudioStream_Starts_And_Stops()
|
|
|
|
|
|
{
|
|
|
|
|
|
var events = new List<VoiceCatEvent>();
|
|
|
|
|
|
using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off,
|
|
|
|
|
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt"));
|
|
|
|
|
|
client.EventReceived += events.Add;
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen"));
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
|
|
|
|
|
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
|
|
|
|
|
|
|
|
|
|
|
// Give the async UDP binding handshake a moment to land before announcing a stream
|
|
|
|
|
|
// (mirrors vccli's 500ms sleep after auth).
|
|
|
|
|
|
Thread.Sleep(500);
|
|
|
|
|
|
|
|
|
|
|
|
var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, startResult);
|
|
|
|
|
|
Assert.True(streamId != 0, "StreamId should be non-zero on success");
|
|
|
|
|
|
|
|
|
|
|
|
// The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp
|
|
|
|
|
|
// handle_stream_announce_result), so we see our own screen-audio stream start.
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000),
|
|
|
|
|
|
"did not receive VC_EVENT_STREAM_STARTED for screen-audio stream");
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, client.StopStream(streamId));
|
|
|
|
|
|
Assert.True(PumpUntil(client,
|
|
|
|
|
|
() => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000),
|
|
|
|
|
|
"did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream");
|
|
|
|
|
|
|
|
|
|
|
|
client.Disconnect();
|
|
|
|
|
|
}
|
2026-06-18 02:06:44 +02:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two
|
|
|
|
|
|
/// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then
|
|
|
|
|
|
/// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in
|
|
|
|
|
|
/// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++
|
|
|
|
|
|
/// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
|
|
|
|
|
public void PerStream_RecvControls_RoundTrip_Through_PInvoke()
|
|
|
|
|
|
{
|
|
|
|
|
|
var eventsA = new List<VoiceCatEvent>();
|
|
|
|
|
|
var eventsB = new List<VoiceCatEvent>();
|
|
|
|
|
|
using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off,
|
|
|
|
|
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt"));
|
|
|
|
|
|
using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off,
|
|
|
|
|
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt"));
|
|
|
|
|
|
a.EventReceived += eventsA.Add;
|
|
|
|
|
|
b.EventReceived += eventsB.Add;
|
|
|
|
|
|
|
|
|
|
|
|
// Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against
|
|
|
|
|
|
// the same server (mirrors the C++ test_m3_multistream harness, which connects A to
|
|
|
|
|
|
// completion before starting B).
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA"));
|
|
|
|
|
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true));
|
|
|
|
|
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result);
|
|
|
|
|
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
|
|
|
|
|
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB"));
|
|
|
|
|
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true));
|
|
|
|
|
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000));
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result);
|
|
|
|
|
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000));
|
|
|
|
|
|
|
|
|
|
|
|
// Both join Lobby (channel 1) so voice relays between them.
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, a.JoinChannel(1, null));
|
|
|
|
|
|
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000),
|
|
|
|
|
|
"A did not receive VC_EVENT_JOIN_RESULT");
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, b.JoinChannel(1, null));
|
|
|
|
|
|
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
|
|
|
|
|
|
"B did not receive VC_EVENT_JOIN_RESULT");
|
|
|
|
|
|
|
|
|
|
|
|
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
|
|
|
|
|
|
Thread.Sleep(500);
|
|
|
|
|
|
|
|
|
|
|
|
// A publishes a MIC stream.
|
|
|
|
|
|
var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic");
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, startResult);
|
|
|
|
|
|
Assert.True(streamId != 0);
|
|
|
|
|
|
|
|
|
|
|
|
// B sees A's stream and can enumerate it.
|
|
|
|
|
|
uint aUid = 0;
|
|
|
|
|
|
Assert.True(PumpUntil(b, () =>
|
|
|
|
|
|
{
|
|
|
|
|
|
return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId);
|
|
|
|
|
|
}, 5000), "B did not see A's STREAM_STARTED");
|
|
|
|
|
|
// Resolve A's user id from B's user list.
|
|
|
|
|
|
Assert.True(PumpUntil(b, () =>
|
|
|
|
|
|
{
|
|
|
|
|
|
aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0;
|
|
|
|
|
|
return aUid != 0;
|
|
|
|
|
|
}, 3000), "could not resolve A's user id on B");
|
|
|
|
|
|
Assert.True(aUid != 0);
|
|
|
|
|
|
|
|
|
|
|
|
Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000),
|
|
|
|
|
|
"B could not enumerate A's stream");
|
|
|
|
|
|
var bStreams = b.ListUserStreams(aUid);
|
|
|
|
|
|
Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic);
|
|
|
|
|
|
|
|
|
|
|
|
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
|
|
|
|
|
|
var (r0, st0) = b.GetRemoteStream(aUid, streamId);
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, r0);
|
|
|
|
|
|
Assert.NotNull(st0);
|
|
|
|
|
|
Assert.Equal(1.0f, st0!.Gain);
|
|
|
|
|
|
Assert.False(st0.Muted);
|
|
|
|
|
|
Assert.False(st0.NoiseReduction);
|
|
|
|
|
|
|
|
|
|
|
|
// B turns A down to 0.4×, mutes, and enables NR — then reads it back.
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true));
|
|
|
|
|
|
var (r1, st1) = b.GetRemoteStream(aUid, streamId);
|
|
|
|
|
|
Assert.Equal(VcResult.Ok, r1);
|
|
|
|
|
|
Assert.NotNull(st1);
|
|
|
|
|
|
Assert.Equal(0.4f, st1!.Gain);
|
|
|
|
|
|
Assert.True(st1.Muted);
|
|
|
|
|
|
Assert.True(st1.NoiseReduction);
|
|
|
|
|
|
|
|
|
|
|
|
// Unknown stream id on a known user -> INVALID_ARG.
|
|
|
|
|
|
var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF);
|
|
|
|
|
|
Assert.Equal(VcResult.InvalidArg, rBad);
|
|
|
|
|
|
Assert.Null(stBad);
|
|
|
|
|
|
|
|
|
|
|
|
a.Disconnect();
|
|
|
|
|
|
b.Disconnect();
|
|
|
|
|
|
}
|
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>
2026-06-17 00:35:16 +02:00
|
|
|
|
}
|