feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients

Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared
Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer.

Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver
accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3
decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2.

Build infrastructure (Phase 0):
- clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges
  libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library
  (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice),
  stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers,
  runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework.

VoiceCatCore Swift Package (Phase 1):
- Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target.
- Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to
  Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config,
  Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models
  (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*),
  Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s
  [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer,
  all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event
  delivery on main queue via coalesced DispatchQueue.main drain).

Tests — 6/6 green (swift test against a real voicecat-server):
- testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips,
  testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two
  static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback
  lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value
  bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green.

Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2,
clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
This commit is contained in:
2026-06-18 14:20:38 +02:00
parent b2af1a3001
commit b4766d2f24
16 changed files with 2019 additions and 61 deletions

View File

@@ -0,0 +1,51 @@
// Callbacks the C function pointers passed to `vc_callbacks`. These are the Swift
// equivalent of the C# client's `[UnmanagedCallersOnly]` static methods (NativeCallbacks.cs).
//
// The critical patterns (carried over from the proven C# implementation):
// 1. `@convention(c)` closures plain C function pointers, NOT GC/ARC-managed closures.
// A @convention(c) closure cannot capture context, which is why the `user` pointer is
// used to resolve back to the VoiceCatClient instance (the C# version uses GCHandle for
// the same thing; Swift uses Unmanaged).
// 2. `Unmanaged.passUnretained(self).toOpaque()` as the `user` context a stable raw
// pointer to the Swift object WITHOUT incrementing the retain count. This is safe
// because `deinit` calls `vc_client_destroy` (which synchronously joins every internal
// thread) BEFORE the object's memory is freed so no callback can fire after the object
// is gone. (The C# equivalent: GCHandle.Alloc + GCHandle.Free in Dispose.)
// 3. Copy `ev.text` to a Swift `String` INSIDE `onEvent` (via `VoiceCatEvent.from(_:)`)
// before returning the raw pointer is dangling after the callback returns. This is
// the #1 lifetime rule from voicecat.h's vc_event doc comment.
import VoiceCatC
import Foundation
/// Internal: builds the `vc_callbacks` struct wired to VoiceCatClient's C function pointers.
/// The `user` context is an Unmanaged-passUnretained pointer to the client resolved back
/// to the client inside `onEvent`/`onLevel` below.
internal enum Callbacks {
/// The `on_event` C function pointer. Non-capturing @convention(c) closure resolves
/// the VoiceCatClient from `user` and enqueues a safe copy of the event.
static let onEvent: @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<vc_event>?
) -> Void = { user, ev in
guard let user, let ev else { return }
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
// Copy the event (including text) to a Swift value NOW the raw vc_event is
// invalid after this callback returns.
client.enqueueEvent(VoiceCatEvent.from(ev.pointee))
}
/// The `on_level` C function pointer. Coalesces to "latest sample per stream_id"
/// (intermediate values are visually irrelevant same as C#'s ConcurrentDictionary).
static let onLevel: @convention(c) (
UnsafeMutableRawPointer?, UInt32, Float
) -> Void = { user, streamId, rms in
guard let user else { return }
let client = Unmanaged<VoiceCatClient>.fromOpaque(user).takeUnretainedValue()
client.enqueueLevel(streamId, rms)
}
/// Construct the vc_callbacks struct for a given client.
static func make(user: UnsafeMutableRawPointer) -> vc_callbacks {
vc_callbacks(on_event: onEvent, on_level: onLevel, user: user)
}
}

View File

@@ -0,0 +1,30 @@
// VoiceCatConfig Swift-idiomatic mirror of `vc_config` (voicecat.h). Passed to
// VoiceCatClient.init. The native CString storage for the string fields is held for the
// client's entire lifetime inside VoiceCatClient see VoiceCatClient.swift's doc comment
// on why (the core stores raw pointers from vc_config by value, it does not copy the data).
import VoiceCatC
/// Configuration for a `VoiceCatClient`. Mirrors `vc_config`.
public struct VoiceCatConfig: Sendable {
/// E.g. "VoiceCat-macOS". Forwarded in `ClientHello.client_name`.
public let clientName: String
/// E.g. "0.0.1". Forwarded in `ClientHello.client_version`.
public let clientVersion: String
public let logLevel: VoiceCatLogLevel
/// Path to the TOFU pin file (see `confirmServerIdentity` / docs/security.md §1.1).
/// nil = built-in relative default (only suitable for tests).
public let tofuStorePath: String?
public init(
clientName: String,
clientVersion: String,
logLevel: VoiceCatLogLevel = .info,
tofuStorePath: String? = nil
) {
self.clientName = clientName
self.clientVersion = clientVersion
self.logLevel = logLevel
self.tofuStorePath = tofuStorePath
}
}

View File

