feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode
Core ABI extensions (voicecat.h): - vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads - VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password - VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_ until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519) - vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only - VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate - vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores it atomically so the audio RT path reads without a lock C++ implementation: - SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id, password_protected, and max_users (were permanently zeroed) - TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS - TofuStore split into peek (read-only) + pin (write) so first-connect only persists after user approval; tofu_store_path in vc_config for per-user pin file location - TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows) - windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests - New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green) Windows client (clients/windows/ — .NET 10 WinForms): - VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks, Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer - VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity- Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode, per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar) - PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation) - PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams - Accessibility: explicit AccessibleName/Description on every control, & mnemonics, Activity log ListBox as durable screen-reader record, AutomationNotification for curated live announcements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,13 +61,31 @@
|
|||||||
"VOICECAT_BUILD_TOOLS": "ON",
|
"VOICECAT_BUILD_TOOLS": "ON",
|
||||||
"VCPKG_TARGET_TRIPLET": "x64-mingw-static"
|
"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": [
|
"buildPresets": [
|
||||||
{ "name": "dev", "configurePreset": "dev" },
|
{ "name": "dev", "configurePreset": "dev" },
|
||||||
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
{ "name": "m1-dev", "configurePreset": "m1-dev" },
|
||||||
{ "name": "m2-dev", "configurePreset": "m2-dev" },
|
{ "name": "m2-dev", "configurePreset": "m2-dev" },
|
||||||
{ "name": "server-release", "configurePreset": "server-release" }
|
{ "name": "server-release", "configurePreset": "server-release" },
|
||||||
|
{ "name": "windows-client", "configurePreset": "windows-client" }
|
||||||
],
|
],
|
||||||
"testPresets": [
|
"testPresets": [
|
||||||
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
{ "name": "dev", "configurePreset": "dev", "output": { "outputOnFailure": true } },
|
||||||
|
|||||||
8
clients/windows/Directory.Build.props
Normal file
8
clients/windows/Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Where the CMake `windows-client` preset puts the built DLL (see ../../CMakePresets.json
|
||||||
|
and clients/windows/README.md). Override with an environment variable or a
|
||||||
|
Directory.Build.props further down the tree if your build/ lives elsewhere. -->
|
||||||
|
<VoiceCatNativeDir Condition="'$(VoiceCatNativeDir)' == ''">$(MSBuildThisFileDirectory)..\..\build\windows-client\bin</VoiceCatNativeDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
187
clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs
generated
Normal file
187
clients/windows/VoiceCat.App/Forms/AddServerDialog.Designer.cs
generated
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
80
clients/windows/VoiceCat.App/Forms/AddServerDialog.cs
Normal file
80
clients/windows/VoiceCat.App/Forms/AddServerDialog.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using VoiceCat.App.Models;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>Add or edit a saved server entry. DialogResult.OK -> read Result.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs
generated
Normal file
83
clients/windows/VoiceCat.App/Forms/ConnectDialog.Designer.cs
generated
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
279
clients/windows/VoiceCat.App/Forms/ConnectDialog.cs
Normal file
279
clients/windows/VoiceCat.App/Forms/ConnectDialog.cs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
using VoiceCat.App.Models;
|
||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public partial class ConnectDialog : Form
|
||||||
|
{
|
||||||
|
private readonly List<SavedServer> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
364
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
Normal file
364
clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs
generated
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
684
clients/windows/VoiceCat.App/Forms/MainForm.cs
Normal file
684
clients/windows/VoiceCat.App/Forms/MainForm.cs
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
|
||||||
|
/// Phase E: channel tree, user list, chat. Phase F: voice controls, device pickers, PTT,
|
||||||
|
/// per-user tuning.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MainForm : Form
|
||||||
|
{
|
||||||
|
private readonly VoiceCatClient _client;
|
||||||
|
private readonly uint _selfUserId;
|
||||||
|
private readonly string _nickname;
|
||||||
|
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
|
||||||
|
|
||||||
|
// Channel / user state
|
||||||
|
private uint _currentChannelId;
|
||||||
|
private List<ChannelInfo> _channels = [];
|
||||||
|
private readonly Dictionary<uint, UserInfo> _users = [];
|
||||||
|
private readonly HashSet<uint> _talkingUsers = [];
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs
generated
Normal file
61
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.Designer.cs
generated
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
15
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs
Normal file
15
clients/windows/VoiceCat.App/Forms/PasswordPromptDialog.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public partial class PasswordPromptDialog : Form
|
||||||
|
{
|
||||||
|
public string Password => txtPassword.Text;
|
||||||
|
|
||||||
|
public PasswordPromptDialog(string promptText)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
lblPrompt.Text = promptText;
|
||||||
|
txtPassword.AccessibleName = "Password";
|
||||||
|
}
|
||||||
|
}
|
||||||
125
clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs
Normal file
125
clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs
Normal file
51
clients/windows/VoiceCat.App/Forms/PttKeyCaptureDialog.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows "Press any key…" and captures the next KeyDown as the PTT key.
|
||||||
|
/// Press Escape to cancel without changing the key.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs
generated
Normal file
65
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.Designer.cs
generated
Normal file
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
66
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs
Normal file
66
clients/windows/VoiceCat.App/Forms/ServerIdentityDialog.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string>();
|
||||||
|
for (int i = 0; i < hex.Length; i += 4)
|
||||||
|
groups.Add(hex.Substring(i, Math.Min(4, hex.Length - i)));
|
||||||
|
return string.Join(' ', groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
clients/windows/VoiceCat.App/Models/PasswordProtector.cs
Normal file
29
clients/windows/VoiceCat.App/Models/PasswordProtector.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
clients/windows/VoiceCat.App/Models/SavedServer.cs
Normal file
26
clients/windows/VoiceCat.App/Models/SavedServer.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace VoiceCat.App.Models;
|
||||||
|
|
||||||
|
public enum AuthMode
|
||||||
|
{
|
||||||
|
Guest,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One entry in the saved-server list (%AppData%\VoiceCat\servers.json).</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public string? ProtectedPasswordBase64 { get; set; }
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
string.IsNullOrWhiteSpace(DisplayName) ? $"{Host}:{Port}" : $"{DisplayName} ({Host}:{Port})";
|
||||||
|
}
|
||||||
42
clients/windows/VoiceCat.App/Models/ServerListStore.cs
Normal file
42
clients/windows/VoiceCat.App/Models/ServerListStore.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VoiceCat.App.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <summary>Passed to VoiceCatClient's tofuStorePath — the C++ core owns the actual pin
|
||||||
|
/// file format/logic (TofuStore), this just picks where it lives.</summary>
|
||||||
|
public static string TofuStorePath => Path.Combine(AppDataDir, "tofu_pins.txt");
|
||||||
|
|
||||||
|
public static List<SavedServer> Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(ServersFilePath)) return new List<SavedServer>();
|
||||||
|
string json = File.ReadAllText(ServersFilePath);
|
||||||
|
return JsonSerializer.Deserialize<List<SavedServer>>(json) ?? new List<SavedServer>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<SavedServer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save(List<SavedServer> servers)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(AppDataDir);
|
||||||
|
string json = JsonSerializer.Serialize(servers, JsonOptions);
|
||||||
|
File.WriteAllText(ServersFilePath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
clients/windows/VoiceCat.App/Program.cs
Normal file
36
clients/windows/VoiceCat.App/Program.cs
Normal file
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
41
clients/windows/VoiceCat.App/VoiceCat.App.csproj
Normal file
41
clients/windows/VoiceCat.App/VoiceCat.App.csproj
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AssemblyName>VoiceCat.App</AssemblyName>
|
||||||
|
<RootNamespace>VoiceCat.App</RootNamespace>
|
||||||
|
<!-- Per-monitor v2 DPI awareness (correct scaling on multi-monitor/high-DPI — matters for
|
||||||
|
low-vision users as much as general UX). Set via this property, not app.manifest —
|
||||||
|
WinForms' own analyzer (WFO0003) flags manifest-based DPI settings as superseded by
|
||||||
|
this property in modern .NET. -->
|
||||||
|
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- System.Security.Cryptography.ProtectedData (PasswordProtector.cs's DPAPI wrapper) ships
|
||||||
|
as part of the Windows Desktop shared framework on net10.0-windows — no PackageReference
|
||||||
|
needed (one was tried and NuGet flagged it as redundant/unprunable, NU1510). -->
|
||||||
|
|
||||||
|
<!-- voicecat.dll must exist (build the `windows-client` CMake preset first — see
|
||||||
|
clients/windows/README.md). -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="$(VoiceCatNativeDir)\voicecat.dll" Condition="Exists('$(VoiceCatNativeDir)\voicecat.dll')">
|
||||||
|
<Link>voicecat.dll</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="VoiceCatCheckNativeDll" BeforeTargets="Build">
|
||||||
|
<Error Condition="!Exists('$(VoiceCatNativeDir)\voicecat.dll')"
|
||||||
|
Text="voicecat.dll not found at '$(VoiceCatNativeDir)'. Build it first: cmake --preset windows-client && cmake --build --preset windows-client (see clients/windows/README.md)." />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
25
clients/windows/VoiceCat.App/app.manifest
Normal file
25
clients/windows/VoiceCat.App/app.manifest
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="VoiceCat.App.app"/>
|
||||||
|
|
||||||
|
<!-- DPI awareness is set via the .csproj's ApplicationHighDpiMode property instead of here
|
||||||
|
— WinForms' WFO0003 analyzer flags manifest-based DPI settings as superseded by that
|
||||||
|
property in modern .NET. -->
|
||||||
|
|
||||||
|
<!-- Windows 10/11 common-controls v6 (visual styles) — also a prerequisite for some UIA
|
||||||
|
features/visuals screen readers rely on. -->
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
|
||||||
|
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 8.1 / 10 / 11 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\VoiceCat.Interop\VoiceCat.Interop.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- voicecat.dll must exist (build the `windows-client` CMake preset first — see
|
||||||
|
clients/windows/README.md) before these tests can run; the P/Invoke smoke tests need
|
||||||
|
a real native library to call into. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="$(VoiceCatNativeDir)\voicecat.dll" Condition="Exists('$(VoiceCatNativeDir)\voicecat.dll')">
|
||||||
|
<Link>voicecat.dll</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="VoiceCatCheckNativeDll" BeforeTargets="Build">
|
||||||
|
<Error Condition="!Exists('$(VoiceCatNativeDir)\voicecat.dll')"
|
||||||
|
Text="voicecat.dll not found at '$(VoiceCatNativeDir)'. Build it first: cmake --preset windows-client && cmake --build --preset windows-client (see clients/windows/README.md)." />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using VoiceCat.Interop;
|
||||||
|
|
||||||
|
namespace VoiceCat.Interop.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow
|
||||||
|
/// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real
|
||||||
|
/// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow
|
||||||
|
/// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through
|
||||||
|
/// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention
|
||||||
|
/// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoiceCatClientSmokeTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempDir;
|
||||||
|
private readonly Process _server;
|
||||||
|
private readonly ushort _port;
|
||||||
|
|
||||||
|
public VoiceCatClientSmokeTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
|
||||||
|
string serverExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-server.exe");
|
||||||
|
Assert.True(File.Exists(serverExe),
|
||||||
|
$"voicecat-server.exe not found at '{serverExe}' — build the m1-dev preset first " +
|
||||||
|
"(cmake --preset m1-dev && cmake --build --preset m1-dev).");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo(serverExe)
|
||||||
|
{
|
||||||
|
Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
_server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe");
|
||||||
|
|
||||||
|
// "[voicecat-server] <name> — TCP :<port> UDP :<port>" — see server/src/server.cpp.
|
||||||
|
// ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a
|
||||||
|
// server that never prints anything (crash, hang) can't hang this constructor forever
|
||||||
|
// — the deadline check must apply to the read itself, not just the loop around it.
|
||||||
|
ushort? port = null;
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
while (port is null && DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
var readTask = _server.StandardOutput.ReadLineAsync();
|
||||||
|
var remaining = deadline - DateTime.UtcNow;
|
||||||
|
if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break;
|
||||||
|
string? line = readTask.Result;
|
||||||
|
if (line is null) break;
|
||||||
|
var m = Regex.Match(line, @"TCP :(\d+)");
|
||||||
|
if (m.Success) port = ushort.Parse(m.Groups[1].Value);
|
||||||
|
}
|
||||||
|
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
||||||
|
_port = port!.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ }
|
||||||
|
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepoRoot()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool PumpUntil(VoiceCatClient client, Func<bool> predicate, int timeoutMs)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
client.PumpEvents();
|
||||||
|
if (predicate()) return true;
|
||||||
|
Thread.Sleep(20);
|
||||||
|
}
|
||||||
|
client.PumpEvents();
|
||||||
|
return predicate();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VersionString_IsNonEmpty()
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Connect_Tofu_Auth_ListChannels_RoundTrips()
|
||||||
|
{
|
||||||
|
var events = new List<VoiceCatEvent>();
|
||||||
|
using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off,
|
||||||
|
tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt"));
|
||||||
|
client.EventReceived += events.Add;
|
||||||
|
|
||||||
|
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
|
||||||
|
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke"));
|
||||||
|
|
||||||
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000),
|
||||||
|
"did not receive VC_EVENT_SERVER_IDENTITY");
|
||||||
|
var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity);
|
||||||
|
Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a);
|
||||||
|
Assert.NotNull(identityEvent.Text);
|
||||||
|
Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators
|
||||||
|
|
||||||
|
// Auth must NOT complete before the identity is confirmed.
|
||||||
|
Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800));
|
||||||
|
|
||||||
|
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
|
||||||
|
|
||||||
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000),
|
||||||
|
"did not receive VC_EVENT_AUTH_RESULT after confirming identity");
|
||||||
|
var authEvent = events.First(e => e.Type == VcEventType.AuthResult);
|
||||||
|
Assert.Equal(VcResult.Ok, authEvent.Result);
|
||||||
|
|
||||||
|
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000),
|
||||||
|
"did not receive VC_EVENT_CHANNEL_LIST");
|
||||||
|
|
||||||
|
var channels = client.ListChannels();
|
||||||
|
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
||||||
|
|
||||||
|
client.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
104
clients/windows/VoiceCat.Interop/Enums.cs
Normal file
104
clients/windows/VoiceCat.Interop/Enums.cs
Normal file
@@ -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,
|
||||||
|
/// <summary>M4: handshake succeeded, waiting on vc_confirm_server_identity().</summary>
|
||||||
|
VerifyingIdentity = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VcTextScope
|
||||||
|
{
|
||||||
|
Channel = 0,
|
||||||
|
Private = 1,
|
||||||
|
Server = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VcDeviceKind
|
||||||
|
{
|
||||||
|
Input = 0,
|
||||||
|
Output = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VcStreamKind
|
||||||
|
{
|
||||||
|
Mic = 0,
|
||||||
|
/// <summary>System/desktop audio (WASAPI loopback on Windows).</summary>
|
||||||
|
ScreenAudio = 1,
|
||||||
|
AuxDevice = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Send-side input gate (docs/voice.md §11).</summary>
|
||||||
|
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,
|
||||||
|
/// <summary>M4: reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
|
||||||
|
JoinResult = 12,
|
||||||
|
/// <summary>M4: the TOFU server-identity gate — see VcTofuStatus.</summary>
|
||||||
|
ServerIdentity = 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public enum VcTofuStatus
|
||||||
|
{
|
||||||
|
FirstConnect = 0,
|
||||||
|
Matched = 1,
|
||||||
|
Mismatch = 2,
|
||||||
|
}
|
||||||
90
clients/windows/VoiceCat.Interop/Marshaling.cs
Normal file
90
clients/windows/VoiceCat.Interop/Marshaling.cs
Normal file
@@ -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<DeviceInfo> ToManaged(ref VcDeviceListNative native)
|
||||||
|
{
|
||||||
|
var result = new List<DeviceInfo>((int)native.Count);
|
||||||
|
int size = Marshal.SizeOf<VcDeviceNative>();
|
||||||
|
for (nuint i = 0; i < native.Count; i++)
|
||||||
|
{
|
||||||
|
var raw = Marshal.PtrToStructure<VcDeviceNative>(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<ChannelInfo> ToManaged(ref VcChannelListNative native)
|
||||||
|
{
|
||||||
|
var result = new List<ChannelInfo>((int)native.Count);
|
||||||
|
int size = Marshal.SizeOf<VcChannelNative>();
|
||||||
|
for (nuint i = 0; i < native.Count; i++)
|
||||||
|
{
|
||||||
|
var raw = Marshal.PtrToStructure<VcChannelNative>(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<UserInfo> ToManaged(ref VcUserListNative native)
|
||||||
|
{
|
||||||
|
var result = new List<UserInfo>((int)native.Count);
|
||||||
|
int size = Marshal.SizeOf<VcUserNative>();
|
||||||
|
for (nuint i = 0; i < native.Count; i++)
|
||||||
|
{
|
||||||
|
var raw = Marshal.PtrToStructure<VcUserNative>(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<StreamSummary> ToManaged(ref VcStreamSummaryListNative native)
|
||||||
|
{
|
||||||
|
var result = new List<StreamSummary>((int)native.Count);
|
||||||
|
int size = Marshal.SizeOf<VcStreamSummaryNative>();
|
||||||
|
for (nuint i = 0; i < native.Count; i++)
|
||||||
|
{
|
||||||
|
var raw = Marshal.PtrToStructure<VcStreamSummaryNative>(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);
|
||||||
|
}
|
||||||
39
clients/windows/VoiceCat.Interop/Models.cs
Normal file
39
clients/windows/VoiceCat.Interop/Models.cs
Normal file
@@ -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);
|
||||||
33
clients/windows/VoiceCat.Interop/NativeCallbacks.cs
Normal file
33
clients/windows/VoiceCat.Interop/NativeCallbacks.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
clients/windows/VoiceCat.Interop/NativeMethods.cs
Normal file
134
clients/windows/VoiceCat.Interop/NativeMethods.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
127
clients/windows/VoiceCat.Interop/Structs.cs
Normal file
127
clients/windows/VoiceCat.Interop/Structs.cs
Normal file
@@ -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<IntPtr, VcEventNative*, void>
|
||||||
|
public IntPtr OnLevel; // delegate* unmanaged<IntPtr, uint, float, void>
|
||||||
|
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;
|
||||||
|
}
|
||||||
13
clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj
Normal file
13
clients/windows/VoiceCat.Interop/VoiceCat.Interop.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- Native function pointers (NativeCallbacks.cs) and struct-array pointer walking
|
||||||
|
(Marshaling.cs) need unsafe blocks. -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<RootNamespace>VoiceCat.Interop</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
253
clients/windows/VoiceCat.Interop/VoiceCatClient.cs
Normal file
253
clients/windows/VoiceCat.Interop/VoiceCatClient.cs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
// The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around
|
||||||
|
// NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates").
|
||||||
|
namespace VoiceCat.Interop;
|
||||||
|
|
||||||
|
public sealed class VoiceCatClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly VoiceCatClientHandle _handle = new();
|
||||||
|
private readonly GCHandle _selfHandle;
|
||||||
|
|
||||||
|
// Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not
|
||||||
|
// just vc_client_create(): client_name/client_version are read later, whenever connect()
|
||||||
|
// actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by
|
||||||
|
// value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy
|
||||||
|
// has returned (which synchronously joins every internal thread, so nothing can still be
|
||||||
|
// reading these pointers by then).
|
||||||
|
private nint _clientNamePtr;
|
||||||
|
private nint _clientVersionPtr;
|
||||||
|
private nint _tofuStorePathPtr;
|
||||||
|
|
||||||
|
private readonly Channel<VoiceCatEvent> _events =
|
||||||
|
Channel.CreateUnbounded<VoiceCatEvent>(new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// on_level fires far more often than on_event and intermediate values are visually
|
||||||
|
// irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one.
|
||||||
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, float> _latestLevels = new();
|
||||||
|
|
||||||
|
/// <summary>Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's
|
||||||
|
/// doc comment) for every event, in order, never coalesced.</summary>
|
||||||
|
public event Action<VoiceCatEvent>? EventReceived;
|
||||||
|
|
||||||
|
/// <summary>Raised from PumpEvents() with the latest RMS level per stream_id since the
|
||||||
|
/// last pump.</summary>
|
||||||
|
public event Action<uint, float>? LevelChanged;
|
||||||
|
|
||||||
|
public unsafe VoiceCatClient(string clientName, string clientVersion,
|
||||||
|
VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null)
|
||||||
|
{
|
||||||
|
_selfHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||||
|
|
||||||
|
_clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName);
|
||||||
|
_clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion);
|
||||||
|
_tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath);
|
||||||
|
|
||||||
|
var cfg = new VcConfigNative
|
||||||
|
{
|
||||||
|
ClientName = _clientNamePtr,
|
||||||
|
ClientVersion = _clientVersionPtr,
|
||||||
|
LogLevel = logLevel,
|
||||||
|
TofuStorePath = _tofuStorePathPtr,
|
||||||
|
};
|
||||||
|
|
||||||
|
var cb = new VcCallbacksNative
|
||||||
|
{
|
||||||
|
OnEvent = (nint)(delegate* unmanaged<nint, VcEventNative*, void>)&NativeCallbacks.OnEvent,
|
||||||
|
OnLevel = (nint)(delegate* unmanaged<nint, uint, float, void>)&NativeCallbacks.OnLevel,
|
||||||
|
User = GCHandle.ToIntPtr(_selfHandle),
|
||||||
|
};
|
||||||
|
|
||||||
|
nint native = NativeMethods.vc_client_create(in cfg, cb);
|
||||||
|
_handle.SetHandle(native);
|
||||||
|
if (_handle.IsInvalid)
|
||||||
|
{
|
||||||
|
FreeConfigStrings();
|
||||||
|
_selfHandle.Free();
|
||||||
|
throw new InvalidOperationException("vc_client_create failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains every event/level sample queued since the last call. Call this from a
|
||||||
|
/// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary
|
||||||
|
/// where the core's own event-delivery thread hands off to the UI thread; see
|
||||||
|
/// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen
|
||||||
|
/// over a message-only window + PostMessage.
|
||||||
|
/// </summary>
|
||||||
|
public void PumpEvents()
|
||||||
|
{
|
||||||
|
while (_events.Reader.TryRead(out var ev))
|
||||||
|
EventReceived?.Invoke(ev);
|
||||||
|
|
||||||
|
if (!_latestLevels.IsEmpty)
|
||||||
|
{
|
||||||
|
foreach (var (streamId, rms) in _latestLevels)
|
||||||
|
LevelChanged?.Invoke(streamId, rms);
|
||||||
|
_latestLevels.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void EnqueueEvent(VoiceCatEvent ev)
|
||||||
|
{
|
||||||
|
// Temporary diagnostic (manual debugging session) — confirms the native callback
|
||||||
|
// chain (UnmanagedCallersOnly -> GCHandle resolve -> here) actually fires, independent
|
||||||
|
// of whether the UI-thread drain (PumpEvents) ever sees it.
|
||||||
|
Console.WriteLine($"[VoiceCatClient] EnqueueEvent (native thread): {ev}");
|
||||||
|
_events.Writer.TryWrite(ev);
|
||||||
|
}
|
||||||
|
internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms;
|
||||||
|
|
||||||
|
// ── Connection & auth ────────────────────────────────────────────────────────────────
|
||||||
|
public VcResult Connect(string host, ushort port) =>
|
||||||
|
NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port);
|
||||||
|
|
||||||
|
public VcResult Disconnect() =>
|
||||||
|
NativeMethods.vc_disconnect(_handle.DangerousGetHandle());
|
||||||
|
|
||||||
|
public VcResult AuthenticateGuest(string nickname) =>
|
||||||
|
NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname);
|
||||||
|
|
||||||
|
public VcResult AuthenticateUser(string username, string password) =>
|
||||||
|
NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password);
|
||||||
|
|
||||||
|
// ── TOFU server-identity gate (M4) ──────────────────────────────────────────────────────
|
||||||
|
public VcResult ConfirmServerIdentity(bool accept) =>
|
||||||
|
NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0);
|
||||||
|
|
||||||
|
/// <summary>The Ed25519 identity fingerprint from ServerHello, hex-formatted — display
|
||||||
|
/// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty
|
||||||
|
/// string if not yet available.</summary>
|
||||||
|
public string GetServerIdentityDisplay()
|
||||||
|
{
|
||||||
|
nint c = _handle.DangerousGetHandle();
|
||||||
|
NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len);
|
||||||
|
if (len == 0) return string.Empty;
|
||||||
|
|
||||||
|
nint buf = Marshal.AllocHGlobal((int)len + 1);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _);
|
||||||
|
return Marshal.PtrToStringUTF8(buf) ?? string.Empty;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
||||||
|
/// <summary>Result arrives as a VcEventType.JoinResult event, not via this return value
|
||||||
|
/// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment).
|
||||||
|
/// NOTE: no in-tree channel has a server-side password to check yet (M5+ feature) — this
|
||||||
|
/// path is wired but not yet exercisable end-to-end.</summary>
|
||||||
|
public VcResult JoinChannel(uint channelId, string? password = null) =>
|
||||||
|
NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password);
|
||||||
|
|
||||||
|
public VcResult LeaveChannel() =>
|
||||||
|
NativeMethods.vc_leave_channel(_handle.DangerousGetHandle());
|
||||||
|
|
||||||
|
public List<ChannelInfo> ListChannels()
|
||||||
|
{
|
||||||
|
NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native);
|
||||||
|
return Marshaling.ToManaged(ref native);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UserInfo> ListUsers()
|
||||||
|
{
|
||||||
|
NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native);
|
||||||
|
return Marshaling.ToManaged(ref native);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<StreamSummary> ListUserStreams(uint userId)
|
||||||
|
{
|
||||||
|
var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native);
|
||||||
|
return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List<StreamSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
||||||
|
public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label)
|
||||||
|
{
|
||||||
|
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr };
|
||||||
|
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
|
||||||
|
return (r, streamId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeCoTaskMem(labelPtr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public VcResult StopStream(uint streamId) =>
|
||||||
|
NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId);
|
||||||
|
|
||||||
|
public VcResult SetInputDevice(uint streamId, string? deviceId) =>
|
||||||
|
NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId);
|
||||||
|
|
||||||
|
public VcResult SetInputMode(VcInputMode mode) =>
|
||||||
|
NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode);
|
||||||
|
|
||||||
|
public VcResult SetVadThreshold(float threshold) =>
|
||||||
|
NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold);
|
||||||
|
|
||||||
|
public VcResult SetPushToTalk(bool active) =>
|
||||||
|
NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0);
|
||||||
|
|
||||||
|
public VcResult SetSelfMute(bool micMuted, bool deafened) =>
|
||||||
|
NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0);
|
||||||
|
|
||||||
|
public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
||||||
|
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
||||||
|
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
||||||
|
|
||||||
|
public (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
|
||||||
|
{
|
||||||
|
var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId,
|
||||||
|
streamId, out var native);
|
||||||
|
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
public VcResult SendText(VcTextScope scope, uint targetId, string utf8) =>
|
||||||
|
NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8);
|
||||||
|
|
||||||
|
// ── Device enumeration (works pre-connect) ──────────────────────────────────────────────
|
||||||
|
public List<DeviceInfo> ListDevices(VcDeviceKind kind)
|
||||||
|
{
|
||||||
|
NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native);
|
||||||
|
return Marshaling.ToManaged(ref native);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
|
||||||
|
// NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never-
|
||||||
|
// freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport
|
||||||
|
// auto-marshal these as `string` (it would try to free a static literal and corrupt the
|
||||||
|
// heap). Marshal.PtrToStringUTF8 just reads; it never frees.
|
||||||
|
public static string VersionString =>
|
||||||
|
Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty;
|
||||||
|
|
||||||
|
public static string ResultString(VcResult code) =>
|
||||||
|
Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously
|
||||||
|
FreeConfigStrings();
|
||||||
|
if (_selfHandle.IsAllocated) _selfHandle.Free();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FreeConfigStrings()
|
||||||
|
{
|
||||||
|
if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; }
|
||||||
|
if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; }
|
||||||
|
if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs
Normal file
18
clients/windows/VoiceCat.Interop/VoiceCatClientHandle.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
clients/windows/VoiceCat.Interop/VoiceCatEvent.cs
Normal file
32
clients/windows/VoiceCat.Interop/VoiceCatEvent.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace VoiceCat.Interop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
5
clients/windows/VoiceCat.slnx
Normal file
5
clients/windows/VoiceCat.slnx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="VoiceCat.App/VoiceCat.App.csproj" />
|
||||||
|
<Project Path="VoiceCat.Interop.Tests/VoiceCat.Interop.Tests.csproj" />
|
||||||
|
<Project Path="VoiceCat.Interop/VoiceCat.Interop.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -5,6 +5,24 @@ file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS
|
|||||||
|
|
||||||
if(VOICECAT_BUILD_SHARED)
|
if(VOICECAT_BUILD_SHARED)
|
||||||
add_library(voicecat SHARED ${VOICECAT_SOURCES})
|
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()
|
else()
|
||||||
add_library(voicecat STATIC ${VOICECAT_SOURCES})
|
add_library(voicecat STATIC ${VOICECAT_SOURCES})
|
||||||
# Static consumers must see VC_API as empty (no dllimport).
|
# Static consumers must see VC_API as empty (no dllimport).
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ typedef enum vc_connection_state {
|
|||||||
VC_STATE_TLS_HANDSHAKE = 2,
|
VC_STATE_TLS_HANDSHAKE = 2,
|
||||||
VC_STATE_AUTHENTICATING = 3,
|
VC_STATE_AUTHENTICATING = 3,
|
||||||
VC_STATE_CONNECTED = 4,
|
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;
|
} vc_connection_state;
|
||||||
|
|
||||||
typedef enum vc_text_scope {
|
typedef enum vc_text_scope {
|
||||||
@@ -106,6 +110,8 @@ typedef enum vc_stream_kind {
|
|||||||
typedef enum vc_input_mode {
|
typedef enum vc_input_mode {
|
||||||
VC_INPUT_VOICE_ACTIVATION = 0,
|
VC_INPUT_VOICE_ACTIVATION = 0,
|
||||||
VC_INPUT_PUSH_TO_TALK = 1,
|
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;
|
} vc_input_mode;
|
||||||
|
|
||||||
typedef enum vc_event_type {
|
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_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
|
||||||
VC_EVENT_ERROR = 10, /* result, text */
|
VC_EVENT_ERROR = 10, /* result, text */
|
||||||
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
|
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;
|
} 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 ──────────────────────────────────────────────────────────────── */
|
/* ── Structs ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -155,6 +185,13 @@ typedef struct vc_config {
|
|||||||
const char* client_name; /* e.g. "VoiceCat-macOS" */
|
const char* client_name; /* e.g. "VoiceCat-macOS" */
|
||||||
const char* client_version; /* e.g. "0.0.1" */
|
const char* client_version; /* e.g. "0.0.1" */
|
||||||
vc_log_level log_level;
|
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;
|
} vc_config;
|
||||||
|
|
||||||
typedef struct vc_stream_desc {
|
typedef struct vc_stream_desc {
|
||||||
@@ -190,6 +227,49 @@ typedef struct vc_device_list {
|
|||||||
size_t count;
|
size_t count;
|
||||||
} vc_device_list;
|
} 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. */
|
/* Opaque client handle. */
|
||||||
typedef struct vc_client vc_client;
|
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);
|
const char* password);
|
||||||
|
|
||||||
/* ── Channels ─────────────────────────────────────────────────────────────── */
|
/* ── 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,
|
VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id,
|
||||||
const char* password /* nullable */);
|
const char* password /* nullable */);
|
||||||
VC_API vc_result vc_leave_channel(vc_client* c);
|
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. */
|
/* 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);
|
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_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);
|
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 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);
|
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 <hex>"). 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)
|
#if defined(__cplusplus)
|
||||||
} /* extern "C" */
|
} /* extern "C" */
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "audio/apm_processor.h"
|
#include "audio/apm_processor.h"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
@@ -41,14 +42,18 @@ class EnergyVadProcessor final : public ApmProcessor {
|
|||||||
sum_sq += s * s;
|
sum_sq += s * s;
|
||||||
}
|
}
|
||||||
double rms = std::sqrt(sum_sq / samples);
|
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_;
|
return (steady_now_ms() - last_voice_ms_) < hang_time_ms_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void set_threshold(float t) override {
|
||||||
|
threshold_.store(t, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
float threshold_;
|
std::atomic<float> threshold_;
|
||||||
int64_t hang_time_ms_;
|
int64_t hang_time_ms_;
|
||||||
int64_t last_voice_ms_ = 0; // epoch start -> gate begins closed until first loud frame
|
int64_t last_voice_ms_ = 0; // epoch start -> gate begins closed until first loud frame
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class ApmProcessor {
|
|||||||
// Returns false → caller should skip encode/send (silence gate).
|
// Returns false → caller should skip encode/send (silence gate).
|
||||||
virtual bool process_capture(int16_t* pcm, int samples, int sample_rate) = 0;
|
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
|
// 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
|
// 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
|
// this stays a passthrough until a real APM/NS backend exists (still inert; see
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
#include "protocol/protocol.h"
|
#include "protocol/protocol.h"
|
||||||
|
|
||||||
@@ -41,7 +42,14 @@ std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
|
|||||||
|
|
||||||
// ── vc_client M1 implementation ───────────────────────────────────────────────
|
// ── 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<voicecat::crypto::TofuStore>(std::move(tofu_path));
|
||||||
|
}
|
||||||
|
|
||||||
vc_client::~vc_client() { disconnect(); }
|
vc_client::~vc_client() { disconnect(); }
|
||||||
|
|
||||||
@@ -96,6 +104,16 @@ vc_result vc_client::disconnect() {
|
|||||||
|
|
||||||
io_stop_.store(true, std::memory_order_release);
|
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.
|
// Close the socket to unblock blocking TLS reads/writes.
|
||||||
int fd = io_fd_.load(std::memory_order_acquire);
|
int fd = io_fd_.load(std::memory_order_acquire);
|
||||||
if (fd != -1) {
|
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<uint8_t, 32> 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<std::mutex> set_lk(tofu_mu_);
|
||||||
|
tofu_decision_pending_ = true;
|
||||||
|
tofu_accept_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
vc_event ev{};
|
||||||
|
ev.type = VC_EVENT_SERVER_IDENTITY;
|
||||||
|
ev.u32a = static_cast<uint32_t>(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.
|
// 50 ms timeout so we can drain sends between reads.
|
||||||
tls_->set_read_timeout(50);
|
tls_->set_read_timeout(50);
|
||||||
|
|
||||||
@@ -317,6 +395,12 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
|
|||||||
case voicecat::v1::Envelope::kUserEvent:
|
case voicecat::v1::Envelope::kUserEvent:
|
||||||
handle_user_event(env.user_event());
|
handle_user_event(env.user_event());
|
||||||
break;
|
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:
|
case voicecat::v1::Envelope::kTextMessage:
|
||||||
handle_text_message(env.text_message());
|
handle_text_message(env.text_message());
|
||||||
break;
|
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*/) {
|
void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
|
||||||
server_udp_port_ = static_cast<uint16_t>(msg.udp_port());
|
server_udp_port_ = static_cast<uint16_t>(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<std::mutex> lk(tofu_mu_);
|
||||||
|
pending_identity_fp_hex_ = std::move(fp_hex);
|
||||||
|
}
|
||||||
|
|
||||||
// Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
|
// Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
|
||||||
std::optional<PendingAuth> auth;
|
std::optional<PendingAuth> 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) {
|
void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) {
|
||||||
session_model_.apply_snapshot(snap);
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||||
|
session_model_.apply_snapshot(snap);
|
||||||
|
}
|
||||||
for (const auto& u : snap.users()) sync_remote_streams(u);
|
for (const auto& u : snap.users()) sync_remote_streams(u);
|
||||||
vc_event ev{};
|
vc_event ev{};
|
||||||
ev.type = VC_EVENT_CHANNEL_LIST;
|
ev.type = VC_EVENT_CHANNEL_LIST;
|
||||||
emit(ev);
|
emit(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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) {
|
void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
|
||||||
session_model_.apply_user_event(ue);
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||||
|
session_model_.apply_user_event(ue);
|
||||||
|
}
|
||||||
|
|
||||||
vc_event ev{};
|
vc_event ev{};
|
||||||
const auto& user = ue.user();
|
const auto& user = ue.user();
|
||||||
@@ -492,11 +616,16 @@ vc_result vc_client::authenticate_user(const char* username, const char* passwor
|
|||||||
return VC_OK;
|
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;
|
if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
voicecat::v1::Envelope req;
|
voicecat::v1::Envelope req;
|
||||||
req.set_request_id(next_req_id_++);
|
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);
|
queue_envelope(req);
|
||||||
return VC_OK;
|
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
|
// 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.
|
// shared music/video audio whenever the user isn't talking, which defeats the feature.
|
||||||
if (kind == static_cast<int>(VC_STREAM_MIC)) {
|
if (kind == static_cast<int>(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
|
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
|
// EnergyVadProcessor never writes through the pointer (see apm_processor.cpp); the
|
||||||
// const_cast is safe and avoids splitting ApmProcessor's interface just for this.
|
// const_cast is safe and avoids splitting ApmProcessor's interface just for this.
|
||||||
if (!mic_vad_->process_capture(const_cast<int16_t*>(pcm), samples, 48000)) return;
|
if (!mic_vad_->process_capture(const_cast<int16_t*>(pcm), samples, 48000)) return;
|
||||||
}
|
}
|
||||||
|
// VC_INPUT_ALWAYS_ON: no gate — fall through and always send.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!media_send_crypto_) return;
|
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
|
// Construct the MIC VAD once, here on io_thread_ (not the RT capture callback) — see
|
||||||
// client.h's comment on mic_vad_.
|
// client.h's comment on mic_vad_.
|
||||||
if (kind == static_cast<int>(VC_STREAM_MIC) && !mic_vad_) {
|
if (kind == static_cast<int>(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;
|
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) {
|
vc_result vc_client::set_push_to_talk(bool active) {
|
||||||
ptt_active_.store(active, std::memory_order_release);
|
ptt_active_.store(active, std::memory_order_release);
|
||||||
return VC_OK;
|
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,
|
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
|
||||||
bool muted, bool noise_reduction) {
|
bool muted, bool noise_reduction) {
|
||||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
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);
|
uint32_t ssrc = 0;
|
||||||
if (!user) return VC_ERR_INVALID_ARG;
|
bool found = false;
|
||||||
for (const auto& s : user->streams) {
|
{
|
||||||
if (s.stream_id == stream_id) {
|
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||||
audio_engine_.set_stream_gain(s.ssrc, gain);
|
const auto* user = session_model_.find_user(user_id);
|
||||||
audio_engine_.set_stream_mute(s.ssrc, muted);
|
if (user) {
|
||||||
audio_engine_.set_stream_noise_reduction(s.ssrc, noise_reduction);
|
for (const auto& s : user->streams) {
|
||||||
return VC_OK;
|
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,
|
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;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||||
const auto* user = session_model_.find_user(user_id);
|
const auto* user = session_model_.find_user(user_id);
|
||||||
if (!user) return VC_ERR_INVALID_ARG;
|
if (!user) return VC_ERR_INVALID_ARG;
|
||||||
for (const auto& s : user->streams) {
|
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
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── M4: channel/user/stream snapshot getters ─────────────────────────────────
|
||||||
|
|
||||||
|
vc_result vc_client::list_channels(vc_channel_list* out) {
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<vc_stream_kind>(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<std::mutex> 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<std::mutex> 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() {
|
void vc_client::run_talk_timer() {
|
||||||
while (!talk_timer_stop_.load(std::memory_order_acquire)) {
|
while (!talk_timer_stop_.load(std::memory_order_acquire)) {
|
||||||
// Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user,
|
// 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::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_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_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_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_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
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) {
|
vc_result vc_client::test_inject_capture(uint32_t, const int16_t*, size_t) {
|
||||||
return VC_ERR_NOT_IMPLEMENTED;
|
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
|
#endif // VOICECAT_HAS_NET
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "audio/audio_engine.h"
|
#include "audio/audio_engine.h"
|
||||||
#include "codec/opus_codec.h"
|
#include "codec/opus_codec.h"
|
||||||
#include "crypto/crypto.h"
|
#include "crypto/crypto.h"
|
||||||
|
#include "crypto/tofu_store.h"
|
||||||
#include "net/voice_frame.h"
|
#include "net/voice_frame.h"
|
||||||
#include "protocol/envelope.h"
|
#include "protocol/envelope.h"
|
||||||
#include "protocol/protocol.h"
|
#include "protocol/protocol.h"
|
||||||
@@ -49,6 +50,7 @@ struct vc_client {
|
|||||||
vc_result stream_stop(uint32_t stream_id);
|
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_device(uint32_t stream_id, const char* device_id);
|
||||||
vc_result set_input_mode(vc_input_mode mode);
|
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_push_to_talk(bool active);
|
||||||
vc_result set_self_mute(bool mic_muted, bool deafened);
|
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,
|
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);
|
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
|
// M3: effective Opus config for a (user_id, stream_id) — our own pending/active local
|
||||||
// streams, or any peer's broadcast StreamInfo.audio.
|
// streams, or any peer's broadcast StreamInfo.audio.
|
||||||
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
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};
|
uint64_t server_session_id_{0};
|
||||||
std::atomic<uint64_t> next_req_id_{1};
|
std::atomic<uint64_t> 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_;
|
voicecat::session::SessionModel session_model_;
|
||||||
|
mutable std::mutex session_model_mu_;
|
||||||
|
|
||||||
|
// ── M4: TOFU server-identity gate ───────────────────────────────────────────
|
||||||
|
std::unique_ptr<voicecat::crypto::TofuStore> 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 ────────────────────────────────────────────────────
|
// ── M2: UDP / media plane ────────────────────────────────────────────────────
|
||||||
std::array<uint8_t, 16> udp_token_{};
|
std::array<uint8_t, 16> udp_token_{};
|
||||||
@@ -187,6 +209,7 @@ struct vc_client {
|
|||||||
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
|
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
|
||||||
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
|
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
|
||||||
std::atomic<bool> ptt_active_{false};
|
std::atomic<bool> ptt_active_{false};
|
||||||
|
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
|
||||||
std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
|
std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
|
||||||
|
|
||||||
// teardown_voice() is called both from run_io()'s own cleanup (on the io_thread_, when
|
// 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_auth_result(const voicecat::v1::AuthResult& msg);
|
||||||
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
|
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
|
||||||
void handle_user_event(const voicecat::v1::UserEvent& ue);
|
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_text_message(const voicecat::v1::TextMessage& msg);
|
||||||
void handle_disconnect(const voicecat::v1::Disconnect& msg);
|
void handle_disconnect(const voicecat::v1::Disconnect& msg);
|
||||||
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);
|
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);
|
||||||
|
|||||||
@@ -283,6 +283,14 @@ bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, s
|
|||||||
ctx, ctx_len, ctx != nullptr) == 0;
|
ctx, ctx_len, ctx != nullptr) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool TlsContext::peer_cert_fingerprint(std::array<uint8_t, 32>& 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::SodiumMediaCrypto(
|
SodiumMediaCrypto::SodiumMediaCrypto(
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ class TlsContext {
|
|||||||
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
||||||
uint8_t* out, size_t out_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<uint8_t, 32>& out) const;
|
||||||
|
|
||||||
// Whether the handshake completed.
|
// Whether the handshake completed.
|
||||||
bool ready() const { return ready_; }
|
bool ready() const { return ready_; }
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port,
|
|||||||
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
|
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TofuResult TofuStore::peek(const std::string& host, uint16_t port,
|
||||||
|
const std::array<uint8_t, 32>& fingerprint) const {
|
||||||
|
std::lock_guard<std::mutex> 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<uint8_t, 32>& fingerprint) {
|
||||||
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
|
pins_[make_key(host, port)] = fingerprint;
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
void TofuStore::remove(const std::string& host, uint16_t port) {
|
void TofuStore::remove(const std::string& host, uint16_t port) {
|
||||||
std::lock_guard<std::mutex> lk(mu_);
|
std::lock_guard<std::mutex> lk(mu_);
|
||||||
pins_.erase(make_key(host, port));
|
pins_.erase(make_key(host, port));
|
||||||
|
|||||||
@@ -29,9 +29,21 @@ class TofuStore {
|
|||||||
|
|
||||||
// Check the fingerprint for host:port. Stores on first connect.
|
// Check the fingerprint for host:port. Stores on first connect.
|
||||||
// Thread-safe (single-writer lock).
|
// 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,
|
TofuResult check_and_pin(const std::string& host, uint16_t port,
|
||||||
const std::array<uint8_t, 32>& fingerprint);
|
const std::array<uint8_t, 32>& 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<uint8_t, 32>& 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<uint8_t, 32>& fingerprint);
|
||||||
|
|
||||||
// Remove the pin for host:port (e.g. after user explicitly acknowledges a key change).
|
// Remove the pin for host:port (e.g. after user explicitly acknowledges a key change).
|
||||||
void remove(const std::string& host, uint16_t port);
|
void remove(const std::string& host, uint16_t port);
|
||||||
|
|
||||||
@@ -44,7 +56,7 @@ class TofuStore {
|
|||||||
void save() const;
|
void save() const;
|
||||||
|
|
||||||
std::filesystem::path path_;
|
std::filesystem::path path_;
|
||||||
std::mutex mu_;
|
mutable std::mutex mu_;
|
||||||
std::unordered_map<std::string, std::array<uint8_t, 32>> pins_;
|
std::unordered_map<std::string, std::array<uint8_t, 32>> pins_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -324,11 +324,34 @@ void TcpServerConn::close() {
|
|||||||
|
|
||||||
// ── TcpAcceptor ─────────────────────────────────────────────────────────────
|
// ── TcpAcceptor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory)
|
namespace {
|
||||||
: acceptor_(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)),
|
// Try IPv6 dual-stack first (one socket handles both ::1 and 127.0.0.1 — fixes the common
|
||||||
factory_(std::move(factory)) {
|
// Windows case where `localhost` resolves to ::1 before 127.0.0.1). Falls back to IPv4-only
|
||||||
acceptor_.set_option(asio::ip::tcp::acceptor::reuse_address(true));
|
// 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(); }
|
void TcpAcceptor::start() { do_accept(); }
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
|
|||||||
channels_.clear();
|
channels_.clear();
|
||||||
for (const auto& pb : snap.channels()) {
|
for (const auto& pb : snap.channels()) {
|
||||||
Channel ch;
|
Channel ch;
|
||||||
ch.id = pb.id();
|
ch.id = pb.id();
|
||||||
ch.name = pb.name();
|
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));
|
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) {
|
if (ev.kind() == Kind::CREATED || ev.kind() == Kind::UPDATED) {
|
||||||
const auto& pb = ev.channel();
|
const auto& pb = ev.channel();
|
||||||
Channel ch;
|
Channel ch;
|
||||||
ch.id = pb.id();
|
ch.id = pb.id();
|
||||||
ch.name = pb.name();
|
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(),
|
auto it = std::find_if(channels_.begin(), channels_.end(),
|
||||||
[&](const Channel& x) { return x.id == ch.id; });
|
[&](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 channels_.push_back(std::move(ch));
|
||||||
|
|
||||||
} else if (ev.kind() == Kind::DELETED) {
|
} 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(),
|
channels_.erase(std::remove_if(channels_.begin(), channels_.end(),
|
||||||
[cid](const Channel& x) { return x.id == cid; }),
|
[cid](const Channel& x) { return x.id == cid; }),
|
||||||
channels_.end());
|
channels_.end());
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode) {
|
|||||||
return c->set_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) {
|
vc_result vc_set_push_to_talk(vc_client* c, int active) {
|
||||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||||
return c->set_push_to_talk(active != 0);
|
return c->set_push_to_talk(active != 0);
|
||||||
@@ -152,4 +157,54 @@ void vc_free_device_list(vc_device_list* list) {
|
|||||||
list->count = 0;
|
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"
|
} // extern "C"
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ void print_help(const char* argv0) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
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 :<port>") 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;
|
voicecat::server::Config cfg;
|
||||||
bool print_config_only = false;
|
bool print_config_only = false;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ void SessionRegistry::init_default_channels() {
|
|||||||
lobby.proto.set_name("Lobby");
|
lobby.proto.set_name("Lobby");
|
||||||
lobby.proto.set_type(voicecat::v1::CHANNEL_PERMANENT);
|
lobby.proto.set_type(voicecat::v1::CHANNEL_PERMANENT);
|
||||||
lobby.proto.set_order(0);
|
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.
|
// Speech profile: mono, low bitrate, FEC+DTX on for resilience/silence-suppression.
|
||||||
auto* a = lobby.proto.mutable_audio();
|
auto* a = lobby.proto.mutable_audio();
|
||||||
|
|||||||
@@ -95,4 +95,22 @@ if(VOICECAT_USE_VCPKG_DEPS)
|
|||||||
target_include_directories(test_vad_ptt_devices PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
target_include_directories(test_vad_ptt_devices PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
|
||||||
add_test(NAME vad_ptt_devices COMMAND test_vad_ptt_devices)
|
add_test(NAME vad_ptt_devices COMMAND test_vad_ptt_devices)
|
||||||
set_tests_properties(vad_ptt_devices PROPERTIES TIMEOUT 60)
|
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()
|
endif()
|
||||||
|
|||||||
334
tests/test_channel_user_list_abi.cpp
Normal file
334
tests/test_channel_user_list_abi.cpp
Normal file
@@ -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 <cstdio>
|
||||||
|
|
||||||
|
#ifdef VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<StreamEvent> stream_events;
|
||||||
|
std::vector<JoinResult> join_results;
|
||||||
|
|
||||||
|
const char* label{nullptr};
|
||||||
|
vc_client* client{nullptr};
|
||||||
|
};
|
||||||
|
|
||||||
|
static void on_event(void* user, const vc_event* ev) {
|
||||||
|
auto* s = static_cast<EventStore*>(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 <typename Pred>
|
||||||
|
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<uint16_t> 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
|
||||||
@@ -42,6 +42,10 @@ struct EventStore {
|
|||||||
std::string last_error;
|
std::string last_error;
|
||||||
bool disconnected{false};
|
bool disconnected{false};
|
||||||
vc_connection_state last_state{VC_STATE_DISCONNECTED};
|
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) {
|
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);
|
std::lock_guard lk(s->mu);
|
||||||
s->last_state = ev->connection_state;
|
s->last_state = ev->connection_state;
|
||||||
switch (ev->type) {
|
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:
|
case VC_EVENT_AUTH_RESULT:
|
||||||
s->auth_result = static_cast<vc_result>(ev->result);
|
s->auth_result = static_cast<vc_result>(ev->result);
|
||||||
s->auth_ok = (ev->result == VC_OK);
|
s->auth_ok = (ev->result == VC_OK);
|
||||||
@@ -171,6 +179,7 @@ int main() {
|
|||||||
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||||
CHECK(clientA != nullptr);
|
CHECK(clientA != nullptr);
|
||||||
|
evA.client = clientA;
|
||||||
|
|
||||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientA, "GuestBob") == 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_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||||
CHECK(clientB != nullptr);
|
CHECK(clientB != nullptr);
|
||||||
|
evB.client = clientB;
|
||||||
|
|
||||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_user(clientB, "alice", "test-pass-alice") == VC_OK);
|
CHECK(vc_authenticate_user(clientB, "alice", "test-pass-alice") == VC_OK);
|
||||||
|
|||||||
@@ -62,12 +62,20 @@ struct EventStore {
|
|||||||
bool disconnected{false};
|
bool disconnected{false};
|
||||||
|
|
||||||
const char* label{nullptr};
|
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) {
|
static void on_event(void* user, const vc_event* ev) {
|
||||||
auto* s = static_cast<EventStore*>(user);
|
auto* s = static_cast<EventStore*>(user);
|
||||||
std::lock_guard lk(s->mu);
|
std::lock_guard lk(s->mu);
|
||||||
switch (ev->type) {
|
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:
|
case VC_EVENT_AUTH_RESULT:
|
||||||
s->auth_ok = (ev->result == VC_OK);
|
s->auth_ok = (ev->result == VC_OK);
|
||||||
s->self_user_id = ev->user_id;
|
s->self_user_id = ev->user_id;
|
||||||
@@ -176,6 +184,7 @@ int main() {
|
|||||||
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||||
CHECK(clientA != nullptr);
|
CHECK(clientA != nullptr);
|
||||||
|
evA.client = clientA;
|
||||||
|
|
||||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientA, "M3-A") == 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_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||||
CHECK(clientB != nullptr);
|
CHECK(clientB != nullptr);
|
||||||
|
evB.client = clientB;
|
||||||
|
|
||||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientB, "M3-B") == VC_OK);
|
CHECK(vc_authenticate_guest(clientB, "M3-B") == VC_OK);
|
||||||
|
|||||||
385
tests/test_tofu_flow.cpp
Normal file
385
tests/test_tofu_flow.cpp
Normal file
@@ -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 <cstdio>
|
||||||
|
|
||||||
|
#ifdef VOICECAT_HAS_NET
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#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<GatedEventStore*>(user);
|
||||||
|
std::lock_guard lk(s->mu);
|
||||||
|
switch (ev->type) {
|
||||||
|
case VC_EVENT_SERVER_IDENTITY:
|
||||||
|
s->got_identity = true;
|
||||||
|
s->identity_status = static_cast<vc_tofu_status>(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<vc_result>(ev->result);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
s->cv.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename Pred>
|
||||||
|
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<voicecat::server::Server> 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<uint16_t> 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<voicecat::server::Server>(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
|
||||||
@@ -62,12 +62,20 @@ struct EventStore {
|
|||||||
bool disconnected{false};
|
bool disconnected{false};
|
||||||
|
|
||||||
const char* label{nullptr};
|
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) {
|
static void on_event(void* user, const vc_event* ev) {
|
||||||
auto* s = static_cast<EventStore*>(user);
|
auto* s = static_cast<EventStore*>(user);
|
||||||
std::lock_guard lk(s->mu);
|
std::lock_guard lk(s->mu);
|
||||||
switch (ev->type) {
|
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:
|
case VC_EVENT_AUTH_RESULT:
|
||||||
s->auth_ok = (ev->result == VC_OK);
|
s->auth_ok = (ev->result == VC_OK);
|
||||||
s->self_user_id = ev->user_id;
|
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_config cfgA{"test-A", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||||
CHECK(clientA != nullptr);
|
CHECK(clientA != nullptr);
|
||||||
|
evA.client = clientA;
|
||||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientA, "VP-A") == VC_OK);
|
CHECK(vc_authenticate_guest(clientA, "VP-A") == VC_OK);
|
||||||
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
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_config cfgB{"test-B", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||||
CHECK(clientB != nullptr);
|
CHECK(clientB != nullptr);
|
||||||
|
evB.client = clientB;
|
||||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientB, "VP-B") == VC_OK);
|
CHECK(vc_authenticate_guest(clientB, "VP-B") == VC_OK);
|
||||||
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
||||||
|
|||||||
@@ -43,12 +43,20 @@ struct EventStore {
|
|||||||
|
|
||||||
const char* label{nullptr};
|
const char* label{nullptr};
|
||||||
bool disconnected{false};
|
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) {
|
static void on_event(void* user, const vc_event* ev) {
|
||||||
auto* s = static_cast<EventStore*>(user);
|
auto* s = static_cast<EventStore*>(user);
|
||||||
std::lock_guard lk(s->mu);
|
std::lock_guard lk(s->mu);
|
||||||
switch (ev->type) {
|
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:
|
case VC_EVENT_AUTH_RESULT:
|
||||||
s->auth_ok = (ev->result == VC_OK);
|
s->auth_ok = (ev->result == VC_OK);
|
||||||
s->self_user_id = ev->user_id;
|
s->self_user_id = ev->user_id;
|
||||||
@@ -144,6 +152,7 @@ int main() {
|
|||||||
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
vc_config cfgA{"test-clientA", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
||||||
CHECK(clientA != nullptr);
|
CHECK(clientA != nullptr);
|
||||||
|
evA.client = clientA;
|
||||||
|
|
||||||
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientA, "VoiceA") == 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_config cfgB{"test-clientB", "0.1", VC_LOG_OFF};
|
||||||
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
||||||
CHECK(clientB != nullptr);
|
CHECK(clientB != nullptr);
|
||||||
|
evB.client = clientB;
|
||||||
|
|
||||||
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
||||||
CHECK(vc_authenticate_guest(clientB, "VoiceB") == VC_OK);
|
CHECK(vc_authenticate_guest(clientB, "VoiceB") == VC_OK);
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ void on_sigint(int) { g_stop.store(true); }
|
|||||||
struct Stats {
|
struct Stats {
|
||||||
std::atomic<bool> auth_done{false};
|
std::atomic<bool> auth_done{false};
|
||||||
std::atomic<bool> auth_ok{false};
|
std::atomic<bool> 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) {
|
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:
|
case VC_EVENT_CONNECTION_STATE:
|
||||||
std::printf("[state] -> %d\n", static_cast<int>(ev->connection_state));
|
std::printf("[state] -> %d\n", static_cast<int>(ev->connection_state));
|
||||||
break;
|
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:
|
case VC_EVENT_AUTH_RESULT:
|
||||||
st->auth_ok = (ev->result == VC_OK);
|
st->auth_ok = (ev->result == VC_OK);
|
||||||
st->auth_done = true;
|
st->auth_done = true;
|
||||||
@@ -205,6 +214,7 @@ int main(int argc, char** argv) {
|
|||||||
std::fprintf(stderr, "failed to create client\n");
|
std::fprintf(stderr, "failed to create client\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
st.client = c;
|
||||||
|
|
||||||
if (list_devices) {
|
if (list_devices) {
|
||||||
// Device enumeration works pre-connect (no server needed) — see docs/voice.md.
|
// Device enumeration works pre-connect (no server needed) — see docs/voice.md.
|
||||||
|
|||||||
Reference in New Issue
Block a user