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 VoiceCat.Interop;
|
|
|
|
|
|
|
|
|
|
|
|
namespace VoiceCat.App.Forms;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
|
|
|
|
|
|
/// Phase E: channel tree, user list, chat. Phase F: voice controls, device pickers, PTT,
|
|
|
|
|
|
/// per-user tuning.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public partial class MainForm : Form
|
|
|
|
|
|
{
|
|
|
|
|
|
private readonly VoiceCatClient _client;
|
|
|
|
|
|
private readonly uint _selfUserId;
|
|
|
|
|
|
private readonly string _nickname;
|
|
|
|
|
|
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
|
|
|
|
|
|
|
|
|
|
|
// Channel / user state
|
|
|
|
|
|
private uint _currentChannelId;
|
|
|
|
|
|
private List<ChannelInfo> _channels = [];
|
|
|
|
|
|
private readonly Dictionary<uint, UserInfo> _users = [];
|
|
|
|
|
|
private readonly HashSet<uint> _talkingUsers = [];
|
2026-06-17 16:31:29 +02:00
|
|
|
|
private PermissionsInfo _ownPermissions = new(false, false, false, false, false, false);
|
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
|
|
|
|
|
|
|
|
|
|
// Voice state
|
|
|
|
|
|
private uint _micStreamId; // 0 = not started
|
|
|
|
|
|
private Keys _pttKey = Keys.F8;
|
2026-06-17 16:31:29 +02:00
|
|
|
|
private bool _serverMuted;
|
|
|
|
|
|
private bool _serverDeafened;
|
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 MainForm(VoiceCatClient client, uint selfUserId, string nickname)
|
|
|
|
|
|
{
|
|
|
|
|
|
InitializeComponent();
|
|
|
|
|
|
_client = client;
|
|
|
|
|
|
_selfUserId = selfUserId;
|
|
|
|
|
|
_nickname = nickname;
|
|
|
|
|
|
|
|
|
|
|
|
Text = $"VoiceCat — {nickname}";
|
|
|
|
|
|
|
|
|
|
|
|
_client.EventReceived += OnEvent;
|
|
|
|
|
|
_client.LevelChanged += OnLevelChanged;
|
|
|
|
|
|
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
|
2026-06-17 16:31:29 +02:00
|
|
|
|
|
|
|
|
|
|
// M5: load own permissions and build permission-aware menus.
|
|
|
|
|
|
_ownPermissions = SafeGetPermissions();
|
|
|
|
|
|
BuildMenus();
|
|
|
|
|
|
BuildChannelContextMenu();
|
|
|
|
|
|
BuildUserContextMenu();
|
|
|
|
|
|
|
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
|
|
|
|
_pumpTimer.Start();
|
|
|
|
|
|
|
|
|
|
|
|
// Channel tree
|
|
|
|
|
|
tvChannels.DoubleClick += TvChannels_DoubleClick;
|
|
|
|
|
|
tvChannels.KeyDown += TvChannels_KeyDown;
|
|
|
|
|
|
lstUsers.DoubleClick += (_, _) => OpenUserTuning();
|
|
|
|
|
|
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); };
|
|
|
|
|
|
|
|
|
|
|
|
// Compose
|
|
|
|
|
|
txtCompose.KeyDown += TxtCompose_KeyDown;
|
|
|
|
|
|
btnSend.Click += (_, _) => SendText();
|
|
|
|
|
|
|
|
|
|
|
|
// Voice controls
|
|
|
|
|
|
btnMicToggle.Click += BtnMicToggle_Click;
|
|
|
|
|
|
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
|
|
|
|
|
|
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
|
|
|
|
|
|
radioVad.CheckedChanged += RadioVad_CheckedChanged;
|
|
|
|
|
|
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
|
|
|
|
|
|
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
|
|
|
|
|
|
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
|
|
|
|
|
|
btnChangePtt.Click += BtnChangePtt_Click;
|
|
|
|
|
|
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
|
|
|
|
|
|
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
|
|
|
|
|
|
|
|
|
|
|
|
// PTT (focus-scoped — works only while this form has focus; documented limitation)
|
|
|
|
|
|
KeyDown += MainForm_KeyDown;
|
|
|
|
|
|
KeyUp += MainForm_KeyUp;
|
|
|
|
|
|
Deactivate += (_, _) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_micStreamId != 0) _client.SetPushToTalk(false); // release PTT on focus loss
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
BootstrapFromServer();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Startup ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void BootstrapFromServer()
|
|
|
|
|
|
{
|
|
|
|
|
|
_channels = _client.ListChannels();
|
|
|
|
|
|
var users = _client.ListUsers();
|
|
|
|
|
|
_users.Clear();
|
|
|
|
|
|
foreach (var u in users)
|
|
|
|
|
|
{
|
|
|
|
|
|
_users[u.Id] = u;
|
2026-06-17 16:31:29 +02:00
|
|
|
|
if (u.Id == _selfUserId)
|
|
|
|
|
|
{
|
|
|
|
|
|
_currentChannelId = u.ChannelId;
|
|
|
|
|
|
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
RefreshChannelTree();
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
RebuildScopeCombo();
|
|
|
|
|
|
UpdateStatusLabel();
|
|
|
|
|
|
AddActivity($"Connected to server as {_nickname}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
protected override void OnLoad(EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
base.OnLoad(e);
|
|
|
|
|
|
splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
|
|
|
|
|
|
splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
|
|
|
|
|
|
LoadInputDevices();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 16:31:29 +02:00
|
|
|
|
// ── Menu / context menu builders (M5) ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private PermissionsInfo SafeGetPermissions()
|
|
|
|
|
|
{
|
|
|
|
|
|
try { return _client.GetPermissions(); }
|
|
|
|
|
|
catch { return new PermissionsInfo(false, false, false, false, false, false); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void BuildMenus()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!_ownPermissions.CanAdminAccounts) return;
|
|
|
|
|
|
|
|
|
|
|
|
var adminMenu = new ToolStripMenuItem("&Admin");
|
|
|
|
|
|
var miAccounts = new ToolStripMenuItem("&Server accounts...");
|
|
|
|
|
|
miAccounts.Click += (_, _) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
using var dlg = new AccountsDialog(_client);
|
|
|
|
|
|
dlg.ShowDialog(this);
|
|
|
|
|
|
};
|
|
|
|
|
|
adminMenu.DropDownItems.Add(miAccounts);
|
|
|
|
|
|
menuStrip.Items.Add(adminMenu);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void BuildChannelContextMenu()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ctx = new ContextMenuStrip();
|
|
|
|
|
|
ctx.Opening += (_, _) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Clear();
|
|
|
|
|
|
bool hasSelection = tvChannels.SelectedNode?.Tag is uint;
|
|
|
|
|
|
bool canCreate = _ownPermissions.CanCreateTempChannel || _ownPermissions.IsAdmin;
|
|
|
|
|
|
bool isAdmin = _ownPermissions.IsAdmin;
|
|
|
|
|
|
|
|
|
|
|
|
if (hasSelection)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add("&Join", null, (_, _) => ChannelTreeJoinSelected());
|
|
|
|
|
|
ctx.Items.Add(new ToolStripSeparator());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (canCreate)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add("&Create channel...", null, (_, _) => CreateChannel());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasSelection && isAdmin)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add("&Edit channel...", null, (_, _) => EditSelectedChannel());
|
|
|
|
|
|
ctx.Items.Add("&Delete channel...", null, (_, _) => DeleteSelectedChannel());
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
tvChannels.ContextMenuStrip = ctx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void BuildUserContextMenu()
|
|
|
|
|
|
{
|
|
|
|
|
|
var ctx = new ContextMenuStrip();
|
|
|
|
|
|
ctx.Opening += (_, _) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Clear();
|
|
|
|
|
|
if (lstUsers.SelectedItem is not UserListItem item) return;
|
|
|
|
|
|
if (!_users.TryGetValue(item.UserId, out var user)) return;
|
|
|
|
|
|
bool isSelf = user.Id == _selfUserId;
|
|
|
|
|
|
|
|
|
|
|
|
var miTune = new ToolStripMenuItem("&Adjust volume and noise settings...");
|
|
|
|
|
|
miTune.Click += (_, _) => OpenUserTuning();
|
|
|
|
|
|
ctx.Items.Add(miTune);
|
|
|
|
|
|
|
|
|
|
|
|
if (!isSelf)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add(new ToolStripSeparator());
|
|
|
|
|
|
|
|
|
|
|
|
if (_ownPermissions.CanMoveUsers || _ownPermissions.IsAdmin)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add("&Move to channel...", null, (_, _) => MoveSelectedUser());
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_ownPermissions.CanKick || _ownPermissions.IsAdmin)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add("&Kick...", null, (_, _) => KickSelectedUser());
|
|
|
|
|
|
}
|
|
|
|
|
|
if (_ownPermissions.CanBan || _ownPermissions.IsAdmin)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add("&Ban...", null, (_, _) => BanSelectedUser());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ctx.Items.Add(new ToolStripSeparator());
|
|
|
|
|
|
|
|
|
|
|
|
if (_ownPermissions.IsAdmin)
|
|
|
|
|
|
{
|
|
|
|
|
|
ctx.Items.Add(user.ServerMuted ? "Server &unmute" : "Server &mute",
|
|
|
|
|
|
null, (_, _) => ToggleServerMuteSelected());
|
|
|
|
|
|
ctx.Items.Add(user.ServerDeafened ? "Server un&deafen" : "Server &deafen",
|
|
|
|
|
|
null, (_, _) => ToggleServerDeafenSelected());
|
|
|
|
|
|
ctx.Items.Add("&Set permissions...", null, (_, _) => SetPermissionsSelected());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
lstUsers.ContextMenuStrip = ctx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// ── Event dispatch ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void OnEvent(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
switch (ev.Type)
|
|
|
|
|
|
{
|
|
|
|
|
|
case VcEventType.ChannelList:
|
|
|
|
|
|
HandleChannelList();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.UserJoined:
|
|
|
|
|
|
HandleUserJoined(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.UserLeft:
|
|
|
|
|
|
HandleUserLeft(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.UserUpdated:
|
|
|
|
|
|
HandleUserUpdated();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.JoinResult:
|
|
|
|
|
|
HandleJoinResult(ev);
|
|
|
|
|
|
break;
|
2026-06-17 16:31:29 +02:00
|
|
|
|
case VcEventType.GenericResult:
|
|
|
|
|
|
HandleGenericResult(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.AccountList:
|
|
|
|
|
|
AddActivity("Account list updated");
|
|
|
|
|
|
break;
|
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
|
|
|
|
case VcEventType.TextMessage:
|
|
|
|
|
|
HandleTextMessage(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.TalkState:
|
|
|
|
|
|
HandleTalkState(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.StreamStarted:
|
|
|
|
|
|
HandleStreamStarted(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.StreamStopped:
|
|
|
|
|
|
if (_users.TryGetValue(ev.UserId, out var stUser) &&
|
|
|
|
|
|
stUser.ChannelId == _currentChannelId)
|
|
|
|
|
|
AddActivity($"{stUser.Nickname} stopped a stream");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case VcEventType.Disconnected:
|
|
|
|
|
|
HandleDisconnected(ev);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Event handlers ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleChannelList()
|
|
|
|
|
|
{
|
|
|
|
|
|
_channels = _client.ListChannels();
|
|
|
|
|
|
var users = _client.ListUsers();
|
|
|
|
|
|
_users.Clear();
|
|
|
|
|
|
foreach (var u in users)
|
|
|
|
|
|
{
|
|
|
|
|
|
_users[u.Id] = u;
|
|
|
|
|
|
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId;
|
|
|
|
|
|
}
|
|
|
|
|
|
RefreshChannelTree();
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
RebuildScopeCombo();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleUserJoined(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
2026-06-17 16:31:29 +02:00
|
|
|
|
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId,
|
|
|
|
|
|
false, false, false, false);
|
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
|
|
|
|
_users[ev.UserId] = user;
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
RebuildScopeCombo();
|
|
|
|
|
|
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
|
|
|
|
|
|
AddActivity($"{user.Nickname} joined the channel");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleUserLeft(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!_users.TryGetValue(ev.UserId, out var user)) return;
|
|
|
|
|
|
bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId;
|
|
|
|
|
|
_users.Remove(ev.UserId);
|
|
|
|
|
|
_talkingUsers.Remove(ev.UserId);
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
RebuildScopeCombo();
|
|
|
|
|
|
if (wasHere) AddActivity($"{user.Nickname} left the channel");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleUserUpdated()
|
|
|
|
|
|
{
|
|
|
|
|
|
var users = _client.ListUsers();
|
|
|
|
|
|
_users.Clear();
|
|
|
|
|
|
foreach (var u in users)
|
|
|
|
|
|
{
|
|
|
|
|
|
_users[u.Id] = u;
|
2026-06-17 16:31:29 +02:00
|
|
|
|
if (u.Id == _selfUserId)
|
|
|
|
|
|
{
|
|
|
|
|
|
_currentChannelId = u.ChannelId;
|
|
|
|
|
|
UpdateSelfServerMuteState(u.ServerMuted, u.ServerDeafened);
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
RefreshChannelTree();
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
RebuildScopeCombo();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleJoinResult(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (ev.Result == VcResult.Ok)
|
|
|
|
|
|
{
|
|
|
|
|
|
_currentChannelId = ev.ChannelId;
|
|
|
|
|
|
// Server doesn't echo UserJoined/UserUpdated back to the mover — patch our
|
|
|
|
|
|
// own entry in _users so RefreshUserList shows us in the new channel.
|
|
|
|
|
|
if (_users.TryGetValue(_selfUserId, out var self))
|
|
|
|
|
|
_users[_selfUserId] = self with { ChannelId = ev.ChannelId };
|
|
|
|
|
|
RefreshChannelTree();
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
UpdateStatusLabel();
|
|
|
|
|
|
string chanName = _channels.FirstOrDefault(c => c.Id == ev.ChannelId)?.Name
|
|
|
|
|
|
?? $"Channel #{ev.ChannelId}";
|
|
|
|
|
|
AddActivity($"Joined {chanName}");
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
AddActivity($"Could not join channel: {ev.Text ?? ev.Result.ToString()}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 16:31:29 +02:00
|
|
|
|
private void HandleGenericResult(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
string prefix = ev.Result == VcResult.Ok ? "Success" : "Failed";
|
|
|
|
|
|
string detail = !string.IsNullOrEmpty(ev.Text) ? $": {ev.Text}" : "";
|
|
|
|
|
|
AddActivity($"{prefix}{detail} ({ev.Result})");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
private void HandleTextMessage(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
string time = ev.TimestampUnixMs > 0
|
|
|
|
|
|
? DateTimeOffset.FromUnixTimeMilliseconds((long)ev.TimestampUnixMs)
|
|
|
|
|
|
.LocalDateTime.ToString("HH:mm")
|
|
|
|
|
|
: DateTime.Now.ToString("HH:mm");
|
|
|
|
|
|
string sender = GetNickname(ev.UserId);
|
|
|
|
|
|
string prefix = ev.TextScope == VcTextScope.Private ? "(private) " : "";
|
|
|
|
|
|
rtbChat.AppendText($"[{time}] {prefix}{sender}: {ev.Text ?? ""}\n");
|
|
|
|
|
|
rtbChat.ScrollToCaret();
|
|
|
|
|
|
if (ev.TextScope == VcTextScope.Private && ev.UserId != _selfUserId)
|
|
|
|
|
|
AddActivity($"Private message from {sender}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleTalkState(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
bool talking = ev.U32a == 1;
|
|
|
|
|
|
if (talking) _talkingUsers.Add(ev.UserId);
|
|
|
|
|
|
else _talkingUsers.Remove(ev.UserId);
|
|
|
|
|
|
RefreshUserList();
|
|
|
|
|
|
if (talking && ev.UserId != _selfUserId &&
|
|
|
|
|
|
_users.TryGetValue(ev.UserId, out var tUser) &&
|
|
|
|
|
|
tUser.ChannelId == _currentChannelId)
|
|
|
|
|
|
AddActivity($"{tUser.Nickname} started talking");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleStreamStarted(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!_users.TryGetValue(ev.UserId, out var sUser) ||
|
|
|
|
|
|
sUser.ChannelId != _currentChannelId) return;
|
|
|
|
|
|
var streams = _client.ListUserStreams(ev.UserId);
|
|
|
|
|
|
var stream = streams.FirstOrDefault(s => s.StreamId == ev.StreamId);
|
|
|
|
|
|
string kind = stream?.Kind switch
|
|
|
|
|
|
{
|
|
|
|
|
|
VcStreamKind.ScreenAudio => "screen audio",
|
|
|
|
|
|
VcStreamKind.AuxDevice => "aux device",
|
|
|
|
|
|
_ => "microphone",
|
|
|
|
|
|
};
|
|
|
|
|
|
AddActivity($"{sUser.Nickname} started {kind} stream");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void HandleDisconnected(VoiceCatEvent ev)
|
|
|
|
|
|
{
|
|
|
|
|
|
string msg = string.IsNullOrEmpty(ev.Text)
|
|
|
|
|
|
? "Disconnected from server."
|
|
|
|
|
|
: $"Disconnected: {ev.Text}";
|
|
|
|
|
|
lblStatus.Text = msg;
|
|
|
|
|
|
AddActivity(msg);
|
|
|
|
|
|
tvChannels.Nodes.Clear();
|
|
|
|
|
|
lstUsers.Items.Clear();
|
|
|
|
|
|
_users.Clear();
|
|
|
|
|
|
_talkingUsers.Clear();
|
|
|
|
|
|
_currentChannelId = 0;
|
|
|
|
|
|
_micStreamId = 0;
|
|
|
|
|
|
txtCompose.Enabled = false;
|
|
|
|
|
|
btnSend.Enabled = false;
|
|
|
|
|
|
btnMicToggle.Enabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Level meter ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void OnLevelChanged(uint streamId, float rms)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (streamId == _micStreamId)
|
|
|
|
|
|
pbLevel.Value = Math.Min(100, (int)(rms * 400));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── UI refresh helpers ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void RefreshChannelTree()
|
|
|
|
|
|
{
|
|
|
|
|
|
uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
|
|
|
|
|
|
tvChannels.BeginUpdate();
|
|
|
|
|
|
tvChannels.Nodes.Clear();
|
|
|
|
|
|
|
|
|
|
|
|
var byParent = _channels
|
|
|
|
|
|
.GroupBy(c => c.ParentId)
|
|
|
|
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
|
|
|
|
|
|
|
|
void AddChildren(TreeNodeCollection nodes, uint parentId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!byParent.TryGetValue(parentId, out var kids)) return;
|
|
|
|
|
|
foreach (var ch in kids.OrderBy(c => c.Name))
|
|
|
|
|
|
{
|
|
|
|
|
|
var label = ch.Name;
|
|
|
|
|
|
if (ch.PasswordProtected) label += " [password]";
|
|
|
|
|
|
if (ch.Id == _currentChannelId) label += " ►";
|
|
|
|
|
|
var node = new TreeNode(label) { Tag = ch.Id };
|
|
|
|
|
|
nodes.Add(node);
|
|
|
|
|
|
AddChildren(node.Nodes, ch.Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
AddChildren(tvChannels.Nodes, 0);
|
|
|
|
|
|
tvChannels.ExpandAll();
|
|
|
|
|
|
SeekAndSelect(tvChannels.Nodes, toSelect);
|
|
|
|
|
|
tvChannels.EndUpdate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (channelId == 0) return false;
|
|
|
|
|
|
foreach (TreeNode n in nodes)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (n.Tag is uint id && id == channelId) { tvChannels.SelectedNode = n; return true; }
|
|
|
|
|
|
if (SeekAndSelect(n.Nodes, channelId)) return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void RefreshUserList()
|
|
|
|
|
|
{
|
|
|
|
|
|
lstUsers.BeginUpdate();
|
|
|
|
|
|
lstUsers.Items.Clear();
|
|
|
|
|
|
foreach (var user in _users.Values
|
|
|
|
|
|
.Where(u => u.ChannelId == _currentChannelId)
|
|
|
|
|
|
.OrderBy(u => u.Nickname))
|
|
|
|
|
|
{
|
|
|
|
|
|
string label = user.Nickname;
|
|
|
|
|
|
if (user.Id == _selfUserId) label += " (you)";
|
|
|
|
|
|
if (_talkingUsers.Contains(user.Id)) label += " (talking)";
|
2026-06-17 16:31:29 +02:00
|
|
|
|
if (user.SelfMicMuted || user.ServerMuted) label += " (muted)";
|
|
|
|
|
|
if (user.SelfDeafened || user.ServerDeafened) label += " (deafened)";
|
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
|
|
|
|
lstUsers.Items.Add(new UserListItem(user.Id, label));
|
|
|
|
|
|
}
|
|
|
|
|
|
lstUsers.EndUpdate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void RebuildScopeCombo()
|
|
|
|
|
|
{
|
|
|
|
|
|
uint prevTarget = (cboScope.SelectedItem is ScopeItem prev &&
|
|
|
|
|
|
prev.Scope == VcTextScope.Private)
|
|
|
|
|
|
? prev.TargetId : 0u;
|
|
|
|
|
|
|
|
|
|
|
|
cboScope.Items.Clear();
|
|
|
|
|
|
cboScope.Items.Add(new ScopeItem("Channel", VcTextScope.Channel, 0));
|
|
|
|
|
|
foreach (var u in _users.Values.OrderBy(u => u.Nickname))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (u.Id == _selfUserId) continue;
|
|
|
|
|
|
cboScope.Items.Add(new ScopeItem($"Private: {u.Nickname}", VcTextScope.Private, u.Id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (prevTarget != 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
for (int i = 1; i < cboScope.Items.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (cboScope.Items[i] is ScopeItem si && si.TargetId == prevTarget)
|
|
|
|
|
|
{
|
|
|
|
|
|
cboScope.SelectedIndex = i;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cboScope.Items.Count > 0) cboScope.SelectedIndex = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void UpdateStatusLabel()
|
|
|
|
|
|
{
|
2026-06-17 16:31:29 +02:00
|
|
|
|
string suffix = "";
|
|
|
|
|
|
if (_serverMuted) suffix += " [server muted]";
|
|
|
|
|
|
if (_serverDeafened) suffix += " [server deafened]";
|
|
|
|
|
|
|
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
|
|
|
|
if (_currentChannelId == 0)
|
|
|
|
|
|
{
|
2026-06-17 16:31:29 +02:00
|
|
|
|
lblStatus.Text = $"Connected as {_nickname}{suffix} — not in a channel.";
|
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
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name
|
|
|
|
|
|
?? $"Channel #{_currentChannelId}";
|
|
|
|
|
|
int count = _users.Values.Count(u => u.ChannelId == _currentChannelId);
|
2026-06-17 16:31:29 +02:00
|
|
|
|
lblStatus.Text = $"Connected as {_nickname}{suffix} — {chanName} ({count} user{(count == 1 ? "" : "s")})";
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Device management ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void LoadInputDevices()
|
|
|
|
|
|
{
|
|
|
|
|
|
var devices = _client.ListDevices(VcDeviceKind.Input);
|
|
|
|
|
|
DeviceInfo? prevDevice = cboInputDevice.SelectedItem as DeviceInfo;
|
|
|
|
|
|
|
|
|
|
|
|
cboInputDevice.Items.Clear();
|
|
|
|
|
|
foreach (var d in devices) cboInputDevice.Items.Add(d);
|
|
|
|
|
|
|
|
|
|
|
|
// Restore selection or pick default
|
|
|
|
|
|
if (prevDevice is not null)
|
|
|
|
|
|
{
|
|
|
|
|
|
for (int i = 0; i < cboInputDevice.Items.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (cboInputDevice.Items[i] is DeviceInfo d && d.Id == prevDevice.Id)
|
|
|
|
|
|
{
|
|
|
|
|
|
cboInputDevice.SelectedIndex = i;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Select default device
|
|
|
|
|
|
for (int i = 0; i < cboInputDevice.Items.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (cboInputDevice.Items[i] is DeviceInfo d && d.IsDefault)
|
|
|
|
|
|
{
|
|
|
|
|
|
cboInputDevice.SelectedIndex = i;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cboInputDevice.Items.Count > 0) cboInputDevice.SelectedIndex = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Voice controls ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void BtnMicToggle_Click(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_micStreamId == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
|
|
|
|
|
|
if (result == VcResult.Ok)
|
|
|
|
|
|
{
|
|
|
|
|
|
_micStreamId = streamId;
|
|
|
|
|
|
// Apply selected device if not default
|
|
|
|
|
|
if (cboInputDevice.SelectedItem is DeviceInfo { IsDefault: false } dev)
|
|
|
|
|
|
_client.SetInputDevice(streamId, dev.Id);
|
|
|
|
|
|
// Apply current mode
|
|
|
|
|
|
_client.SetInputMode(CurrentInputMode());
|
|
|
|
|
|
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
|
|
|
|
|
|
btnMicToggle.Text = "Leave &Voice";
|
|
|
|
|
|
chkMute.Enabled = true;
|
|
|
|
|
|
chkDeafen.Enabled = true;
|
|
|
|
|
|
radioVad.Enabled = true;
|
|
|
|
|
|
radioPtt.Enabled = true;
|
|
|
|
|
|
radioAlwaysOn.Enabled = true;
|
|
|
|
|
|
AddActivity("Joined voice — microphone active");
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
AddActivity($"Failed to start microphone: {result}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
_client.SetPushToTalk(false); // release PTT if held
|
|
|
|
|
|
_client.StopStream(_micStreamId);
|
|
|
|
|
|
_micStreamId = 0;
|
|
|
|
|
|
pbLevel.Value = 0;
|
|
|
|
|
|
btnMicToggle.Text = "&Join Voice";
|
|
|
|
|
|
chkMute.Enabled = false;
|
|
|
|
|
|
chkDeafen.Enabled = false;
|
|
|
|
|
|
radioVad.Enabled = false;
|
|
|
|
|
|
radioPtt.Enabled = false;
|
|
|
|
|
|
radioAlwaysOn.Enabled = false;
|
|
|
|
|
|
AddActivity("Left voice");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ApplySelfMute() =>
|
|
|
|
|
|
_client.SetSelfMute(chkMute.Checked, chkDeafen.Checked);
|
|
|
|
|
|
|
|
|
|
|
|
private void RadioVad_CheckedChanged(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!radioVad.Checked) return;
|
|
|
|
|
|
lblPttKey.Visible = false;
|
|
|
|
|
|
btnChangePtt.Visible = false;
|
|
|
|
|
|
lblVadThreshold.Visible = true;
|
|
|
|
|
|
trkVadThreshold.Visible = true;
|
|
|
|
|
|
if (_micStreamId != 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
_client.SetInputMode(VcInputMode.VoiceActivation);
|
|
|
|
|
|
_client.SetVadThreshold(VadThresholdFromSlider());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void RadioPtt_CheckedChanged(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!radioPtt.Checked) return;
|
|
|
|
|
|
lblPttKey.Text = $"({_pttKey})";
|
|
|
|
|
|
lblPttKey.Visible = true;
|
|
|
|
|
|
btnChangePtt.Visible = true;
|
|
|
|
|
|
lblVadThreshold.Visible = false;
|
|
|
|
|
|
trkVadThreshold.Visible = false;
|
|
|
|
|
|
if (_micStreamId != 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
_client.SetInputMode(VcInputMode.PushToTalk);
|
|
|
|
|
|
_client.SetPushToTalk(false); // start released
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void RadioAlwaysOn_CheckedChanged(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!radioAlwaysOn.Checked) return;
|
|
|
|
|
|
lblPttKey.Visible = false;
|
|
|
|
|
|
btnChangePtt.Visible = false;
|
|
|
|
|
|
lblVadThreshold.Visible = false;
|
|
|
|
|
|
trkVadThreshold.Visible = false;
|
|
|
|
|
|
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_micStreamId != 0 && radioVad.Checked)
|
|
|
|
|
|
_client.SetVadThreshold(VadThresholdFromSlider());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// threshold = 0.1 × (1 − (value−1) / 99): slider=1→0.1 (least sensitive), slider=100→0.001
|
|
|
|
|
|
private float VadThresholdFromSlider() =>
|
|
|
|
|
|
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
|
|
|
|
|
|
|
|
|
|
|
|
private VcInputMode CurrentInputMode() =>
|
|
|
|
|
|
radioPtt.Checked ? VcInputMode.PushToTalk :
|
|
|
|
|
|
radioAlwaysOn.Checked ? VcInputMode.AlwaysOn :
|
|
|
|
|
|
VcInputMode.VoiceActivation;
|
|
|
|
|
|
|
|
|
|
|
|
private void BtnChangePtt_Click(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
using var dlg = new PttKeyCaptureDialog(_pttKey);
|
|
|
|
|
|
if (dlg.ShowDialog(this) == DialogResult.OK)
|
|
|
|
|
|
{
|
|
|
|
|
|
_pttKey = dlg.CapturedKey;
|
|
|
|
|
|
lblPttKey.Text = $"({_pttKey})";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void CboInputDevice_SelectedIndexChanged(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_micStreamId == 0) return;
|
|
|
|
|
|
string? deviceId = (cboInputDevice.SelectedItem as DeviceInfo)?.Id;
|
|
|
|
|
|
_client.SetInputDevice(_micStreamId, deviceId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
|
|
|
|
|
|
// Don't intercept PTT key while user is typing in a text control
|
|
|
|
|
|
if (ActiveControl is TextBox or RichTextBox) return;
|
|
|
|
|
|
_client.SetPushToTalk(true);
|
|
|
|
|
|
lblPttKey.Text = $"({_pttKey} ▶)";
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void MainForm_KeyUp(object? sender, KeyEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
|
|
|
|
|
|
_client.SetPushToTalk(false);
|
|
|
|
|
|
lblPttKey.Text = $"({_pttKey})";
|
|
|
|
|
|
e.Handled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Channel navigation ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void TvChannels_DoubleClick(object? sender, EventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (tvChannels.SelectedNode?.Tag is uint channelId)
|
|
|
|
|
|
JoinChannelRequest(channelId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void TvChannels_KeyDown(object? sender, KeyEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (e.KeyCode == Keys.Enter && tvChannels.SelectedNode?.Tag is uint channelId)
|
|
|
|
|
|
{
|
|
|
|
|
|
JoinChannelRequest(channelId);
|
|
|
|
|
|
e.Handled = e.SuppressKeyPress = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void JoinChannelRequest(uint channelId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (channelId == _currentChannelId) return;
|
|
|
|
|
|
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
|
|
|
|
|
|
string? password = null;
|
|
|
|
|
|
if (channel?.PasswordProtected == true)
|
|
|
|
|
|
{
|
|
|
|
|
|
using var dlg = new PasswordPromptDialog($"Password for channel \"{channel.Name}\":");
|
|
|
|
|
|
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
|
|
|
|
|
password = dlg.Password;
|
|
|
|
|
|
}
|
|
|
|
|
|
_client.JoinChannel(channelId, password);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 16:31:29 +02:00
|
|
|
|
private void ChannelTreeJoinSelected()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (tvChannels.SelectedNode?.Tag is uint channelId)
|
|
|
|
|
|
JoinChannelRequest(channelId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── M5: Moderation helpers ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void UpdateSelfServerMuteState(bool muted, bool deafened)
|
|
|
|
|
|
{
|
|
|
|
|
|
bool wasMuted = _serverMuted;
|
|
|
|
|
|
bool wasDeafened = _serverDeafened;
|
|
|
|
|
|
_serverMuted = muted;
|
|
|
|
|
|
_serverDeafened = deafened;
|
|
|
|
|
|
|
|
|
|
|
|
if (muted && !wasMuted) AddActivity("You have been server-muted");
|
|
|
|
|
|
if (deafened && !wasDeafened) AddActivity("You have been server-deafened");
|
|
|
|
|
|
if (!muted && wasMuted) AddActivity("Server mute cleared");
|
|
|
|
|
|
if (!deafened && wasDeafened) AddActivity("Server deafen cleared");
|
|
|
|
|
|
|
|
|
|
|
|
UpdateStatusLabel();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void CreateChannel()
|
|
|
|
|
|
{
|
|
|
|
|
|
using var dlg = new ChannelEditDialog(_channels);
|
|
|
|
|
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
|
|
|
|
|
_client.CreateChannel(dlg.Result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void EditSelectedChannel()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (tvChannels.SelectedNode?.Tag is not uint channelId) return;
|
|
|
|
|
|
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
|
|
|
|
|
|
if (channel is null) return;
|
|
|
|
|
|
|
|
|
|
|
|
var editInfo = new ChannelEditInfo(
|
|
|
|
|
|
channel.Id,
|
|
|
|
|
|
channel.ParentId,
|
|
|
|
|
|
channel.Name,
|
|
|
|
|
|
channel.Topic,
|
|
|
|
|
|
channel.PasswordProtected,
|
|
|
|
|
|
null,
|
|
|
|
|
|
channel.MaxUsers,
|
|
|
|
|
|
0,
|
|
|
|
|
|
new AudioConfigInfo(0, false, 48000, 0, 20, 0, true, 0, false, 10));
|
|
|
|
|
|
|
|
|
|
|
|
using var dlg = new ChannelEditDialog(_channels, editInfo);
|
|
|
|
|
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
|
|
|
|
|
_client.EditChannel(dlg.Result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void DeleteSelectedChannel()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (tvChannels.SelectedNode?.Tag is not uint channelId) return;
|
|
|
|
|
|
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
|
|
|
|
|
|
if (channel is null) return;
|
|
|
|
|
|
|
|
|
|
|
|
var confirm = MessageBox.Show(this, $"Delete channel \"{channel.Name}\"?",
|
|
|
|
|
|
"VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
|
|
|
|
|
|
if (confirm != DialogResult.Yes) return;
|
|
|
|
|
|
|
|
|
|
|
|
_client.DeleteChannel(channelId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private UserInfo? SelectedUser()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (lstUsers.SelectedItem is not UserListItem item) return null;
|
|
|
|
|
|
_users.TryGetValue(item.UserId, out var user);
|
|
|
|
|
|
return user;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void MoveSelectedUser()
|
|
|
|
|
|
{
|
|
|
|
|
|
var user = SelectedUser();
|
|
|
|
|
|
if (user is null) return;
|
|
|
|
|
|
using var dlg = new MoveUserDialog(_channels, user.ChannelId);
|
|
|
|
|
|
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
|
|
|
|
|
_client.MoveUser(user.Id, dlg.SelectedChannelId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void KickSelectedUser()
|
|
|
|
|
|
{
|
|
|
|
|
|
var user = SelectedUser();
|
|
|
|
|
|
if (user is null) return;
|
|
|
|
|
|
using var dlg = new InputDialog("Kick user", "&Reason:", "Kicked by admin");
|
|
|
|
|
|
string? reason = dlg.ShowDialog(this) == DialogResult.OK ? dlg.TextValue : null;
|
|
|
|
|
|
_client.KickUser(user.Id, reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void BanSelectedUser()
|
|
|
|
|
|
{
|
|
|
|
|
|
var user = SelectedUser();
|
|
|
|
|
|
if (user is null) return;
|
|
|
|
|
|
using var dlg = new BanUserDialog(user.Nickname);
|
|
|
|
|
|
if (dlg.ShowDialog(this) != DialogResult.OK) return;
|
|
|
|
|
|
_client.BanUser(user.Id, dlg.Reason, dlg.ExpiresUnixMs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ToggleServerMuteSelected()
|
|
|
|
|
|
{
|
|
|
|
|
|
var user = SelectedUser();
|
|
|
|
|
|
if (user is null) return;
|
|
|
|
|
|
_client.SetServerMute(user.Id, !user.ServerMuted, user.ServerDeafened);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ToggleServerDeafenSelected()
|
|
|
|
|
|
{
|
|
|
|
|
|
var user = SelectedUser();
|
|
|
|
|
|
if (user is null) return;
|
|
|
|
|
|
_client.SetServerMute(user.Id, user.ServerMuted, !user.ServerDeafened);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void SetPermissionsSelected()
|
|
|
|
|
|
{
|
|
|
|
|
|
var user = SelectedUser();
|
|
|
|
|
|
if (user is null) return;
|
|
|
|
|
|
// The C ABI does not expose a user's current permissions, so start unchecked.
|
|
|
|
|
|
var current = new PermissionsInfo(false, false, false, false, false, false);
|
|
|
|
|
|
using var dlg = new PermissionsDialog(user.Nickname, current);
|
|
|
|
|
|
if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return;
|
|
|
|
|
|
_client.SetPermission(user.Id, dlg.Result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// ── Per-user tuning ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void OpenUserTuning()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (lstUsers.SelectedItem is not UserListItem item) return;
|
|
|
|
|
|
if (!_users.TryGetValue(item.UserId, out var user)) return;
|
|
|
|
|
|
using var dlg = new PerUserTuningDialog(_client, item.UserId, user.Nickname);
|
|
|
|
|
|
dlg.ShowDialog(this);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Text chat ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void TxtCompose_KeyDown(object? sender, KeyEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (e.KeyCode == Keys.Enter)
|
|
|
|
|
|
{
|
|
|
|
|
|
SendText();
|
|
|
|
|
|
e.Handled = e.SuppressKeyPress = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void SendText()
|
|
|
|
|
|
{
|
|
|
|
|
|
string msg = txtCompose.Text.Trim();
|
|
|
|
|
|
if (string.IsNullOrEmpty(msg)) return;
|
|
|
|
|
|
|
|
|
|
|
|
var scope = VcTextScope.Channel;
|
|
|
|
|
|
uint targetId = _currentChannelId;
|
|
|
|
|
|
if (cboScope.SelectedItem is ScopeItem { Scope: VcTextScope.Private } si)
|
|
|
|
|
|
{
|
|
|
|
|
|
scope = VcTextScope.Private;
|
|
|
|
|
|
targetId = si.TargetId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (scope == VcTextScope.Channel && _currentChannelId == 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
_client.SendText(scope, targetId, msg);
|
|
|
|
|
|
txtCompose.Clear();
|
|
|
|
|
|
|
|
|
|
|
|
// Server excludes sender from channel fan-out — echo our own message locally.
|
|
|
|
|
|
string time = DateTime.Now.ToString("HH:mm");
|
|
|
|
|
|
string prefix = scope == VcTextScope.Private
|
|
|
|
|
|
? $"(private to {GetNickname(targetId)}) "
|
|
|
|
|
|
: "";
|
|
|
|
|
|
rtbChat.AppendText($"[{time}] {prefix}{_nickname}: {msg}\n");
|
|
|
|
|
|
rtbChat.ScrollToCaret();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Utility ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private void AddActivity(string text)
|
|
|
|
|
|
{
|
|
|
|
|
|
string entry = $"[{DateTime.Now:HH:mm}] {text}";
|
|
|
|
|
|
lstActivity.Items.Add(entry);
|
|
|
|
|
|
if (lstActivity.Items.Count > 200) lstActivity.Items.RemoveAt(0);
|
|
|
|
|
|
lstActivity.TopIndex = lstActivity.Items.Count - 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private string GetNickname(uint userId)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (userId == _selfUserId) return _nickname;
|
|
|
|
|
|
return _users.TryGetValue(userId, out var u) ? u.Nickname : $"User#{userId}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Lifetime ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
protected override void OnFormClosed(FormClosedEventArgs e)
|
|
|
|
|
|
{
|
|
|
|
|
|
_pumpTimer.Stop();
|
|
|
|
|
|
_client.LevelChanged -= OnLevelChanged;
|
|
|
|
|
|
_client.EventReceived -= OnEvent;
|
|
|
|
|
|
_client.Disconnect();
|
|
|
|
|
|
_client.Dispose();
|
|
|
|
|
|
base.OnFormClosed(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Private types ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
private sealed class ScopeItem(string display, VcTextScope scope, uint targetId)
|
|
|
|
|
|
{
|
|
|
|
|
|
public VcTextScope Scope { get; } = scope;
|
|
|
|
|
|
public uint TargetId { get; } = targetId;
|
|
|
|
|
|
public override string ToString() => display;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private sealed class UserListItem(uint userId, string display)
|
|
|
|
|
|
{
|
|
|
|
|
|
public uint UserId { get; } = userId;
|
|
|
|
|
|
public override string ToString() => display;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|