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:
2026-06-17 00:35:16 +02:00
parent 5be869c61a
commit 63b241cc2e
56 changed files with 4685 additions and 35 deletions

View 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";
}
}

View 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;
}
}

View 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";
}
}

View 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);
}
}

View 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 1100; 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";
}
}

View 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 (value1) / 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;
}
}

View 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";
}
}

View 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";
}
}

View 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);
}
}

View 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();
};
}
}

View 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";
}
}

View 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);
}
}

View 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);
}
}

View 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})";
}

View 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);
}
}

View 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.");
}
}

View 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 &amp;&amp; cmake --build --preset windows-client (see clients/windows/README.md)." />
</Target>
</Project>

View 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>