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 System.Runtime.InteropServices;
|
|
|
|
|
using System.Threading.Channels;
|
|
|
|
|
|
|
|
|
|
// The public, safe C# surface over libvoicecat. Everything below is a thin wrapper around
|
|
|
|
|
// NativeMethods — see docs/architecture.md §4 ("the core owns audio; C# only orchestrates").
|
|
|
|
|
namespace VoiceCat.Interop;
|
|
|
|
|
|
|
|
|
|
public sealed class VoiceCatClient : IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly VoiceCatClientHandle _handle = new();
|
|
|
|
|
private readonly GCHandle _selfHandle;
|
|
|
|
|
|
|
|
|
|
// Native string buffers backing vc_config — must outlive the WHOLE client lifetime, not
|
|
|
|
|
// just vc_client_create(): client_name/client_version are read later, whenever connect()
|
|
|
|
|
// actually runs on io_thread_ (vc_client just stores the raw pointers from vc_config by
|
|
|
|
|
// value, it does not copy the string data). Freed in Dispose(), after vc_client_destroy
|
|
|
|
|
// has returned (which synchronously joins every internal thread, so nothing can still be
|
|
|
|
|
// reading these pointers by then).
|
|
|
|
|
private nint _clientNamePtr;
|
|
|
|
|
private nint _clientVersionPtr;
|
|
|
|
|
private nint _tofuStorePathPtr;
|
|
|
|
|
|
|
|
|
|
private readonly Channel<VoiceCatEvent> _events =
|
|
|
|
|
Channel.CreateUnbounded<VoiceCatEvent>(new UnboundedChannelOptions
|
|
|
|
|
{
|
|
|
|
|
SingleReader = true,
|
|
|
|
|
SingleWriter = true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// on_level fires far more often than on_event and intermediate values are visually
|
|
|
|
|
// irrelevant — coalesce to "latest sample per stream_id" instead of queuing every one.
|
|
|
|
|
private readonly System.Collections.Concurrent.ConcurrentDictionary<uint, float> _latestLevels = new();
|
|
|
|
|
|
|
|
|
|
/// <summary>Raised from PumpEvents() (i.e. on whatever thread calls it — see that method's
|
|
|
|
|
/// doc comment) for every event, in order, never coalesced.</summary>
|
|
|
|
|
public event Action<VoiceCatEvent>? EventReceived;
|
|
|
|
|
|
|
|
|
|
/// <summary>Raised from PumpEvents() with the latest RMS level per stream_id since the
|
|
|
|
|
/// last pump.</summary>
|
|
|
|
|
public event Action<uint, float>? LevelChanged;
|
|
|
|
|
|
|
|
|
|
public unsafe VoiceCatClient(string clientName, string clientVersion,
|
|
|
|
|
VcLogLevel logLevel = VcLogLevel.Info, string? tofuStorePath = null)
|
|
|
|
|
{
|
|
|
|
|
_selfHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
|
|
|
|
|
|
|
|
|
_clientNamePtr = Marshal.StringToCoTaskMemUTF8(clientName);
|
|
|
|
|
_clientVersionPtr = Marshal.StringToCoTaskMemUTF8(clientVersion);
|
|
|
|
|
_tofuStorePathPtr = tofuStorePath is null ? 0 : Marshal.StringToCoTaskMemUTF8(tofuStorePath);
|
|
|
|
|
|
|
|
|
|
var cfg = new VcConfigNative
|
|
|
|
|
{
|
|
|
|
|
ClientName = _clientNamePtr,
|
|
|
|
|
ClientVersion = _clientVersionPtr,
|
|
|
|
|
LogLevel = logLevel,
|
|
|
|
|
TofuStorePath = _tofuStorePathPtr,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var cb = new VcCallbacksNative
|
|
|
|
|
{
|
|
|
|
|
OnEvent = (nint)(delegate* unmanaged<nint, VcEventNative*, void>)&NativeCallbacks.OnEvent,
|
|
|
|
|
OnLevel = (nint)(delegate* unmanaged<nint, uint, float, void>)&NativeCallbacks.OnLevel,
|
|
|
|
|
User = GCHandle.ToIntPtr(_selfHandle),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
nint native = NativeMethods.vc_client_create(in cfg, cb);
|
|
|
|
|
_handle.SetHandle(native);
|
|
|
|
|
if (_handle.IsInvalid)
|
|
|
|
|
{
|
|
|
|
|
FreeConfigStrings();
|
|
|
|
|
_selfHandle.Free();
|
|
|
|
|
throw new InvalidOperationException("vc_client_create failed.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Drains every event/level sample queued since the last call. Call this from a
|
|
|
|
|
/// System.Windows.Forms.Timer.Tick on the UI thread (~30-50ms) — this is the boundary
|
|
|
|
|
/// where the core's own event-delivery thread hands off to the UI thread; see
|
|
|
|
|
/// docs/architecture.md §3 and this project's README for why a Timer + Channel was chosen
|
|
|
|
|
/// over a message-only window + PostMessage.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void PumpEvents()
|
|
|
|
|
{
|
|
|
|
|
while (_events.Reader.TryRead(out var ev))
|
|
|
|
|
EventReceived?.Invoke(ev);
|
|
|
|
|
|
|
|
|
|
if (!_latestLevels.IsEmpty)
|
|
|
|
|
{
|
|
|
|
|
foreach (var (streamId, rms) in _latestLevels)
|
|
|
|
|
LevelChanged?.Invoke(streamId, rms);
|
|
|
|
|
_latestLevels.Clear();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal void EnqueueEvent(VoiceCatEvent ev)
|
|
|
|
|
{
|
|
|
|
|
// Temporary diagnostic (manual debugging session) — confirms the native callback
|
|
|
|
|
// chain (UnmanagedCallersOnly -> GCHandle resolve -> here) actually fires, independent
|
|
|
|
|
// of whether the UI-thread drain (PumpEvents) ever sees it.
|
|
|
|
|
Console.WriteLine($"[VoiceCatClient] EnqueueEvent (native thread): {ev}");
|
|
|
|
|
_events.Writer.TryWrite(ev);
|
|
|
|
|
}
|
|
|
|
|
internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms;
|
|
|
|
|
|
|
|
|
|
// ── Connection & auth ────────────────────────────────────────────────────────────────
|
|
|
|
|
public VcResult Connect(string host, ushort port) =>
|
|
|
|
|
NativeMethods.vc_connect(_handle.DangerousGetHandle(), host, port);
|
|
|
|
|
|
|
|
|
|
public VcResult Disconnect() =>
|
|
|
|
|
NativeMethods.vc_disconnect(_handle.DangerousGetHandle());
|
|
|
|
|
|
|
|
|
|
public VcResult AuthenticateGuest(string nickname) =>
|
|
|
|
|
NativeMethods.vc_authenticate_guest(_handle.DangerousGetHandle(), nickname);
|
|
|
|
|
|
|
|
|
|
public VcResult AuthenticateUser(string username, string password) =>
|
|
|
|
|
NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password);
|
|
|
|
|
|
|
|
|
|
// ── TOFU server-identity gate (M4) ──────────────────────────────────────────────────────
|
|
|
|
|
public VcResult ConfirmServerIdentity(bool accept) =>
|
|
|
|
|
NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
/// <summary>The Ed25519 identity fingerprint from ServerHello, hex-formatted — display
|
|
|
|
|
/// only, NOT the value the TOFU gate pins on (see VcTofuStatus's doc comment). Empty
|
|
|
|
|
/// string if not yet available.</summary>
|
|
|
|
|
public string GetServerIdentityDisplay()
|
|
|
|
|
{
|
|
|
|
|
nint c = _handle.DangerousGetHandle();
|
|
|
|
|
NativeMethods.vc_get_server_identity_display(c, 0, 0, out nuint len);
|
|
|
|
|
if (len == 0) return string.Empty;
|
|
|
|
|
|
|
|
|
|
nint buf = Marshal.AllocHGlobal((int)len + 1);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
NativeMethods.vc_get_server_identity_display(c, buf, len + (nuint)1, out _);
|
|
|
|
|
return Marshal.PtrToStringUTF8(buf) ?? string.Empty;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Marshal.FreeHGlobal(buf);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
/// <summary>Result arrives as a VcEventType.JoinResult event, not via this return value
|
|
|
|
|
/// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment).
|
|
|
|
|
/// NOTE: no in-tree channel has a server-side password to check yet (M5+ feature) — this
|
|
|
|
|
/// path is wired but not yet exercisable end-to-end.</summary>
|
|
|
|
|
public VcResult JoinChannel(uint channelId, string? password = null) =>
|
|
|
|
|
NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password);
|
|
|
|
|
|
|
|
|
|
public VcResult LeaveChannel() =>
|
|
|
|
|
NativeMethods.vc_leave_channel(_handle.DangerousGetHandle());
|
|
|
|
|
|
|
|
|
|
public List<ChannelInfo> ListChannels()
|
|
|
|
|
{
|
|
|
|
|
NativeMethods.vc_list_channels(_handle.DangerousGetHandle(), out var native);
|
|
|
|
|
return Marshaling.ToManaged(ref native);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public List<UserInfo> ListUsers()
|
|
|
|
|
{
|
|
|
|
|
NativeMethods.vc_list_users(_handle.DangerousGetHandle(), out var native);
|
|
|
|
|
return Marshaling.ToManaged(ref native);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public List<StreamSummary> ListUserStreams(uint userId)
|
|
|
|
|
{
|
|
|
|
|
var r = NativeMethods.vc_list_user_streams(_handle.DangerousGetHandle(), userId, out var native);
|
|
|
|
|
return r == VcResult.Ok ? Marshaling.ToManaged(ref native) : new List<StreamSummary>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Local media streams ─────────────────────────────────────────────────────────────────
|
|
|
|
|
public (VcResult Result, uint StreamId) StartStream(VcStreamKind kind, string label)
|
|
|
|
|
{
|
|
|
|
|
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr };
|
|
|
|
|
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
|
|
|
|
|
return (r, streamId);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Marshal.FreeCoTaskMem(labelPtr);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 00:19:38 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Like <see cref="StartStream"/> but sets <c>external_feed = 1</c> so the core skips its
|
|
|
|
|
/// own WASAPI loopback. The caller is responsible for feeding PCM via
|
|
|
|
|
/// <see cref="StreamFeedPcm"/>. Used by the Windows per-app capture path.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public (VcResult Result, uint StreamId) StartStreamExternalFeed(VcStreamKind kind, string label)
|
|
|
|
|
{
|
|
|
|
|
nint labelPtr = Marshal.StringToCoTaskMemUTF8(label);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var desc = new VcStreamDescNative { Kind = kind, DeviceId = 0, Label = labelPtr, ExternalFeed = 1 };
|
|
|
|
|
var r = NativeMethods.vc_stream_start(_handle.DangerousGetHandle(), in desc, out uint streamId);
|
|
|
|
|
return (r, streamId);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Marshal.FreeCoTaskMem(labelPtr);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 VcResult StopStream(uint streamId) =>
|
|
|
|
|
NativeMethods.vc_stream_stop(_handle.DangerousGetHandle(), streamId);
|
|
|
|
|
|
|
|
|
|
public VcResult SetInputDevice(uint streamId, string? deviceId) =>
|
|
|
|
|
NativeMethods.vc_set_input_device(_handle.DangerousGetHandle(), streamId, deviceId);
|
|
|
|
|
|
|
|
|
|
public VcResult SetInputMode(VcInputMode mode) =>
|
|
|
|
|
NativeMethods.vc_set_input_mode(_handle.DangerousGetHandle(), mode);
|
|
|
|
|
|
|
|
|
|
public VcResult SetVadThreshold(float threshold) =>
|
|
|
|
|
NativeMethods.vc_set_vad_threshold(_handle.DangerousGetHandle(), threshold);
|
|
|
|
|
|
|
|
|
|
public VcResult SetPushToTalk(bool active) =>
|
|
|
|
|
NativeMethods.vc_set_push_to_talk(_handle.DangerousGetHandle(), active ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
public VcResult SetSelfMute(bool micMuted, bool deafened) =>
|
|
|
|
|
NativeMethods.vc_set_self_mute(_handle.DangerousGetHandle(), micMuted ? 1 : 0, deafened ? 1 : 0);
|
|
|
|
|
|
feat(windows): UI overhaul -- toolbar, unified log, PM windows, channel counts, output volume
- Voice actions (Join Voice, Share Screen Audio) moved to a ToolStrip toolbar and
a new Voice menu in the menu bar; removed from the bottom voice panel
- Activity log and chat log collapsed into a single RichTextBox (rtbLog); activity
events appear in gray, chat messages in default color
- Private messaging reworked: each conversation opens in its own modeless
PrivateMessageForm instead of sharing the main chat log via a scope dropdown;
cboScope removed; main compose bar always sends to the current channel
- New "Messages -> New Private Message..." menu item (Ctrl+P) opens a UserPickerDialog
listing all connected server users (not just the current channel) so you can PM
anyone on the server
- Channel tree now shows live user counts, e.g. "General (3)" -- counts sourced from
the existing _users dictionary which already tracks all server users with channel IDs
- Global output volume slider (TrackBar, 0-100, default 80) added to the right panel;
wired to new vc_set_output_volume C ABI function that applies a master gain multiplier
in the audio engine playback callback after mixing all streams
- vc_set_output_volume added end-to-end: voicecat.h, audio_engine.h/.cpp,
client.h/.cpp, voicecat.cpp, NativeMethods.cs, VoiceCatClient.cs
- Documented Windows PowerShell ctest requirement in AGENTS.md and CLAUDE.md:
MinGW binaries exit 0xc0000139 in Git Bash; always run ctest/.exe via PowerShell
22/22 ctest green (PowerShell); dotnet build 0 warnings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 14:24:54 +02:00
|
|
|
public VcResult SetOutputVolume(float gain) =>
|
|
|
|
|
NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
|
|
|
|
|
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver
Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were
applied to the core + UI but never saved, so every relaunch reset to VAD
defaults. Each client now persists them and re-applies on connect:
- iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes)
- macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings
(settings window also restores the VAD slider from the stored threshold)
- Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json,
mirrors FeedbackSettings) loaded/applied in MainForm
Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in
on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings,
and a 0-300% (default 100%) mic-volume slider on all three clients.
Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0),
so channel messages went nowhere; now passes session.currentChannelId.
Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press
.contextMenu only (invisible to VoiceOver); UserRow now also exposes the same
buttons via .accessibilityActions (no visual change).
Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes,
reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build
SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built
(WinForms can't build on macOS) — follows existing patterns.
2026-06-23 03:35:26 +02:00
|
|
|
/// <summary>Send-side microphone input gain, applied to captured MIC PCM before VAD/encode.
|
|
|
|
|
/// 0.0 = silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). LOCAL only.</summary>
|
|
|
|
|
public VcResult SetInputGain(float gain) =>
|
|
|
|
|
NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain);
|
|
|
|
|
|
feat(clients): wire RNNoise mic noise reduction into Windows, macOS, and iOS
Expose the existing send-side vc_set_input_noise_reduction C ABI (MIC-only,
mono, LOCAL — denoises captured mic PCM before input gain and VAD/PTT gate)
as a persisted global toggle in each client's audio settings, applied live
and re-applied on Join Voice. Mirrors the existing mic-gain wiring pattern.
- Shared Swift (VoiceCatCore): add setInputNoiseReduction(_:) wrapper
- Windows: P/Invoke + SetInputNoiseReduction wrapper, MicNoiseReduction in
VoiceSettings, new checkbox in AudioSettingsForm (layout shifted +28px),
apply on Join Voice; also fix stale 'planned - currently passthrough'
label on the receive-side per-user NR checkbox (RNNoise now backs it)
- macOS: inputNoiseReduction state + UserDefaults in MainWindowController,
NR checkbox + nrChanged action in SettingsWindowController
- iOS: inputNoiseReduction in VoiceState + setter + restore in SessionState,
NR Toggle in SettingsView Voice section
Aux/screen are out of scope by design (core's NR guards kind == MIC). Apple
builds require a rebuilt VoiceCatCore.xcframework with VOICECAT_HAS_NS.
2026-06-23 14:11:18 +02:00
|
|
|
/// <summary>Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before
|
|
|
|
|
/// the input gain and VAD/PTT gate, so everyone hears the cleaned signal. MIC stream only,
|
|
|
|
|
/// mono only; always LOCAL — no protocol traffic. Independent of per-listener receive-side
|
|
|
|
|
/// NR in <see cref="SetRemoteStream"/>.</summary>
|
|
|
|
|
public VcResult SetInputNoiseReduction(bool enable) =>
|
|
|
|
|
NativeMethods.vc_set_input_noise_reduction(_handle.DangerousGetHandle(), enable ? 1 : 0);
|
|
|
|
|
|
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 VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) =>
|
|
|
|
|
NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain,
|
|
|
|
|
muted ? 1 : 0, noiseReduction ? 1 : 0);
|
|
|
|
|
|
2026-06-18 02:06:44 +02:00
|
|
|
public (VcResult Result, RemoteStreamState? State) GetRemoteStream(uint userId, uint streamId)
|
|
|
|
|
{
|
|
|
|
|
var r = NativeMethods.vc_get_remote_stream(_handle.DangerousGetHandle(), userId,
|
|
|
|
|
streamId, out var native);
|
|
|
|
|
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
|
|
|
|
}
|
|
|
|
|
|
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 (VcResult Result, AudioConfigInfo? Config) GetStreamAudioConfig(uint userId, uint streamId)
|
|
|
|
|
{
|
|
|
|
|
var r = NativeMethods.vc_get_stream_audio_config(_handle.DangerousGetHandle(), userId,
|
|
|
|
|
streamId, out var native);
|
|
|
|
|
return (r, r == VcResult.Ok ? Marshaling.ToManaged(in native) : null);
|
|
|
|
|
}
|
|
|
|
|
|
feat: external PCM feed/tap API (vc_stream_feed_pcm + vc_set_pcm_sink)
Promotes vc_test_inject_capture (mono-only, TEST-ONLY) to a public,
stereo-capable production API and adds a symmetric PCM tap on the
receive side. Enables ReplayKit (iOS), ScreenCaptureKit (macOS), bots,
soundboards, and custom clients — all without a hardware audio device.
Core C++:
- voicecat.h: new vc_stream_feed_pcm, vc_pcm_sink_cb typedef,
vc_set_pcm_sink; vc_test_inject_capture kept as deprecated alias
- audio_engine: stereo-aware inject_capture (channels param + ring
reset on channel-count change); atomic pcm_sink_ fired per decoded
frame in on_playback; RemoteStream carries user_id/stream_id for
RT-safe sink metadata; init_recv_stream takes user_id+stream_id
- client.cpp: stream_feed_pcm / set_pcm_sink implementations;
sync_remote_streams passes user_id/stream_id to init_recv_stream
- voicecat.cpp: trampolines + channels=1/2 validation
Tests: test_external_pcm (headless, 3 sub-tests: mono round-trip,
stereo feed L≠R, sink metadata+disable). ctest 23/23.
Swift: feedPcm / setPcmSink in VoiceCatClient.swift + 4 XCTest
smoke tests (ExternalPcmTests.swift).
C#: StreamFeedPcm / SetPcmSink in VoiceCatClient.cs + NativeMethods.cs
(vc_stream_feed_pcm unsafe P/Invoke, VcPcmSinkCallback delegate,
vc_set_pcm_sink via nint) + 4 xUnit smoke tests (ExternalPcmTests.cs).
Docs: architecture.md §4 new subsection, voice.md §9 updated
(macOS/iOS now reference vc_stream_feed_pcm), protocol.md §8 explicit
no-protocol-change note, roadmap.md M5 entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 17:52:09 +02:00
|
|
|
// ── External PCM feed / tap ─────────────────────────────────────────────────────────
|
|
|
|
|
public unsafe VcResult StreamFeedPcm(uint streamId, ReadOnlySpan<short> pcm,
|
|
|
|
|
int samplesPerChannel, uint channels)
|
|
|
|
|
{
|
|
|
|
|
fixed (short* p = pcm)
|
|
|
|
|
return NativeMethods.vc_stream_feed_pcm(_handle.DangerousGetHandle(),
|
|
|
|
|
streamId, p, (nuint)samplesPerChannel, channels);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pass Marshal.GetFunctionPointerForDelegate(cb) for a managed delegate, or
|
|
|
|
|
// IntPtr.Zero to disable. Keep the delegate alive for the lifetime of the registration.
|
|
|
|
|
public VcResult SetPcmSink(nint cb, IntPtr user) =>
|
|
|
|
|
NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user);
|
|
|
|
|
|
2026-06-17 16:31:29 +02:00
|
|
|
// ── M5: Moderation & admin ───────────────────────────────────────────────────────────
|
|
|
|
|
public VcResult KickUser(uint userId, string? reason = null) =>
|
|
|
|
|
NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason);
|
|
|
|
|
|
|
|
|
|
public VcResult BanUser(uint userId, string? reason = null, ulong expiresUnixMs = 0) =>
|
|
|
|
|
NativeMethods.vc_ban_user(_handle.DangerousGetHandle(), userId, reason, expiresUnixMs);
|
|
|
|
|
|
|
|
|
|
public VcResult SetPermission(uint userId, PermissionsInfo perms)
|
|
|
|
|
{
|
|
|
|
|
var native = new VcPermissionsNative
|
|
|
|
|
{
|
|
|
|
|
CanCreateTempChannel = perms.CanCreateTempChannel ? 1 : 0,
|
|
|
|
|
CanKick = perms.CanKick ? 1 : 0,
|
|
|
|
|
CanBan = perms.CanBan ? 1 : 0,
|
|
|
|
|
CanMoveUsers = perms.CanMoveUsers ? 1 : 0,
|
|
|
|
|
CanAdminAccounts = perms.CanAdminAccounts ? 1 : 0,
|
|
|
|
|
IsAdmin = perms.IsAdmin ? 1 : 0,
|
|
|
|
|
};
|
|
|
|
|
return NativeMethods.vc_set_permission(_handle.DangerousGetHandle(), userId, in native);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public VcResult SetServerMute(uint userId, bool muted, bool deafened) =>
|
|
|
|
|
NativeMethods.vc_set_server_mute(_handle.DangerousGetHandle(), userId,
|
|
|
|
|
muted ? 1 : 0, deafened ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
public VcResult MoveUser(uint userId, uint channelId) =>
|
|
|
|
|
NativeMethods.vc_move_user(_handle.DangerousGetHandle(), userId, channelId);
|
|
|
|
|
|
|
|
|
|
public VcResult CreateChannel(ChannelEditInfo info)
|
|
|
|
|
{
|
|
|
|
|
var native = ToNativeChannelInfo(info);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return NativeMethods.vc_create_channel(_handle.DangerousGetHandle(), in native);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
FreeChannelInfoStrings(native);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public VcResult EditChannel(ChannelEditInfo info)
|
|
|
|
|
{
|
|
|
|
|
var native = ToNativeChannelInfo(info);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return NativeMethods.vc_edit_channel(_handle.DangerousGetHandle(), in native);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
FreeChannelInfoStrings(native);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public VcResult DeleteChannel(uint channelId) =>
|
|
|
|
|
NativeMethods.vc_delete_channel(_handle.DangerousGetHandle(), channelId);
|
|
|
|
|
|
|
|
|
|
public VcResult CreateAccount(string username, string password) =>
|
|
|
|
|
NativeMethods.vc_create_account(_handle.DangerousGetHandle(), username, password);
|
|
|
|
|
|
|
|
|
|
public VcResult ResetPassword(string username, string newPassword) =>
|
|
|
|
|
NativeMethods.vc_reset_password(_handle.DangerousGetHandle(), username, newPassword);
|
|
|
|
|
|
|
|
|
|
public VcResult DeleteAccount(string username) =>
|
|
|
|
|
NativeMethods.vc_delete_account(_handle.DangerousGetHandle(), username);
|
|
|
|
|
|
|
|
|
|
public VcResult RequestAccountList() =>
|
|
|
|
|
NativeMethods.vc_list_accounts(_handle.DangerousGetHandle());
|
|
|
|
|
|
|
|
|
|
public List<AccountInfo> ListAccounts()
|
|
|
|
|
{
|
|
|
|
|
NativeMethods.vc_get_account_list(_handle.DangerousGetHandle(), out var native);
|
|
|
|
|
return Marshaling.ToManaged(ref native);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public PermissionsInfo GetPermissions()
|
|
|
|
|
{
|
|
|
|
|
NativeMethods.vc_get_permissions(_handle.DangerousGetHandle(), out var native);
|
|
|
|
|
return Marshaling.ToManaged(in native);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static VcChannelInfoNative ToNativeChannelInfo(ChannelEditInfo info)
|
|
|
|
|
{
|
|
|
|
|
return new VcChannelInfoNative
|
|
|
|
|
{
|
|
|
|
|
Id = info.Id,
|
|
|
|
|
ParentId = info.ParentId,
|
|
|
|
|
Name = Marshal.StringToCoTaskMemUTF8(info.Name),
|
|
|
|
|
Topic = Marshal.StringToCoTaskMemUTF8(info.Topic),
|
|
|
|
|
PasswordProtected = info.PasswordProtected ? 1 : 0,
|
|
|
|
|
Password = string.IsNullOrEmpty(info.Password)
|
|
|
|
|
? 0
|
|
|
|
|
: Marshal.StringToCoTaskMemUTF8(info.Password),
|
|
|
|
|
MaxUsers = info.MaxUsers,
|
|
|
|
|
SortOrder = info.SortOrder,
|
|
|
|
|
Audio = new VcAudioConfigNative
|
|
|
|
|
{
|
|
|
|
|
Codec = info.Audio.Codec,
|
|
|
|
|
Mode = info.Audio.Stereo ? 1u : 0u,
|
|
|
|
|
SampleRate = info.Audio.SampleRate,
|
|
|
|
|
BitrateBps = info.Audio.BitrateBps,
|
|
|
|
|
FrameMs = info.Audio.FrameMs,
|
|
|
|
|
Application = info.Audio.Application,
|
|
|
|
|
Fec = info.Audio.Fec ? 1 : 0,
|
|
|
|
|
ExpectedPacketLoss = info.Audio.ExpectedPacketLoss,
|
|
|
|
|
Dtx = info.Audio.Dtx ? 1 : 0,
|
|
|
|
|
Complexity = info.Audio.Complexity,
|
feat(clients): expose all channel codec params + guest nickname everywhere
Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.
- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
(backward-compatible Codable), shown in Guest mode, wired into the guest auth
path -- guests could not set a display name on either before (only Windows)
Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
2026-06-21 04:03:50 +02:00
|
|
|
Dred = info.Audio.Dred ? 1 : 0,
|
2026-06-17 16:31:29 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void FreeChannelInfoStrings(VcChannelInfoNative native)
|
|
|
|
|
{
|
|
|
|
|
if (native.Name != 0) Marshal.FreeCoTaskMem(native.Name);
|
|
|
|
|
if (native.Topic != 0) Marshal.FreeCoTaskMem(native.Topic);
|
|
|
|
|
if (native.Password != 0) Marshal.FreeCoTaskMem(native.Password);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// ── Text ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
public VcResult SendText(VcTextScope scope, uint targetId, string utf8) =>
|
|
|
|
|
NativeMethods.vc_send_text(_handle.DangerousGetHandle(), scope, targetId, utf8);
|
|
|
|
|
|
|
|
|
|
// ── Device enumeration (works pre-connect) ──────────────────────────────────────────────
|
|
|
|
|
public List<DeviceInfo> ListDevices(VcDeviceKind kind)
|
|
|
|
|
{
|
|
|
|
|
NativeMethods.vc_list_devices(_handle.DangerousGetHandle(), kind, out var native);
|
|
|
|
|
return Marshaling.ToManaged(ref native);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Lifecycle ────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// NativeMethods.vc_version_string/vc_result_string return raw pointers to static, never-
|
|
|
|
|
// freed string literals — see NativeMethods.cs's comment for why we don't let LibraryImport
|
|
|
|
|
// auto-marshal these as `string` (it would try to free a static literal and corrupt the
|
|
|
|
|
// heap). Marshal.PtrToStringUTF8 just reads; it never frees.
|
|
|
|
|
public static string VersionString =>
|
|
|
|
|
Marshal.PtrToStringUTF8(NativeMethods.vc_version_string()) ?? string.Empty;
|
|
|
|
|
|
|
|
|
|
public static string ResultString(VcResult code) =>
|
|
|
|
|
Marshal.PtrToStringUTF8(NativeMethods.vc_result_string(code)) ?? string.Empty;
|
|
|
|
|
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
_handle.Dispose(); // runs vc_client_destroy (joins every internal thread) synchronously
|
|
|
|
|
FreeConfigStrings();
|
|
|
|
|
if (_selfHandle.IsAllocated) _selfHandle.Free();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void FreeConfigStrings()
|
|
|
|
|
{
|
|
|
|
|
if (_clientNamePtr != 0) { Marshal.FreeCoTaskMem(_clientNamePtr); _clientNamePtr = 0; }
|
|
|
|
|
if (_clientVersionPtr != 0) { Marshal.FreeCoTaskMem(_clientVersionPtr); _clientVersionPtr = 0; }
|
|
|
|
|
if (_tofuStorePathPtr != 0) { Marshal.FreeCoTaskMem(_tofuStorePathPtr); _tofuStorePathPtr = 0; }
|
|
|
|
|
}
|
|
|
|
|
}
|