chore: comment cleanup pass ahead of open-sourcing
Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces, dead nick_buf_ptr, a no-op --print-config flag now implemented for real), fixes stale/misleading comments (channel passwords are no longer a "future M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention, an incomplete BanRecord::subject_type doc, and a smoke test pointing at a build/m1-dev preset that no longer exists), strips internal M1-M5 milestone jargon from comments now that the roadmap is done, trims comments that just restated the following line, and consolidates a few "why" explanations that were duplicated 2-3 times in the same file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,7 +36,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Apple / Windows client build artifacts (added in M4)
|
# Apple / Windows client build artifacts
|
||||||
clients/apple/**/build/
|
clients/apple/**/build/
|
||||||
clients/apple/**/*.xcodeproj/xcuserdata/
|
clients/apple/**/*.xcodeproj/xcuserdata/
|
||||||
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
clients/apple/**/*.xcodeproj/project.xcworkspace/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dev",
|
"name": "dev",
|
||||||
"displayName": "Dev (full real-deps build, vcpkg)",
|
"displayName": "Dev (full real-deps build, vcpkg)",
|
||||||
"description": "Day-to-day development preset. Real protocol, crypto, voice, server — everything from M1 onward. Builds server + tools + tests (21 tests). Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires VCPKG_ROOT.",
|
"description": "Day-to-day development preset. Real protocol, crypto, voice, server. Builds server + tools + tests. Auto-triplet: x64-mingw-static on Windows, x64-linux on Linux, arm64-osx on Apple Silicon. Requires VCPKG_ROOT.",
|
||||||
"inherits": "vcpkg-common",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/dev",
|
"binaryDir": "${sourceDir}/build/dev",
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "windows-client",
|
"name": "windows-client",
|
||||||
"displayName": "Windows client (voicecat.dll for C# WinForms, M4)",
|
"displayName": "Windows client (voicecat.dll for C# WinForms)",
|
||||||
"description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.",
|
"description": "Produces a redistributable Release voicecat.dll with no MinGW runtime DLL dependencies (see core/CMakeLists.txt's static-runtime link flags and clients/windows/README.md). Server/tools/tests are off — this preset exists only to build the DLL. Windows only.",
|
||||||
"inherits": "vcpkg-common",
|
"inherits": "vcpkg-common",
|
||||||
"binaryDir": "${sourceDir}/build/windows-client",
|
"binaryDir": "${sourceDir}/build/windows-client",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
|
|||||||
case tlsHandshake = 2
|
case tlsHandshake = 2
|
||||||
case authenticating = 3
|
case authenticating = 3
|
||||||
case connected = 4
|
case connected = 4
|
||||||
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
|
/// Handshake succeeded, waiting on `confirmServerIdentity()`.
|
||||||
case verifyingIdentity = 5
|
case verifyingIdentity = 5
|
||||||
|
|
||||||
public init(_ cValue: vc_connection_state) {
|
public init(_ cValue: vc_connection_state) {
|
||||||
@@ -125,13 +125,13 @@ public enum VoiceCatEventType: UInt32, Sendable, Equatable {
|
|||||||
case talkState = 9
|
case talkState = 9
|
||||||
case error = 10
|
case error = 10
|
||||||
case disconnected = 11
|
case disconnected = 11
|
||||||
/// M4: reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
/// Reply to `joinChannel()` — see `VoiceCatEvent.result` / `.channelId`.
|
||||||
case joinResult = 12
|
case joinResult = 12
|
||||||
/// M4: the TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
/// The TOFU server-identity gate — see `VoiceCatEvent.tofuStatus` / `.text`.
|
||||||
case serverIdentity = 13
|
case serverIdentity = 13
|
||||||
/// M5: async result for moderation/admin/channel operations.
|
/// Async result for moderation/admin/channel operations.
|
||||||
case genericResult = 14
|
case genericResult = 14
|
||||||
/// M5: reply to `requestAccountList()` — call `listAccounts()` to read.
|
/// Reply to `requestAccountList()` — call `listAccounts()` to read.
|
||||||
case accountList = 15
|
case accountList = 15
|
||||||
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
|
/// Voice-plane subscription state. `u32a` = 1 (subscribed) or 0 (unsubscribed).
|
||||||
case voiceState = 16
|
case voiceState = 16
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ public struct User: Sendable, Equatable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permission bitset — mirrors `vc_permissions` (M5).
|
/// Permission bitset — mirrors `vc_permissions`.
|
||||||
public struct Permissions: Sendable, Equatable {
|
public struct Permissions: Sendable, Equatable {
|
||||||
public let canCreateTempChannel: Bool
|
public let canCreateTempChannel: Bool
|
||||||
public let canKick: Bool
|
public let canKick: Bool
|
||||||
@@ -91,7 +91,7 @@ public struct Permissions: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Account entry — mirrors `vc_account` (M5, reply to `listAccounts()`).
|
/// Account entry — mirrors `vc_account` (reply to `listAccounts()`).
|
||||||
public struct Account: Sendable, Equatable {
|
public struct Account: Sendable, Equatable {
|
||||||
public let username: String
|
public let username: String
|
||||||
public let isAdmin: Bool
|
public let isAdmin: Bool
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
VoiceCatResult(vc_authenticate_user(handle, username, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TOFU server-identity gate (M4)
|
// MARK: - TOFU server-identity gate
|
||||||
|
|
||||||
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
|
||||||
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
|
||||||
@@ -413,7 +413,7 @@ public final class VoiceCatClient {
|
|||||||
|
|
||||||
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
/// Global playback volume applied after mixing all remote streams. gain 0.0 = silent,
|
||||||
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
/// 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. Mirrors the
|
||||||
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume` added in M5.
|
/// Windows client's `SetOutputVolume` and the C ABI `vc_set_output_volume`.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
public func setOutputVolume(_ gain: Float) -> VoiceCatResult {
|
||||||
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain))
|
||||||
@@ -500,7 +500,7 @@ public final class VoiceCatClient {
|
|||||||
return Marshaling.devices(&native)
|
return Marshaling.devices(&native)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Moderation
|
// MARK: - Moderation
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
|
||||||
@@ -535,7 +535,7 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
VoiceCatResult(vc_move_user(handle, userId, channelId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Channel admin
|
// MARK: - Channel admin
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
|
||||||
@@ -558,7 +558,7 @@ public final class VoiceCatClient {
|
|||||||
VoiceCatResult(vc_delete_channel(handle, channelId))
|
VoiceCatResult(vc_delete_channel(handle, channelId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Account admin
|
// MARK: - Account admin
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ private final class ServerHarness {
|
|||||||
}
|
}
|
||||||
self.port = port
|
self.port = port
|
||||||
|
|
||||||
// Provision a known admin account for moderation/admin tests (M5).
|
// Provision a known admin account for moderation/admin tests.
|
||||||
let adminURL = URL(fileURLWithPath: repoRoot)
|
let adminURL = URL(fileURLWithPath: repoRoot)
|
||||||
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
.appendingPathComponent("build/dev/bin/voicecat-admin")
|
||||||
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
|
||||||
@@ -252,12 +252,12 @@ final class VoiceCatClientSmokeTests: XCTestCase {
|
|||||||
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
|
||||||
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
"expected Lobby (channel 1) in \(channels.map { $0.name })")
|
||||||
|
|
||||||
// M5: permissions getter round-trip.
|
// Permissions getter round-trip.
|
||||||
let perms = client.getPermissions()
|
let perms = client.getPermissions()
|
||||||
XCTAssertFalse(perms.isAdmin)
|
XCTAssertFalse(perms.isAdmin)
|
||||||
XCTAssertFalse(perms.canKick)
|
XCTAssertFalse(perms.canKick)
|
||||||
|
|
||||||
// M5: guest ListAccounts is rejected by the server with a GenericResult — proves the
|
// Guest ListAccounts is rejected by the server with a GenericResult — proves the
|
||||||
// moderation wrapper path works end-to-end through the Swift interop layer.
|
// moderation wrapper path works end-to-end through the Swift interop layer.
|
||||||
events.removeAll()
|
events.removeAll()
|
||||||
XCTAssertEqual(client.requestAccountList(), .ok)
|
XCTAssertEqual(client.requestAccountList(), .ok)
|
||||||
|
|||||||
@@ -116,16 +116,6 @@ final class AppState {
|
|||||||
ServerListStore.shared.save(servers)
|
ServerListStore.shared.save(servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Teardown
|
|
||||||
|
|
||||||
/// `AppState` is the @Observable app root owned by the SwiftUI `App`; it lives for the
|
|
||||||
/// whole app process and is torn down only on process exit, at which point OS cleanup
|
|
||||||
/// suffices. Reconnect state (the in-flight `Task` and the `NWPathMonitor`) is cancelled
|
|
||||||
/// via `cancelReconnect()` driven by `disconnect()`/`cancelConnect()` and on successful
|
|
||||||
/// restore — those run on user-initiated teardown, which is the only path that matters.
|
|
||||||
/// (The `Task` captures `[weak self]` and guards on `nil`/`userInitiatedDisconnect`, so a
|
|
||||||
/// stray task left running when AppState is gone is a no-op; the monitor similarly guards.)
|
|
||||||
|
|
||||||
// MARK: - Connect flow
|
// MARK: - Connect flow
|
||||||
|
|
||||||
/// Public connect entry. Always starts a fresh session (no restore).
|
/// Public connect entry. Always starts a fresh session (no restore).
|
||||||
@@ -245,6 +235,13 @@ final class AppState {
|
|||||||
/// Cancel any in-flight reconnect task and stop the path monitor. Safe to call when nothing
|
/// Cancel any in-flight reconnect task and stop the path monitor. Safe to call when nothing
|
||||||
/// is armed (no-op). Does NOT touch `userInitiatedDisconnect` or `lastSession` — callers set
|
/// is armed (no-op). Does NOT touch `userInitiatedDisconnect` or `lastSession` — callers set
|
||||||
/// those as needed (disconnect/cancelConnect clear them; scheduleReconnect keeps them).
|
/// those as needed (disconnect/cancelConnect clear them; scheduleReconnect keeps them).
|
||||||
|
///
|
||||||
|
/// `AppState` is the @Observable app root owned by the SwiftUI `App`; it lives for the
|
||||||
|
/// whole app process and is torn down only on process exit, at which point OS cleanup
|
||||||
|
/// suffices. This method is driven by `disconnect()`/`cancelConnect()` and on successful
|
||||||
|
/// restore — those run on user-initiated teardown, which is the only path that matters.
|
||||||
|
/// (The reconnect `Task` captures `[weak self]` and guards on `nil`/`userInitiatedDisconnect`,
|
||||||
|
/// so a stray task left running when AppState is gone is a no-op; the monitor similarly guards.)
|
||||||
private func cancelReconnect() {
|
private func cancelReconnect() {
|
||||||
reconnectTask?.cancel()
|
reconnectTask?.cancel()
|
||||||
reconnectTask = nil
|
reconnectTask = nil
|
||||||
@@ -310,15 +307,12 @@ final class AppState {
|
|||||||
if prevSig == nil { return }
|
if prevSig == nil { return }
|
||||||
|
|
||||||
if self.session != nil {
|
if self.session != nil {
|
||||||
// Connected — tear down + reconnect on a meaningful path change.
|
// See this method's doc comment for why these conditions trigger a
|
||||||
// `.unsatisfied` (all radios off) OR a primary-interface change (Wi-Fi↔cellular)
|
// proactive reconnect.
|
||||||
// almost always breaks the live TCP connection; reconnecting proactively beats
|
|
||||||
// waiting for the C core's keepalive/reaper timeout.
|
|
||||||
if path.status != .satisfied || sig != prevSig {
|
if path.status != .satisfied || sig != prevSig {
|
||||||
self.proactiveReconnect()
|
self.proactiveReconnect()
|
||||||
}
|
}
|
||||||
} else if self.lastSession != nil {
|
} else if self.lastSession != nil {
|
||||||
// Mid-reconnect — a path is available again; fast-fresh the next attempt.
|
|
||||||
if path.status == .satisfied {
|
if path.status == .satisfied {
|
||||||
self.reconnectAttempt = 0
|
self.reconnectAttempt = 0
|
||||||
self.scheduleReconnect()
|
self.scheduleReconnect()
|
||||||
|
|||||||
@@ -163,13 +163,8 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
|
||||||
private var isApplyingConfiguration = false
|
private var isApplyingConfiguration = false
|
||||||
|
|
||||||
/// Last `overrideOutputAudioPort` value we successfully applied (`.none` or `.speaker`),
|
/// Last `overrideOutputAudioPort` value we successfully applied (`.none` or `.speaker`).
|
||||||
/// so `applyA2dpSpeakerFallback` can skip a redundant `overrideOutputAudioPort` call.
|
/// See `applyA2dpSpeakerFallback`'s doc comment for why this cache exists.
|
||||||
/// That call fires a `.override` route-change notification on every invocation, and on
|
|
||||||
/// an AirPods disconnect the fallback is invoked once per `recoverAudio()` — which
|
|
||||||
/// itself fires on every non-skipped route change — so without this guard the override
|
|
||||||
/// call and the route-change handler ping-pong: the AirPods-disconnect reinitialize
|
|
||||||
/// loop (each iteration also rebuilds the AVAudioEngine via reconfigure()).
|
|
||||||
/// `nil` = "unknown / assume not applied" — reset at the top of `applyConfiguration()`
|
/// `nil` = "unknown / assume not applied" — reset at the top of `applyConfiguration()`
|
||||||
/// because `setCategory` can reset the override out from under us, and on first run.
|
/// because `setCategory` can reset the override out from under us, and on first run.
|
||||||
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
||||||
@@ -382,13 +377,8 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
|
|
||||||
// 3. Input & mic-capsule configuration.
|
// 3. Input & mic-capsule configuration.
|
||||||
if captureChannels == .stereo {
|
if captureChannels == .stereo {
|
||||||
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
|
// See configureStereoCapture's doc comment for the full stereo-capture recipe
|
||||||
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
|
// and why each step is necessary.
|
||||||
// the system routes input to the built-in mic, but without the explicit
|
|
||||||
// preferred-input anchor the route can collapse during the mode switch
|
|
||||||
// (.voiceChat → .default) and the output dies. The channel count is carried by the
|
|
||||||
// engine's mic tap + vc_set_capture_channels, NOT via
|
|
||||||
// setPreferredInputNumberOfChannels(2) — that call collapses the A2DP output route.
|
|
||||||
configureStereoCapture(session: session)
|
configureStereoCapture(session: session)
|
||||||
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
} else if let portId = selectedInputPortId, !portId.isEmpty,
|
||||||
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
|
||||||
@@ -434,8 +424,6 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
try builtIn.setPreferredDataSource(stereoSource)
|
try builtIn.setPreferredDataSource(stereoSource)
|
||||||
try stereoSource.setPreferredPolarPattern(.stereo)
|
try stereoSource.setPreferredPolarPattern(.stereo)
|
||||||
// Anchor the input route explicitly; without it the route can collapse during
|
|
||||||
// the mode switch (.voiceChat → .default) and the A2DP output dies.
|
|
||||||
try session.setPreferredInput(builtIn)
|
try session.setPreferredInput(builtIn)
|
||||||
// Commit the data source at the session level. setPreferredDataSource alone only
|
// Commit the data source at the session level. setPreferredDataSource alone only
|
||||||
// sets the port-level preference; setInputDataSource makes it the active source.
|
// sets the port-level preference; setInputDataSource makes it the active source.
|
||||||
@@ -697,8 +685,6 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
}
|
}
|
||||||
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
||||||
if desired == lastAppliedOutputOverride {
|
if desired == lastAppliedOutputOverride {
|
||||||
// Already in the desired state — calling overrideOutputAudioPort again would just
|
|
||||||
// fire a redundant `.override` route-change notification (the loop driver).
|
|
||||||
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ final class SessionState {
|
|||||||
var accounts: [Account] = []
|
var accounts: [Account] = []
|
||||||
var devices: [Device] = []
|
var devices: [Device] = []
|
||||||
|
|
||||||
/// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent`
|
/// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent`,
|
||||||
/// (`SessionState.swift:87`), `AppState.handleConnectEvent` no longer receives per-session
|
/// `AppState.handleConnectEvent` no longer receives per-session events — so the
|
||||||
/// events — so the `.disconnected` event for a LIVE session arrives here in `handleEvent`,
|
/// `.disconnected` event for a LIVE session arrives here in `handleEvent`, not in AppState.
|
||||||
/// not in AppState. This weak ref lets us hand the disconnect back to AppState (which owns
|
/// This weak ref lets us hand the disconnect back to AppState (which owns the reconnect
|
||||||
/// the reconnect state machine) so the auto-reconnect fires. Set by AppState on auth success.
|
/// state machine) so the auto-reconnect fires. Set by AppState on auth success.
|
||||||
weak var appState: AppState?
|
weak var appState: AppState?
|
||||||
|
|
||||||
// MARK: - Reconnect restore state
|
// MARK: - Reconnect restore state
|
||||||
@@ -260,17 +260,18 @@ final class SessionState {
|
|||||||
// MARK: - Self-channel / server-mute sync
|
// MARK: - Self-channel / server-mute sync
|
||||||
|
|
||||||
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
|
/// Sync currentChannelId from the self user's channelId in the user list. Mirrors macOS
|
||||||
/// MainWindowController.swift:461,491,522. The server auto-places every authed user into
|
/// MainWindowController's bootstrap/event-handling sync. The server auto-places every
|
||||||
/// the Lobby (channel 1) on connect, but without this sync currentChannelId stays 0 and
|
/// authed user into the Lobby (channel 1) on connect, but without this sync
|
||||||
/// the mic button (gated on currentChannelId == 0) stays permanently dimmed.
|
/// currentChannelId stays 0 and the mic button (gated on currentChannelId == 0) stays
|
||||||
|
/// permanently dimmed.
|
||||||
private func syncSelfChannel() {
|
private func syncSelfChannel() {
|
||||||
if let me = users.first(where: { $0.id == selfUserId }) {
|
if let me = users.first(where: { $0.id == selfUserId }) {
|
||||||
currentChannelId = me.channelId
|
currentChannelId = me.channelId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController.swift:693-700.
|
/// Apply server-side mute/deafen state — mirrors macOS MainWindowController's handling
|
||||||
/// iOS was previously ignoring server mute/deafen entirely.
|
/// of UserEvent.UPDATED for the self user.
|
||||||
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
private func applyServerMuteState(muted: Bool, deafened: Bool) {
|
||||||
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
if muted && !voiceState.serverMuted { addActivity("You have been server-muted") }
|
||||||
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
if deafened && !voiceState.serverDeafened { addActivity("You have been server-deafened") }
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ struct PerUserTuningView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Load from first stream if available
|
|
||||||
let streams = streamsForUser
|
let streams = streamsForUser
|
||||||
if let first = streams.first {
|
if let first = streams.first {
|
||||||
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
let (_, state) = session.client.getRemoteStream(userId: user.id, streamId: first.id)
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
private func buildUI() {
|
private func buildUI() {
|
||||||
guard let contentView = window?.contentView else { return }
|
guard let contentView = window?.contentView else { return }
|
||||||
|
|
||||||
// Server list
|
|
||||||
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
|
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("server"))
|
||||||
col.title = "Saved Servers"
|
col.title = "Saved Servers"
|
||||||
serverTableView.addTableColumn(col)
|
serverTableView.addTableColumn(col)
|
||||||
@@ -61,7 +60,6 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
|
serverScrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(serverScrollView)
|
contentView.addSubview(serverScrollView)
|
||||||
|
|
||||||
// Buttons row
|
|
||||||
configureButton(addButton, title: "Add…", action: #selector(addClicked))
|
configureButton(addButton, title: "Add…", action: #selector(addClicked))
|
||||||
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
|
configureButton(editButton, title: "Edit…", action: #selector(editClicked))
|
||||||
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
|
configureButton(removeButton, title: "Remove", action: #selector(removeClicked))
|
||||||
@@ -72,13 +70,11 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
contentView.addSubview(buttonStack)
|
contentView.addSubview(buttonStack)
|
||||||
|
|
||||||
// Status
|
|
||||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
statusLabel.textColor = .secondaryLabelColor
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
statusLabel.setAccessibilityLabel("Connection status")
|
statusLabel.setAccessibilityLabel("Connection status")
|
||||||
contentView.addSubview(statusLabel)
|
contentView.addSubview(statusLabel)
|
||||||
|
|
||||||
// Connect button
|
|
||||||
connectButton.title = "Connect"
|
connectButton.title = "Connect"
|
||||||
connectButton.bezelStyle = .rounded
|
connectButton.bezelStyle = .rounded
|
||||||
connectButton.keyEquivalent = "\r"
|
connectButton.keyEquivalent = "\r"
|
||||||
@@ -376,7 +372,3 @@ extension ConnectWindowController: NSTableViewDataSource, NSTableViewDelegate {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
deinit {
|
deinit {}
|
||||||
NSLog("[VoiceCatMac] MainWindowController deinit — client and event handlers are gone")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UI construction
|
// MARK: - UI construction
|
||||||
|
|
||||||
@@ -455,10 +453,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
}
|
}
|
||||||
ownPermissions = client.getPermissions()
|
ownPermissions = client.getPermissions()
|
||||||
|
|
||||||
NSLog("[VoiceCatMac] bootstrap: channels=%d users=%d perms{admin=%d kick=%d} currentChannelId=%u",
|
|
||||||
channels.count, allUsers.count,
|
|
||||||
ownPermissions.isAdmin, ownPermissions.canKick, currentChannelId)
|
|
||||||
|
|
||||||
// Apply initial output volume (default 80% — matches Windows client)
|
// Apply initial output volume (default 80% — matches Windows client)
|
||||||
client.setOutputVolume(0.8)
|
client.setOutputVolume(0.8)
|
||||||
|
|
||||||
@@ -477,9 +471,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
// MARK: - Event handling
|
// MARK: - Event handling
|
||||||
|
|
||||||
private func handleEvent(_ event: VoiceCatEvent) {
|
private func handleEvent(_ event: VoiceCatEvent) {
|
||||||
NSLog("[VoiceCatMac] event type=%d result=%d userId=%u channelId=%u streamId=%u text=%@",
|
|
||||||
event.type.rawValue, event.result.rawValue, event.userId, event.channelId,
|
|
||||||
event.streamId, event.text ?? "(nil)")
|
|
||||||
switch event.type {
|
switch event.type {
|
||||||
case .channelList:
|
case .channelList:
|
||||||
channels = client.listChannels()
|
channels = client.listChannels()
|
||||||
@@ -1067,7 +1058,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
composeField.stringValue = ""
|
composeField.stringValue = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - M5: Moderation helpers
|
// MARK: - Moderation helpers
|
||||||
|
|
||||||
private func moveUser(_ user: User) {
|
private func moveUser(_ user: User) {
|
||||||
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
|
let sheet = MoveUserSheet(channels: channels, currentChannelId: user.channelId)
|
||||||
@@ -1241,7 +1232,6 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
presentSheet(sheet)
|
presentSheet(sheet)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a PM window from the user context menu.
|
|
||||||
private func openPmWindow(_ user: User) {
|
private func openPmWindow(_ user: User) {
|
||||||
getOrOpenPmWindow(user.id)
|
getOrOpenPmWindow(user.id)
|
||||||
}
|
}
|
||||||
@@ -1288,17 +1278,13 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
|
|||||||
func windowWillClose(_ notification: Notification) {
|
func windowWillClose(_ notification: Notification) {
|
||||||
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
|
if let mon = pttMonitor { NSEvent.removeMonitor(mon) }
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
// Close all PM windows
|
|
||||||
for (_, pmWin) in pmWindows { pmWin.close() }
|
for (_, pmWin) in pmWindows { pmWin.close() }
|
||||||
pmWindows.removeAll()
|
pmWindows.removeAll()
|
||||||
// Close settings window
|
|
||||||
settingsWindowController?.close()
|
settingsWindowController?.close()
|
||||||
settingsWindowController = nil
|
settingsWindowController = nil
|
||||||
// Remove app menus we added
|
|
||||||
if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) }
|
if let item = voiceMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||||
if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) }
|
if let item = messagesMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||||
if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) }
|
if let item = adminMenuItem { NSApp.mainMenu?.removeItem(item) }
|
||||||
// Remove Settings menu item + separator from app menu
|
|
||||||
if let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu {
|
if let appMenu = NSApp.mainMenu?.item(at: 0)?.submenu {
|
||||||
if let item = settingsMenuItem { appMenu.removeItem(item) }
|
if let item = settingsMenuItem { appMenu.removeItem(item) }
|
||||||
// Remove the separator we inserted before Quit
|
// Remove the separator we inserted before Quit
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ public sealed class ProcessAudioMixer : IDisposable
|
|||||||
for (int i = 0; i < frameLen; i++)
|
for (int i = 0; i < frameLen; i++)
|
||||||
{
|
{
|
||||||
int sum = mix[i] + frame[i];
|
int sum = mix[i] + frame[i];
|
||||||
// Saturating clamp
|
|
||||||
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
mix[i] = (short)Math.Clamp(sum, short.MinValue, short.MaxValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,13 +92,8 @@ public partial class ConnectDialog : Form
|
|||||||
|
|
||||||
private void BtnConnect_Click(object? sender, EventArgs e)
|
private void BtnConnect_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
Console.WriteLine("[ConnectDialog] BtnConnect_Click fired");
|
|
||||||
if (lstServers.SelectedItem is not SavedServer server)
|
if (lstServers.SelectedItem is not SavedServer server)
|
||||||
{
|
|
||||||
Console.WriteLine("[ConnectDialog] no SavedServer selected — ignoring click");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
Console.WriteLine($"[ConnectDialog] selected server: Host={server.Host} Port={server.Port} AuthMode={server.AuthMode}");
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
StartConnect(server);
|
StartConnect(server);
|
||||||
@@ -121,21 +116,15 @@ public partial class ConnectDialog : Form
|
|||||||
: server.DisplayName;
|
: server.DisplayName;
|
||||||
|
|
||||||
string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!;
|
string tofuDir = Path.GetDirectoryName(ServerListStore.TofuStorePath)!;
|
||||||
Console.WriteLine($"[ConnectDialog] tofu store dir: {tofuDir}");
|
|
||||||
Directory.CreateDirectory(tofuDir);
|
Directory.CreateDirectory(tofuDir);
|
||||||
|
|
||||||
Console.WriteLine("[ConnectDialog] creating VoiceCatClient...");
|
|
||||||
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
|
_client = new VoiceCatClient("VoiceCat-Windows", VoiceCatClient.VersionString,
|
||||||
VcLogLevel.Info, ServerListStore.TofuStorePath);
|
VcLogLevel.Info, ServerListStore.TofuStorePath);
|
||||||
Console.WriteLine("[ConnectDialog] VoiceCatClient created OK");
|
|
||||||
_client.EventReceived += OnEvent;
|
_client.EventReceived += OnEvent;
|
||||||
_identityDialogShown = false;
|
_identityDialogShown = false;
|
||||||
_pumpTimer.Start();
|
_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);
|
var connectResult = _client.Connect(server.Host, server.Port);
|
||||||
Console.WriteLine($"[ConnectDialog] Connect() returned {connectResult}");
|
|
||||||
if (connectResult != VcResult.Ok)
|
if (connectResult != VcResult.Ok)
|
||||||
{
|
{
|
||||||
lblStatus.Text = $"Connect failed: {connectResult}";
|
lblStatus.Text = $"Connect failed: {connectResult}";
|
||||||
@@ -146,9 +135,7 @@ public partial class ConnectDialog : Form
|
|||||||
if (server.AuthMode == AuthMode.Guest)
|
if (server.AuthMode == AuthMode.Guest)
|
||||||
{
|
{
|
||||||
Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname;
|
Nickname = string.IsNullOrWhiteSpace(server.LastNickname) ? Environment.UserName : server.LastNickname;
|
||||||
Console.WriteLine($"[ConnectDialog] calling AuthenticateGuest({Nickname})...");
|
_client.AuthenticateGuest(Nickname);
|
||||||
var authResult = _client.AuthenticateGuest(Nickname);
|
|
||||||
Console.WriteLine($"[ConnectDialog] AuthenticateGuest() returned {authResult}");
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -169,15 +156,12 @@ public partial class ConnectDialog : Form
|
|||||||
password = pwDlg.Password;
|
password = pwDlg.Password;
|
||||||
}
|
}
|
||||||
Nickname = server.SavedUsername ?? "";
|
Nickname = server.SavedUsername ?? "";
|
||||||
Console.WriteLine($"[ConnectDialog] calling AuthenticateUser({Nickname})...");
|
_client.AuthenticateUser(server.SavedUsername ?? "", password);
|
||||||
var authResult = _client.AuthenticateUser(server.SavedUsername ?? "", password);
|
|
||||||
Console.WriteLine($"[ConnectDialog] AuthenticateUser() returned {authResult}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnEvent(VoiceCatEvent ev)
|
private void OnEvent(VoiceCatEvent ev)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[ConnectDialog] event: {ev}");
|
|
||||||
switch (ev.Type)
|
switch (ev.Type)
|
||||||
{
|
{
|
||||||
case VcEventType.ConnectionState:
|
case VcEventType.ConnectionState:
|
||||||
@@ -225,24 +209,18 @@ public partial class ConnectDialog : Form
|
|||||||
|
|
||||||
private void HandleServerIdentity(VcTofuStatus status, string certFingerprintHex)
|
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 (_identityDialogShown) return; // one decision per connect attempt
|
||||||
if (status == VcTofuStatus.Matched)
|
if (status == VcTofuStatus.Matched)
|
||||||
{
|
{
|
||||||
// Silent success path — no dialog. See ServerIdentityDialog's doc comment.
|
// Silent success path — no dialog. See ServerIdentityDialog's doc comment.
|
||||||
Console.WriteLine("[ConnectDialog] status=Matched -> auto-confirming, no dialog");
|
|
||||||
_client!.ConfirmServerIdentity(true);
|
_client!.ConfirmServerIdentity(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_identityDialogShown = true;
|
_identityDialogShown = true;
|
||||||
Console.WriteLine("[ConnectDialog] showing ServerIdentityDialog...");
|
|
||||||
using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay());
|
using var dlg = new ServerIdentityDialog(status, certFingerprintHex, _client!.GetServerIdentityDisplay());
|
||||||
var dlgResult = dlg.ShowDialog(this);
|
bool accept = dlg.ShowDialog(this) == DialogResult.OK;
|
||||||
Console.WriteLine($"[ConnectDialog] ServerIdentityDialog closed with {dlgResult}");
|
_client.ConfirmServerIdentity(accept);
|
||||||
bool accept = dlgResult == DialogResult.OK;
|
|
||||||
var confirmResult = _client.ConfirmServerIdentity(accept);
|
|
||||||
Console.WriteLine($"[ConnectDialog] ConfirmServerIdentity({accept}) returned {confirmResult}");
|
|
||||||
if (!accept) lblStatus.Text = "Server identity rejected.";
|
if (!accept) lblStatus.Text = "Server identity rejected.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1114,7 +1114,7 @@ public partial class MainForm : Form
|
|||||||
OpenPmWindow(dlg.SelectedUserId);
|
OpenPmWindow(dlg.SelectedUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M5: Moderation helpers ────────────────────────────────────────────────
|
// ── Moderation helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void UpdateSelfServerMuteState(bool muted, bool deafened)
|
private void UpdateSelfServerMuteState(bool muted, bool deafened)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace VoiceCat.App.Forms;
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
/// <summary>Small modal for "type a password right now" — used when a saved server's
|
/// <summary>Small modal for "type a password right now" — used when a saved server's
|
||||||
/// password wasn't remembered, and (Phase E) for password-protected channel joins.</summary>
|
/// password wasn't remembered, and for password-protected channel joins.</summary>
|
||||||
public partial class PasswordPromptDialog : Form
|
public partial class PasswordPromptDialog : Form
|
||||||
{
|
{
|
||||||
public string Password => txtPassword.Text;
|
public string Password => txtPassword.Text;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using VoiceCat.Interop;
|
|||||||
namespace VoiceCat.App.Forms;
|
namespace VoiceCat.App.Forms;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// TOFU server-identity confirmation (M4). Shown only for VcTofuStatus.FirstConnect/Mismatch
|
/// TOFU server-identity confirmation. Shown only for VcTofuStatus.FirstConnect/Mismatch
|
||||||
/// — never Matched (that's the silent-success "subsequent connects verify the pin" path
|
/// — never Matched (that's the silent-success "subsequent connects verify the pin" path
|
||||||
/// docs/security.md describes; showing a dialog on every routine reconnect would be exactly
|
/// docs/security.md describes; showing a dialog on every routine reconnect would be exactly
|
||||||
/// the "overly chatty" experience this project avoids elsewhere too).
|
/// the "overly chatty" experience this project avoids elsewhere too).
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ namespace VoiceCat.App.Notifications;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum SoundEvent
|
public enum SoundEvent
|
||||||
{
|
{
|
||||||
ChannelJoin, // another user joined my channel
|
ChannelJoin,
|
||||||
ChannelLeave, // another user left my channel
|
ChannelLeave,
|
||||||
ChannelRecv, // channel text message from someone else
|
ChannelRecv, // channel text message from someone else
|
||||||
ChannelSent, // channel text message I sent
|
ChannelSent, // channel text message I sent
|
||||||
PmRecv, // private message received
|
PmRecv,
|
||||||
PmSent, // private message I sent
|
PmSent,
|
||||||
Login, // connected / authenticated
|
Login,
|
||||||
Logout, // clean disconnect
|
Logout,
|
||||||
ConnectionLost, // unexpected disconnect
|
ConnectionLost,
|
||||||
VoiceOn, // my microphone stream started
|
VoiceOn,
|
||||||
VoiceOff, // my microphone stream stopped
|
VoiceOff,
|
||||||
VaStart, // my voice-activity began (off by default)
|
VaStart, // my voice-activity began (off by default)
|
||||||
VaStop, // my voice-activity ended (off by default)
|
VaStop, // my voice-activity ended (off by default)
|
||||||
Ptt, // push-to-talk engaged (off by default)
|
Ptt, // push-to-talk engaged (off by default)
|
||||||
|
|||||||
@@ -7,30 +7,21 @@ internal static class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
private static void Main()
|
private static void Main()
|
||||||
{
|
{
|
||||||
// Diagnostic-logging-only for now (manual debugging session) — every exception that
|
// Surface exceptions that WinForms' default message-loop handling would otherwise
|
||||||
// would otherwise be silently caught by WinForms' default message-loop handling (or
|
// swallow silently (or crash with no visible cause).
|
||||||
// crash with no visible cause) gets printed to stdout/stderr first.
|
|
||||||
Application.ThreadException += (_, e) =>
|
Application.ThreadException += (_, e) =>
|
||||||
Console.Error.WriteLine($"[UNHANDLED ThreadException] {e.Exception}");
|
Console.Error.WriteLine($"[UNHANDLED ThreadException] {e.Exception}");
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
Console.Error.WriteLine($"[UNHANDLED AppDomain exception] {e.ExceptionObject}");
|
Console.Error.WriteLine($"[UNHANDLED AppDomain exception] {e.ExceptionObject}");
|
||||||
|
|
||||||
Console.WriteLine("VoiceCat.App starting...");
|
|
||||||
ApplicationConfiguration.Initialize();
|
ApplicationConfiguration.Initialize();
|
||||||
|
|
||||||
using var connectDialog = new ConnectDialog();
|
using var connectDialog = new ConnectDialog();
|
||||||
Console.WriteLine("Showing ConnectDialog...");
|
|
||||||
var result = connectDialog.ShowDialog();
|
var result = connectDialog.ShowDialog();
|
||||||
Console.WriteLine($"ConnectDialog closed with DialogResult={result}, ConnectedClient={(connectDialog.ConnectedClient is null ? "null" : "set")}");
|
|
||||||
if (result != DialogResult.OK || connectDialog.ConnectedClient is null)
|
if (result != DialogResult.OK || connectDialog.ConnectedClient is null)
|
||||||
{
|
|
||||||
Console.WriteLine("Exiting (cancelled or no connected client).");
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Launching MainForm...");
|
|
||||||
Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId,
|
Application.Run(new MainForm(connectDialog.ConnectedClient, connectDialog.SelfUserId,
|
||||||
connectDialog.Nickname, connectDialog.ServerName));
|
connectDialog.Nickname, connectDialog.ServerName));
|
||||||
Console.WriteLine("MainForm closed. Exiting.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(_tempDir);
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
|
||||||
string serverExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-server.exe");
|
string serverExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-server.exe");
|
||||||
Assert.True(File.Exists(serverExe),
|
Assert.True(File.Exists(serverExe),
|
||||||
$"voicecat-server.exe not found at '{serverExe}' — build the m1-dev preset first " +
|
$"voicecat-server.exe not found at '{serverExe}' — build the dev preset first " +
|
||||||
"(cmake --preset m1-dev && cmake --build --preset m1-dev).");
|
"(cmake --preset dev && cmake --build --preset dev).");
|
||||||
|
|
||||||
var psi = new ProcessStartInfo(serverExe)
|
var psi = new ProcessStartInfo(serverExe)
|
||||||
{
|
{
|
||||||
@@ -56,9 +56,9 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
|
||||||
_port = port!.Value;
|
_port = port!.Value;
|
||||||
|
|
||||||
// M5: provision a known admin account so we can exercise moderation wrappers end-to-end.
|
// Provision a known admin account so we can exercise moderation wrappers end-to-end.
|
||||||
string adminExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-admin.exe");
|
string adminExe = Path.Combine(FindRepoRoot(), "build", "dev", "bin", "voicecat-admin.exe");
|
||||||
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the m1-dev preset.");
|
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the dev preset.");
|
||||||
var adminPsi = new ProcessStartInfo(adminExe)
|
var adminPsi = new ProcessStartInfo(adminExe)
|
||||||
{
|
{
|
||||||
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
|
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
|
||||||
@@ -140,14 +140,14 @@ public sealed class VoiceCatClientSmokeTests : IDisposable
|
|||||||
var channels = client.ListChannels();
|
var channels = client.ListChannels();
|
||||||
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
|
||||||
|
|
||||||
// M5: permissions getter round-trip.
|
// Permissions getter round-trip.
|
||||||
var perms = client.GetPermissions();
|
var perms = client.GetPermissions();
|
||||||
Assert.False(perms.IsAdmin);
|
Assert.False(perms.IsAdmin);
|
||||||
Assert.False(perms.CanKick);
|
Assert.False(perms.CanKick);
|
||||||
|
|
||||||
// M5: moderation request wrappers queue without error. As a guest, account listing
|
// Moderation request wrappers queue without error. As a guest, account listing
|
||||||
// is rejected by the server with a GenericResult, which proves the wrapper path works
|
// is rejected by the server with a GenericResult, which proves the wrapper path works
|
||||||
// end-to-end and that the new event type is delivered through P/Invoke.
|
// end-to-end and that the event type is delivered through P/Invoke.
|
||||||
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
Assert.Equal(VcResult.Ok, client.RequestAccountList());
|
||||||
Assert.True(PumpUntil(client,
|
Assert.True(PumpUntil(client,
|
||||||
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
|
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public enum VcConnectionState
|
|||||||
TlsHandshake = 2,
|
TlsHandshake = 2,
|
||||||
Authenticating = 3,
|
Authenticating = 3,
|
||||||
Connected = 4,
|
Connected = 4,
|
||||||
/// <summary>M4: handshake succeeded, waiting on vc_confirm_server_identity().</summary>
|
/// <summary>Handshake succeeded, waiting on vc_confirm_server_identity().</summary>
|
||||||
VerifyingIdentity = 5,
|
VerifyingIdentity = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +84,13 @@ public enum VcEventType
|
|||||||
TalkState = 9,
|
TalkState = 9,
|
||||||
Error = 10,
|
Error = 10,
|
||||||
Disconnected = 11,
|
Disconnected = 11,
|
||||||
/// <summary>M4: reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
|
/// <summary>Reply to VoiceCatClient.JoinChannelAsync's underlying vc_join_channel.</summary>
|
||||||
JoinResult = 12,
|
JoinResult = 12,
|
||||||
/// <summary>M4: the TOFU server-identity gate — see VcTofuStatus.</summary>
|
/// <summary>The TOFU server-identity gate — see VcTofuStatus.</summary>
|
||||||
ServerIdentity = 13,
|
ServerIdentity = 13,
|
||||||
/// <summary>M5: async result for moderation/admin/channel operations.</summary>
|
/// <summary>Async result for moderation/admin/channel operations.</summary>
|
||||||
GenericResult = 14,
|
GenericResult = 14,
|
||||||
/// <summary>M5: reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
|
/// <summary>Reply to VoiceCatClient.RequestAccountList — call ListAccounts() to read.</summary>
|
||||||
AccountList = 15,
|
AccountList = 15,
|
||||||
/// <summary>Voice-plane subscription state. u32a = 1 (subscribed) or 0 (unsubscribed).</summary>
|
/// <summary>Voice-plane subscription state. u32a = 1 (subscribed) or 0 (unsubscribed).</summary>
|
||||||
VoiceState = 16,
|
VoiceState = 16,
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
|
internal static partial void vc_free_device_list(ref VcDeviceListNative list);
|
||||||
|
|
||||||
// ── M4: channel / user / stream snapshot getters ────────────────────────────────────────
|
// ── Channel / user / stream snapshot getters ────────────────────────────────────────────
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
|
internal static partial VcResult vc_list_channels(nint c, out VcChannelListNative outList);
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ internal static partial class NativeMethods
|
|||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
|
internal static partial void vc_free_stream_summary_list(ref VcStreamSummaryListNative list);
|
||||||
|
|
||||||
// ── M4: TOFU server-identity gate ───────────────────────────────────────────────────────
|
// ── TOFU server-identity gate ───────────────────────────────────────────────────────────
|
||||||
[LibraryImport(LibName)]
|
[LibraryImport(LibName)]
|
||||||
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
|
internal static partial VcResult vc_confirm_server_identity(nint c, int accept);
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ internal static partial class NativeMethods
|
|||||||
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
|
internal static partial VcResult vc_get_server_identity_display(nint c, nint outBuf,
|
||||||
nuint bufCap, out nuint outLen);
|
nuint bufCap, out nuint outLen);
|
||||||
|
|
||||||
// ── M5: Moderation & admin ─────────────────────────────────────────────────────────────
|
// ── Moderation & admin ───────────────────────────────────────────────────────────────────
|
||||||
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
[LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)]
|
||||||
internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason);
|
internal static partial VcResult vc_kick_user(nint c, uint userId, string? reason);
|
||||||
|
|
||||||
|
|||||||
@@ -95,10 +95,6 @@ public sealed class VoiceCatClient : IDisposable
|
|||||||
|
|
||||||
internal void EnqueueEvent(VoiceCatEvent ev)
|
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);
|
_events.Writer.TryWrite(ev);
|
||||||
}
|
}
|
||||||
internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms;
|
internal void EnqueueLevel(uint streamId, float rms) => _latestLevels[streamId] = rms;
|
||||||
@@ -116,7 +112,7 @@ public sealed class VoiceCatClient : IDisposable
|
|||||||
public VcResult AuthenticateUser(string username, string password) =>
|
public VcResult AuthenticateUser(string username, string password) =>
|
||||||
NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password);
|
NativeMethods.vc_authenticate_user(_handle.DangerousGetHandle(), username, password);
|
||||||
|
|
||||||
// ── TOFU server-identity gate (M4) ──────────────────────────────────────────────────────
|
// ── TOFU server-identity gate ───────────────────────────────────────────────────────────
|
||||||
public VcResult ConfirmServerIdentity(bool accept) =>
|
public VcResult ConfirmServerIdentity(bool accept) =>
|
||||||
NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0);
|
NativeMethods.vc_confirm_server_identity(_handle.DangerousGetHandle(), accept ? 1 : 0);
|
||||||
|
|
||||||
@@ -143,9 +139,7 @@ public sealed class VoiceCatClient : IDisposable
|
|||||||
|
|
||||||
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
// ── Channels ─────────────────────────────────────────────────────────────────────────
|
||||||
/// <summary>Result arrives as a VcEventType.JoinResult event, not via this return value
|
/// <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).
|
/// (which only reflects "request queued" — see voicecat.h's vc_join_channel doc comment).</summary>
|
||||||
/// 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) =>
|
public VcResult JoinChannel(uint channelId, string? password = null) =>
|
||||||
NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password);
|
NativeMethods.vc_join_channel(_handle.DangerousGetHandle(), channelId, password);
|
||||||
|
|
||||||
@@ -290,7 +284,7 @@ public sealed class VoiceCatClient : IDisposable
|
|||||||
public VcResult SetPcmSink(nint cb, IntPtr user) =>
|
public VcResult SetPcmSink(nint cb, IntPtr user) =>
|
||||||
NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user);
|
NativeMethods.vc_set_pcm_sink(_handle.DangerousGetHandle(), cb, user);
|
||||||
|
|
||||||
// ── M5: Moderation & admin ───────────────────────────────────────────────────────────
|
// ── Moderation & admin ───────────────────────────────────────────────────────────────
|
||||||
public VcResult KickUser(uint userId, string? reason = null) =>
|
public VcResult KickUser(uint userId, string? reason = null) =>
|
||||||
NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason);
|
NativeMethods.vc_kick_user(_handle.DangerousGetHandle(), userId, reason);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS
|
|||||||
if(VOICECAT_BUILD_SHARED)
|
if(VOICECAT_BUILD_SHARED)
|
||||||
add_library(voicecat SHARED ${VOICECAT_SOURCES})
|
add_library(voicecat SHARED ${VOICECAT_SOURCES})
|
||||||
if(WIN32 AND MINGW)
|
if(WIN32 AND MINGW)
|
||||||
# M4: the C# client only ships voicecat.dll itself — no MinGW runtime DLLs alongside
|
# 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,
|
# it. x64-mingw-static only statically links vcpkg's OWN library deps (protobuf,
|
||||||
# sodium, mbedTLS, ...); the GCC/MinGW runtime stays dynamic by default
|
# 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`
|
# (libgcc_s_seh-1.dll/libwinpthread-1.dll/libstdc++-6.dll — confirmed via `objdump -p`
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
* Design: docs/architecture.md §4. Everything here is async + event-driven — calls return
|
* Design: docs/architecture.md §4. Everything here is async + event-driven — calls return
|
||||||
* immediately and results/state changes arrive via the vc_callbacks.on_event callback.
|
* immediately and results/state changes arrive via the vc_callbacks.on_event callback.
|
||||||
*
|
*
|
||||||
* STATUS: real. Control plane, voice, multi-stream, device enumeration, VAD/PTT, and stereo
|
* webrtc AEC/NS/AGC remains an inert passthrough (no Windows/MSVC port upstream — see
|
||||||
* playback all work via core/src/core/client.cpp. webrtc AEC/NS/AGC remains an inert passthrough
|
* docs/voice.md §8/§11).
|
||||||
* (no Windows/MSVC port upstream — docs/voice.md §8/§11, PROGRESS.md).
|
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_H
|
#ifndef VOICECAT_H
|
||||||
#define VOICECAT_H
|
#define VOICECAT_H
|
||||||
@@ -81,7 +80,7 @@ typedef enum vc_connection_state {
|
|||||||
VC_STATE_TLS_HANDSHAKE = 2,
|
VC_STATE_TLS_HANDSHAKE = 2,
|
||||||
VC_STATE_AUTHENTICATING = 3,
|
VC_STATE_AUTHENTICATING = 3,
|
||||||
VC_STATE_CONNECTED = 4,
|
VC_STATE_CONNECTED = 4,
|
||||||
/* M4: between TLS_HANDSHAKE and AUTHENTICATING — the handshake succeeded and the core is
|
/* 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
|
* 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. */
|
* at the end (not inserted) to keep existing enum values stable — additive-only ABI. */
|
||||||
VC_STATE_VERIFYING_IDENTITY = 5,
|
VC_STATE_VERIFYING_IDENTITY = 5,
|
||||||
@@ -125,7 +124,7 @@ typedef enum vc_event_type {
|
|||||||
VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
|
VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
|
||||||
VC_EVENT_ERROR = 10, /* result, text */
|
VC_EVENT_ERROR = 10, /* result, text */
|
||||||
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
|
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
|
||||||
/* M4 additions — appended, not inserted, to keep existing enum values stable. */
|
/* Appended, not inserted, to keep existing enum values stable. */
|
||||||
VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on
|
VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on
|
||||||
failure. Reply to vc_join_channel(). */
|
failure. Reply to vc_join_channel(). */
|
||||||
VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert
|
VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert
|
||||||
@@ -134,7 +133,7 @@ typedef enum vc_event_type {
|
|||||||
attempt, right after the TLS handshake succeeds. The
|
attempt, right after the TLS handshake succeeds. The
|
||||||
connection is held open until vc_confirm_server_identity()
|
connection is held open until vc_confirm_server_identity()
|
||||||
is called. */
|
is called. */
|
||||||
/* M5 additions — appended, not inserted. */
|
/* Appended, not inserted. */
|
||||||
VC_EVENT_GENERIC_RESULT = 14, /* result, u32a = server error code, text = message. Reply
|
VC_EVENT_GENERIC_RESULT = 14, /* result, u32a = server error code, text = message. Reply
|
||||||
to vc_kick_user/vc_ban_user/vc_set_permission/
|
to vc_kick_user/vc_ban_user/vc_set_permission/
|
||||||
vc_move_user/vc_create_channel/vc_edit_channel/
|
vc_move_user/vc_create_channel/vc_edit_channel/
|
||||||
@@ -147,7 +146,7 @@ typedef enum vc_event_type {
|
|||||||
(vc_user) carries per-user voice_subscribed. */
|
(vc_user) carries per-user voice_subscribed. */
|
||||||
} vc_event_type;
|
} vc_event_type;
|
||||||
|
|
||||||
/* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and
|
/* TOFU server-identity classification — see VC_EVENT_SERVER_IDENTITY and
|
||||||
* vc_confirm_server_identity. Pins the TLS leaf certificate's own SHA-256 fingerprint
|
* vc_confirm_server_identity. Pins the TLS leaf certificate's own SHA-256 fingerprint
|
||||||
* (verifiable directly from the handshake), NOT the declared Ed25519
|
* (verifiable directly from the handshake), NOT the declared Ed25519
|
||||||
* server_identity_fingerprint from ServerHello — the TLS cert and the server's Ed25519
|
* server_identity_fingerprint from ServerHello — the TLS cert and the server's Ed25519
|
||||||
@@ -194,7 +193,7 @@ typedef struct vc_config {
|
|||||||
const char* client_name; /* e.g. "VoiceCat-macOS" */
|
const char* client_name; /* e.g. "VoiceCat-macOS" */
|
||||||
const char* client_version; /* e.g. "0.0.1" */
|
const char* client_version; /* e.g. "0.0.1" */
|
||||||
vc_log_level log_level;
|
vc_log_level log_level;
|
||||||
/* M4, optional (added at the end — existing brace-initialized callers default this to
|
/* 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/
|
* 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
|
* vc_confirm_server_identity). NULL = a built-in relative default
|
||||||
* ("./voicecat_tofu_pins.txt") so existing tests need no real persistence. A real app
|
* ("./voicecat_tofu_pins.txt") so existing tests need no real persistence. A real app
|
||||||
@@ -232,7 +231,7 @@ typedef struct vc_audio_config {
|
|||||||
int dred; /* bool — Deep REDundancy (Opus 1.6), off by default */
|
int dred; /* bool — Deep REDundancy (Opus 1.6), off by default */
|
||||||
} vc_audio_config;
|
} vc_audio_config;
|
||||||
|
|
||||||
/* M5: permission bitset (mirrors protocol Permissions). */
|
/* Permission bitset (mirrors protocol Permissions). */
|
||||||
typedef struct vc_permissions {
|
typedef struct vc_permissions {
|
||||||
int can_create_temp_channel; /* bool */
|
int can_create_temp_channel; /* bool */
|
||||||
int can_kick; /* bool */
|
int can_kick; /* bool */
|
||||||
@@ -242,7 +241,7 @@ typedef struct vc_permissions {
|
|||||||
int is_admin; /* bool */
|
int is_admin; /* bool */
|
||||||
} vc_permissions;
|
} vc_permissions;
|
||||||
|
|
||||||
/* M5: account entry (reply to vc_list_accounts / vc_get_account_list). */
|
/* Account entry (reply to vc_list_accounts / vc_get_account_list). */
|
||||||
typedef struct vc_account {
|
typedef struct vc_account {
|
||||||
const char* username;
|
const char* username;
|
||||||
int is_admin; /* bool */
|
int is_admin; /* bool */
|
||||||
@@ -255,7 +254,7 @@ typedef struct vc_account_list {
|
|||||||
size_t count;
|
size_t count;
|
||||||
} vc_account_list;
|
} vc_account_list;
|
||||||
|
|
||||||
/* M5: channel creation/edition descriptor. */
|
/* Channel creation/edition descriptor. */
|
||||||
typedef struct vc_channel_info {
|
typedef struct vc_channel_info {
|
||||||
uint32_t id; /* 0 = new channel for create */
|
uint32_t id; /* 0 = new channel for create */
|
||||||
uint32_t parent_id; /* 0 = root */
|
uint32_t parent_id; /* 0 = root */
|
||||||
@@ -280,7 +279,7 @@ typedef struct vc_device_list {
|
|||||||
size_t count;
|
size_t count;
|
||||||
} vc_device_list;
|
} vc_device_list;
|
||||||
|
|
||||||
/* ── Channel / user / stream snapshots (M4 — for the channel-tree/user-list UI) ───────────
|
/* ── Channel / user / stream snapshots (for the channel-tree/user-list UI) ────────────────
|
||||||
* Pull-based: re-call after VC_EVENT_CHANNEL_LIST / VC_EVENT_USER_JOINED / _LEFT / _UPDATED to
|
* 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
|
* 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
|
* contract as vc_device/vc_device_list above: core-allocated, caller frees with the matching
|
||||||
@@ -363,10 +362,8 @@ VC_API vc_result vc_authenticate_user(vc_client* c, const char* username,
|
|||||||
|
|
||||||
/* ── Channels ─────────────────────────────────────────────────────────────── */
|
/* ── Channels ─────────────────────────────────────────────────────────────── */
|
||||||
/* Result arrives as VC_EVENT_JOIN_RESULT, not a return value beyond "request queued". `password`
|
/* 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
|
* is forwarded to the server's JoinChannelRequest.password and checked against the channel's
|
||||||
* vc_channel.password_protected set; NOTE (M4): no in-tree channel currently has a server-side
|
* stored password for channels with vc_channel.password_protected set. */
|
||||||
* 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,
|
VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id,
|
||||||
const char* password /* nullable */);
|
const char* password /* nullable */);
|
||||||
VC_API vc_result vc_leave_channel(vc_client* c);
|
VC_API vc_result vc_leave_channel(vc_client* c);
|
||||||
@@ -468,7 +465,7 @@ VC_API vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint3
|
|||||||
* music / relay), soundboards, DAW integration. Works for any stream kind (MIC /
|
* music / relay), soundboards, DAW integration. Works for any stream kind (MIC /
|
||||||
* SCREEN_AUDIO / AUX_DEVICE). Thread-safe; may be called from any thread.
|
* SCREEN_AUDIO / AUX_DEVICE). Thread-safe; may be called from any thread.
|
||||||
*
|
*
|
||||||
* Replaces vc_test_inject_capture (deprecated alias, see below). */
|
* Replaces vc_test_inject_capture (deprecated alias, see above). */
|
||||||
VC_API vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id,
|
VC_API vc_result vc_stream_feed_pcm(vc_client* c, uint32_t stream_id,
|
||||||
const int16_t* pcm, size_t samples_per_channel,
|
const int16_t* pcm, size_t samples_per_channel,
|
||||||
uint32_t channels);
|
uint32_t channels);
|
||||||
@@ -538,7 +535,7 @@ 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 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);
|
VC_API void vc_free_device_list(vc_device_list* list);
|
||||||
|
|
||||||
/* ── Channel / user / stream enumeration (M4; mirrors vc_list_devices above) ─────────────── */
|
/* ── Channel / user / stream enumeration (mirrors vc_list_devices above) ──────────────────── */
|
||||||
VC_API vc_result vc_list_channels(vc_client* c, vc_channel_list* out);
|
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 void vc_free_channel_list(vc_channel_list* list);
|
||||||
|
|
||||||
@@ -551,7 +548,7 @@ VC_API vc_result vc_list_user_streams(vc_client* c, uint32_t user_id,
|
|||||||
vc_stream_summary_list* out);
|
vc_stream_summary_list* out);
|
||||||
VC_API void vc_free_stream_summary_list(vc_stream_summary_list* list);
|
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 ── */
|
/* ── TOFU server-identity confirmation — see VC_EVENT_SERVER_IDENTITY/vc_tofu_status ──────── */
|
||||||
/* Accept or reject the pending server-identity check for the in-progress connect(). Must be
|
/* 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
|
* 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
|
* (ClientHello/auth deferred) until this is called, up to a generous internal timeout (after
|
||||||
@@ -570,7 +567,7 @@ VC_API vc_result vc_confirm_server_identity(vc_client* c, int accept /* bool */)
|
|||||||
VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
|
VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
|
||||||
size_t* out_len);
|
size_t* out_len);
|
||||||
|
|
||||||
/* ── M5: Moderation & admin ─────────────────────────────────────────────────
|
/* ── Moderation & admin ───────────────────────────────────────────────────────
|
||||||
* All calls are async; the result arrives as VC_EVENT_GENERIC_RESULT (or
|
* All calls are async; the result arrives as VC_EVENT_GENERIC_RESULT (or
|
||||||
* VC_EVENT_ACCOUNT_LIST for vc_list_accounts). They require VC_STATE_CONNECTED and,
|
* VC_EVENT_ACCOUNT_LIST for vc_list_accounts). They require VC_STATE_CONNECTED and,
|
||||||
* on the server side, the appropriate permission. */
|
* on the server side, the appropriate permission. */
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ message User {
|
|||||||
bool self_deafened = 6;
|
bool self_deafened = 6;
|
||||||
bool server_muted = 7;
|
bool server_muted = 7;
|
||||||
repeated StreamInfo streams = 8;
|
repeated StreamInfo streams = 8;
|
||||||
bool server_deafened = 9; // M5: server-imposed deafen
|
bool server_deafened = 9; // server-imposed deafen
|
||||||
bool voice_subscribed = 10; // true when the user is on the voice plane (hears + can send)
|
bool voice_subscribed = 10; // true when the user is on the voice plane (hears + can send)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ message UserEvent {
|
|||||||
Kind kind = 1;
|
Kind kind = 1;
|
||||||
User user = 2;
|
User user = 2;
|
||||||
uint32 left_id = 3;
|
uint32 left_id = 3;
|
||||||
string reason = 4; // M5: kick/ban reason for LEFT events
|
string reason = 4; // kick/ban reason for LEFT events
|
||||||
}
|
}
|
||||||
message SubscribeRequest { repeated uint32 channel_ids = 1; bool presence = 2; }
|
message SubscribeRequest { repeated uint32 channel_ids = 1; bool presence = 2; }
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* REAL-TIME RULE: audio-callback threads never allocate, lock, or block (architecture.md §3).
|
* REAL-TIME RULE: audio-callback threads never allocate, lock, or block (architecture.md §3).
|
||||||
* The JitterBuffer and per-stream maps are accessed only under a try_lock; a failed lock
|
* The JitterBuffer and per-stream maps are accessed only under a try_lock; a failed lock
|
||||||
* causes PLC for that period (acceptable for M2; lock-free ring buffer is the M3 upgrade).
|
* causes PLC for that period (acceptable today; a lock-free ring buffer would remove even that).
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_AUDIO_AUDIO_ENGINE_H
|
#ifndef VOICECAT_AUDIO_AUDIO_ENGINE_H
|
||||||
#define VOICECAT_AUDIO_AUDIO_ENGINE_H
|
#define VOICECAT_AUDIO_AUDIO_ENGINE_H
|
||||||
@@ -134,11 +134,11 @@ class AudioEngine {
|
|||||||
// Callback type for encoded capture frames ready to be sent. `kind` identifies which
|
// Callback type for encoded capture frames ready to be sent. `kind` identifies which
|
||||||
// local stream this PCM belongs to (a vc_stream_kind value; 0 = MIC for the real capture
|
// local stream this PCM belongs to (a vc_stream_kind value; 0 = MIC for the real capture
|
||||||
// device, which is always the "primary" tap). `channels` is the channel count of the PCM
|
// device, which is always the "primary" tap). `channels` is the channel count of the PCM
|
||||||
// buffer (1 = mono, 2 = stereo interleaved) — the mic capture device is mono in v1, but
|
// buffer (1 = mono, 2 = stereo interleaved) — the mic capture device is mono by default, but
|
||||||
// the WASAPI loopback path (SCREEN_AUDIO) captures in the channel's mode when stereo, so
|
// the WASAPI loopback path (SCREEN_AUDIO) captures in the channel's mode when stereo, so
|
||||||
// the encoder sees real interleaved L/R PCM rather than a mono upmix. M3: multiple
|
// the encoder sees real interleaved L/R PCM rather than a mono upmix. Multiple concurrent
|
||||||
// concurrent local streams are possible (e.g. MIC + SCREEN_AUDIO), each fed via its own
|
// local streams are possible (e.g. MIC + SCREEN_AUDIO), each fed via its own injection tap
|
||||||
// injection tap (see inject_capture) since there is only one real hardware capture device.
|
// (see inject_capture) since there is only one real hardware capture device.
|
||||||
using CaptureCallback = std::function<void(int kind, const int16_t* pcm, int samples,
|
using CaptureCallback = std::function<void(int kind, const int16_t* pcm, int samples,
|
||||||
int channels)>;
|
int channels)>;
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ class AudioEngine {
|
|||||||
// never called. See on_playback's decode loop.
|
// never called. See on_playback's decode loop.
|
||||||
int64_t plc_samples_since_real = 0;
|
int64_t plc_samples_since_real = 0;
|
||||||
|
|
||||||
// M3: listener-chosen, local-only noise reduction (docs/voice.md §10). Lazily
|
// Listener-chosen, local-only noise reduction (docs/voice.md §10). Lazily
|
||||||
// created only when enabled — bounded by how many remote streams this listener
|
// created only when enabled — bounded by how many remote streams this listener
|
||||||
// subscribes to, so no separate instance cap is needed.
|
// subscribes to, so no separate instance cap is needed.
|
||||||
bool noise_reduction_enabled = false;
|
bool noise_reduction_enabled = false;
|
||||||
@@ -485,7 +485,7 @@ class AudioEngine {
|
|||||||
uint32_t user_id = 0;
|
uint32_t user_id = 0;
|
||||||
uint32_t stream_id = 0;
|
uint32_t stream_id = 0;
|
||||||
|
|
||||||
// M3: talk-indicator edge detection (docs/voice.md §7) — updated by push_recv_frame
|
// Talk-indicator edge detection (docs/voice.md §7) — updated by push_recv_frame
|
||||||
// (already off the real-time audio thread), polled by poll_talk_transitions().
|
// (already off the real-time audio thread), polled by poll_talk_transitions().
|
||||||
std::atomic<int64_t> last_voice_ms{0};
|
std::atomic<int64_t> last_voice_ms{0};
|
||||||
bool talking = false;
|
bool talking = false;
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// ── vc_client M1 implementation ───────────────────────────────────────────────
|
// ── vc_client 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
|
// 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.
|
// (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 tofu_path = (cfg.tofu_store_path && cfg.tofu_store_path[0])
|
||||||
? std::filesystem::path(cfg.tofu_store_path)
|
? std::filesystem::path(cfg.tofu_store_path)
|
||||||
@@ -228,7 +228,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TOFU server-identity gate (M4) ──────────────────────────────────────
|
// ── TOFU server-identity gate ────────────────────────────────────────────
|
||||||
// Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the
|
// Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the
|
||||||
// handshake) — NOT the declared Ed25519 server_identity_fingerprint from ServerHello,
|
// 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
|
// which hasn't even arrived yet at this point (it's sent *inside* this now-established
|
||||||
@@ -542,7 +542,7 @@ 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*/) {
|
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());
|
server_udp_port_ = static_cast<uint16_t>(msg.udp_port());
|
||||||
|
|
||||||
// M4: stash the declared Ed25519 fingerprint for vc_get_server_identity_display() —
|
// 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
|
// 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()).
|
// ClientHello was even sent — see the TOFU block above in run_io()).
|
||||||
{
|
{
|
||||||
@@ -687,7 +687,6 @@ void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
|
|||||||
ev.user_id = user.id();
|
ev.user_id = user.id();
|
||||||
ev.channel_id = user.channel_id();
|
ev.channel_id = user.channel_id();
|
||||||
|
|
||||||
static const char* nick_buf_ptr = nullptr;
|
|
||||||
std::string nick = user.nickname();
|
std::string nick = user.nickname();
|
||||||
|
|
||||||
switch (ue.kind()) {
|
switch (ue.kind()) {
|
||||||
@@ -714,7 +713,7 @@ void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
|
|||||||
case voicecat::v1::UserEvent::UPDATED:
|
case voicecat::v1::UserEvent::UPDATED:
|
||||||
ev.type = VC_EVENT_USER_UPDATED;
|
ev.type = VC_EVENT_USER_UPDATED;
|
||||||
emit(ev);
|
emit(ev);
|
||||||
// M5: if this is an update to our own user, reflect server-mute/deafen locally.
|
// If this is an update to our own user, reflect server-mute/deafen locally.
|
||||||
if (user.id() == self_user_id_) {
|
if (user.id() == self_user_id_) {
|
||||||
server_muted_.store(user.server_muted(), std::memory_order_release);
|
server_muted_.store(user.server_muted(), std::memory_order_release);
|
||||||
server_deafened_.store(user.server_deafened(), std::memory_order_release);
|
server_deafened_.store(user.server_deafened(), std::memory_order_release);
|
||||||
@@ -731,7 +730,6 @@ void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
(void)nick_buf_ptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void vc_client::handle_text_message(const voicecat::v1::TextMessage& msg) {
|
void vc_client::handle_text_message(const voicecat::v1::TextMessage& msg) {
|
||||||
@@ -799,9 +797,7 @@ vc_result vc_client::join_channel(uint32_t channel_id, const char* password) {
|
|||||||
req.set_request_id(next_req_id_++);
|
req.set_request_id(next_req_id_++);
|
||||||
auto* jc = req.mutable_join_channel();
|
auto* jc = req.mutable_join_channel();
|
||||||
jc->set_channel_id(channel_id);
|
jc->set_channel_id(channel_id);
|
||||||
// See voicecat.h's vc_join_channel doc comment: wired through to the wire message, but no
|
// See voicecat.h's vc_join_channel doc comment.
|
||||||
// in-tree channel has a server-side password to check yet (no channel-creation feature
|
|
||||||
// exists — M5+).
|
|
||||||
if (password) jc->set_password(password);
|
if (password) jc->set_password(password);
|
||||||
queue_envelope(req);
|
queue_envelope(req);
|
||||||
return VC_OK;
|
return VC_OK;
|
||||||
@@ -828,7 +824,7 @@ vc_result vc_client::send_text(vc_text_scope scope, uint32_t target_id, const ch
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M2: UDP binding ───────────────────────────────────────────────────────────
|
// ── UDP binding ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void vc_client::start_udp_binding() {
|
void vc_client::start_udp_binding() {
|
||||||
voicecat::v1::Envelope req;
|
voicecat::v1::Envelope req;
|
||||||
@@ -977,8 +973,8 @@ int64_t client_now_ms() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Builds an OpusParams from a wire AudioConfig, applying the same field-by-field mapping on
|
// Builds an OpusParams from a wire AudioConfig, applying the same field-by-field mapping on
|
||||||
// both the send (local-stream encoder) and receive (remote-stream decoder) paths — fixes the
|
// both the send (local-stream encoder) and receive (remote-stream decoder) paths — fixes a
|
||||||
// M2 gap where mode/dtx/complexity/application were silently dropped.
|
// gap where mode/dtx/complexity/application were silently dropped.
|
||||||
voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::AudioConfig& a) {
|
voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::AudioConfig& a) {
|
||||||
voicecat::codec::OpusParams p;
|
voicecat::codec::OpusParams p;
|
||||||
// Opus always runs at 48 kHz internally (docs/voice.md §3): the whole AudioEngine clock is
|
// Opus always runs at 48 kHz internally (docs/voice.md §3): the whole AudioEngine clock is
|
||||||
@@ -1038,8 +1034,8 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int
|
|||||||
auto it = local_streams_.find(kind);
|
auto it = local_streams_.find(kind);
|
||||||
if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return;
|
if (it == local_streams_.end() || !it->second.active.load(std::memory_order_acquire)) return;
|
||||||
// "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps
|
// "Mic muted" only gates the MIC stream — a concurrently-running SCREEN_AUDIO share keeps
|
||||||
// playing while the user's mic is muted (docs §M3 scope decision).
|
// playing while the user's mic is muted (scope decision — see docs/voice.md).
|
||||||
// M5: server-mute is also a hard gate on MIC transmission.
|
// Server-mute is also a hard gate on MIC transmission.
|
||||||
if (kind == static_cast<int>(VC_STREAM_MIC) &&
|
if (kind == static_cast<int>(VC_STREAM_MIC) &&
|
||||||
(self_mic_muted_.load(std::memory_order_acquire) ||
|
(self_mic_muted_.load(std::memory_order_acquire) ||
|
||||||
server_muted_.load(std::memory_order_acquire))) return;
|
server_muted_.load(std::memory_order_acquire))) return;
|
||||||
@@ -1308,7 +1304,7 @@ void vc_client::sync_remote_streams(const voicecat::v1::User& user) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M2: stream / device control ──────────────────────────────────────────────
|
// ── Stream / device control ────────────────────────────────────────────────────
|
||||||
|
|
||||||
vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id) {
|
vc_result vc_client::stream_start(const vc_stream_desc& desc, uint32_t* out_stream_id) {
|
||||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
@@ -1884,7 +1880,7 @@ vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) {
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M4: channel/user/stream snapshot getters ─────────────────────────────────
|
// ── Channel/user/stream snapshot getters ──────────────────────────────────────
|
||||||
|
|
||||||
vc_result vc_client::list_channels(vc_channel_list* out) {
|
vc_result vc_client::list_channels(vc_channel_list* out) {
|
||||||
std::lock_guard<std::mutex> lk(session_model_mu_);
|
std::lock_guard<std::mutex> lk(session_model_mu_);
|
||||||
@@ -1962,7 +1958,7 @@ vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list*
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M4: TOFU server-identity gate ─────────────────────────────────────────────
|
// ── TOFU server-identity gate ───────────────────────────────────────────────────
|
||||||
|
|
||||||
vc_result vc_client::confirm_server_identity(bool accept) {
|
vc_result vc_client::confirm_server_identity(bool accept) {
|
||||||
std::lock_guard<std::mutex> lk(tofu_mu_);
|
std::lock_guard<std::mutex> lk(tofu_mu_);
|
||||||
@@ -1987,7 +1983,7 @@ vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap,
|
|||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M5: Moderation & admin ───────────────────────────────────────────────────
|
// ── Moderation & admin ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
vc_result vc_client::kick_user(uint32_t user_id, const char* reason) {
|
vc_result vc_client::kick_user(uint32_t user_id, const char* reason) {
|
||||||
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
|
||||||
|
|||||||
@@ -68,16 +68,16 @@ struct vc_client {
|
|||||||
|
|
||||||
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
|
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
|
||||||
|
|
||||||
// M4: channel/user/stream snapshot getters (read session_model_; see voicecat.h).
|
// Channel/user/stream snapshot getters (read session_model_; see voicecat.h).
|
||||||
vc_result list_channels(vc_channel_list* out);
|
vc_result list_channels(vc_channel_list* out);
|
||||||
vc_result list_users(vc_user_list* out);
|
vc_result list_users(vc_user_list* out);
|
||||||
vc_result list_user_streams(uint32_t user_id, vc_stream_summary_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).
|
// TOFU server-identity gate (see voicecat.h's VC_EVENT_SERVER_IDENTITY doc comment).
|
||||||
vc_result confirm_server_identity(bool accept);
|
vc_result confirm_server_identity(bool accept);
|
||||||
vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len);
|
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
|
// Effective Opus config for a (user_id, stream_id) — our own pending/active local
|
||||||
// streams, or any peer's broadcast StreamInfo.audio.
|
// streams, or any peer's broadcast StreamInfo.audio.
|
||||||
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
|
||||||
vc_audio_config* out);
|
vc_audio_config* out);
|
||||||
@@ -97,7 +97,7 @@ struct vc_client {
|
|||||||
// TEST-ONLY (see voicecat.h) — deprecated alias for stream_feed_pcm(..., channels=1).
|
// TEST-ONLY (see voicecat.h) — deprecated alias for stream_feed_pcm(..., channels=1).
|
||||||
vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples);
|
vc_result test_inject_capture(uint32_t stream_id, const int16_t* pcm, size_t samples);
|
||||||
|
|
||||||
// M5: moderation & admin.
|
// Moderation & admin.
|
||||||
vc_result kick_user(uint32_t user_id, const char* reason);
|
vc_result kick_user(uint32_t user_id, const char* reason);
|
||||||
vc_result ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms);
|
vc_result ban_user(uint32_t user_id, const char* reason, uint64_t expires_unix_ms);
|
||||||
vc_result set_permission(uint32_t user_id, const vc_permissions* perms);
|
vc_result set_permission(uint32_t user_id, const vc_permissions* perms);
|
||||||
@@ -123,7 +123,7 @@ struct vc_client {
|
|||||||
vc_config cfg_{};
|
vc_config cfg_{};
|
||||||
vc_callbacks cb_{};
|
vc_callbacks cb_{};
|
||||||
|
|
||||||
// ── M1: TCP/TLS control channel ─────────────────────────────────────────────
|
// ── TCP/TLS control channel ───────────────────────────────────────────────────
|
||||||
std::atomic<vc_connection_state> state_net_{VC_STATE_DISCONNECTED};
|
std::atomic<vc_connection_state> state_net_{VC_STATE_DISCONNECTED};
|
||||||
|
|
||||||
// Blocking I/O thread (one per vc_client lifetime)
|
// Blocking I/O thread (one per vc_client lifetime)
|
||||||
@@ -187,17 +187,17 @@ struct vc_client {
|
|||||||
std::atomic<int64_t> last_udp_keepalive_ms_{0};
|
std::atomic<int64_t> last_udp_keepalive_ms_{0};
|
||||||
|
|
||||||
// Client-side session model. Mutated only on io_thread_ (handle_server_state/
|
// 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
|
// handle_user_event/handle_channel_event), but read from any thread via the
|
||||||
// list_channels/list_users/list_user_streams getters — session_model_mu_ guards both.
|
// list_channels/list_users/list_user_streams getters — session_model_mu_ guards both.
|
||||||
voicecat::session::SessionModel session_model_;
|
voicecat::session::SessionModel session_model_;
|
||||||
mutable std::mutex session_model_mu_;
|
mutable std::mutex session_model_mu_;
|
||||||
|
|
||||||
// M5: last ListAccountsResult snapshot, populated on io_thread_ when
|
// Last ListAccountsResult snapshot, populated on io_thread_ when
|
||||||
// VC_EVENT_ACCOUNT_LIST fires and read by vc_get_account_list on caller threads.
|
// VC_EVENT_ACCOUNT_LIST fires and read by vc_get_account_list on caller threads.
|
||||||
std::vector<voicecat::v1::AccountEntry> last_account_list_;
|
std::vector<voicecat::v1::AccountEntry> last_account_list_;
|
||||||
mutable std::mutex account_list_mu_;
|
mutable std::mutex account_list_mu_;
|
||||||
|
|
||||||
// ── M4: TOFU server-identity gate ───────────────────────────────────────────
|
// ── TOFU server-identity gate ─────────────────────────────────────────────────
|
||||||
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
|
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
|
||||||
std::mutex tofu_mu_;
|
std::mutex tofu_mu_;
|
||||||
std::condition_variable tofu_cv_;
|
std::condition_variable tofu_cv_;
|
||||||
@@ -205,7 +205,7 @@ struct vc_client {
|
|||||||
bool tofu_accept_{false};
|
bool tofu_accept_{false};
|
||||||
std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only
|
std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only
|
||||||
|
|
||||||
// ── M2: UDP / media plane ────────────────────────────────────────────────────
|
// ── UDP / media plane ──────────────────────────────────────────────────────────
|
||||||
std::array<uint8_t, 16> udp_token_{};
|
std::array<uint8_t, 16> udp_token_{};
|
||||||
uint16_t server_udp_port_{0};
|
uint16_t server_udp_port_{0};
|
||||||
std::string udp_host_;
|
std::string udp_host_;
|
||||||
@@ -220,9 +220,8 @@ struct vc_client {
|
|||||||
|
|
||||||
voicecat::audio::AudioEngine audio_engine_;
|
voicecat::audio::AudioEngine audio_engine_;
|
||||||
|
|
||||||
// M3: one LocalStream per concurrently-active stream kind (MIC/SCREEN_AUDIO/AUX_DEVICE
|
// One LocalStream per concurrently-active stream kind (MIC/SCREEN_AUDIO/AUX_DEVICE
|
||||||
// are each singletons for a given client — see docs/roadmap.md §M3). Replaces the M2
|
// are each singletons for a given client).
|
||||||
// single-stream fields (local_encoder_/local_stream_active_/etc).
|
|
||||||
struct LocalStream {
|
struct LocalStream {
|
||||||
voicecat::codec::OpusEncoder encoder;
|
voicecat::codec::OpusEncoder encoder;
|
||||||
std::atomic<bool> active{false};
|
std::atomic<bool> active{false};
|
||||||
@@ -291,7 +290,7 @@ struct vc_client {
|
|||||||
mutable std::mutex remote_streams_mu_;
|
mutable std::mutex remote_streams_mu_;
|
||||||
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> remote_streams_;
|
std::unordered_map<uint32_t, std::pair<uint32_t, uint32_t>> remote_streams_;
|
||||||
|
|
||||||
// M3: talk-indicator polling thread (separate from udp_thread_ / the miniaudio callback
|
// Talk-indicator polling thread (separate from udp_thread_ / the miniaudio callback
|
||||||
// thread — see architecture.md §3 real-time rule).
|
// thread — see architecture.md §3 real-time rule).
|
||||||
std::thread talk_timer_thread_;
|
std::thread talk_timer_thread_;
|
||||||
std::atomic<bool> talk_timer_stop_{false};
|
std::atomic<bool> talk_timer_stop_{false};
|
||||||
@@ -307,10 +306,10 @@ struct vc_client {
|
|||||||
std::atomic<bool> server_muted_{false};
|
std::atomic<bool> server_muted_{false};
|
||||||
std::atomic<bool> server_deafened_{false};
|
std::atomic<bool> server_deafened_{false};
|
||||||
|
|
||||||
// M5: permissions from last AuthResult.
|
// Permissions from last AuthResult.
|
||||||
vc_permissions own_permissions_{};
|
vc_permissions own_permissions_{};
|
||||||
|
|
||||||
// Follow-up to M3: send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/
|
// Send-side input gate (docs/voice.md §11). MIC-only — SCREEN_AUDIO/
|
||||||
// AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no
|
// AUX_DEVICE are never gated (see PROGRESS.md for the rationale). Pure local state, no
|
||||||
// protocol traffic. mic_vad_ is constructed once the MIC stream's StreamAnnounceResult
|
// protocol traffic. mic_vad_ is constructed once the MIC stream's StreamAnnounceResult
|
||||||
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
|
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
|
||||||
@@ -366,7 +365,7 @@ struct vc_client {
|
|||||||
// Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each).
|
// Stop every active local stream (used on voice leave — emits STREAM_STOPPED for each).
|
||||||
void stop_all_local_streams();
|
void stop_all_local_streams();
|
||||||
|
|
||||||
// ── M2: UDP / media helpers ──────────────────────────────────────────────────
|
// ── UDP / media helpers ────────────────────────────────────────────────────────
|
||||||
// Kicks off TCP UdpBinding request; called once after a successful AuthResult.
|
// Kicks off TCP UdpBinding request; called once after a successful AuthResult.
|
||||||
void start_udp_binding();
|
void start_udp_binding();
|
||||||
// Opens the UDP socket, sends the plaintext bootstrap packet, starts udp_thread_.
|
// Opens the UDP socket, sends the plaintext bootstrap packet, starts udp_thread_.
|
||||||
@@ -378,7 +377,7 @@ struct vc_client {
|
|||||||
// (docs/voice.md §6). Plaintext — no AEAD — to avoid racing the audio thread's seal().
|
// (docs/voice.md §6). Plaintext — no AEAD — to avoid racing the audio thread's seal().
|
||||||
void send_udp_keepalive();
|
void send_udp_keepalive();
|
||||||
// capture_cb passed to audio_engine_.start(): encode + seal + send one frame for the
|
// capture_cb passed to audio_engine_.start(): encode + seal + send one frame for the
|
||||||
// given local stream `kind` (M3: multiple concurrent local streams are possible).
|
// given local stream `kind` (multiple concurrent local streams are possible).
|
||||||
void on_capture_frame(int kind, const int16_t* pcm, int samples, int channels);
|
void on_capture_frame(int kind, const int16_t* pcm, int samples, int channels);
|
||||||
// Encode one frame of exactly ls.frame_samples samples-per-channel (upmixing mono→stereo
|
// Encode one frame of exactly ls.frame_samples samples-per-channel (upmixing mono→stereo
|
||||||
// for a stereo channel as needed), seal it, and send it over UDP, advancing ls.timestamp.
|
// for a stereo channel as needed), seal it, and send it over UDP, advancing ls.timestamp.
|
||||||
@@ -394,7 +393,7 @@ struct vc_client {
|
|||||||
// Joins udp_thread_, stops audio_engine_, clears media crypto/remote-stream state.
|
// Joins udp_thread_, stops audio_engine_, clears media crypto/remote-stream state.
|
||||||
// Safe to call multiple times. Called both from run_io()'s cleanup and disconnect().
|
// Safe to call multiple times. Called both from run_io()'s cleanup and disconnect().
|
||||||
void teardown_voice();
|
void teardown_voice();
|
||||||
// talk_timer_thread_ entry point (M3): polls audio_engine_ for remote talk-state edges
|
// talk_timer_thread_ entry point: polls audio_engine_ for remote talk-state edges
|
||||||
// and local capture activity, emitting VC_EVENT_TALK_STATE. Never the audio RT thread.
|
// and local capture activity, emitting VC_EVENT_TALK_STATE. Never the audio RT thread.
|
||||||
void run_talk_timer();
|
void run_talk_timer();
|
||||||
// Find a LocalStream by its client-assigned stream_id (held under local_streams_mu_ by
|
// Find a LocalStream by its client-assigned stream_id (held under local_streams_mu_ by
|
||||||
|
|||||||
@@ -95,14 +95,12 @@ ServerCert ServerCert::generate(const std::string& server_name) {
|
|||||||
strlen(pers)),
|
strlen(pers)),
|
||||||
"ctr_drbg_seed");
|
"ctr_drbg_seed");
|
||||||
|
|
||||||
// Generate ECDSA-P256 key
|
|
||||||
throw_if(mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)),
|
throw_if(mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)),
|
||||||
"pk_setup");
|
"pk_setup");
|
||||||
throw_if(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key),
|
throw_if(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key),
|
||||||
mbedtls_ctr_drbg_random, &ctr_drbg),
|
mbedtls_ctr_drbg_random, &ctr_drbg),
|
||||||
"ecp_gen_key");
|
"ecp_gen_key");
|
||||||
|
|
||||||
// Build self-signed cert
|
|
||||||
mbedtls_x509write_crt_set_version(&cert, MBEDTLS_X509_CRT_VERSION_3);
|
mbedtls_x509write_crt_set_version(&cert, MBEDTLS_X509_CRT_VERSION_3);
|
||||||
mbedtls_x509write_crt_set_md_alg(&cert, MBEDTLS_MD_SHA256);
|
mbedtls_x509write_crt_set_md_alg(&cert, MBEDTLS_MD_SHA256);
|
||||||
mbedtls_x509write_crt_set_subject_key(&cert, &key);
|
mbedtls_x509write_crt_set_subject_key(&cert, &key);
|
||||||
@@ -112,7 +110,6 @@ ServerCert ServerCert::generate(const std::string& server_name) {
|
|||||||
throw_if(mbedtls_x509write_crt_set_subject_name(&cert, dn.c_str()), "set_subject");
|
throw_if(mbedtls_x509write_crt_set_subject_name(&cert, dn.c_str()), "set_subject");
|
||||||
throw_if(mbedtls_x509write_crt_set_issuer_name(&cert, dn.c_str()), "set_issuer");
|
throw_if(mbedtls_x509write_crt_set_issuer_name(&cert, dn.c_str()), "set_issuer");
|
||||||
|
|
||||||
// Serial = 0x01 (1 byte, value 1)
|
|
||||||
uint8_t serial_raw[] = {0x01};
|
uint8_t serial_raw[] = {0x01};
|
||||||
throw_if(mbedtls_x509write_crt_set_serial_raw(&cert, serial_raw, sizeof(serial_raw)),
|
throw_if(mbedtls_x509write_crt_set_serial_raw(&cert, serial_raw, sizeof(serial_raw)),
|
||||||
"set_serial");
|
"set_serial");
|
||||||
@@ -124,13 +121,11 @@ ServerCert ServerCert::generate(const std::string& server_name) {
|
|||||||
throw_if(mbedtls_x509write_crt_set_basic_constraints(&cert, 0, -1),
|
throw_if(mbedtls_x509write_crt_set_basic_constraints(&cert, 0, -1),
|
||||||
"set_basic_constraints");
|
"set_basic_constraints");
|
||||||
|
|
||||||
// Write PEM cert
|
|
||||||
unsigned char cert_buf[4096] = {};
|
unsigned char cert_buf[4096] = {};
|
||||||
throw_if(mbedtls_x509write_crt_pem(&cert, cert_buf, sizeof(cert_buf),
|
throw_if(mbedtls_x509write_crt_pem(&cert, cert_buf, sizeof(cert_buf),
|
||||||
mbedtls_ctr_drbg_random, &ctr_drbg),
|
mbedtls_ctr_drbg_random, &ctr_drbg),
|
||||||
"write_cert_pem");
|
"write_cert_pem");
|
||||||
|
|
||||||
// Write PEM key
|
|
||||||
unsigned char key_buf[4096] = {};
|
unsigned char key_buf[4096] = {};
|
||||||
throw_if(mbedtls_pk_write_key_pem(&key, key_buf, sizeof(key_buf)), "write_key_pem");
|
throw_if(mbedtls_pk_write_key_pem(&key, key_buf, sizeof(key_buf)), "write_key_pem");
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ struct ServerCert {
|
|||||||
|
|
||||||
// ── TLS 1.3 context ───────────────────────────────────────────────────────────
|
// ── TLS 1.3 context ───────────────────────────────────────────────────────────
|
||||||
// Wraps mbedTLS for one TLS connection (server or client side).
|
// Wraps mbedTLS for one TLS connection (server or client side).
|
||||||
// All public methods except close() must be called from a single thread at a time.
|
// All public methods must be called from a single thread at a time.
|
||||||
class TlsContext {
|
class TlsContext {
|
||||||
public:
|
public:
|
||||||
enum class Role { Server, Client };
|
enum class Role { Server, Client };
|
||||||
@@ -86,7 +86,7 @@ class TlsContext {
|
|||||||
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
|
||||||
uint8_t* out, size_t out_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
|
// 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
|
// 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
|
// 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
|
// of the declared Ed25519 server_identity_fingerprint. Returns false if no peer cert is
|
||||||
@@ -119,7 +119,7 @@ class TlsContext {
|
|||||||
mbedtls_net_context net_ctx_{};
|
mbedtls_net_context net_ctx_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Media AEAD (M2) ───────────────────────────────────────────────────────────
|
// ── Media AEAD ─────────────────────────────────────────────────────────────────
|
||||||
// Per-frame voice encryption. Abstracted so the backend is swappable.
|
// Per-frame voice encryption. Abstracted so the backend is swappable.
|
||||||
class MediaCrypto {
|
class MediaCrypto {
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class TofuStore {
|
|||||||
|
|
||||||
// Check the fingerprint for host:port. Stores on first connect.
|
// Check the fingerprint for host:port. Stores on first connect.
|
||||||
// Thread-safe (single-writer lock).
|
// Thread-safe (single-writer lock).
|
||||||
// NOTE: kept for compatibility; the M4 gated-confirmation flow (vc_client) uses peek()
|
// NOTE: kept for compatibility; vc_client's gated-confirmation flow uses peek()
|
||||||
// + pin() instead, since check_and_pin's unconditional first-connect write is wrong for a
|
// + 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.
|
// flow where the application must approve the fingerprint before it's trusted/persisted.
|
||||||
TofuResult check_and_pin(const std::string& host, uint16_t port,
|
TofuResult check_and_pin(const std::string& host, uint16_t port,
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
*
|
*
|
||||||
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing).
|
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing).
|
||||||
* Implementation uses standalone Asio for sockets and timers.
|
* Implementation uses standalone Asio for sockets and timers.
|
||||||
*
|
|
||||||
* Design: docs/architecture.md (Net thread), docs/protocol.md §1 (framing).
|
|
||||||
* Implementation uses standalone Asio for sockets and timers.
|
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_NET_TRANSPORT_H
|
#ifndef VOICECAT_NET_TRANSPORT_H
|
||||||
#define VOICECAT_NET_TRANSPORT_H
|
#define VOICECAT_NET_TRANSPORT_H
|
||||||
@@ -180,7 +177,7 @@ class TcpAcceptor {
|
|||||||
std::vector<std::shared_ptr<TcpServerConn>> conns_;
|
std::vector<std::shared_ptr<TcpServerConn>> conns_;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── UDP media channel (M2) ───────────────────────────────────────────────────
|
// ── UDP media channel ────────────────────────────────────────────────────────
|
||||||
// Thin async UDP socket. send_to() is thread-safe. Recv callbacks fire on the
|
// Thin async UDP socket. send_to() is thread-safe. Recv callbacks fire on the
|
||||||
// io_context's thread (same thread that runs the io_context::run() loop).
|
// io_context's thread (same thread that runs the io_context::run() loop).
|
||||||
class UdpMediaChannel {
|
class UdpMediaChannel {
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
* request_id ↔ response, and dispatches to handlers. Media frames do NOT come through here
|
* request_id ↔ response, and dispatches to handlers. Media frames do NOT come through here
|
||||||
* (they use the fixed binary header in voice.md §2).
|
* (they use the fixed binary header in voice.md §2).
|
||||||
*
|
*
|
||||||
* STATUS: real. Protobuf codegen is on (core/CMakeLists.txt) for VOICECAT_HAS_NET builds
|
* FrameCodec below is used by both the client (net/transport.h) and the server
|
||||||
* (`dev`/`release`/`server-release`); FrameCodec below is fully implemented and used by both the
|
* (conn_session.cpp). See protocol/envelope.h for the Envelope-level encode/decode that sits
|
||||||
* client (net/transport.h) and the server (conn_session.cpp). See protocol/envelope.h for the
|
* on top of this.
|
||||||
* Envelope-level encode/decode that sits on top of this.
|
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_PROTOCOL_PROTOCOL_H
|
#ifndef VOICECAT_PROTOCOL_PROTOCOL_H
|
||||||
#define VOICECAT_PROTOCOL_PROTOCOL_H
|
#define VOICECAT_PROTOCOL_PROTOCOL_H
|
||||||
|
|||||||
@@ -148,9 +148,7 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
|
|||||||
|
|
||||||
} else if (ev.kind() == Kind::DELETED) {
|
} else if (ev.kind() == Kind::DELETED) {
|
||||||
// deleted_id, not channel().id() — the proto leaves `channel` unset for deletes
|
// 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
|
// (docs/protocol.md, core/proto/voicecat.proto's ChannelEvent).
|
||||||
// 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();
|
uint32_t cid = ev.deleted_id();
|
||||||
channels_.erase(std::remove_if(channels_.begin(), channels_.end(),
|
channels_.erase(std::remove_if(channels_.begin(), channels_.end(),
|
||||||
[cid](const Channel& x) { return x.id == cid; }),
|
[cid](const Channel& x) { return x.id == cid; }),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ struct Stream {
|
|||||||
int kind{0};
|
int kind{0};
|
||||||
std::string label;
|
std::string label;
|
||||||
// Full effective AudioConfig (docs/protocol.md §5), as broadcast by the server in
|
// Full effective AudioConfig (docs/protocol.md §5), as broadcast by the server in
|
||||||
// StreamInfo.audio — mirrors voicecat::v1::AudioConfig field-for-field so M3 per-channel
|
// StreamInfo.audio — mirrors voicecat::v1::AudioConfig field-for-field so per-channel
|
||||||
// tuning (mono/stereo, bitrate, FEC/DTX, application) is observable client-side, not just
|
// tuning (mono/stereo, bitrate, FEC/DTX, application) is observable client-side, not just
|
||||||
// sample_rate/frame_ms.
|
// sample_rate/frame_ms.
|
||||||
uint32_t sample_rate{48000};
|
uint32_t sample_rate{48000};
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
* voicecat.cpp — C ABI implementation.
|
* voicecat.cpp — C ABI implementation.
|
||||||
*
|
*
|
||||||
* Lifecycle (create/destroy) and trivial accessors are always real. Everything else below
|
* Lifecycle (create/destroy) and trivial accessors are handled directly here; everything else
|
||||||
* just delegates to vc_client (core/src/core/client.cpp): under VOICECAT_HAS_NET
|
* delegates to vc_client (core/src/core/client.cpp).
|
||||||
* (`dev`/`release`/`server-release` — see docs/building.md) that's the real M1–M3 implementation;
|
|
||||||
* under the no-deps `skeleton` preset, client.cpp's `#else` branch returns VC_ERR_NOT_IMPLEMENTED
|
|
||||||
* for all of it, to keep that skeleton build green.
|
|
||||||
*/
|
*/
|
||||||
#include "voicecat.h"
|
#include "voicecat.h"
|
||||||
|
|
||||||
|
|||||||
@@ -88,9 +88,8 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
|
|||||||
handle_ping(env.ping());
|
handle_ping(env.ping());
|
||||||
break;
|
break;
|
||||||
case voicecat::v1::Envelope::kDisconnect:
|
case voicecat::v1::Envelope::kDisconnect:
|
||||||
// Client-initiated graceful disconnect (code=0). Falls through to close() which
|
// See handle_client_disconnect for the close()/idempotency rationale. Gated on
|
||||||
// broadcasts UserEvent::LEFT — same as a TCP drop, but immediate (no reaper/EOF
|
// Authenticated so a pre-auth stray Disconnect can't skip cleanup.
|
||||||
// wait). Gated on Authenticated so a pre-auth stray Disconnect can't skip cleanup.
|
|
||||||
if (st == State::Authenticated) handle_client_disconnect(env.disconnect());
|
if (st == State::Authenticated) handle_client_disconnect(env.disconnect());
|
||||||
break;
|
break;
|
||||||
case voicecat::v1::Envelope::kLeaveChannel:
|
case voicecat::v1::Envelope::kLeaveChannel:
|
||||||
@@ -118,7 +117,7 @@ void ConnSession::on_frame(std::vector<uint8_t> frame) {
|
|||||||
handle_unsubscribe_voice(env.request_id());
|
handle_unsubscribe_voice(env.request_id());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ── M5 moderation / admin ─────────────────────────────────────────────
|
// ── Moderation / admin ──────────────────────────────────────────────────
|
||||||
case voicecat::v1::Envelope::kKick:
|
case voicecat::v1::Envelope::kKick:
|
||||||
if (st == State::Authenticated) handle_kick_request(env.request_id(), env.kick());
|
if (st == State::Authenticated) handle_kick_request(env.request_id(), env.kick());
|
||||||
break;
|
break;
|
||||||
@@ -203,7 +202,7 @@ void ConnSession::close() {
|
|||||||
if (close_fn_) close_fn_();
|
if (close_fn_) close_fn_();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M2: media crypto ─────────────────────────────────────────────────────────
|
// ── Media crypto ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void ConnSession::set_media_crypto(
|
void ConnSession::set_media_crypto(
|
||||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
||||||
@@ -223,7 +222,7 @@ voicecat::crypto::SodiumMediaCrypto* ConnSession::recv_crypto() {
|
|||||||
return recv_crypto_.get();
|
return recv_crypto_.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M2: UDP endpoint ─────────────────────────────────────────────────────────
|
// ── UDP endpoint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void ConnSession::set_udp_endpoint(asio::ip::udp::endpoint ep) {
|
void ConnSession::set_udp_endpoint(asio::ip::udp::endpoint ep) {
|
||||||
{
|
{
|
||||||
@@ -335,7 +334,7 @@ void ConnSession::finish_password_auth(const std::string& username,
|
|||||||
// Argon2id runs on the worker pool (deliberately slow).
|
// Argon2id runs on the worker pool (deliberately slow).
|
||||||
auto self = shared_from_this();
|
auto self = shared_from_this();
|
||||||
workers_->post([self, username, password, req_id] {
|
workers_->post([self, username, password, req_id] {
|
||||||
// M5: check username bans before verifying password.
|
// Check username bans before verifying password.
|
||||||
if (self->db_->ban_check("username", username)) {
|
if (self->db_->ban_check("username", username)) {
|
||||||
auto env = make_env(req_id);
|
auto env = make_env(req_id);
|
||||||
env.mutable_auth_result()->set_ok(false);
|
env.mutable_auth_result()->set_ok(false);
|
||||||
@@ -669,7 +668,7 @@ void ConnSession::handle_unsubscribe_voice(uint64_t req_id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M5 handlers ──────────────────────────────────────────────────────────────
|
// ── Moderation & admin handlers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) {
|
void ConnSession::handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg) {
|
||||||
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
if (!is_admin() && !has_permission(&voicecat::v1::Permissions::can_kick)) {
|
||||||
|
|||||||
@@ -60,20 +60,20 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
|||||||
// close a session without making it a friend class.
|
// close a session without making it a friend class.
|
||||||
void send_disconnect_and_close(uint32_t code, const std::string& reason);
|
void send_disconnect_and_close(uint32_t code, const std::string& reason);
|
||||||
|
|
||||||
// ── M2: media key injection (called from on_tls_ready) ───────────────────
|
// ── Media key injection (called from on_tls_ready) ────────────────────────
|
||||||
void set_media_crypto(std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
void set_media_crypto(std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send,
|
||||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv);
|
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv);
|
||||||
|
|
||||||
// ── M2: UDP endpoint (set by MediaRelay on UdpBinding) ────────────────────
|
// ── UDP endpoint (set by MediaRelay on UdpBinding) ────────────────────────
|
||||||
void set_udp_endpoint(asio::ip::udp::endpoint ep);
|
void set_udp_endpoint(asio::ip::udp::endpoint ep);
|
||||||
asio::ip::udp::endpoint udp_endpoint() const;
|
asio::ip::udp::endpoint udp_endpoint() const;
|
||||||
bool has_udp_endpoint() const { return has_udp_ep_.load(); }
|
bool has_udp_endpoint() const { return has_udp_ep_.load(); }
|
||||||
|
|
||||||
// ── M2: media crypto access (for SFU relay) ──────────────────────────────
|
// ── Media crypto access (for SFU relay) ───────────────────────────────────
|
||||||
voicecat::crypto::SodiumMediaCrypto* send_crypto();
|
voicecat::crypto::SodiumMediaCrypto* send_crypto();
|
||||||
voicecat::crypto::SodiumMediaCrypto* recv_crypto();
|
voicecat::crypto::SodiumMediaCrypto* recv_crypto();
|
||||||
|
|
||||||
// ── M2: UDP token (for binding) ───────────────────────────────────────────
|
// ── UDP token (for binding) ────────────────────────────────────────────────
|
||||||
const std::array<uint8_t, 16>& udp_token() const { return udp_token_; }
|
const std::array<uint8_t, 16>& udp_token() const { return udp_token_; }
|
||||||
|
|
||||||
// ── Accessors ──────────────────────────────────────────────────────────────
|
// ── Accessors ──────────────────────────────────────────────────────────────
|
||||||
@@ -107,7 +107,7 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
|||||||
void handle_subscribe_voice(uint64_t req_id);
|
void handle_subscribe_voice(uint64_t req_id);
|
||||||
void handle_unsubscribe_voice(uint64_t req_id);
|
void handle_unsubscribe_voice(uint64_t req_id);
|
||||||
|
|
||||||
// M5 handlers
|
// Moderation & admin handlers
|
||||||
void handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg);
|
void handle_kick_request(uint64_t req_id, const voicecat::v1::KickRequest& msg);
|
||||||
void handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg);
|
void handle_ban_request(uint64_t req_id, const voicecat::v1::BanRequest& msg);
|
||||||
void handle_set_permission(uint64_t req_id, const voicecat::v1::SetPermissionRequest& msg);
|
void handle_set_permission(uint64_t req_id, const voicecat::v1::SetPermissionRequest& msg);
|
||||||
@@ -155,7 +155,7 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
|||||||
// The reaper (server.cpp) drops sessions whose last_seen is older than 45s.
|
// The reaper (server.cpp) drops sessions whose last_seen is older than 45s.
|
||||||
std::atomic<int64_t> last_seen_ms_{0};
|
std::atomic<int64_t> last_seen_ms_{0};
|
||||||
|
|
||||||
// M2 UDP / media
|
// UDP / media
|
||||||
std::array<uint8_t, 16> udp_token_{};
|
std::array<uint8_t, 16> udp_token_{};
|
||||||
mutable std::mutex udp_ep_mu_;
|
mutable std::mutex udp_ep_mu_;
|
||||||
asio::ip::udp::endpoint udp_ep_;
|
asio::ip::udp::endpoint udp_ep_;
|
||||||
@@ -165,13 +165,13 @@ class ConnSession : public std::enable_shared_from_this<ConnSession> {
|
|||||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send_crypto_;
|
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> send_crypto_;
|
||||||
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv_crypto_;
|
std::unique_ptr<voicecat::crypto::SodiumMediaCrypto> recv_crypto_;
|
||||||
|
|
||||||
// M2/M3: locally-announced streams. The server assigns the stream_id (unique per
|
// Locally-announced streams. The server assigns the stream_id (unique per
|
||||||
// session), so a per-session counter + the set of currently-active ids is enough to
|
// session), so a per-session counter + the set of currently-active ids is enough to
|
||||||
// support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user.
|
// support multiple concurrent streams (MIC + SCREEN_AUDIO + AUX_DEVICE) per user.
|
||||||
uint32_t next_stream_id_{1};
|
uint32_t next_stream_id_{1};
|
||||||
std::vector<uint32_t> announced_stream_ids_;
|
std::vector<uint32_t> announced_stream_ids_;
|
||||||
|
|
||||||
// M5: permissions granted at auth time (server-side authority).
|
// Permissions granted at auth time (server-side authority).
|
||||||
voicecat::v1::Permissions permissions_;
|
voicecat::v1::Permissions permissions_;
|
||||||
|
|
||||||
// Voice-plane subscription. When false, the SFU relay excludes this session from the
|
// Voice-plane subscription. When false, the SFU relay excludes this session from the
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ bool Database::open(std::string& error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool Database::migrate(std::string& error) {
|
bool Database::migrate(std::string& error) {
|
||||||
// Ensure server_meta row exists.
|
|
||||||
if (!exec("INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1')",
|
if (!exec("INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1')",
|
||||||
error))
|
error))
|
||||||
return false;
|
return false;
|
||||||
@@ -241,7 +240,6 @@ std::optional<Account> Database::authenticate(const std::string& username,
|
|||||||
if (crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.size()) != 0)
|
if (crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.size()) != 0)
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
|
|
||||||
// Update last_login
|
|
||||||
int64_t now = now_unix();
|
int64_t now = now_unix();
|
||||||
sqlite3_stmt* upd = nullptr;
|
sqlite3_stmt* upd = nullptr;
|
||||||
sqlite3_prepare_v2(db_, "UPDATE accounts SET last_login=? WHERE id=?", -1, &upd, nullptr);
|
sqlite3_prepare_v2(db_, "UPDATE accounts SET last_login=? WHERE id=?", -1, &upd, nullptr);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct ChannelRecord {
|
|||||||
// Persistent ban record.
|
// Persistent ban record.
|
||||||
struct BanRecord {
|
struct BanRecord {
|
||||||
int64_t id{0};
|
int64_t id{0};
|
||||||
std::string subject_type; // "user_id" or "ip"
|
std::string subject_type; // "user_id", "username", or "ip"
|
||||||
std::string subject; // the banned value
|
std::string subject; // the banned value
|
||||||
std::string reason;
|
std::string reason;
|
||||||
int64_t expires_at{0}; // 0 = permanent
|
int64_t expires_at{0}; // 0 = permanent
|
||||||
|
|||||||
@@ -68,10 +68,15 @@ int main(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (print_config_only) {
|
||||||
|
std::printf("server_name = %s\n", cfg.server_name.c_str());
|
||||||
|
std::printf("data_dir = %s\n", cfg.data_dir.c_str());
|
||||||
|
std::printf("bind_port = %u\n", cfg.bind_port);
|
||||||
|
std::printf("allow_guests = %s\n", cfg.allow_guests ? "true" : "false");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
std::printf("[voicecat-server %s] starting\n", vc_version_string());
|
std::printf("[voicecat-server %s] starting\n", vc_version_string());
|
||||||
voicecat::server::Server server(cfg);
|
voicecat::server::Server server(cfg);
|
||||||
if (print_config_only) {
|
|
||||||
// run() currently just prints config; in M1 split this into a pure config dump.
|
|
||||||
}
|
|
||||||
return server.run();
|
return server.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* server/media_relay.h — UDP SFU relay for M2 voice.
|
* server/media_relay.h — UDP SFU relay for voice.
|
||||||
*
|
*
|
||||||
* Design: docs/architecture.md §5, docs/voice.md §2.
|
* Design: docs/architecture.md §5, docs/voice.md §2.
|
||||||
* Receives encrypted UDP voice frames from clients, decrypts+authenticates them,
|
* Receives encrypted UDP voice frames from clients, decrypts+authenticates them,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ int Server::run() {
|
|||||||
// ── Asio io_context ──────────────────────────────────────────────────────
|
// ── Asio io_context ──────────────────────────────────────────────────────
|
||||||
asio::io_context io;
|
asio::io_context io;
|
||||||
|
|
||||||
// ── UDP media relay (M2) ─────────────────────────────────────────────────
|
// ── UDP media relay ────────────────────────────────────────────────────────
|
||||||
auto media_relay = std::make_shared<MediaRelay>(io, registry);
|
auto media_relay = std::make_shared<MediaRelay>(io, registry);
|
||||||
// Control and media share one port number on TCP+UDP (docs/deployment.md): when media_port
|
// Control and media share one port number on TCP+UDP (docs/deployment.md): when media_port
|
||||||
// is left at 0, follow bind_port so a single forward rule covers both. If bind_port is also 0
|
// is left at 0, follow bind_port so a single forward rule covers both. If bind_port is also 0
|
||||||
@@ -116,7 +116,8 @@ int Server::run() {
|
|||||||
auto tcp = std::make_shared<voicecat::net::TcpServerConn>(
|
auto tcp = std::make_shared<voicecat::net::TcpServerConn>(
|
||||||
std::move(sock), std::move(cbs), std::move(tls));
|
std::move(sock), std::move(cbs), std::move(tls));
|
||||||
|
|
||||||
// Give session its send/close capability (weak_ptr avoids cycle)
|
// Give session its send/close capability (weak_ptr — see the shared_ptr/cycle
|
||||||
|
// note above).
|
||||||
std::weak_ptr<voicecat::net::TcpServerConn> weak_tcp = tcp;
|
std::weak_ptr<voicecat::net::TcpServerConn> weak_tcp = tcp;
|
||||||
session->set_io(
|
session->set_io(
|
||||||
[weak_tcp](std::vector<uint8_t> frame) {
|
[weak_tcp](std::vector<uint8_t> frame) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct Config {
|
|||||||
std::string server_name = "VoiceCat Server";
|
std::string server_name = "VoiceCat Server";
|
||||||
std::string data_dir = "voicecat-data";
|
std::string data_dir = "voicecat-data";
|
||||||
uint16_t bind_port = 8384; // 0 = let OS pick (useful for tests)
|
uint16_t bind_port = 8384; // 0 = let OS pick (useful for tests)
|
||||||
uint16_t media_port = 0; // M2 UDP media; 0 = follow bind_port (or OS-pick if that's 0)
|
uint16_t media_port = 0; // UDP media port; 0 = follow bind_port (or OS-pick if that's 0)
|
||||||
bool allow_guests = true;
|
bool allow_guests = true;
|
||||||
// Called with the actual bound TCP port once the acceptor is ready.
|
// Called with the actual bound TCP port once the acceptor is ready.
|
||||||
std::function<void(uint16_t)> on_ready;
|
std::function<void(uint16_t)> on_ready;
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ namespace {
|
|||||||
ue->set_kind(voicecat::v1::UserEvent::LEFT);
|
ue->set_kind(voicecat::v1::UserEvent::LEFT);
|
||||||
ue->mutable_user()->set_id(user_id);
|
ue->mutable_user()->set_id(user_id);
|
||||||
ue->set_left_id(user_id);
|
ue->set_left_id(user_id);
|
||||||
ue->set_reason(reason); // M5 additive field
|
ue->set_reason(reason);
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ bool SessionRegistry::check_channel_password(uint32_t channel_id,
|
|||||||
return db_->check_channel_password(channel_id, password);
|
return db_->check_channel_password(channel_id, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M2: UDP / media ───────────────────────────────────────────────────────────
|
// ── UDP / media ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
|
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
|
||||||
uint64_t session_id) {
|
uint64_t session_id) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* server/session_registry.h — In-memory session, channel, and user registry.
|
* server/session_registry.h — In-memory session, channel, and user registry.
|
||||||
*
|
*
|
||||||
* Tracks all authenticated sessions, the channel tree, user<→>channel assignments,
|
* Tracks all authenticated sessions, the channel tree, user<→>channel assignments,
|
||||||
* UDP endpoint bindings (M2), and SSRC<→>session mappings (M2).
|
* UDP endpoint bindings, and SSRC<→>session mappings.
|
||||||
* Protected by a shared_mutex (many readers, few writers). All methods are thread-safe.
|
* Protected by a shared_mutex (many readers, few writers). All methods are thread-safe.
|
||||||
*/
|
*/
|
||||||
#ifndef VOICECAT_SERVER_SESSION_REGISTRY_H
|
#ifndef VOICECAT_SERVER_SESSION_REGISTRY_H
|
||||||
@@ -144,7 +144,7 @@ class SessionRegistry {
|
|||||||
// Check a channel password.
|
// Check a channel password.
|
||||||
bool check_channel_password(uint32_t channel_id, const std::string& password) const;
|
bool check_channel_password(uint32_t channel_id, const std::string& password) const;
|
||||||
|
|
||||||
// ── M2: UDP / media ────────────────────────────────────────────────────────
|
// ── UDP / media ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Register a session's UDP token (called at auth success).
|
// Register a session's UDP token (called at auth success).
|
||||||
void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id);
|
void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id);
|
||||||
@@ -177,7 +177,7 @@ class SessionRegistry {
|
|||||||
// Return the channel_id of a user (0 if not found).
|
// Return the channel_id of a user (0 if not found).
|
||||||
uint32_t user_channel(uint32_t user_id) const;
|
uint32_t user_channel(uint32_t user_id) const;
|
||||||
|
|
||||||
// Return a channel's authoritative AudioConfig (M3 per-channel Opus tuning), or nullopt
|
// Return a channel's authoritative AudioConfig (per-channel Opus tuning), or nullopt
|
||||||
// if the channel doesn't exist. There is no per-id Channel getter today otherwise —
|
// if the channel doesn't exist. There is no per-id Channel getter today otherwise —
|
||||||
// channel_snapshot() copies every channel, which callers needing just one config should
|
// channel_snapshot() copies every channel, which callers needing just one config should
|
||||||
// avoid.
|
// avoid.
|
||||||
@@ -199,7 +199,7 @@ class SessionRegistry {
|
|||||||
std::unordered_map<uint32_t, ChannelEntry> channels_;
|
std::unordered_map<uint32_t, ChannelEntry> channels_;
|
||||||
std::unordered_map<uint64_t, voicecat::v1::Permissions> session_permissions_;
|
std::unordered_map<uint64_t, voicecat::v1::Permissions> session_permissions_;
|
||||||
|
|
||||||
// M2: token → session_id (populated at auth, cleared on disconnect)
|
// Token → session_id (populated at auth, cleared on disconnect)
|
||||||
struct TokenHash {
|
struct TokenHash {
|
||||||
size_t operator()(const std::array<uint8_t, 16>& t) const {
|
size_t operator()(const std::array<uint8_t, 16>& t) const {
|
||||||
// FNV-1a over 16 bytes
|
// FNV-1a over 16 bytes
|
||||||
@@ -210,10 +210,10 @@ class SessionRegistry {
|
|||||||
};
|
};
|
||||||
std::unordered_map<std::array<uint8_t, 16>, uint64_t, TokenHash> udp_tokens_;
|
std::unordered_map<std::array<uint8_t, 16>, uint64_t, TokenHash> udp_tokens_;
|
||||||
|
|
||||||
// M2: UDP endpoint → session_id (populated after UDP binding packet arrives)
|
// UDP endpoint → session_id (populated after UDP binding packet arrives)
|
||||||
std::unordered_map<asio::ip::udp::endpoint, uint64_t, UdpEndpointHash> udp_endpoints_;
|
std::unordered_map<asio::ip::udp::endpoint, uint64_t, UdpEndpointHash> udp_endpoints_;
|
||||||
|
|
||||||
// M2: ssrc → session_id (populated when StreamAnnounce is processed)
|
// ssrc → session_id (populated when StreamAnnounce is processed)
|
||||||
std::unordered_map<uint32_t, uint64_t> ssrc_to_session_;
|
std::unordered_map<uint32_t, uint64_t> ssrc_to_session_;
|
||||||
std::atomic<uint32_t> next_ssrc_{1};
|
std::atomic<uint32_t> next_ssrc_{1};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/*
|
/*
|
||||||
* vccli — headless test client.
|
* vccli — headless test client.
|
||||||
*
|
*
|
||||||
* This is the primary way the protocol is exercised and verified from M1 onward (see
|
* This is the primary way the protocol is exercised and verified (see AGENTS.md): driving
|
||||||
* AGENTS.md). Each milestone's exit criterion is demonstrated by driving two vccli
|
* two vccli instances against a real voicecat-server.
|
||||||
* instances against a real voicecat-server.
|
|
||||||
*/
|
*/
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -26,11 +25,11 @@ struct Stats {
|
|||||||
std::atomic<bool> auth_done{false};
|
std::atomic<bool> auth_done{false};
|
||||||
std::atomic<bool> auth_ok{false};
|
std::atomic<bool> auth_ok{false};
|
||||||
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
|
// Set right after vc_client_create, before vc_connect — lets on_event auto-confirm the
|
||||||
// M4 TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it
|
// TOFU gate (VC_EVENT_SERVER_IDENTITY below). vccli has no interactive prompt, so it
|
||||||
// trusts-on-first-connect unconditionally (prints the fingerprint for visibility).
|
// trusts-on-first-connect unconditionally (prints the fingerprint for visibility).
|
||||||
vc_client* client{nullptr};
|
vc_client* client{nullptr};
|
||||||
|
|
||||||
// M5 async result tracking. Generic results are used by every moderation/admin/channel
|
// Async result tracking. Generic results are used by every moderation/admin/channel
|
||||||
// request; account-list is its own event. We count generic results so callers can wait
|
// request; account-list is its own event. We count generic results so callers can wait
|
||||||
// for a new one even if several arrived earlier.
|
// for a new one even if several arrived earlier.
|
||||||
std::atomic<int> generic_result_count{0};
|
std::atomic<int> generic_result_count{0};
|
||||||
@@ -125,7 +124,6 @@ bool wait_until(std::atomic<bool>& flag, int timeout_ms) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for a new generic result to arrive. Returns the vc_result from that result.
|
|
||||||
vc_result wait_generic_result(Stats& st, int baseline_count, int timeout_ms) {
|
vc_result wait_generic_result(Stats& st, int baseline_count, int timeout_ms) {
|
||||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||||
while (st.generic_result_count.load() <= baseline_count) {
|
while (st.generic_result_count.load() <= baseline_count) {
|
||||||
@@ -200,7 +198,7 @@ void print_usage() {
|
|||||||
" --username U authenticate as registered user U\n"
|
" --username U authenticate as registered user U\n"
|
||||||
" --password P password for --username\n"
|
" --password P password for --username\n"
|
||||||
" --channel ID channel to join after auth (default 1, Lobby)\n"
|
" --channel ID channel to join after auth (default 1, Lobby)\n"
|
||||||
" --wait-ms N timeout for M5 async result events (default 5000)\n"
|
" --wait-ms N timeout for async result events (default 5000)\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Voice / devices:\n"
|
"Voice / devices:\n"
|
||||||
" --voice start a MIC stream and stay connected until Ctrl+C\n"
|
" --voice start a MIC stream and stay connected until Ctrl+C\n"
|
||||||
@@ -278,7 +276,7 @@ void run_stdin_commands(vc_client* c, std::atomic<bool>& stop) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue an M5 request that produces VC_EVENT_GENERIC_RESULT and wait for it.
|
// Issue a request that produces VC_EVENT_GENERIC_RESULT and wait for it.
|
||||||
// Returns the result code from the event.
|
// Returns the result code from the event.
|
||||||
using RequestFn = std::function<vc_result()>;
|
using RequestFn = std::function<vc_result()>;
|
||||||
|
|
||||||
@@ -536,107 +534,107 @@ int main(int argc, char** argv) {
|
|||||||
std::this_thread::sleep_for(std::chrono::milliseconds(300)); // let the relay land
|
std::this_thread::sleep_for(std::chrono::milliseconds(300)); // let the relay land
|
||||||
}
|
}
|
||||||
|
|
||||||
// M5 moderation / admin requests (executed in a sensible order if multiple are given).
|
// Moderation / admin requests (executed in a sensible order if multiple are given).
|
||||||
bool m5_error = false;
|
bool mod_request_error = false;
|
||||||
|
|
||||||
if (self_mute || self_deafen) {
|
if (self_mute || self_deafen) {
|
||||||
r = vc_set_self_mute(c, self_mute ? 1 : 0, self_deafen ? 1 : 0);
|
r = vc_set_self_mute(c, self_mute ? 1 : 0, self_deafen ? 1 : 0);
|
||||||
std::printf("vc_set_self_mute -> %d (%s)\n", r, vc_result_string(r));
|
std::printf("vc_set_self_mute -> %d (%s)\n", r, vc_result_string(r));
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_kick) {
|
if (!mod_request_error && do_kick) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_kick_user(c, kick_user_id, kick_reason.c_str()); },
|
[&]() { return vc_kick_user(c, kick_user_id, kick_reason.c_str()); },
|
||||||
"vc_kick_user");
|
"vc_kick_user");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_ban) {
|
if (!mod_request_error && do_ban) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_ban_user(c, ban_user_id, ban_reason.c_str(), ban_expires_ms); },
|
[&]() { return vc_ban_user(c, ban_user_id, ban_reason.c_str(), ban_expires_ms); },
|
||||||
"vc_ban_user");
|
"vc_ban_user");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_move) {
|
if (!mod_request_error && do_move) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_move_user(c, move_user_id, move_channel_id); },
|
[&]() { return vc_move_user(c, move_user_id, move_channel_id); },
|
||||||
"vc_move_user");
|
"vc_move_user");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_server_mute_request) {
|
if (!mod_request_error && do_server_mute_request) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_set_server_mute(c, server_mute_user_id, server_mute_muted, server_mute_deafened); },
|
[&]() { return vc_set_server_mute(c, server_mute_user_id, server_mute_muted, server_mute_deafened); },
|
||||||
"vc_set_server_mute");
|
"vc_set_server_mute");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_set_permission) {
|
if (!mod_request_error && do_set_permission) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_set_permission(c, perm_user_id, &perms); },
|
[&]() { return vc_set_permission(c, perm_user_id, &perms); },
|
||||||
"vc_set_permission");
|
"vc_set_permission");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_create_channel) {
|
if (!mod_request_error && do_create_channel) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_create_channel(c, &channel_info); },
|
[&]() { return vc_create_channel(c, &channel_info); },
|
||||||
"vc_create_channel");
|
"vc_create_channel");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_edit_channel) {
|
if (!mod_request_error && do_edit_channel) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_edit_channel(c, &channel_info); },
|
[&]() { return vc_edit_channel(c, &channel_info); },
|
||||||
"vc_edit_channel");
|
"vc_edit_channel");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_delete_channel) {
|
if (!mod_request_error && do_delete_channel) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_delete_channel(c, delete_channel_id); },
|
[&]() { return vc_delete_channel(c, delete_channel_id); },
|
||||||
"vc_delete_channel");
|
"vc_delete_channel");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_create_account) {
|
if (!mod_request_error && do_create_account) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_create_account(c, acct_user.c_str(), acct_pass.c_str()); },
|
[&]() { return vc_create_account(c, acct_user.c_str(), acct_pass.c_str()); },
|
||||||
"vc_create_account");
|
"vc_create_account");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_reset_password) {
|
if (!mod_request_error && do_reset_password) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_reset_password(c, acct_user.c_str(), acct_pass.c_str()); },
|
[&]() { return vc_reset_password(c, acct_user.c_str(), acct_pass.c_str()); },
|
||||||
"vc_reset_password");
|
"vc_reset_password");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_delete_account) {
|
if (!mod_request_error && do_delete_account) {
|
||||||
r = run_generic_request(st, wait_ms,
|
r = run_generic_request(st, wait_ms,
|
||||||
[&]() { return vc_delete_account(c, acct_user.c_str()); },
|
[&]() { return vc_delete_account(c, acct_user.c_str()); },
|
||||||
"vc_delete_account");
|
"vc_delete_account");
|
||||||
if (r != VC_OK) m5_error = true;
|
if (r != VC_OK) mod_request_error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!m5_error && do_list_accounts) {
|
if (!mod_request_error && do_list_accounts) {
|
||||||
st.account_list_received.store(false);
|
st.account_list_received.store(false);
|
||||||
r = vc_list_accounts(c);
|
r = vc_list_accounts(c);
|
||||||
std::printf("vc_list_accounts -> %d (%s)\n", r, vc_result_string(r));
|
std::printf("vc_list_accounts -> %d (%s)\n", r, vc_result_string(r));
|
||||||
if (r != VC_OK) {
|
if (r != VC_OK) {
|
||||||
m5_error = true;
|
mod_request_error = true;
|
||||||
} else {
|
} else {
|
||||||
if (!wait_until(st.account_list_received, wait_ms)) {
|
if (!wait_until(st.account_list_received, wait_ms)) {
|
||||||
std::fprintf(stderr, "vc_list_accounts: timed out waiting for list event\n");
|
std::fprintf(stderr, "vc_list_accounts: timed out waiting for list event\n");
|
||||||
m5_error = true;
|
mod_request_error = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m5_error && !voice_mode) {
|
if (mod_request_error && !voice_mode) {
|
||||||
vc_disconnect(c);
|
vc_disconnect(c);
|
||||||
vc_client_destroy(c);
|
vc_client_destroy(c);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -705,5 +703,5 @@ int main(int argc, char** argv) {
|
|||||||
vc_disconnect(c);
|
vc_disconnect(c);
|
||||||
vc_client_destroy(c);
|
vc_client_destroy(c);
|
||||||
std::printf("ok\n");
|
std::printf("ok\n");
|
||||||
return m5_error ? 1 : 0;
|
return mod_request_error ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ int main(int argc, char** argv) {
|
|||||||
std::string data_dir = "voicecat-data";
|
std::string data_dir = "voicecat-data";
|
||||||
int i = 1;
|
int i = 1;
|
||||||
|
|
||||||
// Parse --data-dir
|
|
||||||
if (i < argc && std::strcmp(argv[i], "--data-dir") == 0) {
|
if (i < argc && std::strcmp(argv[i], "--data-dir") == 0) {
|
||||||
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --data-dir\n"); return 1; }
|
if (++i >= argc) { std::fprintf(stderr, "Missing argument to --data-dir\n"); return 1; }
|
||||||
data_dir = argv[i++];
|
data_dir = argv[i++];
|
||||||
@@ -48,7 +47,7 @@ int main(int argc, char** argv) {
|
|||||||
if (i >= argc || std::strcmp(argv[i], "account") != 0) {
|
if (i >= argc || std::strcmp(argv[i], "account") != 0) {
|
||||||
print_usage(argv[0]); return 1;
|
print_usage(argv[0]); return 1;
|
||||||
}
|
}
|
||||||
++i; // skip "account"
|
++i;
|
||||||
|
|
||||||
if (i >= argc) { print_usage(argv[0]); return 1; }
|
if (i >= argc) { print_usage(argv[0]); return 1; }
|
||||||
std::string subcmd = argv[i++];
|
std::string subcmd = argv[i++];
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
{ "name": "asio", "$why": "TCP/UDP/timers reactor — docs/architecture.md" },
|
{ "name": "asio", "$why": "TCP/UDP/timers reactor — docs/architecture.md" },
|
||||||
{ "name": "sqlite3", "$why": "server accounts/state — docs/security.md" },
|
{ "name": "sqlite3", "$why": "server accounts/state — docs/security.md" },
|
||||||
{ "name": "spdlog", "$why": "logging — docs/tech-stack.md" },
|
{ "name": "spdlog", "$why": "logging — docs/tech-stack.md" },
|
||||||
{ "name": "opus", "$why": "voice codec (libopus) — docs/voice.md — M2" },
|
{ "name": "opus", "$why": "voice codec (libopus) — docs/voice.md" },
|
||||||
{ "name": "miniaudio", "$why": "cross-platform capture/playback — docs/tech-stack.md — M2" }
|
{ "name": "miniaudio", "$why": "cross-platform capture/playback — docs/tech-stack.md" }
|
||||||
],
|
],
|
||||||
"$license-note": "All of the above are permissive (BSD/MIT/ISC/Apache-2.0/public-domain). No GPL/LGPL — see docs/tech-stack.md §5.",
|
"$license-note": "All of the above are permissive (BSD/MIT/ISC/Apache-2.0/public-domain). No GPL/LGPL — see docs/tech-stack.md §5.",
|
||||||
"$vendored-note": "RNNoise (noise suppression) is NOT a vcpkg dep — its port is !windows !arm — so it is vendored in third_party/rnnoise/ (BSD-3 + CC0). See third_party/README.md and docs/voice.md §10."
|
"$vendored-note": "RNNoise (noise suppression) is NOT a vcpkg dep — its port is !windows !arm — so it is vendored in third_party/rnnoise/ (BSD-3 + CC0). See third_party/README.md and docs/voice.md §10."
|
||||||
|
|||||||
Reference in New Issue
Block a user