Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
251 lines
12 KiB
Swift
251 lines
12 KiB
Swift
// 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 let sortOrder: UInt32
|
||
/// Authoritative channel Opus params (docs/voice.md §3). Populated from the Channel proto
|
||
/// so the edit dialog can read back the current config.
|
||
public let audio: AudioConfig
|
||
|
||
public init(id: UInt32, parentId: UInt32, name: String, topic: String,
|
||
passwordProtected: Bool, maxUsers: UInt32, sortOrder: UInt32,
|
||
audio: AudioConfig) {
|
||
self.id = id; self.parentId = parentId; self.name = name; self.topic = topic
|
||
self.passwordProtected = passwordProtected; self.maxUsers = maxUsers
|
||
self.sortOrder = sortOrder; self.audio = audio
|
||
}
|
||
}
|
||
|
||
/// 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 let voiceSubscribed: Bool
|
||
|
||
public init(id: UInt32, nickname: String, isGuest: Bool, channelId: UInt32,
|
||
selfMicMuted: Bool, selfDeafened: Bool, serverMuted: Bool,
|
||
serverDeafened: Bool, voiceSubscribed: 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
|
||
self.voiceSubscribed = voiceSubscribed
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// iOS audio input port — derived from `AVAudioSession.availableInputs`. Unlike the
|
||
/// miniaudio-based `Device` (which returns ~2 entries on iOS), this exposes the real
|
||
/// AVAudioSession input ports (builtInMic, bluetoothHFP, headsetMic, usbAudio, airPlay)
|
||
/// with their data sources (orientation: front/back/top/bottom) and polar patterns
|
||
/// (omni/cardioid/subcardioid/bidirectional). Used by `IOSAudioRouter` + `SettingsView`.
|
||
public struct IOSAudioInputPort: Identifiable, Hashable {
|
||
public let id: String // port UID (stable across route changes)
|
||
public let name: String // human-readable port name
|
||
public let portType: String // AVAudioSession.Port raw value as string
|
||
public let dataSources: [IOSAudioDataSource]?
|
||
public let isSelected: Bool // true if this is the current preferredInput
|
||
|
||
public init(id: String, name: String, portType: String,
|
||
dataSources: [IOSAudioDataSource]?, isSelected: Bool) {
|
||
self.id = id; self.name = name; self.portType = portType
|
||
self.dataSources = dataSources; self.isSelected = isSelected
|
||
}
|
||
}
|
||
|
||
/// iOS audio data source — a sub-selection of an input port (e.g. built-in mic
|
||
/// orientation: front/back/top/bottom). May have polar pattern options.
|
||
public struct IOSAudioDataSource: Identifiable, Hashable {
|
||
public let id: String // dataSource UID
|
||
public let name: String // "Front", "Back", "Top", "Bottom"
|
||
public let polarPatterns: [String]? // AVAudioSession.PolarPattern raw values
|
||
public let isSelected: Bool // true if this is the current preferredDataSource
|
||
public let selectedPolarPattern: String?
|
||
|
||
public init(id: String, name: String, polarPatterns: [String]?,
|
||
isSelected: Bool, selectedPolarPattern: String?) {
|
||
self.id = id; self.name = name; self.polarPatterns = polarPatterns
|
||
self.isSelected = isSelected; self.selectedPolarPattern = selectedPolarPattern
|
||
}
|
||
}
|
||
|
||
/// iOS audio output route — read-only display of `AVAudioSession.currentRoute.outputs`.
|
||
public struct IOSAudioOutputRoute: Identifiable, Hashable {
|
||
public let id: String // port UID
|
||
public let name: String // human-readable route name
|
||
public let portType: String // AVAudioSession.Port raw value as string
|
||
|
||
public init(id: String, name: String, portType: String) {
|
||
self.id = id; self.name = name; self.portType = portType
|
||
}
|
||
}
|
||
|
||
/// 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 let dred: Bool // Deep REDundancy (Opus 1.6), off by default
|
||
|
||
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, dred: Bool = false) {
|
||
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; self.dred = dred
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// When true the caller feeds PCM via `feedPcm` (e.g. the iOS VPIO mic path) and the core
|
||
/// skips opening a hardware capture device for this stream. Mirrors `vc_stream_desc.external_feed`.
|
||
public let externalFeed: Bool
|
||
|
||
public init(kind: VoiceCatStreamKind, deviceId: String? = nil, label: String,
|
||
externalFeed: Bool = false) {
|
||
self.kind = kind; self.deviceId = deviceId; self.label = label
|
||
self.externalFeed = externalFeed
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|