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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user