feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode
Core ABI extensions (voicecat.h):
- vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters
for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads
- VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password
- VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_
until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519)
- vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only
- VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate
- vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores
it atomically so the audio RT path reads without a lock
C++ implementation:
- SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id,
password_protected, and max_users (were permanently zeroed)
- TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS
- TofuStore split into peek (read-only) + pin (write) so first-connect only persists
after user approval; tofu_store_path in vc_config for per-user pin file location
- TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows)
- windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests
- New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green)
Windows client (clients/windows/ — .NET 10 WinForms):
- VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks,
Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer
- VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity-
Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user
ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode,
per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar)
- PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation)
- PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams
- Accessibility: explicit AccessibleName/Description on every control, & mnemonics,
Activity log ListBox as durable screen-reader record, AutomationNotification for
curated live announcements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 00:35:16 +02:00
|
|
|
using VoiceCat.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; } = "";
|
2026-06-24 12:30:48 +02:00
|
|
|
public string ServerName { get; private set; } = "";
|
feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode
Core ABI extensions (voicecat.h):
- vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters
for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads
- VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password
- VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_
until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519)
- vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only
- VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate
- vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores
it atomically so the audio RT path reads without a lock
C++ implementation:
- SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id,
password_protected, and max_users (were permanently zeroed)
- TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS
- TofuStore split into peek (read-only) + pin (write) so first-connect only persists
after user approval; tofu_store_path in vc_config for per-user pin file location
- TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows)
- windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests
- New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green)
Windows client (clients/windows/ — .NET 10 WinForms):
- VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks,
Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer
- VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity-
Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user
ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode,
per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar)
- PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation)
- PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams
- Accessibility: explicit AccessibleName/Description on every control, & mnemonics,
Activity log ListBox as durable screen-reader record, AutomationNotification for
curated live announcements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 00:35:16 +02:00
|
|
|
|
|
|
|
|
public 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...";
|
|
|
|
|
|
2026-06-24 12:30:48 +02:00
|
|
|
ServerName = string.IsNullOrWhiteSpace(server.DisplayName)
|
|
|
|
|
? $"{server.Host}:{server.Port}"
|
|
|
|
|
: server.DisplayName;
|
|
|
|
|
|
feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode
Core ABI extensions (voicecat.h):
- vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters
for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads
- VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password
- VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_
until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519)
- vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only
- VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate
- vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores
it atomically so the audio RT path reads without a lock
C++ implementation:
- SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id,
password_protected, and max_users (were permanently zeroed)
- TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS
- TofuStore split into peek (read-only) + pin (write) so first-connect only persists
after user approval; tofu_store_path in vc_config for per-user pin file location
- TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows)
- windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests
- New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green)
Windows client (clients/windows/ — .NET 10 WinForms):
- VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks,
Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer
- VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity-
Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user
ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode,
per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar)
- PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation)
- PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams
- Accessibility: explicit AccessibleName/Description on every control, & mnemonics,
Activity log ListBox as durable screen-reader record, AutomationNotification for
curated live announcements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 00:35:16 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|