@@ -0,0 +1,155 @@
// Swift-idiomatic mirrors of the voicecat.h C enums. Keep these in lockstep with
// core/include/voicecat.h values are append-only per the C ABI's house rule, so it's
// safe to add new cases at the end here too, but never renumber/remove existing ones.
//
// Swift imports the C enums directly via `import VoiceCatC` (e.g. VoiceCatC.VC_OK), but
// those case names are C-style (VC_ERR_NOT_IMPLEMENTED, VC_EVENT_SERVER_IDENTITY) these
// mirrors give the Swift UI and tests clean dot-syntax (VoiceCatResult.notImplemented,
// VoiceCatEventType.serverIdentity) and a typed bridge to/from the C values.
//
// NOTE: Swift's Clang importer brings C `typedef enum` types in as UInt32-backed enums
// (all our C enum values are non-negative), so these mirrors use UInt32 raw values too.
// The one signed field in the ABI `vc_event.result` is `int32_t` (not `vc_result`) is
// bridged via `UInt32(bitPattern:)` in Event.swift.
import VoiceCatC
/// Result codes mirrors `vc_result` (voicecat.h). Additive-only: new values go at the end.
public enum VoiceCatResult: UInt32, Sendable, Equatable {
case ok = 0
case notImplemented = 1
case invalidArg = 2
case notConnected = 3
case already = 4
case authFailed = 5
case permissionDenied = 6
case timeout = 7
case io = 8
case protocolError = 9
case crypto = 10
case audio = 11
case internalError = 12
/// Human-readable description from the core (vc_result_string returns a static literal).
public var description: String {
String(cString: vc_result_string(vc_result(rawValue)))
}
/// Bridge from the C enum.
public init(_ cValue: vc_result) { self = VoiceCatResult(rawValue: cValue.rawValue) ?? .internalError }
/// Bridge to the C enum.
public var cValue: vc_result { vc_result(rawValue) }
}
/// Log level mirrors `vc_log_level`.
public enum VoiceCatLogLevel: UInt32, Sendable, Equatable {
case trace = 0
case debug = 1
case info = 2
case warn = 3
case error = 4
case off = 5
public init(_ cValue: vc_log_level) { self = VoiceCatLogLevel(rawValue: cValue.rawValue) ?? .info }
public var cValue: vc_log_level { vc_log_level(rawValue) }
}
/// Connection state mirrors `vc_connection_state`.
public enum VoiceCatConnectionState: UInt32, Sendable, Equatable {
case disconnected = 0
case connecting = 1
case tlsHandshake = 2
case authenticating = 3
case connected = 4
/// M4: handshake succeeded, waiting on `confirmServerIdentity()`.
case verifyingIdentity = 5
public init(_ cValue: vc_connection_state) {
self = VoiceCatConnectionState(rawValue: cValue.rawValue) ?? .disconnected
}
public var cValue: vc_connection_state { vc_connection_state(rawValue) }
}
/// Text message scope mirrors `vc_text_scope`.
public enum VoiceCatTextScope: UInt32, Sendable, Equatable {
case channel = 0
case `private` = 1
case server = 2
public init(_ cValue: vc_text_scope) { self = VoiceCatTextScope(rawValue: cValue.rawValue) ?? .channel }
public var cValue: vc_text_scope { vc_text_scope(rawValue) }
}
/// Audio device kind mirrors `vc_device_kind`.
public enum VoiceCatDeviceKind: UInt32, Sendable, Equatable {
case input = 0
case output = 1
public init(_ cValue: vc_device_kind) { self = VoiceCatDeviceKind(rawValue: cValue.rawValue) ?? .input }
public var cValue: vc_device_kind { vc_device_kind(rawValue) }
}
/// Stream kind mirrors `vc_stream_kind`.
public enum VoiceCatStreamKind: UInt32, Sendable, Equatable {
case mic = 0
/// System/desktop audio (docs/voice.md §9).
case screenAudio = 1
case auxDevice = 2
public init(_ cValue: vc_stream_kind) { self = VoiceCatStreamKind(rawValue: cValue.rawValue) ?? .mic }
public var cValue: vc_stream_kind { vc_stream_kind(rawValue) }
}
/// Send-side input gate mode (docs/voice.md §11) mirrors `vc_input_mode`.
public enum VoiceCatInputMode: UInt32, Sendable, Equatable {
case voiceActivation = 0
case pushToTalk = 1
/// Transmit unconditionally, no VAD gate.
case alwaysOn = 2
public init(_ cValue: vc_input_mode) { self = VoiceCatInputMode(rawValue: cValue.rawValue) ?? .voiceActivation }
public var cValue: vc_input_mode { vc_input_mode(rawValue) }
}
/// Event type mirrors `vc_event_type`. Additive-only.
public enum VoiceCatEventType: UInt32, Sendable, Equatable {
case connectionState = 0
case authResult = 1
case channelList = 2
case userJoined = 3
case userLeft = 4
case userUpdated = 5
case textMessage = 6
case streamStarted = 7
case streamStopped = 8
case talkState = 9
case error = 10
case disconnected = 11
/// M4: reply to `joinChannel()` see `VoiceCatEvent.result` / `.channelId`.
case joinResult = 12
/// M4: the TOFU server-identity gate see `VoiceCatEvent.tofuStatus` / `.text`.
case serverIdentity = 13
/// M5: async result for moderation/admin/channel operations.
case genericResult = 14
/// M5: reply to `requestAccountList()` call `listAccounts()` to read.
case accountList = 15
public init(_ cValue: vc_event_type) {
self = VoiceCatEventType(rawValue: cValue.rawValue) ?? .error
}
public var cValue: vc_event_type { vc_event_type(rawValue) }
}
/// TOFU server-identity classification mirrors `vc_tofu_status`. Pins the TLS leaf
/// certificate's SHA-256 fingerprint (NOT the display-only Ed25519 value see
/// docs/security.md §1.1 and `VoiceCatServerIdentity`).
public enum VoiceCatTofuStatus: UInt32, Sendable, Equatable {
case firstConnect = 0
case matched = 1
case mismatch = 2
public init(_ cValue: vc_tofu_status) {
self = VoiceCatTofuStatus(rawValue: cValue.rawValue) ?? .firstConnect
}
public var cValue: vc_tofu_status { vc_tofu_status(rawValue) }
}

View File

