diff --git a/CMakePresets.json b/CMakePresets.json index 62ce022..6551f2c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -61,13 +61,31 @@ "VOICECAT_BUILD_TOOLS": "ON", "VCPKG_TARGET_TRIPLET": "x64-mingw-static" } + }, + { + "name": "windows-client", + "inherits": "vcpkg-base", + "displayName": "Windows client (shared libvoicecat.dll for the C# WinForms app, M4)", + "description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Tools/tests are off — this preset exists only to build the DLL.", + "binaryDir": "${sourceDir}/build/windows-client", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "VOICECAT_USE_VCPKG_DEPS": "ON", + "VOICECAT_BUILD_SHARED": "ON", + "VOICECAT_BUILD_SERVER": "OFF", + "VOICECAT_BUILD_TOOLS": "OFF", + "VOICECAT_BUILD_TESTS": "OFF", + "VCPKG_TARGET_TRIPLET": "x64-mingw-static", + "VCPKG_HOST_TRIPLET": "x64-mingw-static" + } } ], "buildPresets": [ - { "name": "dev", "configurePreset": "dev" }, - { "name": "m1-dev", "configurePreset": "m1-dev" }, - { "name": "m2-dev", "configurePreset": "m2-dev" }, - { "name": "server-release", "configurePreset": "server-release" } + { "name": "dev", "configurePreset": "dev" }, + { "name": "m1-dev", "configurePreset": "m1-dev" }, + { "name": "m2-dev", "configurePreset": "m2-dev" }, + { "name": "server-release", "configurePreset": "server-release" }, + { "name": "windows-client", "configurePreset": "windows-client" } ], "testPresets": [ { "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } }, diff --git a/clients/windows/Directory.Build.props b/clients/windows/Directory.Build.props new file mode 100644 index 0000000..61df7aa --- /dev/null +++ b/clients/windows/Directory.Build.props @@ -0,0 +1,8 @@ + + + + $(MSBuildThisFileDirectory)..\..\build\windows-client\bin + + diff --git a/clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs b/clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs new file mode 100644 index 0000000..dc0fe54 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs @@ -0,0 +1,187 @@ +namespace VoiceCat.App.Forms; + +partial class AddServerDialog +{ + private System.ComponentModel.IContainer components = null!; + private Label lblDisplayName = null!; + private TextBox txtDisplayName = null!; + private Label lblHost = null!; + private TextBox txtHost = null!; + private Label lblPort = null!; + private NumericUpDown numPort = null!; + private GroupBox grpAuth = null!; + private RadioButton radioGuest = null!; + private RadioButton radioPassword = null!; + private Label lblNickname = null!; + private TextBox txtNickname = null!; + private Label lblUsername = null!; + private TextBox txtUsername = null!; + private Label lblPassword = null!; + private TextBox txtPassword = null!; + private CheckBox chkRememberPassword = null!; + private Button btnOk = null!; + private Button btnCancel = null!; + + protected override void Dispose(bool disposing) + { + if (disposing) components?.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + lblDisplayName = new Label(); + txtDisplayName = new TextBox(); + lblHost = new Label(); + txtHost = new TextBox(); + lblPort = new Label(); + numPort = new NumericUpDown(); + grpAuth = new GroupBox(); + radioGuest = new RadioButton(); + radioPassword = new RadioButton(); + lblNickname = new Label(); + txtNickname = new TextBox(); + lblUsername = new Label(); + txtUsername = new TextBox(); + lblPassword = new Label(); + txtPassword = new TextBox(); + chkRememberPassword = new CheckBox(); + btnOk = new Button(); + btnCancel = new Button(); + + int y = 12; + const int rowH = 30; + + lblDisplayName.Text = "&Display name:"; + lblDisplayName.AutoSize = true; + lblDisplayName.Location = new Point(12, y + 3); + lblDisplayName.TabIndex = 0; + txtDisplayName.AccessibleName = "Display name"; + txtDisplayName.Location = new Point(140, y); + txtDisplayName.Size = new Size(280, 23); + txtDisplayName.TabIndex = 1; + y += rowH; + + lblHost.Text = "&Host:"; + lblHost.AutoSize = true; + lblHost.Location = new Point(12, y + 3); + lblHost.TabIndex = 2; + txtHost.AccessibleName = "Host"; + txtHost.Location = new Point(140, y); + txtHost.Size = new Size(200, 23); + txtHost.TabIndex = 3; + txtHost.PlaceholderText = "127.0.0.1"; + y += rowH; + + lblPort.Text = "&Port:"; + lblPort.AutoSize = true; + lblPort.Location = new Point(12, y + 3); + lblPort.TabIndex = 4; + numPort.AccessibleName = "Port"; + numPort.Location = new Point(140, y); + numPort.Size = new Size(100, 23); + numPort.Minimum = 1; + numPort.Maximum = 65535; + numPort.Value = 8384; + numPort.TabIndex = 5; + y += rowH; + + grpAuth.Text = "Authentication"; + grpAuth.Location = new Point(12, y); + grpAuth.Size = new Size(408, 140); + grpAuth.TabIndex = 6; + + radioGuest.Text = "Connect as &guest"; + radioGuest.AutoSize = true; + radioGuest.Location = new Point(12, 24); + radioGuest.Checked = true; + radioGuest.TabIndex = 0; + + lblNickname.Text = "&Nickname:"; + lblNickname.AutoSize = true; + lblNickname.Location = new Point(30, 50); + lblNickname.TabIndex = 1; + txtNickname.AccessibleName = "Guest nickname"; + txtNickname.Location = new Point(140, 47); + txtNickname.Size = new Size(240, 23); + txtNickname.TabIndex = 2; + + radioPassword.Text = "Use a &saved account"; + radioPassword.AutoSize = true; + radioPassword.Location = new Point(12, 76); + radioPassword.TabIndex = 3; + + lblUsername.Text = "&Username:"; + lblUsername.AutoSize = true; + lblUsername.Location = new Point(30, 102); + lblUsername.TabIndex = 4; + txtUsername.AccessibleName = "Username"; + txtUsername.Location = new Point(140, 99); + txtUsername.Size = new Size(240, 23); + txtUsername.TabIndex = 5; + + lblPassword.Text = "Pass&word:"; + lblPassword.AutoSize = true; + lblPassword.Location = new Point(30, 132); + lblPassword.TabIndex = 6; + txtPassword.AccessibleName = "Password"; + txtPassword.Location = new Point(140, 129); + txtPassword.Size = new Size(240, 23); + txtPassword.UseSystemPasswordChar = true; + txtPassword.TabIndex = 7; + + grpAuth.Controls.Add(radioGuest); + grpAuth.Controls.Add(lblNickname); + grpAuth.Controls.Add(txtNickname); + grpAuth.Controls.Add(radioPassword); + grpAuth.Controls.Add(lblUsername); + grpAuth.Controls.Add(txtUsername); + grpAuth.Controls.Add(lblPassword); + grpAuth.Controls.Add(txtPassword); + y += 148; + + chkRememberPassword.Text = "&Remember my password on this computer"; + chkRememberPassword.AutoSize = true; + chkRememberPassword.AccessibleDescription = + "Stores the password protected by Windows Data Protection (DPAPI), decryptable " + + "only by your Windows account on this machine. Leave unchecked to be prompted " + + "for the password every time."; + chkRememberPassword.Location = new Point(12, y); + chkRememberPassword.TabIndex = 7; + y += rowH; + + btnOk.Text = "&OK"; + btnOk.DialogResult = DialogResult.OK; + btnOk.Location = new Point(264, y); + btnOk.Size = new Size(75, 27); + btnOk.TabIndex = 8; + + btnCancel.Text = "&Cancel"; + btnCancel.DialogResult = DialogResult.Cancel; + btnCancel.Location = new Point(345, y); + btnCancel.Size = new Size(75, 27); + btnCancel.TabIndex = 9; + y += rowH + 12; + + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(432, y); + Controls.Add(lblDisplayName); + Controls.Add(txtDisplayName); + Controls.Add(lblHost); + Controls.Add(txtHost); + Controls.Add(lblPort); + Controls.Add(numPort); + Controls.Add(grpAuth); + Controls.Add(chkRememberPassword); + Controls.Add(btnOk); + Controls.Add(btnCancel); + AcceptButton = btnOk; + CancelButton = btnCancel; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + Text = "Add server"; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/AddServerDialog.cs b/clients/windows/VoiceCat.App/Forms/AddServerDialog.cs new file mode 100644 index 0000000..61cb870 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/AddServerDialog.cs @@ -0,0 +1,80 @@ +using VoiceCat.App.Models; + +namespace VoiceCat.App.Forms; + +/// Add or edit a saved server entry. DialogResult.OK -> read Result. +public partial class AddServerDialog : Form +{ + private readonly SavedServer _editing; + private bool _passwordChanged; + + public SavedServer? Result { get; private set; } + + public AddServerDialog(SavedServer? existing = null) + { + InitializeComponent(); + _editing = existing ?? new SavedServer(); + + txtDisplayName.Text = _editing.DisplayName; + txtHost.Text = _editing.Host; + numPort.Value = _editing.Port == 0 ? 8384 : _editing.Port; + txtNickname.Text = _editing.LastNickname; + radioGuest.Checked = _editing.AuthMode == AuthMode.Guest; + radioPassword.Checked = _editing.AuthMode == AuthMode.Password; + txtUsername.Text = _editing.SavedUsername ?? ""; + chkRememberPassword.Checked = _editing.ProtectedPasswordBase64 is not null; + if (_editing.ProtectedPasswordBase64 is not null) + txtPassword.Text = "********"; // placeholder — never decrypt-and-show; re-typing replaces it + + UpdateAuthFieldsEnabled(); + radioGuest.CheckedChanged += (_, _) => UpdateAuthFieldsEnabled(); + radioPassword.CheckedChanged += (_, _) => UpdateAuthFieldsEnabled(); + txtPassword.TextChanged += (_, _) => _passwordChanged = true; + + btnOk.Click += BtnOk_Click; + } + + private void UpdateAuthFieldsEnabled() + { + bool password = radioPassword.Checked; + txtNickname.Enabled = !password; + txtUsername.Enabled = password; + txtPassword.Enabled = password; + chkRememberPassword.Enabled = password; + } + + private void BtnOk_Click(object? sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(txtHost.Text)) + { + MessageBox.Show(this, "Host is required.", "VoiceCat", MessageBoxButtons.OK, MessageBoxIcon.Warning); + DialogResult = DialogResult.None; + return; + } + + _editing.DisplayName = txtDisplayName.Text.Trim(); + _editing.Host = txtHost.Text.Trim(); + _editing.Port = (ushort)numPort.Value; + _editing.AuthMode = radioPassword.Checked ? AuthMode.Password : AuthMode.Guest; + + if (_editing.AuthMode == AuthMode.Guest) + { + _editing.LastNickname = txtNickname.Text.Trim(); + _editing.SavedUsername = null; + _editing.ProtectedPasswordBase64 = null; + } + else + { + _editing.SavedUsername = txtUsername.Text.Trim(); + if (chkRememberPassword.Checked && _passwordChanged && txtPassword.Text.Length > 0) + _editing.ProtectedPasswordBase64 = PasswordProtector.Protect(txtPassword.Text); + else if (!chkRememberPassword.Checked) + _editing.ProtectedPasswordBase64 = null; + // else: remember-password still checked and password box untouched (still shows + // the placeholder) — keep whatever was already protected/stored. + } + + Result = _editing; + DialogResult = DialogResult.OK; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs b/clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs new file mode 100644 index 0000000..7e548b0 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs @@ -0,0 +1,83 @@ +namespace VoiceCat.App.Forms; + +partial class ConnectDialog +{ + private System.ComponentModel.IContainer components = null!; + private Label lblServers = null!; + private ListBox lstServers = null!; + private Button btnConnect = null!; + private Button btnAddNew = null!; + private Button btnEdit = null!; + private Button btnRemove = null!; + private Label lblStatus = null!; + + protected override void Dispose(bool disposing) + { + if (disposing) components?.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + lblServers = new Label(); + lstServers = new ListBox(); + btnConnect = new Button(); + btnAddNew = new Button(); + btnEdit = new Button(); + btnRemove = new Button(); + lblStatus = new Label(); + + lblServers.Text = "&Saved servers:"; + lblServers.AutoSize = true; + lblServers.Location = new Point(12, 12); + lblServers.TabIndex = 0; + + lstServers.AccessibleName = "Saved servers"; + lstServers.Location = new Point(12, 32); + lstServers.Size = new Size(360, 200); + lstServers.TabIndex = 1; + lstServers.SelectedIndexChanged += (_, _) => UpdateButtonsEnabled(); + + btnConnect.Text = "&Connect"; + btnConnect.Location = new Point(384, 32); + btnConnect.Size = new Size(110, 27); + btnConnect.TabIndex = 2; + + btnAddNew.Text = "&Add new..."; + btnAddNew.Location = new Point(384, 65); + btnAddNew.Size = new Size(110, 27); + btnAddNew.TabIndex = 3; + + btnEdit.Text = "&Edit..."; + btnEdit.Location = new Point(384, 98); + btnEdit.Size = new Size(110, 27); + btnEdit.TabIndex = 4; + + btnRemove.Text = "&Remove"; + btnRemove.Location = new Point(384, 131); + btnRemove.Size = new Size(110, 27); + btnRemove.TabIndex = 5; + + lblStatus.AccessibleName = "Connection status"; + lblStatus.Location = new Point(12, 244); + lblStatus.Size = new Size(482, 23); + lblStatus.TabIndex = 6; + + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(506, 280); + Controls.Add(lblServers); + Controls.Add(lstServers); + Controls.Add(btnConnect); + Controls.Add(btnAddNew); + Controls.Add(btnEdit); + Controls.Add(btnRemove); + Controls.Add(lblStatus); + AcceptButton = btnConnect; + MinimizeBox = false; + MaximizeBox = false; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterScreen; + Text = "VoiceCat — Connect to a server"; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs b/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs new file mode 100644 index 0000000..63a0642 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/ConnectDialog.cs @@ -0,0 +1,279 @@ +using VoiceCat.App.Models; +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Saved-server list + connect flow. On success, ConnectedClient/SelfUserId/Nickname are set +/// and DialogResult == OK; the caller (Program.cs) takes ownership of ConnectedClient (does +/// NOT dispose it here — MainForm owns its lifetime from that point on). +/// +public partial class ConnectDialog : Form +{ + private readonly List _servers; + private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 }; + + private VoiceCatClient? _client; + private bool _identityDialogShown; + + public VoiceCatClient? ConnectedClient { get; private set; } + public uint SelfUserId { get; private set; } + public string Nickname { get; private set; } = ""; + + public ConnectDialog() + { + InitializeComponent(); + _servers = ServerListStore.Load(); + RefreshServerList(); + + _pumpTimer.Tick += (_, _) => + { + try { _client?.PumpEvents(); } + catch (Exception ex) { Console.Error.WriteLine($"[ConnectDialog] EXCEPTION in PumpEvents/event handler: {ex}"); } + }; + + btnAddNew.Click += BtnAddNew_Click; + btnEdit.Click += BtnEdit_Click; + btnRemove.Click += BtnRemove_Click; + btnConnect.Click += BtnConnect_Click; + lstServers.DoubleClick += BtnConnect_Click; + } + + private void RefreshServerList() + { + object? previouslySelected = lstServers.SelectedItem; + lstServers.Items.Clear(); + foreach (var s in _servers) lstServers.Items.Add(s); + if (previouslySelected is not null && _servers.Contains(previouslySelected)) + lstServers.SelectedItem = previouslySelected; + else if (lstServers.Items.Count > 0) + lstServers.SelectedIndex = 0; + UpdateButtonsEnabled(); + } + + private void UpdateButtonsEnabled() + { + bool hasSelection = lstServers.SelectedItem is not null; + btnConnect.Enabled = hasSelection; + btnEdit.Enabled = hasSelection; + btnRemove.Enabled = hasSelection; + } + + private void BtnAddNew_Click(object? sender, EventArgs e) + { + using var dlg = new AddServerDialog(); + if (dlg.ShowDialog(this) != DialogResult.OK || dlg.Result is null) return; + _servers.Add(dlg.Result); + ServerListStore.Save(_servers); + RefreshServerList(); + lstServers.SelectedItem = dlg.Result; + } + + private void BtnEdit_Click(object? sender, EventArgs e) + { + if (lstServers.SelectedItem is not SavedServer existing) return; + using var dlg = new AddServerDialog(existing); + if (dlg.ShowDialog(this) != DialogResult.OK) return; + ServerListStore.Save(_servers); + RefreshServerList(); + } + + private void BtnRemove_Click(object? sender, EventArgs e) + { + if (lstServers.SelectedItem is not SavedServer existing) return; + var confirm = MessageBox.Show(this, $"Remove '{existing}' from the saved-server list?", + "VoiceCat", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (confirm != DialogResult.Yes) return; + _servers.Remove(existing); + ServerListStore.Save(_servers); + RefreshServerList(); + } + + private void BtnConnect_Click(object? sender, EventArgs e) + { + Console.WriteLine("[ConnectDialog] BtnConnect_Click fired"); + if (lstServers.SelectedItem is not SavedServer server) + { + Console.WriteLine("[ConnectDialog] no SavedServer selected — ignoring click"); + return; + } + Console.WriteLine($"[ConnectDialog] selected server: Host={server.Host} Port={server.Port} AuthMode={server.AuthMode}"); + try + { + StartConnect(server); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ConnectDialog] EXCEPTION in StartConnect: {ex}"); + lblStatus.Text = $"Internal error: {ex.Message}"; + CleanupFailedAttempt(); + } + } + + private void StartConnect(SavedServer server) + { + SetBusy(true); + lblStatus.Text = "Connecting..."; + + string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!; + Console.WriteLine($"[ConnectDialog] tofu store dir: {tofuDir}"); + Directory.CreateDirectory(tofuDir); + + Console.WriteLine("[ConnectDialog] creating VoiceCatClient..."); + _client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString, + VcLogLevel.Info, ServerListStore.TofuStorePath); + Console.WriteLine("[ConnectDialog] VoiceCatClient created OK"); + _client.EventReceived += OnEvent; + _identityDialogShown = false; + _pumpTimer.Start(); + Console.WriteLine($"[ConnectDialog] pump timer started, Enabled={_pumpTimer.Enabled}, Interval={_pumpTimer.Interval}"); + + Console.WriteLine($"[ConnectDialog] calling Connect({server.Host}, {server.Port})..."); + var connectResult = _client.Connect(server.Host, server.Port); + Console.WriteLine($"[ConnectDialog] Connect() returned {connectResult}"); + if (connectResult != VcResult.Ok) + { + lblStatus.Text = $"Connect failed: {connectResult}"; + CleanupFailedAttempt(); + return; + } + + if (server.AuthMode == AuthMode.Guest) + { + Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname; + Console.WriteLine($"[ConnectDialog] calling AuthenticateGuest({Nickname})..."); + var authResult = _client.AuthenticateGuest(Nickname); + Console.WriteLine($"[ConnectDialog] AuthenticateGuest() returned {authResult}"); + } + else + { + string password; + if (server.ProtectedPasswordBase64 is not null) + { + password = PasswordProtector.Unprotect(server.ProtectedPasswordBase64); + } + else + { + using var pwDlg = new PasswordPromptDialog($"Password for {server.SavedUsername}@{server.Host}:"); + if (pwDlg.ShowDialog(this) != DialogResult.OK) + { + lblStatus.Text = "Cancelled."; + CleanupFailedAttempt(); + return; + } + password = pwDlg.Password; + } + Nickname = server.SavedUsername ?? ""; + Console.WriteLine($"[ConnectDialog] calling AuthenticateUser({Nickname})..."); + var authResult = _client.AuthenticateUser(server.SavedUsername ?? "", password); + Console.WriteLine($"[ConnectDialog] AuthenticateUser() returned {authResult}"); + } + } + + private void OnEvent(VoiceCatEvent ev) + { + Console.WriteLine($"[ConnectDialog] event: {ev}"); + switch (ev.Type) + { + case VcEventType.ConnectionState: + lblStatus.Text = ev.ConnectionState switch + { + VcConnectionState.Connecting => "Connecting...", + VcConnectionState.TlsHandshake => "TLS handshake...", + VcConnectionState.VerifyingIdentity => "Verifying server identity...", + VcConnectionState.Authenticating => "Authenticating...", + VcConnectionState.Connected => "Connected.", + _ => lblStatus.Text, + }; + break; + + case VcEventType.ServerIdentity: + HandleServerIdentity((VcTofuStatus)ev.U32a, ev.Text ?? ""); + break; + + case VcEventType.AuthResult: + if (ev.Result == VcResult.Ok) + { + SelfUserId = ev.UserId; + ConnectedClient = _client; + _pumpTimer.Stop(); + _client!.EventReceived -= OnEvent; + DialogResult = DialogResult.OK; + Close(); + } + else + { + lblStatus.Text = $"Authentication failed: {ev.Text}"; + CleanupFailedAttempt(); + } + break; + + case VcEventType.Disconnected: + if (ConnectedClient is null) + { + lblStatus.Text = string.IsNullOrEmpty(ev.Text) ? "Disconnected." : $"Disconnected: {ev.Text}"; + CleanupFailedAttempt(); + } + break; + } + } + + private void HandleServerIdentity(VcTofuStatus status, string certFingerprintHex) + { + Console.WriteLine($"[ConnectDialog] HandleServerIdentity status={status} fp={certFingerprintHex} alreadyShown={_identityDialogShown}"); + if (_identityDialogShown) return; // one decision per connect attempt + if (status == VcTofuStatus.Matched) + { + // Silent success path — no dialog. See ServerIdentityDialog's doc comment. + Console.WriteLine("[ConnectDialog] status=Matched -> auto-confirming, no dialog"); + _client!.ConfirmServerIdentity(true); + return; + } + + _identityDialogShown = true; + Console.WriteLine("[ConnectDialog] showing ServerIdentityDialog..."); + using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay()); + var dlgResult = dlg.ShowDialog(this); + Console.WriteLine($"[ConnectDialog] ServerIdentityDialog closed with {dlgResult}"); + bool accept = dlgResult == DialogResult.OK; + var confirmResult = _client.ConfirmServerIdentity(accept); + Console.WriteLine($"[ConnectDialog] ConfirmServerIdentity({accept}) returned {confirmResult}"); + if (!accept) lblStatus.Text = "Server identity rejected."; + } + + private void CleanupFailedAttempt() + { + _pumpTimer.Stop(); + if (_client is not null) + { + _client.EventReceived -= OnEvent; + _client.Dispose(); + _client = null; + } + SetBusy(false); + } + + private void SetBusy(bool busy) + { + lstServers.Enabled = !busy; + btnConnect.Enabled = !busy && lstServers.SelectedItem is not null; + btnAddNew.Enabled = !busy; + btnEdit.Enabled = !busy && lstServers.SelectedItem is not null; + btnRemove.Enabled = !busy && lstServers.SelectedItem is not null; + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (DialogResult != DialogResult.OK) + { + _pumpTimer.Stop(); + if (_client is not null) + { + _client.EventReceived -= OnEvent; + _client.Dispose(); + _client = null; + } + } + base.OnFormClosing(e); + } +} diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs new file mode 100644 index 0000000..f3112e0 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs @@ -0,0 +1,364 @@ +namespace VoiceCat.App.Forms; + +partial class MainForm +{ + private System.ComponentModel.IContainer components = null!; + + // Status bar + private Label lblStatus = null!; + + // Main left/right split + private SplitContainer splitMain = null!; + + // Left panel: channel tree on top, user list on bottom + private SplitContainer splitLeft = null!; + private Label lblChannels = null!; + private TreeView tvChannels = null!; + private Label lblUsers = null!; + private ListBox lstUsers = null!; + + // Right panel: chat transcript, compose row, activity log + private TableLayoutPanel tblRight = null!; + private Label lblChat = null!; + private RichTextBox rtbChat = null!; + private TableLayoutPanel tblCompose = null!; + private ComboBox cboScope = null!; + private TextBox txtCompose = null!; + private Button btnSend = null!; + private Label lblActivity = null!; + private ListBox lstActivity = null!; + + // Voice control panel (docked Bottom) + private Panel pnlVoice = null!; + private FlowLayoutPanel flpVoiceTop = null!; + private FlowLayoutPanel flpVoiceBottom = null!; + private Button btnMicToggle = null!; + private CheckBox chkMute = null!; + private CheckBox chkDeafen = null!; + private RadioButton radioVad = null!; + private RadioButton radioPtt = null!; + private RadioButton radioAlwaysOn = null!; + private Label lblPttKey = null!; + private Button btnChangePtt = null!; + private Label lblInputDevice = null!; + private ComboBox cboInputDevice = null!; + private Button btnRefreshDevices = null!; + private Label lblLevel = null!; + private ProgressBar pbLevel = null!; + private Label lblVadThreshold = null!; + private TrackBar trkVadThreshold = null!; + + protected override void Dispose(bool disposing) + { + if (disposing) components?.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + + lblStatus = new Label(); + splitMain = new SplitContainer(); + splitLeft = new SplitContainer(); + lblChannels = new Label(); + tvChannels = new TreeView(); + lblUsers = new Label(); + lstUsers = new ListBox(); + tblRight = new TableLayoutPanel(); + lblChat = new Label(); + rtbChat = new RichTextBox(); + tblCompose = new TableLayoutPanel(); + cboScope = new ComboBox(); + txtCompose = new TextBox(); + btnSend = new Button(); + lblActivity = new Label(); + lstActivity = new ListBox(); + pnlVoice = new Panel(); + flpVoiceTop = new FlowLayoutPanel(); + flpVoiceBottom = new FlowLayoutPanel(); + btnMicToggle = new Button(); + chkMute = new CheckBox(); + chkDeafen = new CheckBox(); + radioVad = new RadioButton(); + radioPtt = new RadioButton(); + radioAlwaysOn = new RadioButton(); + lblPttKey = new Label(); + btnChangePtt = new Button(); + lblInputDevice = new Label(); + cboInputDevice = new ComboBox(); + btnRefreshDevices = new Button(); + lblLevel = new Label(); + pbLevel = new ProgressBar(); + lblVadThreshold = new Label(); + trkVadThreshold = new TrackBar(); + + // ── Status label ────────────────────────────────────────────────────── + lblStatus.AccessibleName = "Connection status"; + lblStatus.Dock = DockStyle.Top; + lblStatus.AutoSize = true; + lblStatus.Padding = new Padding(6, 4, 6, 4); + lblStatus.TabIndex = 0; + lblStatus.Text = "Connecting..."; + + // ── Channel tree ────────────────────────────────────────────────────── + lblChannels.Text = "Channels:"; + lblChannels.Dock = DockStyle.Top; + lblChannels.AutoSize = true; + lblChannels.Padding = new Padding(4, 4, 4, 2); + + tvChannels.AccessibleName = "Channel list"; + tvChannels.AccessibleDescription = + "Double-click or press Enter to join a channel. " + + "Channels marked [password] require a password."; + tvChannels.Dock = DockStyle.Fill; + tvChannels.HideSelection = false; + tvChannels.TabIndex = 0; + + // ── User list ───────────────────────────────────────────────────────── + lblUsers.Text = "Users in channel:"; + lblUsers.Dock = DockStyle.Top; + lblUsers.AutoSize = true; + lblUsers.Padding = new Padding(4, 4, 4, 2); + + lstUsers.AccessibleName = "Users in current channel"; + lstUsers.AccessibleDescription = + "People in the same channel. Double-click or press Enter for per-user volume settings. " + + "Talking users are marked (talking)."; + lstUsers.Dock = DockStyle.Fill; + lstUsers.TabIndex = 1; + + // ── Left split (channels top, users bottom) ─────────────────────────── + splitLeft.Orientation = Orientation.Horizontal; + splitLeft.Dock = DockStyle.Fill; + splitLeft.Panel1MinSize = 100; + splitLeft.Panel2MinSize = 80; + splitLeft.TabIndex = 0; + splitLeft.Panel1.Controls.Add(tvChannels); + splitLeft.Panel1.Controls.Add(lblChannels); + splitLeft.Panel2.Controls.Add(lstUsers); + splitLeft.Panel2.Controls.Add(lblUsers); + + // ── Chat transcript ─────────────────────────────────────────────────── + lblChat.Text = "Chat:"; + lblChat.AutoSize = true; + lblChat.Padding = new Padding(2, 2, 2, 1); + + rtbChat.AccessibleName = "Chat transcript"; + rtbChat.AccessibleDescription = "History of channel and private messages."; + rtbChat.Dock = DockStyle.Fill; + rtbChat.ReadOnly = true; + rtbChat.ScrollBars = RichTextBoxScrollBars.Vertical; + rtbChat.BackColor = SystemColors.Window; + rtbChat.TabIndex = 0; + + // ── Compose row ─────────────────────────────────────────────────────── + cboScope.AccessibleName = "Send to"; + cboScope.AccessibleDescription = + "Choose Channel to send to everyone, or a specific user for a private message."; + cboScope.DropDownStyle = ComboBoxStyle.DropDownList; + cboScope.Dock = DockStyle.Fill; + cboScope.TabIndex = 1; + + txtCompose.AccessibleName = "Message text"; + txtCompose.AccessibleDescription = "Type your message. Press Enter or click Send to send."; + txtCompose.Dock = DockStyle.Fill; + txtCompose.TabIndex = 2; + + btnSend.Text = "&Send"; + btnSend.Dock = DockStyle.Fill; + btnSend.TabIndex = 3; + + tblCompose.ColumnCount = 3; + tblCompose.RowCount = 1; + tblCompose.Dock = DockStyle.Fill; + tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 165F)); + tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + tblCompose.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 68F)); + tblCompose.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + tblCompose.Padding = new Padding(0, 3, 0, 0); + tblCompose.Controls.Add(cboScope, 0, 0); + tblCompose.Controls.Add(txtCompose, 1, 0); + tblCompose.Controls.Add(btnSend, 2, 0); + + // ── Activity log ────────────────────────────────────────────────────── + lblActivity.Text = "Activity:"; + lblActivity.AutoSize = true; + lblActivity.Padding = new Padding(2, 4, 2, 1); + + lstActivity.AccessibleName = "Activity log"; + lstActivity.AccessibleDescription = + "Record of joins, leaves, talk-state changes, and server messages."; + lstActivity.Dock = DockStyle.Fill; + lstActivity.HorizontalScrollbar = true; + lstActivity.TabIndex = 4; + + // ── Right table layout ──────────────────────────────────────────────── + tblRight.ColumnCount = 1; + tblRight.RowCount = 5; + tblRight.Dock = DockStyle.Fill; + tblRight.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + tblRight.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); + tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 34F)); + tblRight.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + tblRight.RowStyles.Add(new RowStyle(SizeType.Absolute, 120F)); + tblRight.Controls.Add(lblChat, 0, 0); + tblRight.Controls.Add(rtbChat, 0, 1); + tblRight.Controls.Add(tblCompose, 0, 2); + tblRight.Controls.Add(lblActivity, 0, 3); + tblRight.Controls.Add(lstActivity, 0, 4); + + // ── Main split ──────────────────────────────────────────────────────── + // SplitterDistance set in OnLoad — see MainForm.cs. + splitMain.Dock = DockStyle.Fill; + splitMain.Panel1MinSize = 150; + splitMain.TabIndex = 1; + splitMain.Panel1.Controls.Add(splitLeft); + splitMain.Panel2.Controls.Add(tblRight); + + // ── Voice control panel ─────────────────────────────────────────────── + // Top row: mic start/stop, mute/deafen, VAD/PTT + btnMicToggle.Text = "&Join Voice"; + btnMicToggle.AutoSize = true; + btnMicToggle.Margin = new Padding(0, 2, 6, 0); + btnMicToggle.TabIndex = 0; + + chkMute.Text = "&Mute mic"; + chkMute.AutoSize = true; + chkMute.Enabled = false; + chkMute.Margin = new Padding(0, 4, 6, 0); + chkMute.TabIndex = 1; + + chkDeafen.Text = "&Deafen"; + chkDeafen.AutoSize = true; + chkDeafen.Enabled = false; + chkDeafen.Margin = new Padding(0, 4, 12, 0); + chkDeafen.TabIndex = 2; + + var lblMode = new Label { Text = "Mode:", AutoSize = true, Margin = new Padding(0, 5, 4, 0) }; + + radioVad.Text = "&Voice activation"; + radioVad.AutoSize = true; + radioVad.Checked = true; + radioVad.Enabled = false; + radioVad.Margin = new Padding(0, 4, 6, 0); + radioVad.TabIndex = 3; + + radioPtt.Text = "&Push to talk"; + radioPtt.AutoSize = true; + radioPtt.Enabled = false; + radioPtt.Margin = new Padding(0, 4, 4, 0); + radioPtt.TabIndex = 4; + + radioAlwaysOn.Text = "A&lways on"; + radioAlwaysOn.AutoSize = true; + radioAlwaysOn.Enabled = false; + radioAlwaysOn.Margin = new Padding(0, 4, 12, 0); + radioAlwaysOn.TabIndex = 5; + + lblPttKey.Text = "(F8)"; + lblPttKey.AutoSize = true; + lblPttKey.Margin = new Padding(2, 5, 4, 0); + lblPttKey.Visible = false; + + btnChangePtt.Text = "Change key..."; + btnChangePtt.AutoSize = true; + btnChangePtt.Margin = new Padding(0, 2, 0, 0); + btnChangePtt.Visible = false; + btnChangePtt.TabIndex = 6; + + flpVoiceTop.Dock = DockStyle.Top; + flpVoiceTop.Height = 34; + flpVoiceTop.AutoSize = false; + flpVoiceTop.Padding = new Padding(4, 2, 4, 0); + flpVoiceTop.Controls.Add(btnMicToggle); + flpVoiceTop.Controls.Add(chkMute); + flpVoiceTop.Controls.Add(chkDeafen); + flpVoiceTop.Controls.Add(lblMode); + flpVoiceTop.Controls.Add(radioVad); + flpVoiceTop.Controls.Add(radioPtt); + flpVoiceTop.Controls.Add(radioAlwaysOn); + flpVoiceTop.Controls.Add(lblPttKey); + flpVoiceTop.Controls.Add(btnChangePtt); + + // Bottom row: device picker + level meter + lblInputDevice.Text = "Input:"; + lblInputDevice.AutoSize = true; + lblInputDevice.Margin = new Padding(0, 5, 4, 0); + + cboInputDevice.AccessibleName = "Input device"; + cboInputDevice.AccessibleDescription = "Select which microphone or audio device to use."; + cboInputDevice.DropDownStyle = ComboBoxStyle.DropDownList; + cboInputDevice.Width = 200; + cboInputDevice.Margin = new Padding(0, 2, 4, 0); + cboInputDevice.TabIndex = 7; + + btnRefreshDevices.Text = "Re&fresh"; + btnRefreshDevices.AutoSize = true; + btnRefreshDevices.Margin = new Padding(0, 2, 12, 0); + btnRefreshDevices.TabIndex = 8; + + lblLevel.Text = "Level:"; + lblLevel.AutoSize = true; + lblLevel.Margin = new Padding(0, 5, 4, 0); + + pbLevel.AccessibleName = "Microphone level"; + pbLevel.AccessibleDescription = "Current input level from the microphone."; + pbLevel.Width = 120; + pbLevel.Height = 16; + pbLevel.Maximum = 100; + pbLevel.Margin = new Padding(0, 6, 0, 0); + pbLevel.Style = ProgressBarStyle.Continuous; + pbLevel.TabStop = false; // informational, not actionable + + lblVadThreshold.Text = "Sensitivity:"; + lblVadThreshold.AutoSize = true; + lblVadThreshold.Margin = new Padding(12, 5, 4, 0); + lblVadThreshold.Visible = true; // shown when VAD mode active + + trkVadThreshold.AccessibleName = "VAD sensitivity"; + trkVadThreshold.AccessibleDescription = + "Voice detection sensitivity. Higher = more sensitive (triggers on quieter sounds). " + + "Range 1–100; default 25."; + trkVadThreshold.Minimum = 1; + trkVadThreshold.Maximum = 100; + trkVadThreshold.Value = 76; // maps to ~0.024 (≈ default 0.025 threshold) + trkVadThreshold.TickFrequency = 10; + trkVadThreshold.SmallChange = 1; + trkVadThreshold.LargeChange = 10; + trkVadThreshold.Width = 120; + trkVadThreshold.Margin = new Padding(0, 2, 0, 0); + trkVadThreshold.TabIndex = 9; + trkVadThreshold.Visible = true; + + flpVoiceBottom.Dock = DockStyle.Fill; + flpVoiceBottom.Padding = new Padding(4, 0, 4, 2); + flpVoiceBottom.Controls.Add(lblInputDevice); + flpVoiceBottom.Controls.Add(cboInputDevice); + flpVoiceBottom.Controls.Add(btnRefreshDevices); + flpVoiceBottom.Controls.Add(lblLevel); + flpVoiceBottom.Controls.Add(pbLevel); + flpVoiceBottom.Controls.Add(lblVadThreshold); + flpVoiceBottom.Controls.Add(trkVadThreshold); + + pnlVoice.Dock = DockStyle.Bottom; + pnlVoice.Height = 68; + pnlVoice.BorderStyle = BorderStyle.FixedSingle; + pnlVoice.Padding = new Padding(0); + pnlVoice.Controls.Add(flpVoiceBottom); // Fill — added first + pnlVoice.Controls.Add(flpVoiceTop); // Top — added last + + // ── Form ────────────────────────────────────────────────────────────── + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(960, 680); + MinimumSize = new Size(700, 540); + KeyPreview = true; // form sees KeyDown/KeyUp before focused control (needed for PTT) + // Controls added in reverse docking priority: Fill first, then Bottom, then Top. + Controls.Add(splitMain); // DockStyle.Fill + Controls.Add(pnlVoice); // DockStyle.Bottom + Controls.Add(lblStatus); // DockStyle.Top + StartPosition = FormStartPosition.CenterScreen; + Text = "VoiceCat"; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs new file mode 100644 index 0000000..a9c6e32 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -0,0 +1,684 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// 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. +/// +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 _channels = []; + private readonly Dictionary _users = []; + private readonly HashSet _talkingUsers = []; + + // Voice state + private uint _micStreamId; // 0 = not started + private Keys _pttKey = Keys.F8; + + 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(); + _pumpTimer.Start(); + + // Channel tree + tvChannels.DoubleClick += TvChannels_DoubleClick; + tvChannels.KeyDown += TvChannels_KeyDown; + + // User list — double-click or Enter for per-user tuning, right-click for context menu + var ctxUsers = new ContextMenuStrip(); + var miTune = new ToolStripMenuItem("&Adjust volume and noise settings..."); + miTune.Click += (_, _) => OpenUserTuning(); + ctxUsers.Opening += (_, _) => miTune.Enabled = lstUsers.SelectedItem is UserListItem; + ctxUsers.Items.Add(miTune); + lstUsers.ContextMenuStrip = ctxUsers; + 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; + if (u.Id == _selfUserId) _currentChannelId = u.ChannelId; + } + 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(); + } + + // ── 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; + 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) + { + var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId); + _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; + if (u.Id == _selfUserId) _currentChannelId = u.ChannelId; + } + 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()}"); + } + } + + 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)"; + 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() + { + if (_currentChannelId == 0) + { + lblStatus.Text = $"Connected as {_nickname} — not in a channel."; + return; + } + string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name + ?? $"Channel #{_currentChannelId}"; + int count = _users.Values.Count(u => u.ChannelId == _currentChannelId); + lblStatus.Text = $"Connected as {_nickname} — {chanName} ({count} user{(count == 1 ? "" : "s")})"; + } + + // ── 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); + } + + // ── 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; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs b/clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs new file mode 100644 index 0000000..a09cf81 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs @@ -0,0 +1,61 @@ +namespace VoiceCat.App.Forms; + +partial class PasswordPromptDialog +{ + private System.ComponentModel.IContainer components = null!; + private Label lblPrompt = null!; + private TextBox txtPassword = null!; + private Button btnOk = null!; + private Button btnCancel = null!; + + protected override void Dispose(bool disposing) + { + if (disposing) components?.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + lblPrompt = new Label(); + txtPassword = new TextBox(); + btnOk = new Button(); + btnCancel = new Button(); + + lblPrompt.AutoSize = true; + lblPrompt.Location = new Point(12, 12); + lblPrompt.MaximumSize = new Size(360, 0); + lblPrompt.TabIndex = 0; + + txtPassword.Location = new Point(12, 40); + txtPassword.Size = new Size(360, 23); + txtPassword.UseSystemPasswordChar = true; + txtPassword.TabIndex = 1; + + btnOk.Text = "&OK"; + btnOk.DialogResult = DialogResult.OK; + btnOk.Location = new Point(216, 75); + btnOk.Size = new Size(75, 27); + btnOk.TabIndex = 2; + + btnCancel.Text = "&Cancel"; + btnCancel.DialogResult = DialogResult.Cancel; + btnCancel.Location = new Point(297, 75); + btnCancel.Size = new Size(75, 27); + btnCancel.TabIndex = 3; + + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(384, 114); + Controls.Add(lblPrompt); + Controls.Add(txtPassword); + Controls.Add(btnOk); + Controls.Add(btnCancel); + AcceptButton = btnOk; + CancelButton = btnCancel; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + Text = "Password required"; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs b/clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs new file mode 100644 index 0000000..abfef83 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs @@ -0,0 +1,15 @@ +namespace VoiceCat.App.Forms; + +/// Small modal for "type a password right now" — used when a saved server's +/// password wasn't remembered, and (Phase E) for password-protected channel joins. +public partial class PasswordPromptDialog : Form +{ + public string Password => txtPassword.Text; + + public PasswordPromptDialog(string promptText) + { + InitializeComponent(); + lblPrompt.Text = promptText; + txtPassword.AccessibleName = "Password"; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs new file mode 100644 index 0000000..ce9dbdd --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs @@ -0,0 +1,125 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// Per-remote-user gain, mute, and noise reduction settings. +/// Changes are applied in real-time as the user adjusts controls — no OK/Cancel round-trip +/// for gain and mute (the close button just dismisses). The settings live in the core and +/// are not persisted across sessions. +/// +public sealed class PerUserTuningDialog : Form +{ + private readonly VoiceCatClient _client; + private readonly uint _userId; + + private readonly TrackBar _trkGain; + private readonly Label _lblGainValue; + private readonly CheckBox _chkMute; + private readonly CheckBox _chkNr; + + public PerUserTuningDialog(VoiceCatClient client, uint userId, string nickname) + { + _client = client; + _userId = userId; + + // ── Controls ────────────────────────────────────────────────────────── + var lblTitle = new Label + { + Text = $"Settings for {nickname}", + AutoSize = true, + Font = new Font(Font, FontStyle.Bold), + Location = new Point(12, 12), + TabIndex = 0, + }; + + var lblGainLabel = new Label + { + Text = "&Gain:", + AutoSize = true, + Location = new Point(12, 46), + TabIndex = 1, + }; + + _trkGain = new TrackBar + { + AccessibleName = "Gain", + AccessibleDescription = "Volume level for this user. 100 is normal (1.0×), 200 is double.", + Location = new Point(55, 38), + Size = new Size(220, 45), + Minimum = 0, + Maximum = 200, + Value = 100, + TickFrequency = 25, + SmallChange = 5, + LargeChange = 25, + TabIndex = 2, + }; + + _lblGainValue = new Label + { + Text = "100% (1.0×)", + AutoSize = true, + Location = new Point(280, 46), + TabIndex = 3, + }; + + _chkMute = new CheckBox + { + Text = "&Mute this user", + AutoSize = true, + Location = new Point(12, 92), + TabIndex = 4, + }; + + _chkNr = new CheckBox + { + Text = "&Noise reduction (planned — currently passthrough)", + AutoSize = true, + Location = new Point(12, 118), + TabIndex = 5, + }; + + var btnClose = new Button + { + Text = "&Close", + DialogResult = DialogResult.OK, + Location = new Point(296, 152), + Size = new Size(75, 27), + TabIndex = 6, + }; + + // ── Wire events ─────────────────────────────────────────────────────── + _trkGain.Scroll += (_, _) => + { + float gain = _trkGain.Value / 100f; + _lblGainValue.Text = $"{_trkGain.Value}% ({gain:F1}×)"; + ApplySettings(); + }; + _chkMute.CheckedChanged += (_, _) => ApplySettings(); + _chkNr.CheckedChanged += (_, _) => ApplySettings(); + + // ── Form ────────────────────────────────────────────────────────────── + AcceptButton = btnClose; + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(384, 192); + Controls.AddRange([lblTitle, lblGainLabel, _trkGain, _lblGainValue, + _chkMute, _chkNr, btnClose]); + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + Text = $"User settings — {nickname}"; + } + + private void ApplySettings() + { + float gain = _trkGain.Value / 100f; + bool muted = _chkMute.Checked; + bool nr = _chkNr.Checked; + // Apply to all of this user's streams + var streams = _client.ListUserStreams(_userId); + foreach (var s in streams) + _client.SetRemoteStream(_userId, s.StreamId, gain, muted, nr); + } +} diff --git a/clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs b/clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs new file mode 100644 index 0000000..de64135 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs @@ -0,0 +1,51 @@ +namespace VoiceCat.App.Forms; + +/// +/// Shows "Press any key…" and captures the next KeyDown as the PTT key. +/// Press Escape to cancel without changing the key. +/// +public sealed class PttKeyCaptureDialog : Form +{ + private readonly Label _label; + + public Keys CapturedKey { get; private set; } + + public PttKeyCaptureDialog(Keys current) + { + CapturedKey = current; + + _label = new Label + { + Text = $"Current PTT key: {current}\n\nPress any key to set a new PTT key,\nor press Escape to keep the current key.", + AutoSize = false, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleCenter, + TabIndex = 0, + }; + + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(340, 130); + Controls.Add(_label); + FormBorderStyle = FormBorderStyle.FixedDialog; + KeyPreview = true; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + Text = "Set PTT key"; + + KeyDown += (_, e) => + { + e.SuppressKeyPress = true; + if (e.KeyCode == Keys.Escape) + { + DialogResult = DialogResult.Cancel; + } + else + { + CapturedKey = e.KeyCode; + DialogResult = DialogResult.OK; + } + Close(); + }; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs b/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs new file mode 100644 index 0000000..ffdb352 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs @@ -0,0 +1,65 @@ +namespace VoiceCat.App.Forms; + +partial class ServerIdentityDialog +{ + private System.ComponentModel.IContainer components = null!; + private Label lblWarning = null!; + private Label lblFingerprint = null!; + private Button btnAccept = null!; + private Button btnCancel = null!; + + protected override void Dispose(bool disposing) + { + if (disposing) components?.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + lblWarning = new Label(); + lblFingerprint = new Label(); + btnAccept = new Button(); + btnCancel = new Button(); + + lblWarning.Location = new Point(12, 12); + lblWarning.Size = new Size(460, 90); + lblWarning.TabStop = true; // a Label can receive focus when TabStop=true — used + // deliberately so OnShown's Focus() call forces a screen + // reader to read this text immediately on activation. + lblWarning.TabIndex = 0; + + lblFingerprint.Location = new Point(12, 110); + lblFingerprint.Size = new Size(460, 90); + lblFingerprint.TabStop = true; + lblFingerprint.TabIndex = 1; + lblFingerprint.Font = new Font(FontFamily.GenericMonospace, 9f); + + btnAccept.Location = new Point(150, 210); + btnAccept.Size = new Size(220, 27); + btnAccept.TabIndex = 2; + btnAccept.Click += (_, _) => { DialogResult = DialogResult.OK; Close(); }; + + btnCancel.Text = "&Cancel"; + btnCancel.Location = new Point(397, 210); + btnCancel.Size = new Size(75, 27); + btnCancel.TabIndex = 3; + btnCancel.Click += (_, _) => { DialogResult = DialogResult.Cancel; Close(); }; + + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(484, 250); + Controls.Add(lblWarning); + Controls.Add(lblFingerprint); + Controls.Add(btnAccept); + Controls.Add(btnCancel); + // Cancel is the default/Esc-bound button — the SAFE default for an unverified or + // changed identity is to NOT silently trust it. + AcceptButton = btnCancel; + CancelButton = btnCancel; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + StartPosition = FormStartPosition.CenterParent; + Text = "Server identity"; + } +} diff --git a/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs b/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs new file mode 100644 index 0000000..153e6b5 --- /dev/null +++ b/clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs @@ -0,0 +1,66 @@ +using VoiceCat.Interop; + +namespace VoiceCat.App.Forms; + +/// +/// TOFU server-identity confirmation (M4). Shown only for VcTofuStatus.FirstConnect/Mismatch +/// — never Matched (that's the silent-success "subsequent connects verify the pin" path +/// docs/security.md describes; showing a dialog on every routine reconnect would be exactly +/// the "overly chatty" experience this project avoids elsewhere too). +/// DialogResult.OK = accept (and the caller should call ConfirmServerIdentity(true) and, for +/// FirstConnect/Mismatch, the core persists the new pin); DialogResult.Cancel = reject. +/// +public partial class ServerIdentityDialog : Form +{ + public ServerIdentityDialog(VcTofuStatus status, string certFingerprintHex, string identityDisplayHex) + { + InitializeComponent(); + + string formattedCertFp = FormatFingerprint(certFingerprintHex); + string formattedIdentityFp = string.IsNullOrEmpty(identityDisplayHex) + ? "(not yet available)" + : FormatFingerprint(identityDisplayHex); + + if (status == VcTofuStatus.Mismatch) + { + Text = "WARNING: Server identity changed"; + lblWarning.Text = + "WARNING: This server's identity has CHANGED since you last connected.\r\n\r\n" + + "This could mean the server was reinstalled, OR that someone is intercepting " + + "your connection. If you did not expect this server's identity to change, " + + "choose Cancel."; + lblFingerprint.Text = $"New certificate fingerprint:\r\n{formattedCertFp}\r\n\r\n" + + $"Server also identifies as:\r\n{formattedIdentityFp}"; + btnAccept.Text = "&Trust the new identity anyway"; + } + else + { + Text = "New server — verify identity"; + lblWarning.Text = + "You have not connected to this server before. If you have verified its " + + "fingerprint with the server operator through another channel, choose Trust " + + "and connect. Otherwise, choose Cancel."; + lblFingerprint.Text = $"Certificate fingerprint:\r\n{formattedCertFp}\r\n\r\n" + + $"Server also identifies as:\r\n{formattedIdentityFp}"; + btnAccept.Text = "&Trust and connect"; + } + } + + protected override void OnShown(EventArgs e) + { + base.OnShown(e); + // Force a screen reader to read the warning text immediately on dialog activation, + // rather than landing focus straight on a button and silently skipping it. + lblWarning.Focus(); + } + + private static string FormatFingerprint(string hex) + { + // Groups of 4 hex chars, like a product key — easier to read/dictate/compare than one + // unbroken 64-character string. + var groups = new List(); + for (int i = 0; i < hex.Length; i += 4) + groups.Add(hex.Substring(i, Math.Min(4, hex.Length - i))); + return string.Join(' ', groups); + } +} diff --git a/clients/windows/VoiceCat.App/Models/PasswordProtector.cs b/clients/windows/VoiceCat.App/Models/PasswordProtector.cs new file mode 100644 index 0000000..0fb9b42 --- /dev/null +++ b/clients/windows/VoiceCat.App/Models/PasswordProtector.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VoiceCat.App.Models; + +/// +/// DPAPI (CurrentUser scope) wrapper for the opt-in "remember my password on this computer" +/// feature (SavedServer.ProtectedPasswordBase64). DPAPI keys are derived from the user's +/// Windows credentials and are not portable/exportable — a stolen servers.json file alone, +/// copied to another machine or read by another OS user, is not decryptable. This is the same +/// primitive Windows Credential Manager and many first-party Windows apps use for exactly this +/// "remember a secret only on this machine, only for this user" case. +/// +public static class PasswordProtector +{ + public static string Protect(string plaintext) + { + byte[] data = Encoding.UTF8.GetBytes(plaintext); + byte[] protectedData = ProtectedData.Protect(data, optionalEntropy: null, DataProtectionScope.CurrentUser); + return Convert.ToBase64String(protectedData); + } + + public static string Unprotect(string protectedBase64) + { + byte[] protectedData = Convert.FromBase64String(protectedBase64); + byte[] data = ProtectedData.Unprotect(protectedData, optionalEntropy: null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(data); + } +} diff --git a/clients/windows/VoiceCat.App/Models/SavedServer.cs b/clients/windows/VoiceCat.App/Models/SavedServer.cs new file mode 100644 index 0000000..3d7009b --- /dev/null +++ b/clients/windows/VoiceCat.App/Models/SavedServer.cs @@ -0,0 +1,26 @@ +namespace VoiceCat.App.Models; + +public enum AuthMode +{ + Guest, + Password, +} + +/// One entry in the saved-server list (%AppData%\VoiceCat\servers.json). +public sealed class SavedServer +{ + public string DisplayName { get; set; } = ""; + public string Host { get; set; } = ""; + public ushort Port { get; set; } = 8384; + public string LastNickname { get; set; } = ""; + public AuthMode AuthMode { get; set; } = AuthMode.Guest; + public string? SavedUsername { get; set; } + + /// DPAPI-protected (CurrentUser scope), base64 — only set when the user opts in + /// via the "Remember my password on this computer" checkbox. Never plaintext, never the + /// default. See PasswordProtector. + public string? ProtectedPasswordBase64 { get; set; } + + public override string ToString() => + string.IsNullOrWhiteSpace(DisplayName) ? $"{Host}:{Port}" : $"{DisplayName} ({Host}:{Port})"; +} diff --git a/clients/windows/VoiceCat.App/Models/ServerListStore.cs b/clients/windows/VoiceCat.App/Models/ServerListStore.cs new file mode 100644 index 0000000..1ccd19f --- /dev/null +++ b/clients/windows/VoiceCat.App/Models/ServerListStore.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace VoiceCat.App.Models; + +/// +/// Simple Load()/Save() over %AppData%\VoiceCat\servers.json. Tolerant of a missing/corrupt +/// file — that yields an empty list rather than throwing and blocking app startup. +/// +public static class ServerListStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private static string AppDataDir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat"); + + private static string ServersFilePath => Path.Combine(AppDataDir, "servers.json"); + + /// Passed to VoiceCatClient's tofuStorePath — the C++ core owns the actual pin + /// file format/logic (TofuStore), this just picks where it lives. + public static string TofuStorePath => Path.Combine(AppDataDir, "tofu_pins.txt"); + + public static List Load() + { + try + { + if (!File.Exists(ServersFilePath)) return new List(); + string json = File.ReadAllText(ServersFilePath); + return JsonSerializer.Deserialize>(json) ?? new List(); + } + catch + { + return new List(); + } + } + + public static void Save(List servers) + { + Directory.CreateDirectory(AppDataDir); + string json = JsonSerializer.Serialize(servers, JsonOptions); + File.WriteAllText(ServersFilePath, json); + } +} diff --git a/clients/windows/VoiceCat.App/Program.cs b/clients/windows/VoiceCat.App/Program.cs new file mode 100644 index 0000000..f2f3b46 --- /dev/null +++ b/clients/windows/VoiceCat.App/Program.cs @@ -0,0 +1,36 @@ +using VoiceCat.App.Forms; + +namespace VoiceCat.App; + +internal static class Program +{ + [STAThread] + private static void Main() + { + // Diagnostic-logging-only for now (manual debugging session) — every exception that + // would otherwise be silently caught by WinForms' default message-loop handling (or + // crash with no visible cause) gets printed to stdout/stderr first. + Application.ThreadException += (_, e) => + Console.Error.WriteLine($"[UNHANDLED ThreadException] {e.Exception}"); + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Console.Error.WriteLine($"[UNHANDLED AppDomain exception] {e.ExceptionObject}"); + + Console.WriteLine("VoiceCat.App starting..."); + ApplicationConfiguration.Initialize(); + + using var connectDialog = new ConnectDialog(); + Console.WriteLine("Showing ConnectDialog..."); + var result = connectDialog.ShowDialog(); + Console.WriteLine($"ConnectDialog closed with DialogResult={result}, ConnectedClient={(connectDialog.ConnectedClient is null ? "null" : "set")}"); + if (result != DialogResult.OK || connectDialog.ConnectedClient is null) + { + Console.WriteLine("Exiting (cancelled or no connected client)."); + return; + } + + Console.WriteLine("Launching MainForm..."); + Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId, + connectDialog.Nickname)); + Console.WriteLine("MainForm closed. Exiting."); + } +} diff --git a/clients/windows/VoiceCat.App/VoiceCat.App.csproj b/clients/windows/VoiceCat.App/VoiceCat.App.csproj new file mode 100644 index 0000000..7b05392 --- /dev/null +++ b/clients/windows/VoiceCat.App/VoiceCat.App.csproj @@ -0,0 +1,41 @@ + + + + + + + + Exe + net10.0-windows + enable + true + enable + app.manifest + VoiceCat.App + VoiceCat.App + + PerMonitorV2 + + + + + + + + voicecat.dll + PreserveNewest + + + + + + + + \ No newline at end of file diff --git a/clients/windows/VoiceCat.App/app.manifest b/clients/windows/VoiceCat.App/app.manifest new file mode 100644 index 0000000..4e575af --- /dev/null +++ b/clients/windows/VoiceCat.App/app.manifest @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/windows/VoiceCat.Interop.Tests/VoiceCat.Interop.Tests.csproj b/clients/windows/VoiceCat.Interop.Tests/VoiceCat.Interop.Tests.csproj new file mode 100644 index 0000000..f6f8863 --- /dev/null +++ b/clients/windows/VoiceCat.Interop.Tests/VoiceCat.Interop.Tests.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + voicecat.dll + PreserveNewest + + + + + + + + \ No newline at end of file diff --git a/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs new file mode 100644 index 0000000..27e2a6b --- /dev/null +++ b/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs @@ -0,0 +1,129 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using VoiceCat.Interop; + +namespace VoiceCat.Interop.Tests; + +/// +/// 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. +/// +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] — TCP : UDP :" — 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; + } + + 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 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(); + 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"); + + client.Disconnect(); + } +} diff --git a/clients/windows/VoiceCat.Interop/Enums.cs b/clients/windows/VoiceCat.Interop/Enums.cs new file mode 100644 index 0000000..41f9777 --- /dev/null +++ b/clients/windows/VoiceCat.Interop/Enums.cs @@ -0,0 +1,104 @@ +// Mirrors of voicecat.h's enums. Keep these in lockstep with core/include/voicecat.h — +// values are append-only per the C ABI's house rule, so it's safe to add new members at the +// end here too, but never renumber/remove existing ones. +namespace VoiceCat.Interop; + +public enum VcResult +{ + Ok = 0, + NotImplemented = 1, + InvalidArg = 2, + NotConnected = 3, + Already = 4, + AuthFailed = 5, + PermissionDenied = 6, + Timeout = 7, + Io = 8, + Protocol = 9, + Crypto = 10, + Audio = 11, + Internal = 12, +} + +public enum VcLogLevel +{ + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, + Off = 5, +} + +public enum VcConnectionState +{ + Disconnected = 0, + Connecting = 1, + TlsHandshake = 2, + Authenticating = 3, + Connected = 4, + /// M4: handshake succeeded, waiting on vc_confirm_server_identity(). + VerifyingIdentity = 5, +} + +public enum VcTextScope +{ + Channel = 0, + Private = 1, + Server = 2, +} + +public enum VcDeviceKind +{ + Input = 0, + Output = 1, +} + +public enum VcStreamKind +{ + Mic = 0, + /// System/desktop audio (WASAPI loopback on Windows). + ScreenAudio = 1, + AuxDevice = 2, +} + +/// Send-side input gate (docs/voice.md §11). +public enum VcInputMode +{ + VoiceActivation = 0, + PushToTalk = 1, + AlwaysOn = 2, // transmit unconditionally, no VAD gate +} + +public enum VcEventType +{ + ConnectionState = 0, + AuthResult = 1, + ChannelList = 2, + UserJoined = 3, + UserLeft = 4, + UserUpdated = 5, + TextMessage = 6, + StreamStarted = 7, + StreamStopped = 8, + TalkState = 9, + Error = 10, + Disconnected = 11, + /// M4: reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel. + JoinResult = 12, + /// M4: the TOFU server-identity gate — see VcTofuStatus. + ServerIdentity = 13, +} + +/// +/// TOFU server-identity classification. Pins the TLS leaf certificate's own SHA-256 +/// fingerprint (real, verifiable from the handshake) — NOT the declared Ed25519 identity +/// fingerprint from ServerHello, which is informational/display-only (see +/// VoiceCatClient.GetServerIdentityDisplay and docs/security.md §1.1). +/// +public enum VcTofuStatus +{ + FirstConnect = 0, + Matched = 1, + Mismatch = 2, +} diff --git a/clients/windows/VoiceCat.Interop/Marshaling.cs b/clients/windows/VoiceCat.Interop/Marshaling.cs new file mode 100644 index 0000000..651b3a9 --- /dev/null +++ b/clients/windows/VoiceCat.Interop/Marshaling.cs @@ -0,0 +1,90 @@ +using System.Runtime.InteropServices; + +// Shared "walk a native array of owned-struct entries, convert to managed records, free the +// native list" pattern — identical shape for vc_device_list/vc_channel_list/vc_user_list/ +// vc_stream_summary_list (all core-allocated, caller-freed; see voicecat.h's doc comments on +// each). The matching vc_free_*_list call happens INSIDE each ToManaged here, immediately +// after the conversion, so callers never need to remember to free anything themselves. +namespace VoiceCat.Interop; + +internal static class Marshaling +{ + public static List ToManaged(ref VcDeviceListNative native) + { + var result = new List((int)native.Count); + int size = Marshal.SizeOf(); + for (nuint i = 0; i < native.Count; i++) + { + var raw = Marshal.PtrToStructure(native.Items + (int)i * size); + result.Add(new DeviceInfo( + Marshal.PtrToStringUTF8(raw.Id) ?? string.Empty, + Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, + raw.IsDefault != 0)); + } + NativeMethods.vc_free_device_list(ref native); + return result; + } + + public static List ToManaged(ref VcChannelListNative native) + { + var result = new List((int)native.Count); + int size = Marshal.SizeOf(); + for (nuint i = 0; i < native.Count; i++) + { + var raw = Marshal.PtrToStructure(native.Items + (int)i * size); + result.Add(new ChannelInfo( + raw.Id, + raw.ParentId, + Marshal.PtrToStringUTF8(raw.Name) ?? string.Empty, + raw.PasswordProtected != 0, + raw.MaxUsers)); + } + NativeMethods.vc_free_channel_list(ref native); + return result; + } + + public static List ToManaged(ref VcUserListNative native) + { + var result = new List((int)native.Count); + int size = Marshal.SizeOf(); + for (nuint i = 0; i < native.Count; i++) + { + var raw = Marshal.PtrToStructure(native.Items + (int)i * size); + result.Add(new UserInfo( + raw.Id, + Marshal.PtrToStringUTF8(raw.Nickname) ?? string.Empty, + raw.IsGuest != 0, + raw.ChannelId)); + } + NativeMethods.vc_free_user_list(ref native); + return result; + } + + public static List ToManaged(ref VcStreamSummaryListNative native) + { + var result = new List((int)native.Count); + int size = Marshal.SizeOf(); + for (nuint i = 0; i < native.Count; i++) + { + var raw = Marshal.PtrToStructure(native.Items + (int)i * size); + result.Add(new StreamSummary( + raw.StreamId, + raw.Kind, + Marshal.PtrToStringUTF8(raw.Label) ?? string.Empty)); + } + NativeMethods.vc_free_stream_summary_list(ref native); + return result; + } + + public static AudioConfigInfo ToManaged(in VcAudioConfigNative native) => new( + native.Codec, + native.Mode != 0, + native.SampleRate, + native.BitrateBps, + native.FrameMs, + native.Application, + native.Fec != 0, + native.ExpectedPacketLoss, + native.Dtx != 0, + native.Complexity); +} diff --git a/clients/windows/VoiceCat.Interop/Models.cs b/clients/windows/VoiceCat.Interop/Models.cs new file mode 100644 index 0000000..89d7e35 --- /dev/null +++ b/clients/windows/VoiceCat.Interop/Models.cs @@ -0,0 +1,39 @@ +// Plain managed record types — what survives past the native struct/free-list lifetime +// (Marshaling.cs converts the native *Native structs into these and immediately frees the +// native list). Nothing here holds an IntPtr. +namespace VoiceCat.Interop; + +public sealed record ChannelInfo( + uint Id, + uint ParentId, + string Name, + bool PasswordProtected, + uint MaxUsers); + +public sealed record UserInfo( + uint Id, + string Nickname, + bool IsGuest, + uint ChannelId); + +public sealed record StreamSummary( + uint StreamId, + VcStreamKind Kind, + string Label); + +public sealed record DeviceInfo( + string Id, + string Name, + bool IsDefault); + +public sealed record AudioConfigInfo( + uint Codec, + bool Stereo, + uint SampleRate, + uint BitrateBps, + uint FrameMs, + uint Application, + bool Fec, + uint ExpectedPacketLoss, + bool Dtx, + uint Complexity); diff --git a/clients/windows/VoiceCat.Interop/NativeCallbacks.cs b/clients/windows/VoiceCat.Interop/NativeCallbacks.cs new file mode 100644 index 0000000..00b1a86 --- /dev/null +++ b/clients/windows/VoiceCat.Interop/NativeCallbacks.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; + +// [UnmanagedCallersOnly] static methods for vc_callbacks.on_event/on_level — true native +// function pointers, not GC-tracked delegates (avoids the classic P/Invoke pitfall where a +// delegate is collected by the GC sometime after the call that registered it returns; see +// docs/tech-stack.md §2). vc_callbacks.user is a GCHandle-wrapped VoiceCatClient (allocated in +// VoiceCatClient's constructor, freed in Dispose) — these methods must be static, so they +// resolve back to the right client instance via that handle rather than closing over state. +namespace VoiceCat.Interop; + +internal static unsafe class NativeCallbacks +{ + [UnmanagedCallersOnly] + internal static void OnEvent(IntPtr userContext, VcEventNative* ev) + { + if (userContext == IntPtr.Zero) return; + if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return; + + // CRITICAL (voicecat.h's vc_event doc comment): ev->Text is owned by the core and + // valid ONLY for the duration of this callback. Convert to a managed string NOW, + // before returning — never store/queue the raw VcEventNative across the callback + // boundary, or Text will be a dangling pointer by the time it's read. + client.EnqueueEvent(VoiceCatEvent.FromNative(*ev)); + } + + [UnmanagedCallersOnly] + internal static void OnLevel(IntPtr userContext, uint streamId, float rms) + { + if (userContext == IntPtr.Zero) return; + if (GCHandle.FromIntPtr(userContext).Target is not VoiceCatClient client) return; + client.EnqueueLevel(streamId, rms); + } +} diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs new file mode 100644 index 0000000..9e309ce --- /dev/null +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -0,0 +1,134 @@ +using System.Runtime.InteropServices; + +// Raw P/Invoke surface over core/include/voicecat.h, via LibraryImport (source-generated — +// no runtime reflection marshaling stub; see docs/tech-stack.md §2). One entry per voicecat.h +// function. `vc_client*` is represented as a raw `nint` here — VoiceCatClientHandle (a +// SafeHandle) owns the create/destroy lifetime one level up; these declarations never see a +// SafeHandle directly, per .NET's own SafeHandle convention. +// +// "voicecat" resolves to voicecat.dll via the OS's standard DLL search order (same directory +// as the .exe first) — see clients/windows/README.md for how it gets there at build time. +namespace VoiceCat.Interop; + +internal static partial class NativeMethods +{ + private const string LibName = "voicecat"; + + // ── Lifecycle ──────────────────────────────────────────────────────────────────────── + // NOTE: these two return `const char*` pointing at STATIC string literals the core never + // expects the caller to free. Declaring them as `string` with StringMarshalling.Utf8 + // would be wrong: the built-in Utf8StringMarshaller's return-value convention assumes the + // native callee allocated the string FOR this call and that the marshaller should free it + // afterward — calling that on a static literal corrupts the heap (confirmed: it crashes + // with STATUS_HEAP_CORRUPTION / 0xC0000374). Return the raw pointer instead and convert + // with Marshal.PtrToStringUTF8 ourselves, without ever freeing it — see VoiceCatClient.cs. + [LibraryImport(LibName)] + internal static partial nint vc_version_string(); + + [LibraryImport(LibName)] + internal static partial nint vc_result_string(VcResult code); + + [LibraryImport(LibName)] + internal static partial nint vc_client_create(in VcConfigNative cfg, VcCallbacksNative cb); + + [LibraryImport(LibName)] + internal static partial void vc_client_destroy(nint c); + + // ── Connection & auth (async; results via on_event) ──────────────────────────────────── + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_connect(nint c, string host, ushort port); + + [LibraryImport(LibName)] + internal static partial VcResult vc_disconnect(nint c); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_authenticate_guest(nint c, string nickname); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_authenticate_user(nint c, string username, string password); + + // ── Channels ───────────────────────────────────────────────────────────────────────── + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_join_channel(nint c, uint channelId, string? password); + + [LibraryImport(LibName)] + internal static partial VcResult vc_leave_channel(nint c); + + // ── Local media streams ───────────────────────────────────────────────────────────────── + [LibraryImport(LibName)] + internal static partial VcResult vc_stream_start(nint c, in VcStreamDescNative desc, + out uint outStreamId); + + [LibraryImport(LibName)] + internal static partial VcResult vc_stream_stop(nint c, uint streamId); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_set_input_device(nint c, uint streamId, string? deviceId); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_input_mode(nint c, VcInputMode mode); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_vad_threshold(nint c, float threshold); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_push_to_talk(nint c, int active); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_self_mute(nint c, int micMuted, int deafened); + + [LibraryImport(LibName)] + internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId, + float gain, int muted, int noiseReduction); + + [LibraryImport(LibName)] + internal static partial VcResult vc_get_stream_audio_config(nint c, uint userId, + uint streamId, out VcAudioConfigNative outCfg); + + // TEST-ONLY in the core (see voicecat.h) — declared for ABI parity; the real app never + // calls this (no microphone-bypass path in production UI). + [LibraryImport(LibName)] + internal static unsafe partial VcResult vc_test_inject_capture(nint c, uint streamId, + short* pcm, nuint samples); + + // ── Text ───────────────────────────────────────────────────────────────────────────── + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + internal static partial VcResult vc_send_text(nint c, VcTextScope scope, uint targetId, + string utf8); + + // ── Device enumeration ─────────────────────────────────────────────────────────────── + [LibraryImport(LibName)] + internal static partial VcResult vc_list_devices(nint c, VcDeviceKind kind, + out VcDeviceListNative outList); + + [LibraryImport(LibName)] + internal static partial void vc_free_device_list(ref VcDeviceListNative list); + + // ── M4: channel / user / stream snapshot getters ──────────────────────────────────────── + [LibraryImport(LibName)] + internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList); + + [LibraryImport(LibName)] + internal static partial void vc_free_channel_list(ref VcChannelListNative list); + + [LibraryImport(LibName)] + internal static partial VcResult vc_list_users(nint c, out VcUserListNative outList); + + [LibraryImport(LibName)] + internal static partial void vc_free_user_list(ref VcUserListNative list); + + [LibraryImport(LibName)] + internal static partial VcResult vc_list_user_streams(nint c, uint userId, + out VcStreamSummaryListNative outList); + + [LibraryImport(LibName)] + internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list); + + // ── M4: TOFU server-identity gate ─────────────────────────────────────────────────────── + [LibraryImport(LibName)] + internal static partial VcResult vc_confirm_server_identity(nint c, int accept); + + [LibraryImport(LibName)] + internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf, + nuint bufCap, out nuint outLen); +} diff --git a/clients/windows/VoiceCat.Interop/Structs.cs b/clients/windows/VoiceCat.Interop/Structs.cs new file mode 100644 index 0000000..4081042 --- /dev/null +++ b/clients/windows/VoiceCat.Interop/Structs.cs @@ -0,0 +1,127 @@ +using System.Runtime.InteropServices; + +// Native (blittable) struct layouts mirroring voicecat.h field-for-field. These are the raw +// P/Invoke shapes — NativeMethods.cs uses them directly; Marshaling.cs converts them to the +// managed record types in Models.cs. const char* fields stay IntPtr here (LibraryImport's +// StringMarshalling only auto-converts top-level string parameters/returns, not struct +// fields) and must be hand-marshaled — see Marshaling.cs and VoiceCatClient.cs. +namespace VoiceCat.Interop; + +[StructLayout(LayoutKind.Sequential)] +internal struct VcEventNative +{ + public VcEventType Type; + public VcConnectionState ConnectionState; + public int Result; // vc_result + public uint UserId; + public uint ChannelId; + public uint StreamId; + public VcTextScope TextScope; + public uint U32a; + public IntPtr Text; // owned by core, valid ONLY for the callback's duration + public ulong TimestampUnixMs; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcConfigNative +{ + public IntPtr ClientName; + public IntPtr ClientVersion; + public VcLogLevel LogLevel; + public IntPtr TofuStorePath; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcCallbacksNative +{ + public IntPtr OnEvent; // delegate* unmanaged + public IntPtr OnLevel; // delegate* unmanaged + public IntPtr User; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcStreamDescNative +{ + public VcStreamKind Kind; + public IntPtr DeviceId; // unused by vc_stream_start today — device selection is a + // separate vc_set_input_device call; always IntPtr.Zero here. + public IntPtr Label; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcAudioConfigNative +{ + public uint Codec; + public uint Mode; + public uint SampleRate; + public uint BitrateBps; + public uint FrameMs; + public uint Application; + public int Fec; + public uint ExpectedPacketLoss; + public int Dtx; + public uint Complexity; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcDeviceNative +{ + public IntPtr Id; + public IntPtr Name; + public int IsDefault; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcDeviceListNative +{ + public IntPtr Items; + public nuint Count; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcChannelNative +{ + public uint Id; + public uint ParentId; + public IntPtr Name; + public int PasswordProtected; + public uint MaxUsers; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcChannelListNative +{ + public IntPtr Items; + public nuint Count; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcUserNative +{ + public uint Id; + public IntPtr Nickname; + public int IsGuest; + public uint ChannelId; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcUserListNative +{ + public IntPtr Items; + public nuint Count; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcStreamSummaryNative +{ + public uint StreamId; + public VcStreamKind Kind; + public IntPtr Label; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct VcStreamSummaryListNative +{ + public IntPtr Items; + public nuint Count; +} diff --git a/clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj b/clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj new file mode 100644 index 0000000..bbc450d --- /dev/null +++ b/clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + true + VoiceCat.Interop + + + diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs new file mode 100644 index 0000000..8db1fcb --- /dev/null +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -0,0 +1,253 @@ +using System.Runtime.InteropServices; +using System.Threading.Channels; + +// The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around +// NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates"). +namespace VoiceCat.Interop; + +public sealed class VoiceCatClient : IDisposable +{ + private readonly VoiceCatClientHandle _handle = new(); + private readonly GCHandle _selfHandle; + + // Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not + // just vc_client_create(): client_name/client_version are read later, whenever connect() + // actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by + // value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy + // has returned (which synchronously joins every internal thread, so nothing can still be + // reading these pointers by then). + private nint _clientNamePtr; + private nint _clientVersionPtr; + private nint _tofuStorePathPtr; + + private readonly Channel _events = + Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + + // on_level fires far more often than on_event and intermediate values are visually + // irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one. + private readonly System.Collections.Concurrent.ConcurrentDictionary _latestLevels = new(); + + /// Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's + /// doc comment) for every event, in order, never coalesced. + public event Action? EventReceived; + + /// Raised from PumpEvents() with the latest RMS level per stream_id since the + /// last pump. + public event Action? LevelChanged; + + public unsafe VoiceCatClient(string clientName, string clientVersion, + VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null) + { + _selfHandle = GCHandle.Alloc(this, GCHandleType.Normal); + + _clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName); + _clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion); + _tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath); + + var cfg = new VcConfigNative + { + ClientName = _clientNamePtr, + ClientVersion = _clientVersionPtr, + LogLevel = logLevel, + TofuStorePath = _tofuStorePathPtr, + }; + + var cb = new VcCallbacksNative + { + OnEvent = (nint)(delegate* unmanaged)&NativeCallbacks.OnEvent, + OnLevel = (nint)(delegate* unmanaged)&NativeCallbacks.OnLevel, + User = GCHandle.ToIntPtr(_selfHandle), + }; + + nint native = NativeMethods.vc_client_create(in cfg, cb); + _handle.SetHandle(native); + if (_handle.IsInvalid) + { + FreeConfigStrings(); + _selfHandle.Free(); + throw new InvalidOperationException("vc_client_create failed."); + } + } + + /// + /// Drains every event/level sample queued since the last call. Call this from a + /// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary + /// where the core's own event-delivery thread hands off to the UI thread; see + /// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen + /// over a message-only window + PostMessage. + /// + public void PumpEvents() + { + while (_events.Reader.TryRead(out var ev)) + EventReceived?.Invoke(ev); + + if (!_latestLevels.IsEmpty) + { + foreach (var (streamId, rms) in _latestLevels) + LevelChanged?.Invoke(streamId, rms); + _latestLevels.Clear(); + } + } + + internal void EnqueueEvent(VoiceCatEvent ev) + { + // Temporary diagnostic (manual debugging session) — confirms the native callback + // chain (UnmanagedCallersOnly -> GCHandle resolve -> here) actually fires, independent + // of whether the UI-thread drain (PumpEvents) ever sees it. + Console.WriteLine($"[VoiceCatClient] EnqueueEvent (native thread): {ev}"); + _events.Writer.TryWrite(ev); + } + internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms; + + // ── Connection & auth ──────────────────────────────────────────────────────────────── + public VcResult Connect(string host, ushort port) => + NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port); + + public VcResult Disconnect() => + NativeMethods.vc_disconnect(_handle.DangerousGetHandle()); + + public VcResult AuthenticateGuest(string nickname) => + NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname); + + public VcResult AuthenticateUser(string username, string password) => + NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password); + + // ── TOFU server-identity gate (M4) ────────────────────────────────────────────────────── + public VcResult ConfirmServerIdentity(bool accept) => + NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0); + + /// The Ed25519 identity fingerprint from ServerHello, hex-formatted — display + /// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty + /// string if not yet available. + public string GetServerIdentityDisplay() + { + nint c = _handle.DangerousGetHandle(); + NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len); + if (len == 0) return string.Empty; + + nint buf = Marshal.AllocHGlobal((int)len + 1); + try + { + NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _); + return Marshal.PtrToStringUTF8(buf) ?? string.Empty; + } + finally + { + Marshal.FreeHGlobal(buf); + } + } + + // ── Channels ───────────────────────────────────────────────────────────────────────── + /// Result arrives as a VcEventType.JoinResult event, not via this return value + /// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment). + /// NOTE: no in-tree channel has a server-side password to check yet (M5+ feature) — this + /// path is wired but not yet exercisable end-to-end. + public VcResult JoinChannel(uint channelId, string? password = null) => + NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password); + + public VcResult LeaveChannel() => + NativeMethods.vc_leave_channel(_handle.DangerousGetHandle()); + + public List ListChannels() + { + NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native); + return Marshaling.ToManaged(ref native); + } + + public List ListUsers() + { + NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native); + return Marshaling.ToManaged(ref native); + } + + public List ListUserStreams(uint userId) + { + var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native); + return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List(); + } + + // ── Local media streams ───────────────────────────────────────────────────────────────── + public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label) + { + nint labelPtr = Marshal.StringToCoTaskMemUTF8(label); + try + { + var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr }; + var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId); + return (r, streamId); + } + finally + { + Marshal.FreeCoTaskMem(labelPtr); + } + } + + public VcResult StopStream(uint streamId) => + NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId); + + public VcResult SetInputDevice(uint streamId, string? deviceId) => + NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId); + + public VcResult SetInputMode(VcInputMode mode) => + NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode); + + public VcResult SetVadThreshold(float threshold) => + NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold); + + public VcResult SetPushToTalk(bool active) => + NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0); + + public VcResult SetSelfMute(bool micMuted, bool deafened) => + NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0); + + public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) => + NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain, + muted ? 1 : 0, noiseReduction ? 1 : 0); + + public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId) + { + var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId, + streamId, out var native); + return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null); + } + + // ── Text ───────────────────────────────────────────────────────────────────────────── + public VcResult SendText(VcTextScope scope, uint targetId, string utf8) => + NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8); + + // ── Device enumeration (works pre-connect) ────────────────────────────────────────────── + public List ListDevices(VcDeviceKind kind) + { + NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native); + return Marshaling.ToManaged(ref native); + } + + // ── Lifecycle ──────────────────────────────────────────────────────────────────────── + // NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never- + // freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport + // auto-marshal these as `string` (it would try to free a static literal and corrupt the + // heap). Marshal.PtrToStringUTF8 just reads; it never frees. + public static string VersionString => + Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty; + + public static string ResultString(VcResult code) => + Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty; + + public void Dispose() + { + _handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously + FreeConfigStrings(); + if (_selfHandle.IsAllocated) _selfHandle.Free(); + } + + private void FreeConfigStrings() + { + if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; } + if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; } + if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; } + } +} diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs b/clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs new file mode 100644 index 0000000..e112345 --- /dev/null +++ b/clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs @@ -0,0 +1,18 @@ +using Microsoft.Win32.SafeHandles; + +// Standard SafeHandle pattern for vc_client* — guarantees vc_client_destroy runs even on an +// unhandled exception or a finalizer pass, which a bare `nint` field would not. +namespace VoiceCat.Interop; + +internal sealed class VoiceCatClientHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public VoiceCatClientHandle() : base(ownsHandle: true) { } + + public new void SetHandle(nint handle) => base.SetHandle(handle); + + protected override bool ReleaseHandle() + { + NativeMethods.vc_client_destroy(handle); + return true; + } +} diff --git a/clients/windows/VoiceCat.Interop/VoiceCatEvent.cs b/clients/windows/VoiceCat.Interop/VoiceCatEvent.cs new file mode 100644 index 0000000..93c4fab --- /dev/null +++ b/clients/windows/VoiceCat.Interop/VoiceCatEvent.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; + +namespace VoiceCat.Interop; + +/// +/// Managed copy of a vc_event — safe to hold/queue past the native callback's return (unlike +/// VcEventNative, whose Text pointer is only valid during the callback). +/// +public sealed record VoiceCatEvent( + VcEventType Type, + VcConnectionState ConnectionState, + VcResult Result, + uint UserId, + uint ChannelId, + uint StreamId, + VcTextScope TextScope, + uint U32a, + string? Text, + ulong TimestampUnixMs) +{ + internal static VoiceCatEvent FromNative(in VcEventNative ev) => new( + ev.Type, + ev.ConnectionState, + (VcResult)ev.Result, + ev.UserId, + ev.ChannelId, + ev.StreamId, + ev.TextScope, + ev.U32a, + ev.Text == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ev.Text), + ev.TimestampUnixMs); +} diff --git a/clients/windows/VoiceCat.slnx b/clients/windows/VoiceCat.slnx new file mode 100644 index 0000000..1b833b2 --- /dev/null +++ b/clients/windows/VoiceCat.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index b2871a5..a2b8ae6 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -5,6 +5,24 @@ file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS if(VOICECAT_BUILD_SHARED) add_library(voicecat SHARED ${VOICECAT_SOURCES}) + if(WIN32 AND MINGW) + # M4: the C# client only ships voicecat.dll itself — no MinGW runtime DLLs alongside + # it. x64-mingw-static only statically links vcpkg's OWN library deps (protobuf, + # sodium, mbedTLS, ...); the GCC/MinGW runtime stays dynamic by default + # (libgcc_s_seh-1.dll/libwinpthread-1.dll/libstdc++-6.dll — confirmed via `objdump -p` + # on the existing vccli.exe). These flags are the standard fully-static-MinGW + # recipe. Verify after building (see clients/windows/README.md): + # objdump -p build/windows-client/bin/voicecat.dll | grep "DLL Name" + # should show only Windows system DLLs. + target_link_options(voicecat PRIVATE + -static-libgcc -static-libstdc++ -static -lwinpthread) + endif() + if(WIN32) + # CMake's default SHARED naming on MinGW adds a "lib" prefix (libvoicecat.dll) — + # drop it so the output is exactly voicecat.dll, matching the C ABI/library name the + # C# [LibraryImport] surface and docs use everywhere else. + set_target_properties(voicecat PROPERTIES PREFIX "") + endif() else() add_library(voicecat STATIC ${VOICECAT_SOURCES}) # Static consumers must see VC_API as empty (no dllimport). diff --git a/core/include/voicecat.h b/core/include/voicecat.h index 81693c0..c9e3e51 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -83,6 +83,10 @@ typedef enum vc_connection_state { VC_STATE_TLS_HANDSHAKE = 2, VC_STATE_AUTHENTICATING = 3, VC_STATE_CONNECTED = 4, + /* M4: between TLS_HANDSHAKE and AUTHENTICATING — the handshake succeeded and the core is + * waiting for vc_confirm_server_identity() (see VC_EVENT_SERVER_IDENTITY below). Appended + * at the end (not inserted) to keep existing enum values stable — additive-only ABI. */ + VC_STATE_VERIFYING_IDENTITY = 5, } vc_connection_state; typedef enum vc_text_scope { @@ -106,6 +110,8 @@ typedef enum vc_stream_kind { typedef enum vc_input_mode { VC_INPUT_VOICE_ACTIVATION = 0, VC_INPUT_PUSH_TO_TALK = 1, + /* Transmit unconditionally — no VAD gate. Added at the end to keep existing values stable. */ + VC_INPUT_ALWAYS_ON = 2, } vc_input_mode; typedef enum vc_event_type { @@ -121,8 +127,32 @@ typedef enum vc_event_type { VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */ VC_EVENT_ERROR = 10, /* result, text */ VC_EVENT_DISCONNECTED = 11, /* result, text = reason */ + /* M4 additions — appended, not inserted, to keep existing values stable. */ + VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on + failure. Reply to vc_join_channel(). */ + VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert + SHA-256 fingerprint (the value being pinned — see + vc_confirm_server_identity). Emitted once per connect + attempt, right after the TLS handshake succeeds. The + connection is held open until vc_confirm_server_identity() + is called. */ } vc_event_type; +/* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and + * vc_confirm_server_identity. Pins the TLS leaf certificate's own SHA-256 fingerprint + * (verifiable directly from the handshake), NOT the declared Ed25519 + * server_identity_fingerprint from ServerHello — the TLS cert and the server's Ed25519 + * identity key are generated independently with no cryptographic binding between them today + * (docs/security.md §1.1), so pinning the self-declared value would be circular. The Ed25519 + * fingerprint is still available for human-readable display via + * vc_get_server_identity_display(), it just isn't the value this gate accepts/rejects on. */ +typedef enum vc_tofu_status { + VC_TOFU_FIRST_CONNECT = 0, /* no pin on file yet for this host:port */ + VC_TOFU_MATCHED = 1, /* matches the previously pinned fingerprint */ + VC_TOFU_MISMATCH = 2, /* DIFFERENT from the pinned fingerprint — possible MITM or a + legitimate server key rotation; warn loudly */ +} vc_tofu_status; + /* ── Structs ──────────────────────────────────────────────────────────────── */ /* @@ -155,6 +185,13 @@ typedef struct vc_config { const char* client_name; /* e.g. "VoiceCat-macOS" */ const char* client_version; /* e.g. "0.0.1" */ vc_log_level log_level; + /* M4, optional (added at the end — existing brace-initialized callers default this to + * NULL, no source change needed). Path to the TOFU pin file (see VC_EVENT_SERVER_IDENTITY/ + * vc_confirm_server_identity). NULL = a built-in relative default + * ("./voicecat_tofu_pins.txt") so existing tests need no real persistence. A real app + * (e.g. the Windows client) should pass an explicit per-user path, e.g. + * "%AppData%\VoiceCat\tofu_pins.txt". */ + const char* tofu_store_path; } vc_config; typedef struct vc_stream_desc { @@ -190,6 +227,49 @@ typedef struct vc_device_list { size_t count; } vc_device_list; +/* ── Channel / user / stream snapshots (M4 — for the channel-tree/user-list UI) ─────────── + * Pull-based: re-call after VC_EVENT_CHANNEL_LIST / VC_EVENT_USER_JOINED / _LEFT / _UPDATED to + * refresh — there is no push variant; those events just mean "go look". Same ownership + * contract as vc_device/vc_device_list above: core-allocated, caller frees with the matching + * vc_free_*, items' const char* fields are invalid after that call. */ +typedef struct vc_channel { + uint32_t id; + uint32_t parent_id; /* 0 = root */ + const char* name; + int password_protected; /* bool */ + uint32_t max_users; /* 0 = unlimited */ +} vc_channel; + +typedef struct vc_channel_list { + vc_channel* items; + size_t count; +} vc_channel_list; + +typedef struct vc_user { + uint32_t id; + const char* nickname; + int is_guest; /* bool */ + uint32_t channel_id; +} vc_user; + +typedef struct vc_user_list { + vc_user* items; + size_t count; +} vc_user_list; + +/* Per-user stream summary — lighter than vc_audio_config; for the full effective Opus config + * of a specific (user_id, stream_id), use the existing vc_get_stream_audio_config. */ +typedef struct vc_stream_summary { + uint32_t stream_id; + vc_stream_kind kind; + const char* label; +} vc_stream_summary; + +typedef struct vc_stream_summary_list { + vc_stream_summary* items; + size_t count; +} vc_stream_summary_list; + /* Opaque client handle. */ typedef struct vc_client vc_client; @@ -208,6 +288,11 @@ VC_API vc_result vc_authenticate_user(vc_client* c, const char* username, const char* password); /* ── Channels ─────────────────────────────────────────────────────────────── */ +/* Result arrives as VC_EVENT_JOIN_RESULT, not a return value beyond "request queued". `password` + * is forwarded to the server's JoinChannelRequest.password for channels with + * vc_channel.password_protected set; NOTE (M4): no in-tree channel currently has a server-side + * password to check against — channel creation/passwords are a future (M5+) feature, so this + * path is wired but not yet exercisable end-to-end. */ VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id, const char* password /* nullable */); VC_API vc_result vc_leave_channel(vc_client* c); @@ -221,6 +306,10 @@ VC_API vc_result vc_set_input_device(vc_client* c, uint32_t stream_id, /* Send-side: input gate mode + PTT key state, and self mute/deafen. */ VC_API vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode); +/* VAD threshold: normalized RMS 0.0–1.0; default ~0.025. Takes effect immediately — + * recreates the VAD gate if a MIC stream is already active. No-op when mode != VOICE_ACTIVATION + * (value is remembered and applied if the mode switches back). */ +VC_API vc_result vc_set_vad_threshold(vc_client* c, float threshold); VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */); VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened); @@ -249,6 +338,38 @@ VC_API vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target VC_API vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out); VC_API void vc_free_device_list(vc_device_list* list); +/* ── Channel / user / stream enumeration (M4; mirrors vc_list_devices above) ─────────────── */ +VC_API vc_result vc_list_channels(vc_client* c, vc_channel_list* out); +VC_API void vc_free_channel_list(vc_channel_list* list); + +VC_API vc_result vc_list_users(vc_client* c, vc_user_list* out); +VC_API void vc_free_user_list(vc_user_list* list); + +/* Streams currently owned by user_id (their mic/screen-audio/aux), per the last snapshot/ + * event. VC_ERR_INVALID_ARG if user_id is unknown. */ +VC_API vc_result vc_list_user_streams(vc_client* c, uint32_t user_id, + vc_stream_summary_list* out); +VC_API void vc_free_stream_summary_list(vc_stream_summary_list* list); + +/* ── TOFU server-identity confirmation (M4) — see VC_EVENT_SERVER_IDENTITY/vc_tofu_status ── */ +/* Accept or reject the pending server-identity check for the in-progress connect(). Must be + * called after a VC_EVENT_SERVER_IDENTITY event; the io_thread_ holds the connection open + * (ClientHello/auth deferred) until this is called, up to a generous internal timeout (after + * which it's treated as a reject). accept=0 aborts the connection (emits + * VC_EVENT_DISCONNECTED, result=VC_ERR_CRYPTO) and does NOT update the pin file. accept=1 on + * FIRST_CONNECT/MISMATCH updates the pin file to the new fingerprint and proceeds; accept=1 on + * MATCHED is a no-op confirmation (always safe) and proceeds. VC_ERR_INVALID_ARG if no + * identity confirmation is currently pending. */ +VC_API vc_result vc_confirm_server_identity(vc_client* c, int accept /* bool */); + +/* The Ed25519 identity fingerprint from ServerHello, hex-formatted for display (e.g. "this + * server also identifies as "). Purely informational — NOT the value + * vc_confirm_server_identity gates on (see vc_tofu_status's doc comment). Empty string if not + * yet available. Pass out_buf=NULL to query the required buffer size via *out_len first; + * otherwise out_buf must be >= *out_len + 1 bytes (NUL-terminated UTF-8/ASCII hex). */ +VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap, + size_t* out_len); + #if defined(__cplusplus) } /* extern "C" */ #endif diff --git a/core/src/audio/apm_processor.cpp b/core/src/audio/apm_processor.cpp index 7911e9d..acdd5e4 100644 --- a/core/src/audio/apm_processor.cpp +++ b/core/src/audio/apm_processor.cpp @@ -1,5 +1,6 @@ #include "audio/apm_processor.h" +#include #include #include @@ -41,14 +42,18 @@ class EnergyVadProcessor final : public ApmProcessor { sum_sq += s * s; } double rms = std::sqrt(sum_sq / samples); - if (rms >= threshold_) last_voice_ms_ = steady_now_ms(); + if (rms >= threshold_.load(std::memory_order_relaxed)) last_voice_ms_ = steady_now_ms(); } return (steady_now_ms() - last_voice_ms_) < hang_time_ms_; } + void set_threshold(float t) override { + threshold_.store(t, std::memory_order_relaxed); + } + private: - float threshold_; - int64_t hang_time_ms_; + std::atomic threshold_; + int64_t hang_time_ms_; int64_t last_voice_ms_ = 0; // epoch start -> gate begins closed until first loud frame }; diff --git a/core/src/audio/apm_processor.h b/core/src/audio/apm_processor.h index 880ab20..8bd467e 100644 --- a/core/src/audio/apm_processor.h +++ b/core/src/audio/apm_processor.h @@ -24,6 +24,10 @@ class ApmProcessor { // Returns false → caller should skip encode/send (silence gate). virtual bool process_capture(int16_t* pcm, int samples, int sample_rate) = 0; + // Update the VAD RMS threshold in-place (used by EnergyVadProcessor; no-op in passthrough). + // Safe to call from any thread — EnergyVadProcessor stores it atomically. + virtual void set_threshold(float) {} + // Factory: returns a real APM if VOICECAT_HAS_APM is defined, else a passthrough. Used for // recv-side per-stream noise reduction (docs/voice.md §10) — gating doesn't apply there, so // this stays a passthrough until a real APM/NS backend exists (still inert; see diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index dd3e152..f64ae67 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include "protocol/protocol.h" @@ -41,7 +42,14 @@ std::vector make_frame(const voicecat::v1::Envelope& env) { // ── vc_client M1 implementation ─────────────────────────────────────────────── -vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {} +vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) { + // M4 TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests + // (which never set this field) keep working without real per-user persistence. + std::filesystem::path tofu_path = (cfg.tofu_store_path && cfg.tofu_store_path[0]) + ? std::filesystem::path(cfg.tofu_store_path) + : std::filesystem::path("voicecat_tofu_pins.txt"); + tofu_store_ = std::make_unique(std::move(tofu_path)); +} vc_client::~vc_client() { disconnect(); } @@ -96,6 +104,16 @@ vc_result vc_client::disconnect() { io_stop_.store(true, std::memory_order_release); + // Unblock a run_io() thread that's currently waiting on vc_confirm_server_identity() — + // without this, disconnecting mid-dialog would strand io_thread_ until the 120s timeout. + { + std::lock_guard lk(tofu_mu_); + if (tofu_decision_pending_) { + tofu_decision_pending_ = false; + tofu_cv_.notify_all(); + } + } + // Close the socket to unblock blocking TLS reads/writes. int fd = io_fd_.load(std::memory_order_acquire); if (fd != -1) { @@ -175,6 +193,66 @@ void vc_client::run_io(std::string host, uint16_t port) { } } + // ── TOFU server-identity gate (M4) ────────────────────────────────────── + // Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the + // handshake) — NOT the declared Ed25519 server_identity_fingerprint from ServerHello, + // which hasn't even arrived yet at this point (it's sent *inside* this now-established + // tunnel) and isn't cryptographically bound to this cert anyway (docs/security.md + // §1.1). See voicecat.h's vc_tofu_status doc comment. + set_state(VC_STATE_VERIFYING_IDENTITY); + { + std::array peer_fp{}; + vc_tofu_status status = VC_TOFU_MISMATCH; + if (tls_->peer_cert_fingerprint(peer_fp) && tofu_store_) { + auto r = tofu_store_->peek(host, port, peer_fp); + status = (r == voicecat::crypto::TofuResult::FirstConnect) ? VC_TOFU_FIRST_CONNECT + : (r == voicecat::crypto::TofuResult::Matched) ? VC_TOFU_MATCHED + : VC_TOFU_MISMATCH; + } + + std::string fp_hex; + { + static const char* hex = "0123456789abcdef"; + fp_hex.reserve(peer_fp.size() * 2); + for (auto b : peer_fp) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; } + } + + { + std::lock_guard set_lk(tofu_mu_); + tofu_decision_pending_ = true; + tofu_accept_ = false; + } + + vc_event ev{}; + ev.type = VC_EVENT_SERVER_IDENTITY; + ev.u32a = static_cast(status); + ev.text = fp_hex.c_str(); + emit(ev); + + bool accepted; + { + std::unique_lock lk(tofu_mu_); + tofu_cv_.wait_for(lk, std::chrono::seconds(120), [&] { + return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire); + }); + // Timeout or an external stop (disconnect() during the wait) both leave + // tofu_decision_pending_ true here — treated as a reject, per voicecat.h. + accepted = tofu_decision_pending_ ? false : tofu_accept_; + tofu_decision_pending_ = false; + } + + if (!accepted) { + tls_.reset(); + emit_disconnected(VC_ERR_CRYPTO, "server identity rejected"); + close_sock(sock); + io_fd_.store(-1); + goto cleanup; + } + if (status != VC_TOFU_MATCHED && tofu_store_) { + tofu_store_->pin(host, port, peer_fp); + } + } + // 50 ms timeout so we can drain sends between reads. tls_->set_read_timeout(50); @@ -317,6 +395,12 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { case voicecat::v1::Envelope::kUserEvent: handle_user_event(env.user_event()); break; + case voicecat::v1::Envelope::kChannelEvent: + handle_channel_event(env.channel_event()); + break; + case voicecat::v1::Envelope::kJoinChannelResult: + handle_join_channel_result(env.join_channel_result()); + break; case voicecat::v1::Envelope::kTextMessage: handle_text_message(env.text_message()); break; @@ -339,6 +423,19 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) { void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) { server_udp_port_ = static_cast(msg.udp_port()); + // M4: stash the declared Ed25519 fingerprint for vc_get_server_identity_display() — + // display-only, not the TOFU-pinned value (that's the TLS cert fingerprint, gated before + // ClientHello was even sent — see the TOFU block above in run_io()). + { + const std::string& raw = msg.server_identity_fingerprint(); + static const char* hex = "0123456789abcdef"; + std::string fp_hex; + fp_hex.reserve(raw.size() * 2); + for (unsigned char b : raw) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; } + std::lock_guard lk(tofu_mu_); + pending_identity_fp_hex_ = std::move(fp_hex); + } + // Server acknowledged our ClientHello. Now send AuthRequest (or queue it). std::optional auth; { @@ -383,15 +480,42 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) { } void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) { - session_model_.apply_snapshot(snap); + { + std::lock_guard lk(session_model_mu_); + session_model_.apply_snapshot(snap); + } for (const auto& u : snap.users()) sync_remote_streams(u); vc_event ev{}; ev.type = VC_EVENT_CHANNEL_LIST; emit(ev); } +void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) { + { + std::lock_guard lk(session_model_mu_); + session_model_.apply_channel_event(ce); + } + // Same "go look" signal vc_list_channels' callers already poll on after the initial + // snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment. + vc_event ev{}; + ev.type = VC_EVENT_CHANNEL_LIST; + emit(ev); +} + +void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) { + vc_event ev{}; + ev.type = VC_EVENT_JOIN_RESULT; + ev.result = msg.ok() ? VC_OK : VC_ERR_PROTOCOL; + ev.channel_id = msg.channel_id(); + if (!msg.ok()) ev.text = msg.error().c_str(); + emit(ev); +} + void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) { - session_model_.apply_user_event(ue); + { + std::lock_guard lk(session_model_mu_); + session_model_.apply_user_event(ue); + } vc_event ev{}; const auto& user = ue.user(); @@ -492,11 +616,16 @@ vc_result vc_client::authenticate_user(const char* username, const char* passwor return VC_OK; } -vc_result vc_client::join_channel(uint32_t channel_id, const char* /*password*/) { +vc_result vc_client::join_channel(uint32_t channel_id, const char* password) { if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; voicecat::v1::Envelope req; req.set_request_id(next_req_id_++); - req.mutable_join_channel()->set_channel_id(channel_id); + auto* jc = req.mutable_join_channel(); + jc->set_channel_id(channel_id); + // See voicecat.h's vc_join_channel doc comment: wired through to the wire message, but no + // in-tree channel has a server-side password to check yet (no channel-creation feature + // exists — M5+). + if (password) jc->set_password(password); queue_envelope(req); return VC_OK; } @@ -662,13 +791,15 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples) { // bypass this: gating a screen-share on the user's own voice activity would silently drop // shared music/video audio whenever the user isn't talking, which defeats the feature. if (kind == static_cast(VC_STREAM_MIC)) { - if (current_input_mode_.load(std::memory_order_acquire) == VC_INPUT_PUSH_TO_TALK) { + auto mode = current_input_mode_.load(std::memory_order_acquire); + if (mode == VC_INPUT_PUSH_TO_TALK) { if (!ptt_active_.load(std::memory_order_acquire)) return; // gate closed - } else if (mic_vad_) { + } else if (mode == VC_INPUT_VOICE_ACTIVATION && mic_vad_) { // EnergyVadProcessor never writes through the pointer (see apm_processor.cpp); the // const_cast is safe and avoids splitting ApmProcessor's interface just for this. if (!mic_vad_->process_capture(const_cast(pcm), samples, 48000)) return; } + // VC_INPUT_ALWAYS_ON: no gate — fall through and always send. } if (!media_send_crypto_) return; @@ -875,7 +1006,8 @@ void vc_client::handle_stream_announce_result(uint64_t req_id, // Construct the MIC VAD once, here on io_thread_ (not the RT capture callback) — see // client.h's comment on mic_vad_. if (kind == static_cast(VC_STREAM_MIC) && !mic_vad_) { - mic_vad_ = voicecat::audio::ApmProcessor::create_vad(); + mic_vad_ = voicecat::audio::ApmProcessor::create_vad( + vad_threshold_.load(std::memory_order_relaxed)); } } @@ -961,6 +1093,13 @@ vc_result vc_client::set_input_mode(vc_input_mode mode) { return VC_OK; } +vc_result vc_client::set_vad_threshold(float threshold) { + if (threshold < 0.0f || threshold > 1.0f) return VC_ERR_INVALID_ARG; + vad_threshold_.store(threshold, std::memory_order_relaxed); + if (mic_vad_) mic_vad_->set_threshold(threshold); + return VC_OK; +} + vc_result vc_client::set_push_to_talk(bool active) { ptt_active_.store(active, std::memory_order_release); return VC_OK; @@ -982,17 +1121,22 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) { vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, bool noise_reduction) { if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; - const auto* user = session_model_.find_user(user_id); - if (!user) return VC_ERR_INVALID_ARG; - for (const auto& s : user->streams) { - if (s.stream_id == stream_id) { - audio_engine_.set_stream_gain(s.ssrc, gain); - audio_engine_.set_stream_mute(s.ssrc, muted); - audio_engine_.set_stream_noise_reduction(s.ssrc, noise_reduction); - return VC_OK; + uint32_t ssrc = 0; + bool found = false; + { + std::lock_guard lk(session_model_mu_); + const auto* user = session_model_.find_user(user_id); + if (user) { + for (const auto& s : user->streams) { + if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; } + } } } - return VC_ERR_INVALID_ARG; + if (!found) return VC_ERR_INVALID_ARG; + audio_engine_.set_stream_gain(ssrc, gain); + audio_engine_.set_stream_mute(ssrc, muted); + audio_engine_.set_stream_noise_reduction(ssrc, noise_reduction); + return VC_OK; } vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id, @@ -1017,6 +1161,7 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i return VC_OK; } + std::lock_guard lk(session_model_mu_); const auto* user = session_model_.find_user(user_id); if (!user) return VC_ERR_INVALID_ARG; for (const auto& s : user->streams) { @@ -1079,6 +1224,88 @@ vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) { #endif } +// ── M4: channel/user/stream snapshot getters ───────────────────────────────── + +vc_result vc_client::list_channels(vc_channel_list* out) { + std::lock_guard lk(session_model_mu_); + const auto& channels = session_model_.channels(); + auto* items = new vc_channel[channels.size()]; + for (size_t i = 0; i < channels.size(); ++i) { + const auto& ch = channels[i]; + auto* name = new char[ch.name.size() + 1]; + std::memcpy(name, ch.name.c_str(), ch.name.size() + 1); + items[i].id = ch.id; + items[i].parent_id = ch.parent_id; + items[i].name = name; + items[i].password_protected = ch.password_protected ? 1 : 0; + items[i].max_users = ch.max_users; + } + out->items = items; + out->count = channels.size(); + return VC_OK; +} + +vc_result vc_client::list_users(vc_user_list* out) { + std::lock_guard lk(session_model_mu_); + const auto& users = session_model_.users(); + auto* items = new vc_user[users.size()]; + for (size_t i = 0; i < users.size(); ++i) { + const auto& u = users[i]; + auto* nick = new char[u.nickname.size() + 1]; + std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1); + items[i].id = u.id; + items[i].nickname = nick; + items[i].is_guest = u.is_guest ? 1 : 0; + items[i].channel_id = u.channel_id; + } + out->items = items; + out->count = users.size(); + return VC_OK; +} + +vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list* out) { + std::lock_guard lk(session_model_mu_); + const auto* user = session_model_.find_user(user_id); + if (!user) return VC_ERR_INVALID_ARG; + auto* items = new vc_stream_summary[user->streams.size()]; + for (size_t i = 0; i < user->streams.size(); ++i) { + const auto& s = user->streams[i]; + auto* label = new char[s.label.size() + 1]; + std::memcpy(label, s.label.c_str(), s.label.size() + 1); + items[i].stream_id = s.stream_id; + items[i].kind = static_cast(s.kind); + items[i].label = label; + } + out->items = items; + out->count = user->streams.size(); + return VC_OK; +} + +// ── M4: TOFU server-identity gate ───────────────────────────────────────────── + +vc_result vc_client::confirm_server_identity(bool accept) { + std::lock_guard lk(tofu_mu_); + if (!tofu_decision_pending_) return VC_ERR_INVALID_ARG; + tofu_accept_ = accept; + tofu_decision_pending_ = false; + tofu_cv_.notify_all(); + return VC_OK; +} + +vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap, + size_t* out_len) { + std::string display; + { + std::lock_guard lk(tofu_mu_); + display = pending_identity_fp_hex_; + } + if (out_len) *out_len = display.size(); + if (!out_buf) return VC_OK; // size-query mode + if (buf_cap < display.size() + 1) return VC_ERR_INVALID_ARG; + std::memcpy(out_buf, display.c_str(), display.size() + 1); + return VC_OK; +} + void vc_client::run_talk_timer() { while (!talk_timer_stop_.load(std::memory_order_acquire)) { // Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user, @@ -1148,6 +1375,7 @@ vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_ vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::set_vad_threshold(float) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) { @@ -1165,5 +1393,25 @@ vc_result vc_client::get_stream_audio_config(uint32_t, uint32_t, vc_audio_config vc_result vc_client::test_inject_capture(uint32_t, const int16_t*, size_t) { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::list_channels(vc_channel_list* out) { + out->items = nullptr; + out->count = 0; + return VC_ERR_NOT_IMPLEMENTED; +} +vc_result vc_client::list_users(vc_user_list* out) { + out->items = nullptr; + out->count = 0; + return VC_ERR_NOT_IMPLEMENTED; +} +vc_result vc_client::list_user_streams(uint32_t, vc_stream_summary_list* out) { + out->items = nullptr; + out->count = 0; + return VC_ERR_NOT_IMPLEMENTED; +} +vc_result vc_client::confirm_server_identity(bool) { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::get_server_identity_display(char*, size_t, size_t* out_len) { + if (out_len) *out_len = 0; + return VC_ERR_NOT_IMPLEMENTED; +} #endif // VOICECAT_HAS_NET diff --git a/core/src/core/client.h b/core/src/core/client.h index c1bebde..d535d37 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -22,6 +22,7 @@ #include "audio/audio_engine.h" #include "codec/opus_codec.h" #include "crypto/crypto.h" +#include "crypto/tofu_store.h" #include "net/voice_frame.h" #include "protocol/envelope.h" #include "protocol/protocol.h" @@ -49,6 +50,7 @@ struct vc_client { vc_result stream_stop(uint32_t stream_id); vc_result set_input_device(uint32_t stream_id, const char* device_id); vc_result set_input_mode(vc_input_mode mode); + vc_result set_vad_threshold(float threshold); vc_result set_push_to_talk(bool active); vc_result set_self_mute(bool mic_muted, bool deafened); vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, @@ -58,6 +60,15 @@ struct vc_client { vc_result list_devices(vc_device_kind kind, vc_device_list* out); + // M4: channel/user/stream snapshot getters (read session_model_; see voicecat.h). + vc_result list_channels(vc_channel_list* out); + vc_result list_users(vc_user_list* out); + vc_result list_user_streams(uint32_t user_id, vc_stream_summary_list* out); + + // M4: TOFU server-identity gate (see voicecat.h's VC_EVENT_SERVER_IDENTITY doc comment). + vc_result confirm_server_identity(bool accept); + vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len); + // M3: effective Opus config for a (user_id, stream_id) — our own pending/active local // streams, or any peer's broadcast StreamInfo.audio. vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id, @@ -113,8 +124,19 @@ struct vc_client { uint64_t server_session_id_{0}; std::atomic next_req_id_{1}; - // Client-side session model + // Client-side session model. Mutated only on io_thread_ (handle_server_state/ + // handle_user_event/handle_channel_event), but read from any thread via the M4 + // list_channels/list_users/list_user_streams getters — session_model_mu_ guards both. voicecat::session::SessionModel session_model_; + mutable std::mutex session_model_mu_; + + // ── M4: TOFU server-identity gate ─────────────────────────────────────────── + std::unique_ptr tofu_store_; // owns the pin file + std::mutex tofu_mu_; + std::condition_variable tofu_cv_; + bool tofu_decision_pending_{false}; + bool tofu_accept_{false}; + std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only // ── M2: UDP / media plane ──────────────────────────────────────────────────── std::array udp_token_{}; @@ -187,6 +209,7 @@ struct vc_client { // lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback). std::atomic current_input_mode_{VC_INPUT_VOICE_ACTIVATION}; std::atomic ptt_active_{false}; + std::atomic vad_threshold_{0.025f}; // remembered across mode switches std::unique_ptr mic_vad_; // teardown_voice() is called both from run_io()'s own cleanup (on the io_thread_, when @@ -206,6 +229,8 @@ struct vc_client { void handle_auth_result(const voicecat::v1::AuthResult& msg); void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap); void handle_user_event(const voicecat::v1::UserEvent& ue); + void handle_channel_event(const voicecat::v1::ChannelEvent& ce); + void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg); void handle_text_message(const voicecat::v1::TextMessage& msg); void handle_disconnect(const voicecat::v1::Disconnect& msg); void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg); diff --git a/core/src/crypto/crypto.cpp b/core/src/crypto/crypto.cpp index 87843fb..dbc17c0 100644 --- a/core/src/crypto/crypto.cpp +++ b/core/src/crypto/crypto.cpp @@ -283,6 +283,14 @@ bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, s ctx, ctx_len, ctx != nullptr) == 0; } +bool TlsContext::peer_cert_fingerprint(std::array& out) const { + if (!ready_) return false; + const mbedtls_x509_crt* peer = mbedtls_ssl_get_peer_cert(&ssl_); + if (!peer) return false; + mbedtls_sha256(peer->raw.p, peer->raw.len, out.data(), 0); + return true; +} + // ── SodiumMediaCrypto ───────────────────────────────────────────────────────── SodiumMediaCrypto::SodiumMediaCrypto( diff --git a/core/src/crypto/crypto.h b/core/src/crypto/crypto.h index 37f6cea..0fc32ed 100644 --- a/core/src/crypto/crypto.h +++ b/core/src/crypto/crypto.h @@ -88,6 +88,13 @@ class TlsContext { bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len, uint8_t* out, size_t out_len); + // M4 TOFU: SHA-256 of the peer's leaf X.509 certificate (DER), valid only after a + // successful Role::Client handshake(). This is the value vc_client pins — see + // voicecat.h's vc_tofu_status doc comment for why the cert fingerprint is pinned instead + // of the declared Ed25519 server_identity_fingerprint. Returns false if no peer cert is + // available (e.g. Role::Server, or handshake() hasn't succeeded). + bool peer_cert_fingerprint(std::array& out) const; + // Whether the handshake completed. bool ready() const { return ready_; } diff --git a/core/src/crypto/tofu_store.cpp b/core/src/crypto/tofu_store.cpp index 8329ba5..fe2fdf6 100644 --- a/core/src/crypto/tofu_store.cpp +++ b/core/src/crypto/tofu_store.cpp @@ -25,6 +25,21 @@ TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port, return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch; } +TofuResult TofuStore::peek(const std::string& host, uint16_t port, + const std::array& fingerprint) const { + std::lock_guard lk(mu_); + auto it = pins_.find(make_key(host, port)); + if (it == pins_.end()) return TofuResult::FirstConnect; + return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch; +} + +void TofuStore::pin(const std::string& host, uint16_t port, + const std::array& fingerprint) { + std::lock_guard lk(mu_); + pins_[make_key(host, port)] = fingerprint; + save(); +} + void TofuStore::remove(const std::string& host, uint16_t port) { std::lock_guard lk(mu_); pins_.erase(make_key(host, port)); diff --git a/core/src/crypto/tofu_store.h b/core/src/crypto/tofu_store.h index 311531a..f923c70 100644 --- a/core/src/crypto/tofu_store.h +++ b/core/src/crypto/tofu_store.h @@ -29,9 +29,21 @@ class TofuStore { // Check the fingerprint for host:port. Stores on first connect. // Thread-safe (single-writer lock). + // NOTE: kept for compatibility; the M4 gated-confirmation flow (vc_client) uses peek() + // + pin() instead, since check_and_pin's unconditional first-connect write is wrong for a + // flow where the application must approve the fingerprint before it's trusted/persisted. TofuResult check_and_pin(const std::string& host, uint16_t port, const std::array& fingerprint); + // Read-only — classifies the fingerprint against any existing pin WITHOUT writing to disk. + // Use this before the application has had a chance to approve a first-connect/mismatch. + TofuResult peek(const std::string& host, uint16_t port, + const std::array& fingerprint) const; + + // Persist the pin for host:port. Call only after the caller has accepted a FIRST_CONNECT + // or MISMATCH classification from peek() — accepting a MATCHED result needs no call here. + void pin(const std::string& host, uint16_t port, const std::array& fingerprint); + // Remove the pin for host:port (e.g. after user explicitly acknowledges a key change). void remove(const std::string& host, uint16_t port); @@ -44,7 +56,7 @@ class TofuStore { void save() const; std::filesystem::path path_; - std::mutex mu_; + mutable std::mutex mu_; std::unordered_map> pins_; }; diff --git a/core/src/net/transport.cpp b/core/src/net/transport.cpp index 3e24b24..5709103 100644 --- a/core/src/net/transport.cpp +++ b/core/src/net/transport.cpp @@ -324,11 +324,34 @@ void TcpServerConn::close() { // ── TcpAcceptor ───────────────────────────────────────────────────────────── -TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory) - : acceptor_(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)), - factory_(std::move(factory)) { - acceptor_.set_option(asio::ip::tcp::acceptor::reuse_address(true)); +namespace { +// Try IPv6 dual-stack first (one socket handles both ::1 and 127.0.0.1 — fixes the common +// Windows case where `localhost` resolves to ::1 before 127.0.0.1). Falls back to IPv4-only +// if the OS has IPv6 disabled or the dual-stack bind fails for any reason. +asio::ip::tcp::acceptor make_acceptor(asio::io_context& io, uint16_t port) { + asio::ip::tcp::acceptor acc(io); + std::error_code ec; + acc.open(asio::ip::tcp::v6(), ec); + if (!ec) { + acc.set_option(asio::ip::v6_only(false), ec); // dual-stack + acc.set_option(asio::ip::tcp::acceptor::reuse_address(true)); + acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v6(), port), ec); + if (!ec) acc.listen(asio::socket_base::max_listen_connections, ec); + } + if (ec) { + if (acc.is_open()) { std::error_code ignored; acc.close(ignored); } + acc.open(asio::ip::tcp::v4()); + acc.set_option(asio::ip::tcp::acceptor::reuse_address(true)); + acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)); + acc.listen(asio::socket_base::max_listen_connections); + } + return acc; } +} // namespace + +TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory) + : acceptor_(make_acceptor(io, port)), + factory_(std::move(factory)) {} void TcpAcceptor::start() { do_accept(); } diff --git a/core/src/session/session.cpp b/core/src/session/session.cpp index fda1ee6..6e15738 100644 --- a/core/src/session/session.cpp +++ b/core/src/session/session.cpp @@ -55,8 +55,11 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap) channels_.clear(); for (const auto& pb : snap.channels()) { Channel ch; - ch.id = pb.id(); - ch.name = pb.name(); + ch.id = pb.id(); + ch.parent_id = pb.parent_id(); + ch.name = pb.name(); + ch.password_protected = pb.password_protected(); + ch.max_users = pb.max_users(); channels_.push_back(std::move(ch)); } @@ -103,8 +106,11 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) { if (ev.kind() == Kind::CREATED || ev.kind() == Kind::UPDATED) { const auto& pb = ev.channel(); Channel ch; - ch.id = pb.id(); - ch.name = pb.name(); + ch.id = pb.id(); + ch.parent_id = pb.parent_id(); + ch.name = pb.name(); + ch.password_protected = pb.password_protected(); + ch.max_users = pb.max_users(); auto it = std::find_if(channels_.begin(), channels_.end(), [&](const Channel& x) { return x.id == ch.id; }); @@ -112,7 +118,11 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) { else channels_.push_back(std::move(ch)); } else if (ev.kind() == Kind::DELETED) { - uint32_t cid = ev.channel().id(); + // deleted_id, not channel().id() — the proto leaves `channel` unset for deletes + // (docs/protocol.md, core/proto/voicecat.proto's ChannelEvent). Pre-existing bug, dead + // code until something actually emits ChannelEvent (no channel CRUD exists yet — M5+), + // fixed here while touching this function for the M4 field-population fix. + uint32_t cid = ev.deleted_id(); channels_.erase(std::remove_if(channels_.begin(), channels_.end(), [cid](const Channel& x) { return x.id == cid; }), channels_.end()); diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 8773d8f..32aef3f 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -102,6 +102,11 @@ vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode) { return c->set_input_mode(mode); } +vc_result vc_set_vad_threshold(vc_client* c, float threshold) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->set_vad_threshold(threshold); +} + vc_result vc_set_push_to_talk(vc_client* c, int active) { if (c == nullptr) return VC_ERR_INVALID_ARG; return c->set_push_to_talk(active != 0); @@ -152,4 +157,54 @@ void vc_free_device_list(vc_device_list* list) { list->count = 0; } +vc_result vc_list_channels(vc_client* c, vc_channel_list* out) { + if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; + return c->list_channels(out); +} + +void vc_free_channel_list(vc_channel_list* list) { + if (list == nullptr || list->items == nullptr) return; + for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].name; + delete[] list->items; + list->items = nullptr; + list->count = 0; +} + +vc_result vc_list_users(vc_client* c, vc_user_list* out) { + if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; + return c->list_users(out); +} + +void vc_free_user_list(vc_user_list* list) { + if (list == nullptr || list->items == nullptr) return; + for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].nickname; + delete[] list->items; + list->items = nullptr; + list->count = 0; +} + +vc_result vc_list_user_streams(vc_client* c, uint32_t user_id, vc_stream_summary_list* out) { + if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG; + return c->list_user_streams(user_id, out); +} + +void vc_free_stream_summary_list(vc_stream_summary_list* list) { + if (list == nullptr || list->items == nullptr) return; + for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].label; + delete[] list->items; + list->items = nullptr; + list->count = 0; +} + +vc_result vc_confirm_server_identity(vc_client* c, int accept) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->confirm_server_identity(accept != 0); +} + +vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap, + size_t* out_len) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->get_server_identity_display(out_buf, buf_cap, out_len); +} + } // extern "C" diff --git a/server/src/main.cpp b/server/src/main.cpp index d7eea54..4d46f13 100644 --- a/server/src/main.cpp +++ b/server/src/main.cpp @@ -32,6 +32,13 @@ void print_help(const char* argv0) { } // namespace int main(int argc, char** argv) { + // MSVCRT/MinGW treat _IOLBF as full buffering for non-console streams (e.g. when a + // launcher redirects stdout to a pipe, as the C# interop smoke test's Process does to + // read the bound port) — go unbuffered so the startup banner (incl. "TCP :") is + // visible immediately instead of sitting in the CRT's buffer until it fills or the + // process exits. Same fix as tools/vccli/src/main.cpp. + std::setvbuf(stdout, nullptr, _IONBF, 0); + voicecat::server::Config cfg; bool print_config_only = false; diff --git a/server/src/session_registry.cpp b/server/src/session_registry.cpp index 1beb464..903ed79 100644 --- a/server/src/session_registry.cpp +++ b/server/src/session_registry.cpp @@ -17,6 +17,9 @@ void SessionRegistry::init_default_channels() { lobby.proto.set_name("Lobby"); lobby.proto.set_type(voicecat::v1::CHANNEL_PERMANENT); lobby.proto.set_order(0); + // Non-zero so vc_list_channels/SessionModel round-trip this field for real (a regression + // test for the M4 SessionModel field-population fix needs at least one non-default value). + lobby.proto.set_max_users(20); { // Speech profile: mono, low bitrate, FEC+DTX on for resilience/silence-suppression. auto* a = lobby.proto.mutable_audio(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fdd4fde..45f5b83 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -95,4 +95,22 @@ if(VOICECAT_USE_VCPKG_DEPS) target_include_directories(test_vad_ptt_devices PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) add_test(NAME vad_ptt_devices COMMAND test_vad_ptt_devices) set_tests_properties(vad_ptt_devices PROPERTIES TIMEOUT 60) + + # M4: channel/user/stream snapshot getters (vc_list_channels/vc_list_users/ + # vc_list_user_streams) — through the real C ABI against a real in-process server. + add_executable(test_channel_user_list_abi test_channel_user_list_abi.cpp) + target_link_libraries(test_channel_user_list_abi PRIVATE voicecat::server) + target_compile_features(test_channel_user_list_abi PRIVATE cxx_std_20) + target_include_directories(test_channel_user_list_abi PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) + add_test(NAME channel_user_list_abi COMMAND test_channel_user_list_abi) + set_tests_properties(channel_user_list_abi PROPERTIES TIMEOUT 60) + + # M4: TOFU server-identity gate (VC_EVENT_SERVER_IDENTITY / vc_confirm_server_identity / + # vc_get_server_identity_display) — real TLS handshakes against real in-process servers. + add_executable(test_tofu_flow test_tofu_flow.cpp) + target_link_libraries(test_tofu_flow PRIVATE voicecat::server) + target_compile_features(test_tofu_flow PRIVATE cxx_std_20) + target_include_directories(test_tofu_flow PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) + add_test(NAME tofu_flow COMMAND test_tofu_flow) + set_tests_properties(tofu_flow PROPERTIES TIMEOUT 90) endif() diff --git a/tests/test_channel_user_list_abi.cpp b/tests/test_channel_user_list_abi.cpp new file mode 100644 index 0000000..c6c60b4 --- /dev/null +++ b/tests/test_channel_user_list_abi.cpp @@ -0,0 +1,334 @@ +/* + * test_channel_user_list_abi — M4 channel/user/stream snapshot getters. + * + * Covers the new pull-based ABI surface (voicecat.h): vc_list_channels, vc_list_users, + * vc_list_user_streams, and the new VC_EVENT_JOIN_RESULT feedback for vc_join_channel. + * Mirrors test_vad_ptt_devices.cpp's approach (real vc_client instances against a real + * in-process server, not raw sockets). + * + * 1. Pre-data: a freshly created (not yet connected) client's getters return VC_OK, + * count=0 — never an error just because nothing has arrived yet. + * 2. After two guests connect and auth: vc_list_channels reflects the server's real + * channel config (this is the regression test for the SessionModel field-population + * fix — parent_id/password_protected/max_users were silently dropped before); + * vc_list_users on either client includes both users with correct nickname/channel_id. + * 3. After A starts a MIC stream: B's vc_list_user_streams(A's user_id) shows it, with the + * same stream_id as A's own VC_EVENT_STREAM_STARTED. + * 4. vc_list_user_streams with an unknown user_id returns VC_ERR_INVALID_ARG. + * 5. vc_join_channel's result arrives via VC_EVENT_JOIN_RESULT — success (re-joining the + * channel already in) and failure (an unknown channel id). + * 6. Every vc_free_*_list is idempotent (safe to call twice). + */ +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "voicecat.h" +#include "server.h" + +// ── Event tracking ──────────────────────────────────────────────────────────── + +struct StreamEvent { + bool started; + uint32_t user_id; + uint32_t stream_id; +}; + +struct JoinResult { + bool ok; + uint32_t channel_id; + std::string error; +}; + +struct EventStore { + std::mutex mu; + std::condition_variable cv; + + bool auth_ok{false}; + uint32_t self_user_id{0}; + bool channel_list_received{false}; + std::vector stream_events; + std::vector join_results; + + const char* label{nullptr}; + vc_client* client{nullptr}; +}; + +static void on_event(void* user, const vc_event* ev) { + auto* s = static_cast(user); + std::lock_guard lk(s->mu); + switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + vc_confirm_server_identity(s->client, 1); + break; + case VC_EVENT_AUTH_RESULT: + s->auth_ok = (ev->result == VC_OK); + s->self_user_id = ev->user_id; + break; + case VC_EVENT_CHANNEL_LIST: + s->channel_list_received = true; + break; + case VC_EVENT_STREAM_STARTED: + s->stream_events.push_back({true, ev->user_id, ev->stream_id}); + break; + case VC_EVENT_STREAM_STOPPED: + s->stream_events.push_back({false, ev->user_id, ev->stream_id}); + break; + case VC_EVENT_JOIN_RESULT: + s->join_results.push_back( + {ev->result == VC_OK, ev->channel_id, ev->text ? ev->text : ""}); + break; + default: + break; + } + s->cv.notify_all(); +} + +template +static bool wait_for(EventStore& s, Pred pred, int timeout_ms) { + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + std::unique_lock lk(s.mu); + return s.cv.wait_until(lk, deadline, [&] { return pred(s); }); +} + +// ── Test harness ────────────────────────────────────────────────────────────── + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +// ── 1. Pre-data: getters never error just because nothing has arrived yet ────── +static void test_pre_data_empty() { + vc_config cfg{"test-predata", "0.1", VC_LOG_OFF}; + vc_callbacks cb{}; + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + + vc_channel_list cl{}; + CHECK(vc_list_channels(c, &cl) == VC_OK); + CHECK(cl.count == 0); + vc_free_channel_list(&cl); + vc_free_channel_list(&cl); // idempotent + + vc_user_list ul{}; + CHECK(vc_list_users(c, &ul) == VC_OK); + CHECK(ul.count == 0); + vc_free_user_list(&ul); + vc_free_user_list(&ul); // idempotent + + // No users known yet — any user_id is "unknown". + vc_stream_summary_list sl{}; + CHECK(vc_list_user_streams(c, 1, &sl) == VC_ERR_INVALID_ARG); + + vc_client_destroy(c); + std::printf("test_pre_data_empty: ok\n"); +} + +// ── 2-6. Real connect/auth/join/stream against a real in-process server ──────── +static void test_live_channel_user_list() { + auto tmp = std::filesystem::temp_directory_path() / + ("vctest_chanlist_" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(tmp); + std::string data_dir = tmp.string(); + + std::atomic bound_port{0}; + std::mutex ready_mu; + std::condition_variable ready_cv; + bool ready{false}; + + voicecat::server::Config cfg; + cfg.data_dir = data_dir; + cfg.bind_port = 0; + cfg.media_port = 0; + cfg.server_name = "VoiceCat-ChanListTest"; + cfg.allow_guests = true; + cfg.on_ready = [&](uint16_t p) { + bound_port.store(p); + { std::lock_guard lk(ready_mu); ready = true; } + ready_cv.notify_all(); + }; + + voicecat::server::Server server(cfg); + std::thread server_thread([&] { server.run(); }); + + { + std::unique_lock lk(ready_mu); + bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; }); + if (!ok) { + std::printf("FAIL: server did not become ready within 10s\n"); + ++g_failures; + server.stop(); + server_thread.join(); + std::filesystem::remove_all(tmp); + return; + } + } + + uint16_t port = bound_port.load(); + std::printf("test_live_channel_user_list: server ready on :%u\n", port); + + EventStore evA; + evA.label = "A"; + vc_callbacks cbA{on_event, nullptr, &evA}; + vc_config cfgA{"test-A", "0.1", VC_LOG_OFF}; + vc_client* clientA = vc_client_create(&cfgA, cbA); + CHECK(clientA != nullptr); + evA.client = clientA; + CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(clientA, "CL-A") == VC_OK); + CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000)); + CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000)); + + EventStore evB; + evB.label = "B"; + vc_callbacks cbB{on_event, nullptr, &evB}; + vc_config cfgB{"test-B", "0.1", VC_LOG_OFF}; + vc_client* clientB = vc_client_create(&cfgB, cbB); + CHECK(clientB != nullptr); + evB.client = clientB; + CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(clientB, "CL-B") == VC_OK); + CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000)); + CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000)); + + uint32_t a_uid = 0, b_uid = 0; + { std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; } + { std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; } + + // ── 2a. vc_list_channels reflects the real server config (regression test for the + // SessionModel field-population fix — parent_id/password_protected/max_users). ────────── + { + vc_channel_list cl{}; + CHECK(vc_list_channels(clientA, &cl) == VC_OK); + CHECK(cl.count == 2); // Lobby (1) + Music Room (2), per session_registry.cpp + bool found_lobby = false; + for (size_t i = 0; i < cl.count; ++i) { + CHECK(cl.items[i].name != nullptr); + if (cl.items[i].id == 1) { + found_lobby = true; + CHECK(std::strcmp(cl.items[i].name, "Lobby") == 0); + CHECK(cl.items[i].parent_id == 0); + CHECK(cl.items[i].password_protected == 0); + CHECK(cl.items[i].max_users == 20); // non-default — proves the fix + } + } + CHECK(found_lobby); + vc_free_channel_list(&cl); + vc_free_channel_list(&cl); // idempotent + } + + // ── 2b. vc_list_users includes both A and B with correct nickname/channel_id ───────────── + { + vc_user_list ul{}; + CHECK(vc_list_users(clientB, &ul) == VC_OK); + CHECK(ul.count == 2); + bool found_a = false, found_b = false; + for (size_t i = 0; i < ul.count; ++i) { + CHECK(ul.items[i].nickname != nullptr); + CHECK(ul.items[i].channel_id == 1); // both default into Lobby on auth + if (ul.items[i].id == a_uid) { found_a = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-A") == 0); } + if (ul.items[i].id == b_uid) { found_b = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-B") == 0); } + } + CHECK(found_a); + CHECK(found_b); + vc_free_user_list(&ul); + vc_free_user_list(&ul); // idempotent + } + + // ── 3. vc_list_user_streams reflects a real stream, cross-checked against the + // STREAM_STARTED event's stream_id. ────────────────────────────────────────────────────── + vc_stream_desc mic_desc{}; + mic_desc.kind = VC_STREAM_MIC; + mic_desc.label = "mic"; + uint32_t mic_sid = 0; + CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK); + CHECK(wait_for(evB, [](EventStore& s) { return !s.stream_events.empty(); }, 5000)); + + { + vc_stream_summary_list sl{}; + CHECK(vc_list_user_streams(clientB, a_uid, &sl) == VC_OK); + CHECK(sl.count == 1); + if (sl.count == 1) { + CHECK(sl.items[0].stream_id == mic_sid); + CHECK(sl.items[0].kind == VC_STREAM_MIC); + CHECK(sl.items[0].label != nullptr); + } + vc_free_stream_summary_list(&sl); + vc_free_stream_summary_list(&sl); // idempotent + } + + // ── 4. Unknown user_id ─────────────────────────────────────────────────────────────────── + { + vc_stream_summary_list sl{}; + CHECK(vc_list_user_streams(clientB, 0xDEADBEEF, &sl) == VC_ERR_INVALID_ARG); + } + + // ── 5. VC_EVENT_JOIN_RESULT — success (re-join the channel already in) and failure + // (unknown channel id). ───────────────────────────────────────────────────────────────── + CHECK(vc_join_channel(clientA, 1, nullptr) == VC_OK); + CHECK(wait_for(evA, [](EventStore& s) { return !s.join_results.empty(); }, 3000)); + { + std::lock_guard lk(evA.mu); + CHECK(evA.join_results.back().ok); + CHECK(evA.join_results.back().channel_id == 1); + } + + size_t mark; + { std::lock_guard lk(evA.mu); mark = evA.join_results.size(); } + CHECK(vc_join_channel(clientA, 999999, nullptr) == VC_OK); + CHECK(wait_for(evA, [&](EventStore& s) { return s.join_results.size() > mark; }, 3000)); + { + std::lock_guard lk(evA.mu); + CHECK(!evA.join_results.back().ok); + CHECK(!evA.join_results.back().error.empty()); + } + + vc_stream_stop(clientA, mic_sid); + vc_disconnect(clientA); + vc_disconnect(clientB); + vc_client_destroy(clientA); + vc_client_destroy(clientB); + + server.stop(); + server_thread.join(); + std::filesystem::remove_all(tmp); + + std::printf("test_live_channel_user_list: done\n"); +} + +int main() { + test_pre_data_empty(); + test_live_channel_user_list(); + + if (g_failures == 0) { + std::printf("channel_user_list_abi: all checks passed\n"); + return 0; + } + std::printf("channel_user_list_abi: %d failure(s)\n", g_failures); + return 1; +} + +#else // !VOICECAT_HAS_NET + +int main() { + std::printf("channel_user_list_abi: SKIP (VOICECAT_HAS_NET not defined)\n"); + return 0; +} + +#endif // VOICECAT_HAS_NET diff --git a/tests/test_m1_integration.cpp b/tests/test_m1_integration.cpp index 118475c..4805fce 100644 --- a/tests/test_m1_integration.cpp +++ b/tests/test_m1_integration.cpp @@ -42,6 +42,10 @@ struct EventStore { std::string last_error; bool disconnected{false}; vc_connection_state last_state{VC_STATE_DISCONNECTED}; + + // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the + // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test. + vc_client* client{nullptr}; }; static void on_event(void* user, const vc_event* ev) { @@ -49,6 +53,10 @@ static void on_event(void* user, const vc_event* ev) { std::lock_guard lk(s->mu); s->last_state = ev->connection_state; switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + // No human to ask in a headless test — trust on first connect unconditionally. + vc_confirm_server_identity(s->client, 1); + break; case VC_EVENT_AUTH_RESULT: s->auth_result = static_cast(ev->result); s->auth_ok = (ev->result == VC_OK); @@ -171,6 +179,7 @@ int main() { vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF}; vc_client* clientA = vc_client_create(&cfgA, cbA); CHECK(clientA != nullptr); + evA.client = clientA; CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientA, "GuestBob") == VC_OK); @@ -190,6 +199,7 @@ int main() { vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF}; vc_client* clientB = vc_client_create(&cfgB, cbB); CHECK(clientB != nullptr); + evB.client = clientB; CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_user(clientB, "alice", "test-pass-alice") == VC_OK); diff --git a/tests/test_m3_multistream.cpp b/tests/test_m3_multistream.cpp index 41064e3..aa5d42e 100644 --- a/tests/test_m3_multistream.cpp +++ b/tests/test_m3_multistream.cpp @@ -62,12 +62,20 @@ struct EventStore { bool disconnected{false}; const char* label{nullptr}; + + // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the + // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test. + vc_client* client{nullptr}; }; static void on_event(void* user, const vc_event* ev) { auto* s = static_cast(user); std::lock_guard lk(s->mu); switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + // No human to ask in a headless test — trust on first connect unconditionally. + vc_confirm_server_identity(s->client, 1); + break; case VC_EVENT_AUTH_RESULT: s->auth_ok = (ev->result == VC_OK); s->self_user_id = ev->user_id; @@ -176,6 +184,7 @@ int main() { vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF}; vc_client* clientA = vc_client_create(&cfgA, cbA); CHECK(clientA != nullptr); + evA.client = clientA; CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientA, "M3-A") == VC_OK); @@ -189,6 +198,7 @@ int main() { vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF}; vc_client* clientB = vc_client_create(&cfgB, cbB); CHECK(clientB != nullptr); + evB.client = clientB; CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientB, "M3-B") == VC_OK); diff --git a/tests/test_tofu_flow.cpp b/tests/test_tofu_flow.cpp new file mode 100644 index 0000000..8ca8d4a --- /dev/null +++ b/tests/test_tofu_flow.cpp @@ -0,0 +1,385 @@ +/* + * test_tofu_flow — M4 TOFU server-identity gate (voicecat.h's VC_EVENT_SERVER_IDENTITY / + * vc_confirm_server_identity / vc_get_server_identity_display). + * + * Needs a real server (in-process, like the other ABI tests) so a real TLS handshake + * happens — pinning a fingerprint against a mock would prove nothing. + * + * 1. First connect to a fresh server blocks (no AUTH_RESULT) until + * vc_confirm_server_identity() is called; then it proceeds normally. + * 2. Rejecting (accept=0) disconnects with VC_ERR_CRYPTO and does NOT persist a pin — a + * second attempt to the same server still reports FIRST_CONNECT. + * 3. Reconnecting to a server with the SAME identity (same data_dir, restarted on the + * same port) reports MATCHED. + * 4. Reconnecting to a server with a DIFFERENT identity on the same host:port (key + * rotation / MITM) reports MISMATCH. + * 5. vc_confirm_server_identity with nothing pending returns VC_ERR_INVALID_ARG. + * 6. vc_get_server_identity_display is empty pre-connect and populated (64 hex chars — + * the raw, colon-free encoding of the Ed25519 fingerprint) after ServerHello. + */ +#include + +#ifdef VOICECAT_HAS_NET + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "voicecat.h" +#include "server.h" + +// ── Event tracking — deliberately does NOT auto-confirm, so the test drives the gate ────── + +struct GatedEventStore { + std::mutex mu; + std::condition_variable cv; + + bool auth_ok{false}; + bool got_identity{false}; + vc_tofu_status identity_status{}; + bool disconnected{false}; + vc_result disconnect_result{VC_OK}; + + vc_client* client{nullptr}; +}; + +static void on_event_gated(void* user, const vc_event* ev) { + auto* s = static_cast(user); + std::lock_guard lk(s->mu); + switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + s->got_identity = true; + s->identity_status = static_cast(ev->u32a); + break; + case VC_EVENT_AUTH_RESULT: + s->auth_ok = (ev->result == VC_OK); + break; + case VC_EVENT_DISCONNECTED: + s->disconnected = true; + s->disconnect_result = static_cast(ev->result); + break; + default: + break; + } + s->cv.notify_all(); +} + +template +static bool wait_for(GatedEventStore& s, Pred pred, int timeout_ms) { + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + std::unique_lock lk(s.mu); + return s.cv.wait_until(lk, deadline, [&] { return pred(s); }); +} + +// ── Test harness ────────────────────────────────────────────────────────────── + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +// ── A small helper to start/stop an in-process server on a chosen (or OS-assigned) port ─── + +struct RunningServer { + voicecat::server::Config cfg; + std::unique_ptr server; + std::thread server_thread; + uint16_t port{0}; + + bool start(const std::string& data_dir, uint16_t want_port, const char* name) { + std::atomic bound_port{0}; + std::mutex ready_mu; + std::condition_variable ready_cv; + bool ready{false}; + + cfg.data_dir = data_dir; + cfg.bind_port = want_port; + cfg.media_port = 0; + cfg.server_name = name; + cfg.allow_guests = true; + cfg.on_ready = [&](uint16_t p) { + bound_port.store(p); + { std::lock_guard lk(ready_mu); ready = true; } + ready_cv.notify_all(); + }; + + server = std::make_unique(cfg); + server_thread = std::thread([this] { server->run(); }); + + std::unique_lock lk(ready_mu); + bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; }); + if (!ok) return false; + port = bound_port.load(); + return true; + } + + void stop_and_join() { + if (server) server->stop(); + if (server_thread.joinable()) server_thread.join(); + } +}; + +// ── 1. First connect blocks until confirmed ───────────────────────────────────── +static void test_first_connect_blocks(uint16_t port, const std::string& tofu_path) { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-gate", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(c, "Gated") == VC_OK); + + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } + + // No confirmation yet — auth must NOT complete within a short window. + CHECK(!wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 1000)); + + CHECK(vc_confirm_server_identity(c, 1) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); + + vc_disconnect(c); + vc_client_destroy(c); + std::printf("test_first_connect_blocks: ok\n"); +} + +// ── 2. Reject doesn't persist a pin ────────────────────────────────────────────── +static void test_reject_does_not_persist(uint16_t port, const std::string& tofu_path) { + { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-reject", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } + + CHECK(vc_confirm_server_identity(c, 0) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.disconnect_result == VC_ERR_CRYPTO); } + + vc_client_destroy(c); + } + // Second attempt to the SAME server, SAME pin file: still FIRST_CONNECT — the rejected + // pin from above must not have been written to disk. + { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-reject2", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } + + vc_disconnect(c); + vc_client_destroy(c); + } + std::printf("test_reject_does_not_persist: ok\n"); +} + +// ── 3/4. MATCHED on identity reuse, MISMATCH on identity rotation ────────────── +static void test_matched_and_mismatch(const std::string& tofu_path) { + auto tmp = std::filesystem::temp_directory_path() / + ("vctest_tofu_" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(tmp); + auto data_dir_1 = (tmp / "server1").string(); // identity A + auto data_dir_2 = (tmp / "server2").string(); // identity B (different) + + // ── Server 1 (identity A), first connect: accept + pin ────────────────────── + RunningServer server1; + CHECK(server1.start(data_dir_1, 0, "VoiceCat-TofuA")); + uint16_t port = server1.port; + std::printf("test_matched_and_mismatch: server1 ready on :%u\n", port); + + { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-pin", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(c, "Pin") == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } + CHECK(vc_confirm_server_identity(c, 1) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); + vc_disconnect(c); + vc_client_destroy(c); + } + server1.stop_and_join(); + + // ── Server 1 restarted on the SAME port, SAME data_dir (identity A reloaded from disk + // — ServerIdentityManager::init's load-existing-files path) — expect MATCHED. ────────── + RunningServer server1_restarted; + CHECK(server1_restarted.start(data_dir_1, port, "VoiceCat-TofuA")); + { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-matched", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(c, "Matched") == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); } + CHECK(vc_confirm_server_identity(c, 1) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); + vc_disconnect(c); + vc_client_destroy(c); + } + server1_restarted.stop_and_join(); + + // ── A DIFFERENT server (identity B, fresh data_dir) on the SAME port — expect + // MISMATCH. Reject it, and confirm the pin file still reflects identity A afterwards. ─── + RunningServer server2; + CHECK(server2.start(data_dir_2, port, "VoiceCat-TofuB")); + { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-mismatch", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MISMATCH); } + CHECK(vc_confirm_server_identity(c, 0) == VC_OK); // reject the rotated identity + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000)); + vc_client_destroy(c); + } + server2.stop_and_join(); + + // ── Server 1 (identity A) once more — rejecting the mismatch above must not have + // clobbered the original pin. ─────────────────────────────────────────────────────────── + RunningServer server1_again; + CHECK(server1_again.start(data_dir_1, port, "VoiceCat-TofuA")); + { + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_config cfg{"test-still-matched", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); } + vc_disconnect(c); + vc_client_destroy(c); + } + server1_again.stop_and_join(); + + std::filesystem::remove_all(tmp); + std::printf("test_matched_and_mismatch: ok\n"); +} + +// ── 5. confirm_server_identity with nothing pending ───────────────────────────── +static void test_confirm_with_nothing_pending() { + vc_config cfg{"test-nopending", "0.1", VC_LOG_OFF}; + vc_callbacks cb{}; + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + CHECK(vc_confirm_server_identity(c, 1) == VC_ERR_INVALID_ARG); + vc_client_destroy(c); + std::printf("test_confirm_with_nothing_pending: ok\n"); +} + +// ── 6. vc_get_server_identity_display ─────────────────────────────────────────── +static void test_get_server_identity_display(uint16_t port, const std::string& tofu_path) { + vc_config cfg{"test-display", "0.1", VC_LOG_OFF}; + cfg.tofu_store_path = tofu_path.c_str(); + + GatedEventStore ev; + vc_callbacks cb{on_event_gated, nullptr, &ev}; + vc_client* c = vc_client_create(&cfg, cb); + CHECK(c != nullptr); + ev.client = c; + + // Pre-connect: empty. + size_t len = 12345; + CHECK(vc_get_server_identity_display(c, nullptr, 0, &len) == VC_OK); + CHECK(len == 0); + + CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); + CHECK(vc_authenticate_guest(c, "Display") == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); + CHECK(vc_confirm_server_identity(c, 1) == VC_OK); + CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); + + char buf[256] = {}; + CHECK(vc_get_server_identity_display(c, buf, sizeof(buf), &len) == VC_OK); + CHECK(len == 64); // 32-byte Ed25519 fingerprint, raw hex, no colons + CHECK(std::strlen(buf) == 64); + + vc_disconnect(c); + vc_client_destroy(c); + std::printf("test_get_server_identity_display: ok\n"); +} + +int main() { + test_confirm_with_nothing_pending(); + + auto tmp = std::filesystem::temp_directory_path() / + ("vctest_tofu_main_" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(tmp); + + { + RunningServer server; + CHECK(server.start((tmp / "srv").string(), 0, "VoiceCat-TofuFlow")); + uint16_t port = server.port; + std::printf("test_tofu_flow: server ready on :%u\n", port); + + test_first_connect_blocks(port, (tmp / "pins_blocks.txt").string()); + test_reject_does_not_persist(port, (tmp / "pins_reject.txt").string()); + test_get_server_identity_display(port, (tmp / "pins_display.txt").string()); + + server.stop_and_join(); + } + + test_matched_and_mismatch((tmp / "pins_matched.txt").string()); + + std::filesystem::remove_all(tmp); + + if (g_failures == 0) { + std::printf("tofu_flow: all checks passed\n"); + return 0; + } + std::printf("tofu_flow: %d failure(s)\n", g_failures); + return 1; +} + +#else // !VOICECAT_HAS_NET + +int main() { + std::printf("tofu_flow: SKIP (VOICECAT_HAS_NET not defined)\n"); + return 0; +} + +#endif // VOICECAT_HAS_NET diff --git a/tests/test_vad_ptt_devices.cpp b/tests/test_vad_ptt_devices.cpp index 79a2fe7..2666386 100644 --- a/tests/test_vad_ptt_devices.cpp +++ b/tests/test_vad_ptt_devices.cpp @@ -62,12 +62,20 @@ struct EventStore { bool disconnected{false}; const char* label{nullptr}; + + // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the + // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test. + vc_client* client{nullptr}; }; static void on_event(void* user, const vc_event* ev) { auto* s = static_cast(user); std::lock_guard lk(s->mu); switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + // No human to ask in a headless test — trust on first connect unconditionally. + vc_confirm_server_identity(s->client, 1); + break; case VC_EVENT_AUTH_RESULT: s->auth_ok = (ev->result == VC_OK); s->self_user_id = ev->user_id; @@ -273,6 +281,7 @@ static void test_vad_and_ptt_gate() { vc_config cfgA{"test-A", "0.1", VC_LOG_OFF}; vc_client* clientA = vc_client_create(&cfgA, cbA); CHECK(clientA != nullptr); + evA.client = clientA; CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientA, "VP-A") == VC_OK); CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000)); @@ -284,6 +293,7 @@ static void test_vad_and_ptt_gate() { vc_config cfgB{"test-B", "0.1", VC_LOG_OFF}; vc_client* clientB = vc_client_create(&cfgB, cbB); CHECK(clientB != nullptr); + evB.client = clientB; CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientB, "VP-B") == VC_OK); CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000)); diff --git a/tests/test_voice_client_abi.cpp b/tests/test_voice_client_abi.cpp index 0ebfbe5..98c7e6e 100644 --- a/tests/test_voice_client_abi.cpp +++ b/tests/test_voice_client_abi.cpp @@ -43,12 +43,20 @@ struct EventStore { const char* label{nullptr}; bool disconnected{false}; + + // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the + // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below) for this headless test. + vc_client* client{nullptr}; }; static void on_event(void* user, const vc_event* ev) { auto* s = static_cast(user); std::lock_guard lk(s->mu); switch (ev->type) { + case VC_EVENT_SERVER_IDENTITY: + // No human to ask in a headless test — trust on first connect unconditionally. + vc_confirm_server_identity(s->client, 1); + break; case VC_EVENT_AUTH_RESULT: s->auth_ok = (ev->result == VC_OK); s->self_user_id = ev->user_id; @@ -144,6 +152,7 @@ int main() { vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF}; vc_client* clientA = vc_client_create(&cfgA, cbA); CHECK(clientA != nullptr); + evA.client = clientA; CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientA, "VoiceA") == VC_OK); @@ -157,6 +166,7 @@ int main() { vc_config cfgB{"test-clientB", "0.1", VC_LOG_OFF}; vc_client* clientB = vc_client_create(&cfgB, cbB); CHECK(clientB != nullptr); + evB.client = clientB; CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(clientB, "VoiceB") == VC_OK); diff --git a/tools/vccli/src/main.cpp b/tools/vccli/src/main.cpp index 5718ade..f2277d9 100644 --- a/tools/vccli/src/main.cpp +++ b/tools/vccli/src/main.cpp @@ -25,6 +25,10 @@ void on_sigint(int) { g_stop.store(true); } struct Stats { std::atomic auth_done{false}; std::atomic auth_ok{false}; + // Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the + // M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it + // trusts-on-first-connect unconditionally (prints the fingerprint for visibility). + vc_client* client{nullptr}; }; void on_event(void* user, const vc_event* ev) { @@ -33,6 +37,11 @@ void on_event(void* user, const vc_event* ev) { case VC_EVENT_CONNECTION_STATE: std::printf("[state] -> %d\n", static_cast(ev->connection_state)); break; + case VC_EVENT_SERVER_IDENTITY: + std::printf("[tofu] status=%u fingerprint=%s (auto-trusting — vccli has no " + "interactive prompt)\n", ev->u32a, ev->text ? ev->text : ""); + vc_confirm_server_identity(st->client, 1); + break; case VC_EVENT_AUTH_RESULT: st->auth_ok = (ev->result == VC_OK); st->auth_done = true; @@ -205,6 +214,7 @@ int main(int argc, char** argv) { std::fprintf(stderr, "failed to create client\n"); return 1; } + st.client = c; if (list_devices) { // Device enumeration works pre-connect (no server needed) — see docs/voice.md.