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:
@@ -5,6 +5,24 @@ file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS
|
||||
|
||||
if(VOICECAT_BUILD_SHARED)
|
||||
add_library(voicecat SHARED ${VOICECAT_SOURCES})
|
||||
if(WIN32 AND MINGW)
|
||||
# M4: the C# client only ships voicecat.dll itself — no MinGW runtime DLLs alongside
|
||||
# it. x64-mingw-static only statically links vcpkg's OWN library deps (protobuf,
|
||||
# sodium, mbedTLS, ...); the GCC/MinGW runtime stays dynamic by default
|
||||
# (libgcc_s_seh-1.dll/libwinpthread-1.dll/libstdc++-6.dll — confirmed via `objdump -p`
|
||||
# on the existing vccli.exe). These flags are the standard fully-static-MinGW
|
||||
# recipe. Verify after building (see clients/windows/README.md):
|
||||
# objdump -p build/windows-client/bin/voicecat.dll | grep "DLL Name"
|
||||
# should show only Windows system DLLs.
|
||||
target_link_options(voicecat PRIVATE
|
||||
-static-libgcc -static-libstdc++ -static -lwinpthread)
|
||||
endif()
|
||||
if(WIN32)
|
||||
# CMake's default SHARED naming on MinGW adds a "lib" prefix (libvoicecat.dll) —
|
||||
# drop it so the output is exactly voicecat.dll, matching the C ABI/library name the
|
||||
# C# [LibraryImport] surface and docs use everywhere else.
|
||||
set_target_properties(voicecat PROPERTIES PREFIX "")
|
||||
endif()
|
||||
else()
|
||||
add_library(voicecat STATIC ${VOICECAT_SOURCES})
|
||||
# Static consumers must see VC_API as empty (no dllimport).
|
||||
|
||||
@@ -83,6 +83,10 @@ typedef enum vc_connection_state {
|
||||
VC_STATE_TLS_HANDSHAKE = 2,
|
||||
VC_STATE_AUTHENTICATING = 3,
|
||||
VC_STATE_CONNECTED = 4,
|
||||
/* M4: between TLS_HANDSHAKE and AUTHENTICATING — the handshake succeeded and the core is
|
||||
* waiting for vc_confirm_server_identity() (see VC_EVENT_SERVER_IDENTITY below). Appended
|
||||
* at the end (not inserted) to keep existing enum values stable — additive-only ABI. */
|
||||
VC_STATE_VERIFYING_IDENTITY = 5,
|
||||
} vc_connection_state;
|
||||
|
||||
typedef enum vc_text_scope {
|
||||
@@ -106,6 +110,8 @@ typedef enum vc_stream_kind {
|
||||
typedef enum vc_input_mode {
|
||||
VC_INPUT_VOICE_ACTIVATION = 0,
|
||||
VC_INPUT_PUSH_TO_TALK = 1,
|
||||
/* Transmit unconditionally — no VAD gate. Added at the end to keep existing values stable. */
|
||||
VC_INPUT_ALWAYS_ON = 2,
|
||||
} vc_input_mode;
|
||||
|
||||
typedef enum vc_event_type {
|
||||
@@ -121,8 +127,32 @@ typedef enum vc_event_type {
|
||||
VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
|
||||
VC_EVENT_ERROR = 10, /* result, text */
|
||||
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
|
||||
/* M4 additions — appended, not inserted, to keep existing values stable. */
|
||||
VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on
|
||||
failure. Reply to vc_join_channel(). */
|
||||
VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert
|
||||
SHA-256 fingerprint (the value being pinned — see
|
||||
vc_confirm_server_identity). Emitted once per connect
|
||||
attempt, right after the TLS handshake succeeds. The
|
||||
connection is held open until vc_confirm_server_identity()
|
||||
is called. */
|
||||
} vc_event_type;
|
||||
|
||||
/* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and
|
||||
* vc_confirm_server_identity. Pins the TLS leaf certificate's own SHA-256 fingerprint
|
||||
* (verifiable directly from the handshake), NOT the declared Ed25519
|
||||
* server_identity_fingerprint from ServerHello — the TLS cert and the server's Ed25519
|
||||
* identity key are generated independently with no cryptographic binding between them today
|
||||
* (docs/security.md §1.1), so pinning the self-declared value would be circular. The Ed25519
|
||||
* fingerprint is still available for human-readable display via
|
||||
* vc_get_server_identity_display(), it just isn't the value this gate accepts/rejects on. */
|
||||
typedef enum vc_tofu_status {
|
||||
VC_TOFU_FIRST_CONNECT = 0, /* no pin on file yet for this host:port */
|
||||
VC_TOFU_MATCHED = 1, /* matches the previously pinned fingerprint */
|
||||
VC_TOFU_MISMATCH = 2, /* DIFFERENT from the pinned fingerprint — possible MITM or a
|
||||
legitimate server key rotation; warn loudly */
|
||||
} vc_tofu_status;
|
||||
|
||||
/* ── Structs ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
@@ -155,6 +185,13 @@ typedef struct vc_config {
|
||||
const char* client_name; /* e.g. "VoiceCat-macOS" */
|
||||
const char* client_version; /* e.g. "0.0.1" */
|
||||
vc_log_level log_level;
|
||||
/* M4, optional (added at the end — existing brace-initialized callers default this to
|
||||
* NULL, no source change needed). Path to the TOFU pin file (see VC_EVENT_SERVER_IDENTITY/
|
||||
* vc_confirm_server_identity). NULL = a built-in relative default
|
||||
* ("./voicecat_tofu_pins.txt") so existing tests need no real persistence. A real app
|
||||
* (e.g. the Windows client) should pass an explicit per-user path, e.g.
|
||||
* "%AppData%\VoiceCat\tofu_pins.txt". */
|
||||
const char* tofu_store_path;
|
||||
} vc_config;
|
||||
|
||||
typedef struct vc_stream_desc {
|
||||
@@ -190,6 +227,49 @@ typedef struct vc_device_list {
|
||||
size_t count;
|
||||
} vc_device_list;
|
||||
|
||||
/* ── Channel / user / stream snapshots (M4 — for the channel-tree/user-list UI) ───────────
|
||||
* Pull-based: re-call after VC_EVENT_CHANNEL_LIST / VC_EVENT_USER_JOINED / _LEFT / _UPDATED to
|
||||
* refresh — there is no push variant; those events just mean "go look". Same ownership
|
||||
* contract as vc_device/vc_device_list above: core-allocated, caller frees with the matching
|
||||
* vc_free_*, items' const char* fields are invalid after that call. */
|
||||
typedef struct vc_channel {
|
||||
uint32_t id;
|
||||
uint32_t parent_id; /* 0 = root */
|
||||
const char* name;
|
||||
int password_protected; /* bool */
|
||||
uint32_t max_users; /* 0 = unlimited */
|
||||
} vc_channel;
|
||||
|
||||
typedef struct vc_channel_list {
|
||||
vc_channel* items;
|
||||
size_t count;
|
||||
} vc_channel_list;
|
||||
|
||||
typedef struct vc_user {
|
||||
uint32_t id;
|
||||
const char* nickname;
|
||||
int is_guest; /* bool */
|
||||
uint32_t channel_id;
|
||||
} vc_user;
|
||||
|
||||
typedef struct vc_user_list {
|
||||
vc_user* items;
|
||||
size_t count;
|
||||
} vc_user_list;
|
||||
|
||||
/* Per-user stream summary — lighter than vc_audio_config; for the full effective Opus config
|
||||
* of a specific (user_id, stream_id), use the existing vc_get_stream_audio_config. */
|
||||
typedef struct vc_stream_summary {
|
||||
uint32_t stream_id;
|
||||
vc_stream_kind kind;
|
||||
const char* label;
|
||||
} vc_stream_summary;
|
||||
|
||||
typedef struct vc_stream_summary_list {
|
||||
vc_stream_summary* items;
|
||||
size_t count;
|
||||
} vc_stream_summary_list;
|
||||
|
||||
/* Opaque client handle. */
|
||||
typedef struct vc_client vc_client;
|
||||
|
||||
@@ -208,6 +288,11 @@ VC_API vc_result vc_authenticate_user(vc_client* c, const char* username,
|
||||
const char* password);
|
||||
|
||||
/* ── Channels ─────────────────────────────────────────────────────────────── */
|
||||
/* Result arrives as VC_EVENT_JOIN_RESULT, not a return value beyond "request queued". `password`
|
||||
* is forwarded to the server's JoinChannelRequest.password for channels with
|
||||
* vc_channel.password_protected set; NOTE (M4): no in-tree channel currently has a server-side
|
||||
* password to check against — channel creation/passwords are a future (M5+) feature, so this
|
||||
* path is wired but not yet exercisable end-to-end. */
|
||||
VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id,
|
||||
const char* password /* nullable */);
|
||||
VC_API vc_result vc_leave_channel(vc_client* c);
|
||||
@@ -221,6 +306,10 @@ VC_API vc_result vc_set_input_device(vc_client* c, uint32_t stream_id,
|
||||
|
||||
/* Send-side: input gate mode + PTT key state, and self mute/deafen. */
|
||||
VC_API vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode);
|
||||
/* VAD threshold: normalized RMS 0.0–1.0; default ~0.025. Takes effect immediately —
|
||||
* recreates the VAD gate if a MIC stream is already active. No-op when mode != VOICE_ACTIVATION
|
||||
* (value is remembered and applied if the mode switches back). */
|
||||
VC_API vc_result vc_set_vad_threshold(vc_client* c, float threshold);
|
||||
VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */);
|
||||
VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened);
|
||||
|
||||
@@ -249,6 +338,38 @@ VC_API vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target
|
||||
VC_API vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out);
|
||||
VC_API void vc_free_device_list(vc_device_list* list);
|
||||
|
||||
/* ── Channel / user / stream enumeration (M4; mirrors vc_list_devices above) ─────────────── */
|
||||
VC_API vc_result vc_list_channels(vc_client* c, vc_channel_list* out);
|
||||
VC_API void vc_free_channel_list(vc_channel_list* list);
|
||||
|
||||
VC_API vc_result vc_list_users(vc_client* c, vc_user_list* out);
|
||||
VC_API void vc_free_user_list(vc_user_list* list);
|
||||
|
||||
/* Streams currently owned by user_id (their mic/screen-audio/aux), per the last snapshot/
|
||||
* event. VC_ERR_INVALID_ARG if user_id is unknown. */
|
||||
VC_API vc_result vc_list_user_streams(vc_client* c, uint32_t user_id,
|
||||
vc_stream_summary_list* out);
|
||||
VC_API void vc_free_stream_summary_list(vc_stream_summary_list* list);
|
||||
|
||||
/* ── TOFU server-identity confirmation (M4) — see VC_EVENT_SERVER_IDENTITY/vc_tofu_status ── */
|
||||
/* Accept or reject the pending server-identity check for the in-progress connect(). Must be
|
||||
* called after a VC_EVENT_SERVER_IDENTITY event; the io_thread_ holds the connection open
|
||||
* (ClientHello/auth deferred) until this is called, up to a generous internal timeout (after
|
||||
* which it's treated as a reject). accept=0 aborts the connection (emits
|
||||
* VC_EVENT_DISCONNECTED, result=VC_ERR_CRYPTO) and does NOT update the pin file. accept=1 on
|
||||
* FIRST_CONNECT/MISMATCH updates the pin file to the new fingerprint and proceeds; accept=1 on
|
||||
* MATCHED is a no-op confirmation (always safe) and proceeds. VC_ERR_INVALID_ARG if no
|
||||
* identity confirmation is currently pending. */
|
||||
VC_API vc_result vc_confirm_server_identity(vc_client* c, int accept /* bool */);
|
||||
|
||||
/* The Ed25519 identity fingerprint from ServerHello, hex-formatted for display (e.g. "this
|
||||
* server also identifies as <hex>"). Purely informational — NOT the value
|
||||
* vc_confirm_server_identity gates on (see vc_tofu_status's doc comment). Empty string if not
|
||||
* yet available. Pass out_buf=NULL to query the required buffer size via *out_len first;
|
||||
* otherwise out_buf must be >= *out_len + 1 bytes (NUL-terminated UTF-8/ASCII hex). */
|
||||
VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
|
||||
size_t* out_len);
|
||||
|
||||
#if defined(__cplusplus)
|
||||
} /* extern "C" */
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "audio/apm_processor.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
@@ -41,14 +42,18 @@ class EnergyVadProcessor final : public ApmProcessor {
|
||||
sum_sq += s * s;
|
||||
}
|
||||
double rms = std::sqrt(sum_sq / samples);
|
||||
if (rms >= threshold_) last_voice_ms_ = steady_now_ms();
|
||||
if (rms >= threshold_.load(std::memory_order_relaxed)) last_voice_ms_ = steady_now_ms();
|
||||
}
|
||||
return (steady_now_ms() - last_voice_ms_) < hang_time_ms_;
|
||||
}
|
||||
|
||||
void set_threshold(float t) override {
|
||||
threshold_.store(t, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
float threshold_;
|
||||
int64_t hang_time_ms_;
|
||||
std::atomic<float> threshold_;
|
||||
int64_t hang_time_ms_;
|
||||
int64_t last_voice_ms_ = 0; // epoch start -> gate begins closed until first loud frame
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ class ApmProcessor {
|
||||
// Returns false → caller should skip encode/send (silence gate).
|
||||
virtual bool process_capture(int16_t* pcm, int samples, int sample_rate) = 0;
|
||||
|
||||
// Update the VAD RMS threshold in-place (used by EnergyVadProcessor; no-op in passthrough).
|
||||
// Safe to call from any thread — EnergyVadProcessor stores it atomically.
|
||||
virtual void set_threshold(float) {}
|
||||
|
||||
// Factory: returns a real APM if VOICECAT_HAS_APM is defined, else a passthrough. Used for
|
||||
// recv-side per-stream noise reduction (docs/voice.md §10) — gating doesn't apply there, so
|
||||
// this stays a passthrough until a real APM/NS backend exists (still inert; see
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
|
||||
#include "protocol/protocol.h"
|
||||
|
||||
@@ -41,7 +42,14 @@ std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
|
||||
|
||||
// ── vc_client M1 implementation ───────────────────────────────────────────────
|
||||
|
||||
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
|
||||
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {
|
||||
// M4 TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests
|
||||
// (which never set this field) keep working without real per-user persistence.
|
||||
std::filesystem::path tofu_path = (cfg.tofu_store_path && cfg.tofu_store_path[0])
|
||||
? std::filesystem::path(cfg.tofu_store_path)
|
||||
: std::filesystem::path("voicecat_tofu_pins.txt");
|
||||
tofu_store_ = std::make_unique<voicecat::crypto::TofuStore>(std::move(tofu_path));
|
||||
}
|
||||
|
||||
vc_client::~vc_client() { disconnect(); }
|
||||
|
||||
@@ -96,6 +104,16 @@ vc_result vc_client::disconnect() {
|
||||
|
||||
io_stop_.store(true, std::memory_order_release);
|
||||
|
||||
// Unblock a run_io() thread that's currently waiting on vc_confirm_server_identity() —
|
||||
// without this, disconnecting mid-dialog would strand io_thread_ until the 120s timeout.
|
||||
{
|
||||
std::lock_guard lk(tofu_mu_);
|
||||
if (tofu_decision_pending_) {
|
||||
tofu_decision_pending_ = false;
|
||||
tofu_cv_.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
// Close the socket to unblock blocking TLS reads/writes.
|
||||
int fd = io_fd_.load(std::memory_order_acquire);
|
||||
if (fd != -1) {
|
||||
@@ -175,6 +193,66 @@ void vc_client::run_io(std::string host, uint16_t port) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOFU server-identity gate (M4) ──────────────────────────────────────
|
||||
// Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the
|
||||
// handshake) — NOT the declared Ed25519 server_identity_fingerprint from ServerHello,
|
||||
// which hasn't even arrived yet at this point (it's sent *inside* this now-established
|
||||
// tunnel) and isn't cryptographically bound to this cert anyway (docs/security.md
|
||||
// §1.1). See voicecat.h's vc_tofu_status doc comment.
|
||||
set_state(VC_STATE_VERIFYING_IDENTITY);
|
||||
{
|
||||
std::array<uint8_t, 32> peer_fp{};
|
||||
vc_tofu_status status = VC_TOFU_MISMATCH;
|
||||
if (tls_->peer_cert_fingerprint(peer_fp) && tofu_store_) {
|
||||
auto r = tofu_store_->peek(host, port, peer_fp);
|
||||
status = (r == voicecat::crypto::TofuResult::FirstConnect) ? VC_TOFU_FIRST_CONNECT
|
||||
: (r == voicecat::crypto::TofuResult::Matched) ? VC_TOFU_MATCHED
|
||||
: VC_TOFU_MISMATCH;
|
||||
}
|
||||
|
||||
std::string fp_hex;
|
||||
{
|
||||
static const char* hex = "0123456789abcdef";
|
||||
fp_hex.reserve(peer_fp.size() * 2);
|
||||
for (auto b : peer_fp) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; }
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> set_lk(tofu_mu_);
|
||||
tofu_decision_pending_ = true;
|
||||
tofu_accept_ = false;
|
||||
}
|
||||
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_SERVER_IDENTITY;
|
||||
ev.u32a = static_cast<uint32_t>(status);
|
||||
ev.text = fp_hex.c_str();
|
||||
emit(ev);
|
||||
|
||||
bool accepted;
|
||||
{
|
||||
std::unique_lock lk(tofu_mu_);
|
||||
tofu_cv_.wait_for(lk, std::chrono::seconds(120), [&] {
|
||||
return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire);
|
||||
});
|
||||
// Timeout or an external stop (disconnect() during the wait) both leave
|
||||
// tofu_decision_pending_ true here — treated as a reject, per voicecat.h.
|
||||
accepted = tofu_decision_pending_ ? false : tofu_accept_;
|
||||
tofu_decision_pending_ = false;
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
tls_.reset();
|
||||
emit_disconnected(VC_ERR_CRYPTO, "server identity rejected");
|
||||
close_sock(sock);
|
||||
io_fd_.store(-1);
|
||||
goto cleanup;
|
||||
}
|
||||
if (status != VC_TOFU_MATCHED && tofu_store_) {
|
||||
tofu_store_->pin(host, port, peer_fp);
|
||||
}
|
||||
}
|
||||
|
||||
// 50 ms timeout so we can drain sends between reads.
|
||||
tls_->set_read_timeout(50);
|
||||
|
||||
@@ -317,6 +395,12 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
|
||||
case voicecat::v1::Envelope::kUserEvent:
|
||||
handle_user_event(env.user_event());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kChannelEvent:
|
||||
handle_channel_event(env.channel_event());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kJoinChannelResult:
|
||||
handle_join_channel_result(env.join_channel_result());
|
||||
break;
|
||||
case voicecat::v1::Envelope::kTextMessage:
|
||||
handle_text_message(env.text_message());
|
||||
break;
|
||||
@@ -339,6 +423,19 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
|
||||
void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
|
||||
server_udp_port_ = static_cast<uint16_t>(msg.udp_port());
|
||||
|
||||
// M4: stash the declared Ed25519 fingerprint for vc_get_server_identity_display() —
|
||||
// display-only, not the TOFU-pinned value (that's the TLS cert fingerprint, gated before
|
||||
// ClientHello was even sent — see the TOFU block above in run_io()).
|
||||
{
|
||||
const std::string& raw = msg.server_identity_fingerprint();
|
||||
static const char* hex = "0123456789abcdef";
|
||||
std::string fp_hex;
|
||||
fp_hex.reserve(raw.size() * 2);
|
||||
for (unsigned char b : raw) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; }
|
||||
std::lock_guard<std::mutex> lk(tofu_mu_);
|
||||
pending_identity_fp_hex_ = std::move(fp_hex);
|
||||
}
|
||||
|
||||
// Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
|
||||
std::optional<PendingAuth> auth;
|
||||
{
|
||||
@@ -383,15 +480,42 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
|
||||
}
|
||||
|
||||
void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) {
|
||||
session_model_.apply_snapshot(snap);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
session_model_.apply_snapshot(snap);
|
||||
}
|
||||
for (const auto& u : snap.users()) sync_remote_streams(u);
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_CHANNEL_LIST;
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
session_model_.apply_channel_event(ce);
|
||||
}
|
||||
// Same "go look" signal vc_list_channels' callers already poll on after the initial
|
||||
// snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment.
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_CHANNEL_LIST;
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) {
|
||||
vc_event ev{};
|
||||
ev.type = VC_EVENT_JOIN_RESULT;
|
||||
ev.result = msg.ok() ? VC_OK : VC_ERR_PROTOCOL;
|
||||
ev.channel_id = msg.channel_id();
|
||||
if (!msg.ok()) ev.text = msg.error().c_str();
|
||||
emit(ev);
|
||||
}
|
||||
|
||||
void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
|
||||
session_model_.apply_user_event(ue);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
session_model_.apply_user_event(ue);
|
||||
}
|
||||
|
||||
vc_event ev{};
|
||||
const auto& user = ue.user();
|
||||
@@ -492,11 +616,16 @@ vc_result vc_client::authenticate_user(const char* username, const char* passwor
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::join_channel(uint32_t channel_id, const char* /*password*/) {
|
||||
vc_result vc_client::join_channel(uint32_t channel_id, const char* password) {
|
||||
if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
voicecat::v1::Envelope req;
|
||||
req.set_request_id(next_req_id_++);
|
||||
req.mutable_join_channel()->set_channel_id(channel_id);
|
||||
auto* jc = req.mutable_join_channel();
|
||||
jc->set_channel_id(channel_id);
|
||||
// See voicecat.h's vc_join_channel doc comment: wired through to the wire message, but no
|
||||
// in-tree channel has a server-side password to check yet (no channel-creation feature
|
||||
// exists — M5+).
|
||||
if (password) jc->set_password(password);
|
||||
queue_envelope(req);
|
||||
return VC_OK;
|
||||
}
|
||||
@@ -662,13 +791,15 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples) {
|
||||
// bypass this: gating a screen-share on the user's own voice activity would silently drop
|
||||
// shared music/video audio whenever the user isn't talking, which defeats the feature.
|
||||
if (kind == static_cast<int>(VC_STREAM_MIC)) {
|
||||
if (current_input_mode_.load(std::memory_order_acquire) == VC_INPUT_PUSH_TO_TALK) {
|
||||
auto mode = current_input_mode_.load(std::memory_order_acquire);
|
||||
if (mode == VC_INPUT_PUSH_TO_TALK) {
|
||||
if (!ptt_active_.load(std::memory_order_acquire)) return; // gate closed
|
||||
} else if (mic_vad_) {
|
||||
} else if (mode == VC_INPUT_VOICE_ACTIVATION && mic_vad_) {
|
||||
// EnergyVadProcessor never writes through the pointer (see apm_processor.cpp); the
|
||||
// const_cast is safe and avoids splitting ApmProcessor's interface just for this.
|
||||
if (!mic_vad_->process_capture(const_cast<int16_t*>(pcm), samples, 48000)) return;
|
||||
}
|
||||
// VC_INPUT_ALWAYS_ON: no gate — fall through and always send.
|
||||
}
|
||||
|
||||
if (!media_send_crypto_) return;
|
||||
@@ -875,7 +1006,8 @@ void vc_client::handle_stream_announce_result(uint64_t req_id,
|
||||
// Construct the MIC VAD once, here on io_thread_ (not the RT capture callback) — see
|
||||
// client.h's comment on mic_vad_.
|
||||
if (kind == static_cast<int>(VC_STREAM_MIC) && !mic_vad_) {
|
||||
mic_vad_ = voicecat::audio::ApmProcessor::create_vad();
|
||||
mic_vad_ = voicecat::audio::ApmProcessor::create_vad(
|
||||
vad_threshold_.load(std::memory_order_relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,6 +1093,13 @@ vc_result vc_client::set_input_mode(vc_input_mode mode) {
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::set_vad_threshold(float threshold) {
|
||||
if (threshold < 0.0f || threshold > 1.0f) return VC_ERR_INVALID_ARG;
|
||||
vad_threshold_.store(threshold, std::memory_order_relaxed);
|
||||
if (mic_vad_) mic_vad_->set_threshold(threshold);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::set_push_to_talk(bool active) {
|
||||
ptt_active_.store(active, std::memory_order_release);
|
||||
return VC_OK;
|
||||
@@ -982,17 +1121,22 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) {
|
||||
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
|
||||
bool muted, bool noise_reduction) {
|
||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||
const auto* user = session_model_.find_user(user_id);
|
||||
if (!user) return VC_ERR_INVALID_ARG;
|
||||
for (const auto& s : user->streams) {
|
||||
if (s.stream_id == stream_id) {
|
||||
audio_engine_.set_stream_gain(s.ssrc, gain);
|
||||
audio_engine_.set_stream_mute(s.ssrc, muted);
|
||||
audio_engine_.set_stream_noise_reduction(s.ssrc, noise_reduction);
|
||||
return VC_OK;
|
||||
uint32_t ssrc = 0;
|
||||
bool found = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
const auto* user = session_model_.find_user(user_id);
|
||||
if (user) {
|
||||
for (const auto& s : user->streams) {
|
||||
if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return VC_ERR_INVALID_ARG;
|
||||
if (!found) return VC_ERR_INVALID_ARG;
|
||||
audio_engine_.set_stream_gain(ssrc, gain);
|
||||
audio_engine_.set_stream_mute(ssrc, muted);
|
||||
audio_engine_.set_stream_noise_reduction(ssrc, noise_reduction);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
||||
@@ -1017,6 +1161,7 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
const auto* user = session_model_.find_user(user_id);
|
||||
if (!user) return VC_ERR_INVALID_ARG;
|
||||
for (const auto& s : user->streams) {
|
||||
@@ -1079,6 +1224,88 @@ vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) {
|
||||
#endif
|
||||
}
|
||||
|
||||
// ── M4: channel/user/stream snapshot getters ─────────────────────────────────
|
||||
|
||||
vc_result vc_client::list_channels(vc_channel_list* out) {
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
const auto& channels = session_model_.channels();
|
||||
auto* items = new vc_channel[channels.size()];
|
||||
for (size_t i = 0; i < channels.size(); ++i) {
|
||||
const auto& ch = channels[i];
|
||||
auto* name = new char[ch.name.size() + 1];
|
||||
std::memcpy(name, ch.name.c_str(), ch.name.size() + 1);
|
||||
items[i].id = ch.id;
|
||||
items[i].parent_id = ch.parent_id;
|
||||
items[i].name = name;
|
||||
items[i].password_protected = ch.password_protected ? 1 : 0;
|
||||
items[i].max_users = ch.max_users;
|
||||
}
|
||||
out->items = items;
|
||||
out->count = channels.size();
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::list_users(vc_user_list* out) {
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
const auto& users = session_model_.users();
|
||||
auto* items = new vc_user[users.size()];
|
||||
for (size_t i = 0; i < users.size(); ++i) {
|
||||
const auto& u = users[i];
|
||||
auto* nick = new char[u.nickname.size() + 1];
|
||||
std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1);
|
||||
items[i].id = u.id;
|
||||
items[i].nickname = nick;
|
||||
items[i].is_guest = u.is_guest ? 1 : 0;
|
||||
items[i].channel_id = u.channel_id;
|
||||
}
|
||||
out->items = items;
|
||||
out->count = users.size();
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list* out) {
|
||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||
const auto* user = session_model_.find_user(user_id);
|
||||
if (!user) return VC_ERR_INVALID_ARG;
|
||||
auto* items = new vc_stream_summary[user->streams.size()];
|
||||
for (size_t i = 0; i < user->streams.size(); ++i) {
|
||||
const auto& s = user->streams[i];
|
||||
auto* label = new char[s.label.size() + 1];
|
||||
std::memcpy(label, s.label.c_str(), s.label.size() + 1);
|
||||
items[i].stream_id = s.stream_id;
|
||||
items[i].kind = static_cast<vc_stream_kind>(s.kind);
|
||||
items[i].label = label;
|
||||
}
|
||||
out->items = items;
|
||||
out->count = user->streams.size();
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
// ── M4: TOFU server-identity gate ─────────────────────────────────────────────
|
||||
|
||||
vc_result vc_client::confirm_server_identity(bool accept) {
|
||||
std::lock_guard<std::mutex> lk(tofu_mu_);
|
||||
if (!tofu_decision_pending_) return VC_ERR_INVALID_ARG;
|
||||
tofu_accept_ = accept;
|
||||
tofu_decision_pending_ = false;
|
||||
tofu_cv_.notify_all();
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap,
|
||||
size_t* out_len) {
|
||||
std::string display;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(tofu_mu_);
|
||||
display = pending_identity_fp_hex_;
|
||||
}
|
||||
if (out_len) *out_len = display.size();
|
||||
if (!out_buf) return VC_OK; // size-query mode
|
||||
if (buf_cap < display.size() + 1) return VC_ERR_INVALID_ARG;
|
||||
std::memcpy(out_buf, display.c_str(), display.size() + 1);
|
||||
return VC_OK;
|
||||
}
|
||||
|
||||
void vc_client::run_talk_timer() {
|
||||
while (!talk_timer_stop_.load(std::memory_order_acquire)) {
|
||||
// Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user,
|
||||
@@ -1148,6 +1375,7 @@ vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_
|
||||
vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_vad_threshold(float) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
|
||||
@@ -1165,5 +1393,25 @@ vc_result vc_client::get_stream_audio_config(uint32_t, uint32_t, vc_audio_config
|
||||
vc_result vc_client::test_inject_capture(uint32_t, const int16_t*, size_t) {
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::list_channels(vc_channel_list* out) {
|
||||
out->items = nullptr;
|
||||
out->count = 0;
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::list_users(vc_user_list* out) {
|
||||
out->items = nullptr;
|
||||
out->count = 0;
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::list_user_streams(uint32_t, vc_stream_summary_list* out) {
|
||||
out->items = nullptr;
|
||||
out->count = 0;
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
vc_result vc_client::confirm_server_identity(bool) { return VC_ERR_NOT_IMPLEMENTED; }
|
||||
vc_result vc_client::get_server_identity_display(char*, size_t, size_t* out_len) {
|
||||
if (out_len) *out_len = 0;
|
||||
return VC_ERR_NOT_IMPLEMENTED;
|
||||
}
|
||||
|
||||
#endif // VOICECAT_HAS_NET
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "audio/audio_engine.h"
|
||||
#include "codec/opus_codec.h"
|
||||
#include "crypto/crypto.h"
|
||||
#include "crypto/tofu_store.h"
|
||||
#include "net/voice_frame.h"
|
||||
#include "protocol/envelope.h"
|
||||
#include "protocol/protocol.h"
|
||||
@@ -49,6 +50,7 @@ struct vc_client {
|
||||
vc_result stream_stop(uint32_t stream_id);
|
||||
vc_result set_input_device(uint32_t stream_id, const char* device_id);
|
||||
vc_result set_input_mode(vc_input_mode mode);
|
||||
vc_result set_vad_threshold(float threshold);
|
||||
vc_result set_push_to_talk(bool active);
|
||||
vc_result set_self_mute(bool mic_muted, bool deafened);
|
||||
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
|
||||
@@ -58,6 +60,15 @@ struct vc_client {
|
||||
|
||||
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
|
||||
|
||||
// M4: channel/user/stream snapshot getters (read session_model_; see voicecat.h).
|
||||
vc_result list_channels(vc_channel_list* out);
|
||||
vc_result list_users(vc_user_list* out);
|
||||
vc_result list_user_streams(uint32_t user_id, vc_stream_summary_list* out);
|
||||
|
||||
// M4: TOFU server-identity gate (see voicecat.h's VC_EVENT_SERVER_IDENTITY doc comment).
|
||||
vc_result confirm_server_identity(bool accept);
|
||||
vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len);
|
||||
|
||||
// M3: effective Opus config for a (user_id, stream_id) — our own pending/active local
|
||||
// streams, or any peer's broadcast StreamInfo.audio.
|
||||
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
||||
@@ -113,8 +124,19 @@ struct vc_client {
|
||||
uint64_t server_session_id_{0};
|
||||
std::atomic<uint64_t> next_req_id_{1};
|
||||
|
||||
// Client-side session model
|
||||
// Client-side session model. Mutated only on io_thread_ (handle_server_state/
|
||||
// handle_user_event/handle_channel_event), but read from any thread via the M4
|
||||
// list_channels/list_users/list_user_streams getters — session_model_mu_ guards both.
|
||||
voicecat::session::SessionModel session_model_;
|
||||
mutable std::mutex session_model_mu_;
|
||||
|
||||
// ── M4: TOFU server-identity gate ───────────────────────────────────────────
|
||||
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
|
||||
std::mutex tofu_mu_;
|
||||
std::condition_variable tofu_cv_;
|
||||
bool tofu_decision_pending_{false};
|
||||
bool tofu_accept_{false};
|
||||
std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only
|
||||
|
||||
// ── M2: UDP / media plane ────────────────────────────────────────────────────
|
||||
std::array<uint8_t, 16> udp_token_{};
|
||||
@@ -187,6 +209,7 @@ struct vc_client {
|
||||
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
|
||||
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
|
||||
std::atomic<bool> ptt_active_{false};
|
||||
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
|
||||
std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
|
||||
|
||||
// teardown_voice() is called both from run_io()'s own cleanup (on the io_thread_, when
|
||||
@@ -206,6 +229,8 @@ struct vc_client {
|
||||
void handle_auth_result(const voicecat::v1::AuthResult& msg);
|
||||
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
|
||||
void handle_user_event(const voicecat::v1::UserEvent& ue);
|
||||
void handle_channel_event(const voicecat::v1::ChannelEvent& ce);
|
||||
void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg);
|
||||
void handle_text_message(const voicecat::v1::TextMessage& msg);
|
||||
void handle_disconnect(const voicecat::v1::Disconnect& msg);
|
||||
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);
|
||||
|
||||
@@ -283,6 +283,14 @@ bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, s
|
||||
ctx, ctx_len, ctx != nullptr) == 0;
|
||||
}
|
||||
|
||||
bool TlsContext::peer_cert_fingerprint(std::array<uint8_t, 32>& out) const {
|
||||
if (!ready_) return false;
|
||||
const mbedtls_x509_crt* peer = mbedtls_ssl_get_peer_cert(&ssl_);
|
||||
if (!peer) return false;
|
||||
mbedtls_sha256(peer->raw.p, peer->raw.len, out.data(), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── SodiumMediaCrypto ─────────────────────────────────────────────────────────
|
||||
|
||||
SodiumMediaCrypto::SodiumMediaCrypto(
|
||||
|
||||
@@ -88,6 +88,13 @@ class TlsContext {
|
||||
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
||||
uint8_t* out, size_t out_len);
|
||||
|
||||
// M4 TOFU: SHA-256 of the peer's leaf X.509 certificate (DER), valid only after a
|
||||
// successful Role::Client handshake(). This is the value vc_client pins — see
|
||||
// voicecat.h's vc_tofu_status doc comment for why the cert fingerprint is pinned instead
|
||||
// of the declared Ed25519 server_identity_fingerprint. Returns false if no peer cert is
|
||||
// available (e.g. Role::Server, or handshake() hasn't succeeded).
|
||||
bool peer_cert_fingerprint(std::array<uint8_t, 32>& out) const;
|
||||
|
||||
// Whether the handshake completed.
|
||||
bool ready() const { return ready_; }
|
||||
|
||||
|
||||
@@ -25,6 +25,21 @@ TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port,
|
||||
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
|
||||
}
|
||||
|
||||
TofuResult TofuStore::peek(const std::string& host, uint16_t port,
|
||||
const std::array<uint8_t, 32>& fingerprint) const {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
auto it = pins_.find(make_key(host, port));
|
||||
if (it == pins_.end()) return TofuResult::FirstConnect;
|
||||
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
|
||||
}
|
||||
|
||||
void TofuStore::pin(const std::string& host, uint16_t port,
|
||||
const std::array<uint8_t, 32>& fingerprint) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
pins_[make_key(host, port)] = fingerprint;
|
||||
save();
|
||||
}
|
||||
|
||||
void TofuStore::remove(const std::string& host, uint16_t port) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
pins_.erase(make_key(host, port));
|
||||
|
||||
@@ -29,9 +29,21 @@ class TofuStore {
|
||||
|
||||
// Check the fingerprint for host:port. Stores on first connect.
|
||||
// Thread-safe (single-writer lock).
|
||||
// NOTE: kept for compatibility; the M4 gated-confirmation flow (vc_client) uses peek()
|
||||
// + pin() instead, since check_and_pin's unconditional first-connect write is wrong for a
|
||||
// flow where the application must approve the fingerprint before it's trusted/persisted.
|
||||
TofuResult check_and_pin(const std::string& host, uint16_t port,
|
||||
const std::array<uint8_t, 32>& fingerprint);
|
||||
|
||||
// Read-only — classifies the fingerprint against any existing pin WITHOUT writing to disk.
|
||||
// Use this before the application has had a chance to approve a first-connect/mismatch.
|
||||
TofuResult peek(const std::string& host, uint16_t port,
|
||||
const std::array<uint8_t, 32>& fingerprint) const;
|
||||
|
||||
// Persist the pin for host:port. Call only after the caller has accepted a FIRST_CONNECT
|
||||
// or MISMATCH classification from peek() — accepting a MATCHED result needs no call here.
|
||||
void pin(const std::string& host, uint16_t port, const std::array<uint8_t, 32>& fingerprint);
|
||||
|
||||
// Remove the pin for host:port (e.g. after user explicitly acknowledges a key change).
|
||||
void remove(const std::string& host, uint16_t port);
|
||||
|
||||
@@ -44,7 +56,7 @@ class TofuStore {
|
||||
void save() const;
|
||||
|
||||
std::filesystem::path path_;
|
||||
std::mutex mu_;
|
||||
mutable std::mutex mu_;
|
||||
std::unordered_map<std::string, std::array<uint8_t, 32>> pins_;
|
||||
};
|
||||
|
||||
|
||||
@@ -324,11 +324,34 @@ void TcpServerConn::close() {
|
||||
|
||||
// ── TcpAcceptor ─────────────────────────────────────────────────────────────
|
||||
|
||||
TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory)
|
||||
: acceptor_(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)),
|
||||
factory_(std::move(factory)) {
|
||||
acceptor_.set_option(asio::ip::tcp::acceptor::reuse_address(true));
|
||||
namespace {
|
||||
// Try IPv6 dual-stack first (one socket handles both ::1 and 127.0.0.1 — fixes the common
|
||||
// Windows case where `localhost` resolves to ::1 before 127.0.0.1). Falls back to IPv4-only
|
||||
// if the OS has IPv6 disabled or the dual-stack bind fails for any reason.
|
||||
asio::ip::tcp::acceptor make_acceptor(asio::io_context& io, uint16_t port) {
|
||||
asio::ip::tcp::acceptor acc(io);
|
||||
std::error_code ec;
|
||||
acc.open(asio::ip::tcp::v6(), ec);
|
||||
if (!ec) {
|
||||
acc.set_option(asio::ip::v6_only(false), ec); // dual-stack
|
||||
acc.set_option(asio::ip::tcp::acceptor::reuse_address(true));
|
||||
acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v6(), port), ec);
|
||||
if (!ec) acc.listen(asio::socket_base::max_listen_connections, ec);
|
||||
}
|
||||
if (ec) {
|
||||
if (acc.is_open()) { std::error_code ignored; acc.close(ignored); }
|
||||
acc.open(asio::ip::tcp::v4());
|
||||
acc.set_option(asio::ip::tcp::acceptor::reuse_address(true));
|
||||
acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port));
|
||||
acc.listen(asio::socket_base::max_listen_connections);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory)
|
||||
: acceptor_(make_acceptor(io, port)),
|
||||
factory_(std::move(factory)) {}
|
||||
|
||||
void TcpAcceptor::start() { do_accept(); }
|
||||
|
||||
|
||||
@@ -55,8 +55,11 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
|
||||
channels_.clear();
|
||||
for (const auto& pb : snap.channels()) {
|
||||
Channel ch;
|
||||
ch.id = pb.id();
|
||||
ch.name = pb.name();
|
||||
ch.id = pb.id();
|
||||
ch.parent_id = pb.parent_id();
|
||||
ch.name = pb.name();
|
||||
ch.password_protected = pb.password_protected();
|
||||
ch.max_users = pb.max_users();
|
||||
channels_.push_back(std::move(ch));
|
||||
}
|
||||
|
||||
@@ -103,8 +106,11 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
|
||||
if (ev.kind() == Kind::CREATED || ev.kind() == Kind::UPDATED) {
|
||||
const auto& pb = ev.channel();
|
||||
Channel ch;
|
||||
ch.id = pb.id();
|
||||
ch.name = pb.name();
|
||||
ch.id = pb.id();
|
||||
ch.parent_id = pb.parent_id();
|
||||
ch.name = pb.name();
|
||||
ch.password_protected = pb.password_protected();
|
||||
ch.max_users = pb.max_users();
|
||||
|
||||
auto it = std::find_if(channels_.begin(), channels_.end(),
|
||||
[&](const Channel& x) { return x.id == ch.id; });
|
||||
@@ -112,7 +118,11 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
|
||||
else channels_.push_back(std::move(ch));
|
||||
|
||||
} else if (ev.kind() == Kind::DELETED) {
|
||||
uint32_t cid = ev.channel().id();
|
||||
// deleted_id, not channel().id() — the proto leaves `channel` unset for deletes
|
||||
// (docs/protocol.md, core/proto/voicecat.proto's ChannelEvent). Pre-existing bug, dead
|
||||
// code until something actually emits ChannelEvent (no channel CRUD exists yet — M5+),
|
||||
// fixed here while touching this function for the M4 field-population fix.
|
||||
uint32_t cid = ev.deleted_id();
|
||||
channels_.erase(std::remove_if(channels_.begin(), channels_.end(),
|
||||
[cid](const Channel& x) { return x.id == cid; }),
|
||||
channels_.end());
|
||||
|
||||
@@ -102,6 +102,11 @@ vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode) {
|
||||
return c->set_input_mode(mode);
|
||||
}
|
||||
|
||||
vc_result vc_set_vad_threshold(vc_client* c, float threshold) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->set_vad_threshold(threshold);
|
||||
}
|
||||
|
||||
vc_result vc_set_push_to_talk(vc_client* c, int active) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->set_push_to_talk(active != 0);
|
||||
@@ -152,4 +157,54 @@ void vc_free_device_list(vc_device_list* list) {
|
||||
list->count = 0;
|
||||
}
|
||||
|
||||
vc_result vc_list_channels(vc_client* c, vc_channel_list* out) {
|
||||
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->list_channels(out);
|
||||
}
|
||||
|
||||
void vc_free_channel_list(vc_channel_list* list) {
|
||||
if (list == nullptr || list->items == nullptr) return;
|
||||
for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].name;
|
||||
delete[] list->items;
|
||||
list->items = nullptr;
|
||||
list->count = 0;
|
||||
}
|
||||
|
||||
vc_result vc_list_users(vc_client* c, vc_user_list* out) {
|
||||
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->list_users(out);
|
||||
}
|
||||
|
||||
void vc_free_user_list(vc_user_list* list) {
|
||||
if (list == nullptr || list->items == nullptr) return;
|
||||
for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].nickname;
|
||||
delete[] list->items;
|
||||
list->items = nullptr;
|
||||
list->count = 0;
|
||||
}
|
||||
|
||||
vc_result vc_list_user_streams(vc_client* c, uint32_t user_id, vc_stream_summary_list* out) {
|
||||
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->list_user_streams(user_id, out);
|
||||
}
|
||||
|
||||
void vc_free_stream_summary_list(vc_stream_summary_list* list) {
|
||||
if (list == nullptr || list->items == nullptr) return;
|
||||
for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].label;
|
||||
delete[] list->items;
|
||||
list->items = nullptr;
|
||||
list->count = 0;
|
||||
}
|
||||
|
||||
vc_result vc_confirm_server_identity(vc_client* c, int accept) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->confirm_server_identity(accept != 0);
|
||||
}
|
||||
|
||||
vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
|
||||
size_t* out_len) {
|
||||
if (c == nullptr) return VC_ERR_INVALID_ARG;
|
||||
return c->get_server_identity_display(out_buf, buf_cap, out_len);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
Reference in New Issue
Block a user