@@ -0,0 +1,61 @@
// VoiceCatEvent a Swift value type that is safe to hold/queue past the native callback's
// return. This is the Swift analog of the C# client's `VoiceCatEvent` record.
//
// CRITICAL (voicecat.h's vc_event doc comment): the native `vc_event.text` pointer is owned
// by the core and valid ONLY for the duration of the `on_event` callback. `from(_:)` copies
// it to a Swift `String` immediately never hold the raw `vc_event` across the callback
// boundary, or `text` will be a dangling pointer by the time it's read. This is the #1
// lifetime rule carried over from the Windows client (NativeCallbacks.cs / VoiceCatEvent.cs).
import VoiceCatC
/// A Swift-safe copy of a `vc_event`. Produced inside the `on_event` callback (see
/// Callbacks.swift) all pointer fields are converted to value types before the callback
/// returns.
public struct VoiceCatEvent: Sendable, Equatable {
public let type: VoiceCatEventType
public let connectionState: VoiceCatConnectionState
public let result: VoiceCatResult
public let userId: UInt32
public let channelId: UInt32
public let streamId: UInt32
public let textScope: VoiceCatTextScope
/// Generic small payload, meaning per event type. For `.serverIdentity` this is the
/// `VoiceCatTofuStatus`; for `.genericResult` the server error code; for `.talkState`
/// talking(0/1).
public let u32a: UInt32
/// Copied from the core's `vc_event.text` inside the callback. nil if the core passed NULL.
public let text: String?
public let timestampUnixMs: UInt64
/// Convenience: the TOFU status, valid when `type == .serverIdentity` (maps `u32a`).
public var tofuStatus: VoiceCatTofuStatus? {
type == .serverIdentity ? VoiceCatTofuStatus(rawValue: u32a) : nil
}
/// Copy a native `vc_event` into a safe Swift value. MUST be called inside the callback
/// while `ev.text` is still valid `String(cString:)` copies the bytes here.
@inline(__always)
public static func from(_ ev: vc_event) -> VoiceCatEvent {
let text: String?
if let raw = ev.text {
text = String(cString: raw) // copies safe to hold past callback return
} else {
text = nil
}
// ev.result is int32_t (not vc_result) per voicecat.h bridge via bitPattern.
// ev.u32a is uint32_t matches VoiceCatTofuStatus's UInt32 raw value directly.
return VoiceCatEvent(
type: VoiceCatEventType(ev.type),
connectionState: VoiceCatConnectionState(ev.connection_state),
result: VoiceCatResult(rawValue: UInt32(bitPattern: ev.result)) ?? .internalError,
userId: ev.user_id,
channelId: ev.channel_id,
streamId: ev.stream_id,
textScope: VoiceCatTextScope(ev.text_scope),
u32a: ev.u32a,
text: text,
timestampUnixMs: ev.timestamp_unix_ms
)
}
}

View File

@@ -0,0 +1,107 @@
// Marshaling shared "walk a native array of owned-struct entries, convert to Swift value
// types, free the native list" pattern. Identical shape for vc_device_list / vc_channel_list
// / vc_user_list / vc_stream_summary_list / vc_account_list (all core-allocated, caller-freed
// per voicecat.h). The matching vc_free_*_list call happens INSIDE each function here,
// immediately after the conversion, so callers never need to remember to free anything
// themselves. This is the Swift analog of the C# client's Marshaling.cs.
import VoiceCatC
import Foundation
/// Internal marshaling helpers convert core-allocated C arrays to Swift arrays and
/// immediately free the native list. Not part of the public API.
internal enum Marshaling {
/// Convert a nullable `const char*` to a Swift `String` (empty if NULL).
@inline(__always)
static func string(_ ptr: UnsafePointer<CChar>?) -> String {
guard let ptr else { return "" }
return String(cString: ptr)
}
static func devices(_ list: inout vc_device_list) -> [Device] {
guard let items = list.items else { vc_free_device_list(&list); return [] }
var result: [Device] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let d = items.advanced(by: i).pointee
result.append(Device(id: string(d.id), name: string(d.name), isDefault: d.is_default != 0))
}
vc_free_device_list(&list)
return result
}
static func channels(_ list: inout vc_channel_list) -> [Channel] {
guard let items = list.items else { vc_free_channel_list(&list); return [] }
var result: [Channel] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let c = items.advanced(by: i).pointee
result.append(Channel(id: c.id, parentId: c.parent_id, name: string(c.name),
topic: string(c.topic), passwordProtected: c.password_protected != 0,
maxUsers: c.max_users))
}
vc_free_channel_list(&list)
return result
}
static func users(_ list: inout vc_user_list) -> [User] {
guard let items = list.items else { vc_free_user_list(&list); return [] }
var result: [User] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let u = items.advanced(by: i).pointee
result.append(User(id: u.id, nickname: string(u.nickname), isGuest: u.is_guest != 0,
channelId: u.channel_id, selfMicMuted: u.self_mic_muted != 0,
selfDeafened: u.self_deafened != 0, serverMuted: u.server_muted != 0,
serverDeafened: u.server_deafened != 0))
}
vc_free_user_list(&list)
return result
}
static func streamSummaries(_ list: inout vc_stream_summary_list) -> [StreamSummary] {
guard let items = list.items else { vc_free_stream_summary_list(&list); return [] }
var result: [StreamSummary] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let s = items.advanced(by: i).pointee
result.append(StreamSummary(streamId: s.stream_id, kind: VoiceCatStreamKind(s.kind),
label: string(s.label)))
}
vc_free_stream_summary_list(&list)
return result
}
static func accounts(_ list: inout vc_account_list) -> [Account] {
guard let items = list.items else { vc_free_account_list(&list); return [] }
var result: [Account] = []
result.reserveCapacity(list.count)
for i in 0..<list.count {
let a = items.advanced(by: i).pointee
result.append(Account(username: string(a.username), isAdmin: a.is_admin != 0,
createdAtUnixMs: a.created_at_unix_ms,
lastLoginUnixMs: a.last_login_unix_ms))
}
vc_free_account_list(&list)
return result
}
static func remoteStreamState(_ s: vc_remote_stream_state) -> RemoteStreamState {
RemoteStreamState(gain: s.gain, muted: s.muted != 0, noiseReduction: s.noise_reduction != 0)
}
static func audioConfig(_ c: vc_audio_config) -> AudioConfig {
AudioConfig(codec: c.codec, stereo: c.mode != 0, sampleRate: c.sample_rate,
bitrateBps: c.bitrate_bps, frameMs: c.frame_ms, application: c.application,
fec: c.fec != 0, expectedPacketLoss: c.expected_packet_loss,
dtx: c.dtx != 0, complexity: c.complexity)
}
static func permissions(_ p: vc_permissions) -> Permissions {
Permissions(canCreateTempChannel: p.can_create_temp_channel != 0,
canKick: p.can_kick != 0, canBan: p.can_ban != 0,
canMoveUsers: p.can_move_users != 0,
canAdminAccounts: p.can_admin_accounts != 0,
isAdmin: p.is_admin != 0)
}
}

