chore: comment cleanup pass ahead of open-sourcing
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

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:
2026-07-03 10:20:18 +01:00
parent bda37ec27b
commit bba605401d
50 changed files with 229 additions and 331 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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-Ficellular) // 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()

View File

@@ -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
} }

View File

@@ -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") }

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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);
} }
} }

View File

@@ -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.";
} }

View File

@@ -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)
{ {

View File

@@ -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;

View File

@@ -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).

View File

@@ -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)

View File

@@ -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.");
} }
} }

View File

@@ -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),

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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`

View File

@@ -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. */

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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");

View File

@@ -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:

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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; }),

View File

@@ -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};

View File

@@ -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 M1M3 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"

View File

@@ -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)) {

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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();
} }

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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};
}; };

View File

@@ -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;
} }

View File

@@ -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++];

View File

@@ -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."