View File

@@ -0,0 +1,190 @@
// Plain Swift value types what survives past the native struct/free-list lifetime
// (Marshaling.swift converts the C structs into these and immediately frees the native
// list). Nothing here holds a raw pointer. This is the Swift analog of the C# client's
// Models.cs. Field naming follows Swift camelCase (the C structs use snake_case).
import VoiceCatC
/// Channel snapshot mirrors `vc_channel` (the pull-based view; re-call `listChannels()`
/// after `.channelList` / `.userJoined` / `.userLeft` / `.userUpdated` events).
public struct Channel: Sendable, Equatable, Identifiable {
public let id: UInt32
/// 0 = root.
public let parentId: UInt32
public let name: String
public let topic: String
public let passwordProtected: Bool
/// 0 = unlimited.
public let maxUsers: UInt32
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
passwordProtected: Bool, maxUsers: UInt32) {
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
}
}
/// Channel creation/edition descriptor mirrors `vc_channel_info`. Used by
/// `createChannel(_:)` and `editChannel(_:)`. `id == 0` means new channel (for create).
public struct ChannelEdit: Sendable, Equatable {
public let id: UInt32 // 0 = new channel for create
public let parentId: UInt32 // 0 = root
public let name: String
public let topic: String
public let passwordProtected: Bool
public let password: String? // nil/empty ignored if passwordProtected == false
public let maxUsers: UInt32 // 0 = unlimited
public let sortOrder: UInt32
/// 0/nil fields use server defaults.
public let audio: AudioConfig
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
passwordProtected: Bool, password: String?, maxUsers: UInt32,
sortOrder: UInt32, audio: AudioConfig) {
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
self.passwordProtected = passwordProtected; self.password = password
self.maxUsers = maxUsers; self.sortOrder = sortOrder; self.audio = audio
}
}
/// User snapshot mirrors `vc_user`.
public struct User: Sendable, Equatable, Identifiable {
public let id: UInt32
public let nickname: String
public let isGuest: Bool
public let channelId: UInt32
public let selfMicMuted: Bool
public let selfDeafened: Bool
public let serverMuted: Bool
public let serverDeafened: Bool
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
serverDeafened: Bool) {
self.id = id; self.nickname = nickname; self.isGuest = isGuest; self.channelId = channelId
self.selfMicMuted = selfMicMuted; self.selfDeafened = selfDeafened
self.serverMuted = serverMuted; self.serverDeafened = serverDeafened
}
}
/// Permission bitset mirrors `vc_permissions` (M5).
public struct Permissions: Sendable, Equatable {
public let canCreateTempChannel: Bool
public let canKick: Bool
public let canBan: Bool
public let canMoveUsers: Bool
public let canAdminAccounts: Bool
public let isAdmin: Bool
public init(canCreateTempChannel: Bool, canKick: Bool, canBan: Bool,
canMoveUsers: Bool, canAdminAccounts: Bool, isAdmin: Bool) {
self.canCreateTempChannel = canCreateTempChannel; self.canKick = canKick; self.canBan = canBan
self.canMoveUsers = canMoveUsers; self.canAdminAccounts = canAdminAccounts; self.isAdmin = isAdmin
}
}
/// Account entry mirrors `vc_account` (M5, reply to `listAccounts()`).
public struct Account: Sendable, Equatable {
public let username: String
public let isAdmin: Bool
public let createdAtUnixMs: UInt64
public let lastLoginUnixMs: UInt64
public init(username: String, isAdmin: Bool, createdAtUnixMs: UInt64,
lastLoginUnixMs: UInt64) {
self.username = username; self.isAdmin = isAdmin
self.createdAtUnixMs = createdAtUnixMs; self.lastLoginUnixMs = lastLoginUnixMs
}
}
/// Per-user stream summary mirrors `vc_stream_summary`. For the full effective Opus
/// config of a specific (user_id, stream_id), use `VoiceCatClient.getStreamAudioConfig`.
public struct StreamSummary: Sendable, Equatable, Identifiable {
public let id: UInt32 // stream_id
public let kind: VoiceCatStreamKind
public let label: String
public init(streamId: UInt32, kind: VoiceCatStreamKind, label: String) {
self.id = streamId; self.kind = kind; self.label = label
}
}
/// Receive-side state the local listener chose for a specific remote stream mirrors
/// `vc_remote_stream_state`. All LOCAL (no protocol traffic) docs/voice.md §10.
/// Defaults (if `setRemoteStream` was never called): gain 1.0, unmuted, NR off.
public struct RemoteStreamState: Sendable, Equatable {
public let gain: Float // 0.0 ; default 1.0
public let muted: Bool
public let noiseReduction: Bool
public init(gain: Float, muted: Bool, noiseReduction: Bool) {
self.gain = gain; self.muted = muted; self.noiseReduction = noiseReduction
}
}
/// Audio device mirrors `vc_device`. `id` is an opaque, internally-encoded handle
/// (currently hex-encoded `ma_device_id`) always round-trip an id from `listDevices`;
/// never construct one by hand (docs/architecture.md §4).
public struct Device: Sendable, Equatable, Identifiable {
public let id: String
public let name: String
public let isDefault: Bool
public init(id: String, name: String, isDefault: Bool) {
self.id = id; self.name = name; self.isDefault = isDefault
}
}
/// Effective Opus configuration mirrors `vc_audio_config`.
public struct AudioConfig: Sendable, Equatable {
public let codec: UInt32 // 0 = OPUS
public let stereo: Bool // mode: 0 = mono, 1 = stereo
public let sampleRate: UInt32
public let bitrateBps: UInt32
public let frameMs: UInt32
public let application: UInt32 // 0 = VOIP, 1 = AUDIO, 2 = LOWDELAY
public let fec: Bool
public let expectedPacketLoss: UInt32 // % 0..100
public let dtx: Bool
public let complexity: UInt32 // 0..10
public init(codec: UInt32 = 0, stereo: Bool = false, sampleRate: UInt32 = 48000,
bitrateBps: UInt32 = 64000, frameMs: UInt32 = 20, application: UInt32 = 0,
fec: Bool = true, expectedPacketLoss: UInt32 = 5, dtx: Bool = false,
complexity: UInt32 = 10) {
self.codec = codec; self.stereo = stereo; self.sampleRate = sampleRate
self.bitrateBps = bitrateBps; self.frameMs = frameMs; self.application = application
self.fec = fec; self.expectedPacketLoss = expectedPacketLoss; self.dtx = dtx
self.complexity = complexity
}
}
/// Stream descriptor mirrors `vc_stream_desc`. Used by `startStream(kind:deviceId:label:)`.
public struct StreamDescriptor: Sendable, Equatable {
public let kind: VoiceCatStreamKind
/// nil = default device for this kind.
public let deviceId: String?
public let label: String
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String) {
self.kind = kind; self.deviceId = deviceId; self.label = label
}
}
/// Server identity info parsed from a `.serverIdentity` event + `getServerIdentityDisplay()`.
/// The `tlsCertFingerprint` (SHA-256 hex of the TLS leaf cert) is the value the TOFU gate
/// actually pins on; `ed25519Fingerprint` is display-only (docs/security.md §1.1).
public struct ServerIdentity: Sendable, Equatable {
public let tofuStatus: VoiceCatTofuStatus
/// SHA-256 hex of the TLS leaf certificate the pinned value. No separators (64 chars).
public let tlsCertFingerprint: String
/// Ed25519 identity fingerprint from ServerHello, hex-formatted display only.
/// Empty if not yet available.
public let ed25519Fingerprint: String
public init(tofuStatus: VoiceCatTofuStatus, tlsCertFingerprint: String,
ed25519Fingerprint: String) {
self.tofuStatus = tofuStatus; self.tlsCertFingerprint = tlsCertFingerprint
self.ed25519Fingerprint = ed25519Fingerprint
}
}

View File

@@ -0,0 +1,503 @@
// VoiceCatClient the public, Swift-idiomatic surface over libvoicecat. This is the Swift
// analog of the C# client's `VoiceCatClient.cs` (clients/windows/VoiceCat.Interop).
//
// Key patterns carried over from the proven C# implementation (see docs/architecture.md §4
// per-platform binding notes):
//
// 1. HANDLE OWNERSHIP: the class owns `vc_client*`; `deinit` calls `vc_client_destroy`
// (which synchronously joins every internal thread, so nothing can still be reading the
// config-string pointers or firing callbacks by the time it returns).
//
// 2. CONFIG STRING LIFETIMES: the core stores raw pointers from `vc_config` by value it
// does NOT copy the string data. `client_name`/`client_version`/`tofu_store_path` are
// read later, whenever `connect()` actually runs on the io_thread_. So the native CString
// storage (`_clientNamePtr` etc.) must outlive the WHOLE client, not just `init`. It's
// freed in `deinit`, AFTER `vc_client_destroy` has returned. (C#: Marshal.StringToCoTask
// MemUTF8 in ctor, FreeCoTaskMem in Dispose after destroy.)
//
// 3. EVENT DELIVERY THREAD HANDOFF: `on_event` fires on the core's event thread. Events are
// buffered in a lock-protected array and drained on `DispatchQueue.main` this is the
// boundary where the core's thread hands off to the UI thread. The C# analog is
// `Channel<VoiceCatEvent>` drained by a 30ms WinForms Timer; the Swift analog is a
// coalesced main-queue drain (only one async block scheduled at a time). `on_event`'s
// `text` is copied to a Swift `String` inside the callback (Callbacks.swift) before
// enqueueing the raw pointer is dangling by the time the main thread drains.
//
// 4. LEVEL METER COALESCING: `on_level` fires far more often than `on_event` and
// intermediate values are visually irrelevant coalesced to "latest sample per
// stream_id" in a lock-protected dictionary, drained on main alongside events.
// (C#: ConcurrentDictionary<uint,float> cleared in PumpEvents.)
//
// 5. IMMEDIATE vc_free_* ON LIST READS: `listChannels()`/`listUsers()`/etc. walk the native
// array, convert to Swift value types, and call `vc_free_*_list` INSIDE the function
// callers never manage native list lifetime. (C#: Marshaling.ToManaged does the same.)
import VoiceCatC
import Foundation
/// The Swift wrapper around `vc_client*`. Owns the native handle for its entire lifetime;
/// `deinit` destroys it. Events and level meters are delivered on the main queue via the
/// `onEvent` / `onLevel` closures.
///
/// Thread-safety: the public methods are not thread-safe call them from the main thread
/// (the standard AppKit/SwiftUI pattern). The internal event/level buffers are thread-safe
/// (lock-protected) because they're written from the core's event thread.
public final class VoiceCatClient {
// MARK: - Stored properties
/// The opaque C handle (`vc_client*` Swift imports the incomplete C struct as
/// `OpaquePointer`). Set in `init`, passed to every C function, destroyed in `deinit`.
private var handle: OpaquePointer?
/// Unmanaged pointer to `self` passed as `vc_callbacks.user` so the C function-pointer
/// callbacks can resolve back to this instance. `passUnretained` (not `passRetained`)
/// because we want normal ARC to control the object's lifetime `deinit` calls
/// `vc_client_destroy` (joins all threads) before the object's memory is freed, so no
/// callback can fire with a dangling `user` pointer. See Callbacks.swift.
///
/// Computed (not stored) to break a circular init dependency: it needs `self`, but
/// stored properties must be initialized before `self` is available. `Unmanaged.passUn
/// retained(self).toOpaque()` always returns the same address for a given instance, so
/// computing it on demand is safe and consistent.
private var selfPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(self).toOpaque()
}
/// Native CString storage backing `vc_config` must outlive the whole client (the core
/// stores raw pointers, doesn't copy). Freed in `deinit` after `vc_client_destroy`.
private var clientNamePtr: UnsafeMutablePointer<CChar>?
private var clientVersionPtr: UnsafeMutablePointer<CChar>?
private var tofuStorePathPtr: UnsafeMutablePointer<CChar>?
// MARK: - Event / level delivery (main-queue)
/// Called on the main queue for every event, in order, never coalesced. Set this from
/// the main thread (AppKit/SwiftUI) to drive your UI.
public var onEvent: ((VoiceCatEvent) -> Void)?
/// Called on the main queue with the latest RMS level per stream_id since the last drain.
/// Intermediate values are coalesced (only the latest per stream_id is delivered).
public var onLevel: ((UInt32, Float) -> Void)?
/// Lock-protected buffers, written from the core's event thread, drained on main.
private let bufferLock = NSLock()
private var eventBuffer: [VoiceCatEvent] = []
private var levelSamples: [UInt32: Float] = [:]
private var drainScheduled = false
// MARK: - Init / deinit
/// Create a client. `config.clientName`/`clientVersion`/`tofuStorePath` are copied to
/// native CString storage held for the client's entire lifetime (the core reads them
/// later, e.g. when `connect()` runs on the io thread).
public init(config: VoiceCatConfig) {
// Allocate native C strings must persist until after vc_client_destroy in deinit.
// These don't need `self`, so they're safe to set first.
self.clientNamePtr = strdup(config.clientName)
self.clientVersionPtr = strdup(config.clientVersion)
self.tofuStorePathPtr = config.tofuStorePath.flatMap { strdup($0) }
self.handle = nil // placeholder set below after callbacks are wired
// All stored properties are now initialized `self` is fully available, so we can
// call `selfPointer` (the computed property) to build the callbacks struct.
var nativeConfig = vc_config()
nativeConfig.client_name = UnsafePointer(clientNamePtr)
nativeConfig.client_version = UnsafePointer(clientVersionPtr)
nativeConfig.log_level = config.logLevel.cValue
nativeConfig.tofu_store_path = UnsafePointer(tofuStorePathPtr)
let callbacks = Callbacks.make(user: selfPointer)
self.handle = vc_client_create(&nativeConfig, callbacks)
if handle == nil {
free(clientNamePtr); clientNamePtr = nil
free(clientVersionPtr); clientVersionPtr = nil
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
fatalError("vc_client_create returned nil")
}
}
deinit {
if let handle {
// Joins every internal thread synchronously no callbacks can fire after this
// returns, so the selfPointer and config-string pointers are safe to free.
vc_client_destroy(handle)
self.handle = nil
}
// Free config strings AFTER destroy (the core may have been reading them up until
// destroy joined the io thread).
free(clientNamePtr); clientNamePtr = nil
free(clientVersionPtr); clientVersionPtr = nil
if let tofuStorePathPtr { free(tofuStorePathPtr); self.tofuStorePathPtr = nil }
}
// MARK: - Internal: event/level enqueue (called from the core's event thread)
/// Called by Callbacks.onEvent on the core's event thread. Buffers the event and
/// schedules a coalesced main-queue drain.
internal func enqueueEvent(_ event: VoiceCatEvent) {
bufferLock.lock()
eventBuffer.append(event)
let shouldSchedule = !drainScheduled
drainScheduled = true
bufferLock.unlock()
if shouldSchedule {
DispatchQueue.main.async { [weak self] in self?.drain() }
}
}
/// Called by Callbacks.onLevel on the core's event thread. Coalesces to latest-per-stream
/// and schedules a coalesced main-queue drain.
internal func enqueueLevel(_ streamId: UInt32, _ rms: Float) {
bufferLock.lock()
levelSamples[streamId] = rms
let shouldSchedule = !drainScheduled
drainScheduled = true
bufferLock.unlock()
if shouldSchedule {
DispatchQueue.main.async { [weak self] in self?.drain() }
}
}
/// Drains buffered events + coalesced levels on the main queue. Only one drain is
/// scheduled at a time (debounced via `drainScheduled`).
private func drain() {
bufferLock.lock()
let events = eventBuffer
eventBuffer.removeAll()
let levels = levelSamples
levelSamples.removeAll()
drainScheduled = false
bufferLock.unlock()
for event in events { onEvent?(event) }
for (streamId, rms) in levels { onLevel?(streamId, rms) }
}
// MARK: - Lifecycle (statics)
/// The core's version string (e.g. "VoiceCat 0.0.1 (protocol v1)"). Static literal never freed.
public static var versionString: String {
String(cString: vc_version_string())
}
/// Human-readable description of a result code. Static literal never freed.
public static func resultString(_ code: VoiceCatResult) -> String {
String(cString: vc_result_string(code.cValue))
}
// MARK: - Connection & auth (async; results via onEvent)
@discardableResult
public func connect(host: String, port: UInt16) -> VoiceCatResult {
VoiceCatResult(vc_connect(handle, host, port))
}
@discardableResult
public func disconnect() -> VoiceCatResult {
VoiceCatResult(vc_disconnect(handle))
}
@discardableResult
public func authenticateGuest(_ nickname: String) -> VoiceCatResult {
VoiceCatResult(vc_authenticate_guest(handle, nickname))
}
@discardableResult
public func authenticateUser(_ username: String, password: String) -> VoiceCatResult {
VoiceCatResult(vc_authenticate_user(handle, username, password))
}
// MARK: - TOFU server-identity gate (M4)
/// Accept or reject the pending server-identity check. Call after a `.serverIdentity`
/// event. `accept=true` on firstConnect/mismatch updates the pin file and proceeds;
/// `accept=false` aborts (emits `.disconnected` with `.crypto`). See docs/security.md §1.1.
@discardableResult
public func confirmServerIdentity(accept: Bool) -> VoiceCatResult {
VoiceCatResult(vc_confirm_server_identity(handle, accept ? 1 : 0))
}
/// The Ed25519 identity fingerprint from ServerHello, hex-formatted DISPLAY ONLY, not
/// the value the TOFU gate pins on (see docs/security.md §1.1). Empty if not yet available.
/// Uses the two-call idiom: query size with nil buffer, then allocate + fetch.
public func getServerIdentityDisplay() -> String {
var len: Int = 0
_ = vc_get_server_identity_display(handle, nil, 0, &len)
if len == 0 { return "" }
let buf = UnsafeMutablePointer<CChar>.allocate(capacity: len + 1)
defer { buf.deallocate() }
_ = vc_get_server_identity_display(handle, buf, len + 1, &len)
return String(cString: buf)
}
// MARK: - Channels
/// Join a channel. Result arrives as a `.joinResult` event (not via the return value,
/// which only reflects "request queued"). `password` is for password-protected channels.
@discardableResult
public func joinChannel(_ channelId: UInt32, password: String? = nil) -> VoiceCatResult {
VoiceCatResult(vc_join_channel(handle, channelId, password))
}
@discardableResult
public func leaveChannel() -> VoiceCatResult {
VoiceCatResult(vc_leave_channel(handle))
}
/// Pull the current channel tree. Re-call after `.channelList`/`.userJoined`/`.userLeft`/
/// `.userUpdated` events. The native list is freed inside this call callers never
/// manage native lifetime.
public func listChannels() -> [Channel] {
var native = vc_channel_list()
_ = vc_list_channels(handle, &native)
return Marshaling.channels(&native)
}
public func listUsers() -> [User] {
var native = vc_user_list()
_ = vc_list_users(handle, &native)
return Marshaling.users(&native)
}
public func listUserStreams(_ userId: UInt32) -> [StreamSummary] {
var native = vc_stream_summary_list()
let r = vc_list_user_streams(handle, userId, &native)
guard r == VC_OK else { return [] }
return Marshaling.streamSummaries(&native)
}
// MARK: - Local media streams
/// Start a mic / screen-audio / aux stream. Returns `(result, streamId)` `streamId`
/// is non-zero on success. The `label` and `deviceId` C strings are only needed for the
/// duration of the call (the core copies what it needs), so we use temporary strdup'd
/// buffers freed via `defer`.
@discardableResult
public func startStream(_ descriptor: StreamDescriptor) -> (VoiceCatResult, UInt32) {
var streamId: UInt32 = 0
let labelPtr = strdup(descriptor.label)
defer { free(labelPtr) }
let deviceIdPtr = descriptor.deviceId.flatMap { strdup($0) }
defer { if let deviceIdPtr { free(deviceIdPtr) } }
var desc = vc_stream_desc()
desc.kind = descriptor.kind.cValue
desc.device_id = deviceIdPtr.map { UnsafePointer($0) }
desc.label = UnsafePointer(labelPtr)
let r = vc_stream_start(handle, &desc, &streamId)
return (VoiceCatResult(r), streamId)
}
@discardableResult
public func stopStream(_ streamId: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_stream_stop(handle, streamId))
}
@discardableResult
public func setInputDevice(streamId: UInt32, deviceId: String?) -> VoiceCatResult {
VoiceCatResult(vc_set_input_device(handle, streamId, deviceId))
}
@discardableResult
public func setInputMode(_ mode: VoiceCatInputMode) -> VoiceCatResult {
VoiceCatResult(vc_set_input_mode(handle, mode.cValue))
}
/// VAD threshold: normalized RMS 0.01.0 (default ~0.025). Takes effect immediately.
@discardableResult
public func setVadThreshold(_ threshold: Float) -> VoiceCatResult {
VoiceCatResult(vc_set_vad_threshold(handle, threshold))
}
@discardableResult
public func setPushToTalk(_ active: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_push_to_talk(handle, active ? 1 : 0))
}
@discardableResult
public func setSelfMute(micMuted: Bool, deafened: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_self_mute(handle, micMuted ? 1 : 0, deafened ? 1 : 0))
}
// MARK: - Receive-side, per remote stream (LOCAL no protocol traffic; docs/voice.md §10)
@discardableResult
public func setRemoteStream(userId: UInt32, streamId: UInt32, gain: Float,
muted: Bool, noiseReduction: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_remote_stream(handle, userId, streamId, gain,
muted ? 1 : 0, noiseReduction ? 1 : 0))
}
public func getRemoteStream(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, RemoteStreamState?) {
var state = vc_remote_stream_state()
let r = vc_get_remote_stream(handle, userId, streamId, &state)
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
return (VoiceCatResult(r), Marshaling.remoteStreamState(state))
}
public func getStreamAudioConfig(userId: UInt32, streamId: UInt32) -> (VoiceCatResult, AudioConfig?) {
var cfg = vc_audio_config()
let r = vc_get_stream_audio_config(handle, userId, streamId, &cfg)
guard r == VC_OK else { return (VoiceCatResult(r), nil) }
return (VoiceCatResult(r), Marshaling.audioConfig(cfg))
}
// MARK: - Text
@discardableResult
public func sendText(scope: VoiceCatTextScope, targetId: UInt32, text: String) -> VoiceCatResult {
VoiceCatResult(vc_send_text(handle, scope.cValue, targetId, text))
}
// MARK: - Device enumeration (works pre-connect)
public func listDevices(_ kind: VoiceCatDeviceKind) -> [Device] {
var native = vc_device_list()
_ = vc_list_devices(handle, kind.cValue, &native)
return Marshaling.devices(&native)
}
// MARK: - M5: Moderation
@discardableResult
public func kickUser(_ userId: UInt32, reason: String? = nil) -> VoiceCatResult {
VoiceCatResult(vc_kick_user(handle, userId, reason))
}
@discardableResult
public func banUser(_ userId: UInt32, reason: String? = nil,
expiresUnixMs: UInt64 = 0) -> VoiceCatResult {
VoiceCatResult(vc_ban_user(handle, userId, reason, expiresUnixMs))
}
@discardableResult
public func setPermission(_ userId: UInt32, perms: Permissions) -> VoiceCatResult {
var native = vc_permissions()
native.can_create_temp_channel = perms.canCreateTempChannel ? 1 : 0
native.can_kick = perms.canKick ? 1 : 0
native.can_ban = perms.canBan ? 1 : 0
native.can_move_users = perms.canMoveUsers ? 1 : 0
native.can_admin_accounts = perms.canAdminAccounts ? 1 : 0
native.is_admin = perms.isAdmin ? 1 : 0
return VoiceCatResult(vc_set_permission(handle, userId, &native))
}
@discardableResult
public func setServerMute(_ userId: UInt32, muted: Bool, deafened: Bool) -> VoiceCatResult {
VoiceCatResult(vc_set_server_mute(handle, userId, muted ? 1 : 0, deafened ? 1 : 0))
}
@discardableResult
public func moveUser(_ userId: UInt32, toChannel channelId: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_move_user(handle, userId, channelId))
}
// MARK: - M5: Channel admin
@discardableResult
public func createChannel(_ info: ChannelEdit) -> VoiceCatResult {
var native = vc_channel_info()
Self.populateChannelInfo(&native, from: info)
defer { Self.freeChannelInfoStrings(&native) }
return VoiceCatResult(vc_create_channel(handle, &native))
}
@discardableResult
public func editChannel(_ info: ChannelEdit) -> VoiceCatResult {
var native = vc_channel_info()
Self.populateChannelInfo(&native, from: info)
defer { Self.freeChannelInfoStrings(&native) }
return VoiceCatResult(vc_edit_channel(handle, &native))
}
@discardableResult
public func deleteChannel(_ channelId: UInt32) -> VoiceCatResult {
VoiceCatResult(vc_delete_channel(handle, channelId))
}
// MARK: - M5: Account admin
@discardableResult
public func createAccount(_ username: String, password: String) -> VoiceCatResult {
VoiceCatResult(vc_create_account(handle, username, password))
}
@discardableResult
public func resetPassword(_ username: String, newPassword: String) -> VoiceCatResult {
VoiceCatResult(vc_reset_password(handle, username, newPassword))
}
@discardableResult
public func deleteAccount(_ username: String) -> VoiceCatResult {
VoiceCatResult(vc_delete_account(handle, username))
}
/// Request the account list result arrives as a `.accountList` event, then call
/// `listAccounts()` to pull the cached list.
@discardableResult
public func requestAccountList() -> VoiceCatResult {
VoiceCatResult(vc_list_accounts(handle))
}
public func listAccounts() -> [Account] {
var native = vc_account_list()
_ = vc_get_account_list(handle, &native)
return Marshaling.accounts(&native)
}
public func getPermissions() -> Permissions {
var native = vc_permissions()
_ = vc_get_permissions(handle, &native)
return Marshaling.permissions(native)
}
}
// MARK: - Helpers for vc_channel_info / vc_audio_config construction
extension VoiceCatClient {
/// Populate a `vc_channel_info` from a Swift `ChannelEdit`. The string fields
/// (`name`/`topic`/`password`) are strdup'd the caller MUST call
/// `freeChannelInfoStrings(_:)` after the C call returns (the core copies what it needs
/// during the call, so the temporary buffers can be freed via `defer`).
internal static func populateChannelInfo(_ native: inout vc_channel_info, from info: ChannelEdit) {
native.id = info.id
native.parent_id = info.parentId
native.name = UnsafePointer(strdup(info.name))
native.topic = UnsafePointer(strdup(info.topic))
native.password_protected = info.passwordProtected ? 1 : 0
native.password = (info.passwordProtected && !(info.password?.isEmpty ?? true))
? UnsafePointer(strdup(info.password!)) : nil
native.max_users = info.maxUsers
native.sort_order = info.sortOrder
native.audio = info.audio.toNative()
}
/// Free the strdup'd string fields of a `vc_channel_info` populated by
/// `populateChannelInfo`. Call this in a `defer` after the C call.
internal static func freeChannelInfoStrings(_ native: inout vc_channel_info) {
if let p = native.name { free(UnsafeMutablePointer(mutating: p)); native.name = nil }
if let p = native.topic { free(UnsafeMutablePointer(mutating: p)); native.topic = nil }
if let p = native.password { free(UnsafeMutablePointer(mutating: p)); native.password = nil }
}
}
extension AudioConfig {
/// Convert to a native `vc_audio_config`.
internal func toNative() -> vc_audio_config {
var n = vc_audio_config()
n.codec = codec
n.mode = stereo ? 1 : 0
n.sample_rate = sampleRate
n.bitrate_bps = bitrateBps
n.frame_ms = frameMs
n.application = application
n.fec = fec ? 1 : 0
n.expected_packet_loss = expectedPacketLoss
n.dtx = dtx ? 1 : 0
n.complexity = complexity
return n
}
}