feat(clients): expose all channel codec params + guest nickname everywhere

Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.

- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
  C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
  complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
  SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
  (backward-compatible Codable), shown in Guest mode, wired into the guest auth
  path -- guests could not set a display name on either before (only Windows)

Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
This commit is contained in:
2026-06-21 04:03:50 +02:00
parent b07362e525
commit 6b7f06a282
22 changed files with 292 additions and 24 deletions

View File

@@ -94,7 +94,7 @@ internal enum Marshaling {
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)
dtx: c.dtx != 0, complexity: c.complexity, dred: c.dred != 0)
}
static func permissions(_ p: vc_permissions) -> Permissions {

View File

@@ -193,15 +193,16 @@ public struct AudioConfig: Sendable, Equatable {
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) {
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.complexity = complexity; self.dred = dred
}
}

View File

@@ -586,6 +586,7 @@ extension AudioConfig {
n.expected_packet_loss = expectedPacketLoss
n.dtx = dtx ? 1 : 0
n.complexity = complexity
n.dred = dred ? 1 : 0
return n
}
}

View File

@@ -76,7 +76,7 @@ final class AppState {
// Auth is queued immediately the core serialises it behind TLS + TOFU.
switch server.authMode {
case .guest:
let nick = server.savedUsername.isEmpty ? "iOS User" : server.savedUsername
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
client.authenticateGuest(nick)
case .password:
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)

View File

@@ -6,17 +6,22 @@ struct SavedServer: Codable, Identifiable, Equatable {
var port: UInt16
var authMode: AuthMode
var savedUsername: String
/// Free-form display name used when connecting as a guest. Distinct from the account
/// `savedUsername`. Empty falls back to a default. Optional for backward-compatible decoding.
var nickname: String?
var keychainTag: String
enum AuthMode: String, Codable { case guest, password }
init(id: UUID = UUID(), host: String, port: UInt16,
authMode: AuthMode = .guest, savedUsername: String = "", keychainTag: String = "") {
authMode: AuthMode = .guest, savedUsername: String = "",
nickname: String? = nil, keychainTag: String = "") {
self.id = id
self.host = host
self.port = port
self.authMode = authMode
self.savedUsername = savedUsername
self.nickname = nickname
self.keychainTag = keychainTag.isEmpty ? id.uuidString : keychainTag
}

View File

@@ -342,13 +342,14 @@ final class SessionState {
client.setServerMute(userId, muted: muted, deafened: deafened)
}
func createChannel(name: String, topic: String) {
let info = ChannelEdit(id: 0, parentId: 0, name: name, topic: topic,
passwordProtected: false, password: nil,
maxUsers: 0, sortOrder: 0, audio: AudioConfig())
func createChannel(_ info: ChannelEdit) {
client.createChannel(info)
}
func editChannel(_ info: ChannelEdit) {
client.editChannel(info)
}
func deleteChannel(_ channelId: UInt32) {
client.deleteChannel(channelId)
}

View File

@@ -9,6 +9,7 @@ struct AddServerView: View {
@State private var host = ""
@State private var port = "7878"
@State private var authMode = SavedServer.AuthMode.guest
@State private var nickname = ""
@State private var username = ""
@State private var password = ""
@State private var savePassword = false
@@ -35,6 +36,13 @@ struct AddServerView: View {
.pickerStyle(.segmented)
.accessibilityLabel("Authentication mode")
if authMode == .guest {
TextField("Nickname (optional)", text: $nickname)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.accessibilityLabel("Guest nickname, optional display name")
}
if authMode == .password {
TextField("Username", text: $username)
.textContentType(.username)
@@ -67,6 +75,7 @@ struct AddServerView: View {
port = "\(s.port)"
authMode = s.authMode
username = s.savedUsername
nickname = s.nickname ?? ""
}
}
}
@@ -76,17 +85,21 @@ struct AddServerView: View {
guard !trimmedHost.isEmpty, let portNum = UInt16(port) else { return }
let pw = (savePassword && authMode == .password && !password.isEmpty) ? password : nil
let trimmedNick = nickname.trimmingCharacters(in: .whitespaces)
let nick: String? = (authMode == .guest && !trimmedNick.isEmpty) ? trimmedNick : nil
if var s = editing {
s.host = trimmedHost
s.port = portNum
s.authMode = authMode
s.savedUsername = authMode == .password ? username : ""
s.nickname = nick
appState.updateServer(s, password: pw)
} else {
let s = SavedServer(host: trimmedHost, port: portNum,
authMode: authMode,
savedUsername: authMode == .password ? username : "")
savedUsername: authMode == .password ? username : "",
nickname: nick)
appState.addServer(s, password: pw)
}
dismiss()

View File

@@ -7,6 +7,7 @@ import VoiceCatCore
struct ChannelBrowserView: View {
@Bindable var session: SessionState
@State private var showCreateChannel = false
@State private var editChannel: Channel?
var body: some View {
NavigationStack {
@@ -26,6 +27,16 @@ struct ChannelBrowserView: View {
}
}
}
.swipeActions(edge: .leading) {
if session.permissions.isAdmin {
Button {
editChannel = ch
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
}
}
.navigationTitle("Channels")
@@ -44,6 +55,9 @@ struct ChannelBrowserView: View {
.sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session)
}
.sheet(item: $editChannel) { ch in
ChannelEditView(channelId: ch.id, session: session)
}
}
}

View File

@@ -1,12 +1,34 @@
import SwiftUI
import VoiceCatCore
struct ChannelEditView: View {
let channelId: UInt32?
@Bindable var session: SessionState
@Environment(\.dismiss) private var dismiss
// General
@State private var name = ""
@State private var topic = ""
@State private var parentId: UInt32 = 0
@State private var passwordProtected = false
@State private var password = ""
@State private var maxUsers = "0"
@State private var sortOrder = "0"
// Audio (Opus). Note: the channel list does not carry the current audio config, so when
// editing an existing channel these start from the codec defaults (same as macOS/Windows).
@State private var stereo = false
@State private var bitrate = "64000"
@State private var sampleRate = "48000"
@State private var frameMs: UInt32 = 20
@State private var application: UInt32 = 0
@State private var packetLoss = "5"
@State private var complexity = 10
@State private var fec = true
@State private var dtx = false
@State private var dred = false
private var isEditing: Bool { channelId != nil }
var body: some View {
NavigationStack {
@@ -17,9 +39,57 @@ struct ChannelEditView: View {
.accessibilityLabel("Channel name")
TextField("Topic (optional)", text: $topic)
.accessibilityLabel("Channel topic, optional")
Picker("Parent", selection: $parentId) {
Text("(root)").tag(UInt32(0))
ForEach(parentOptions) { ch in
Text(ch.name).tag(ch.id)
}
}
.accessibilityLabel("Parent channel")
Toggle("Password protected", isOn: $passwordProtected)
if passwordProtected {
SecureField("Password (blank keeps existing)", text: $password)
.accessibilityLabel("Channel password")
}
TextField("Max users (0 = unlimited)", text: $maxUsers)
.keyboardType(.numberPad)
.accessibilityLabel("Maximum users, zero means unlimited")
TextField("Sort order", text: $sortOrder)
.keyboardType(.numberPad)
.accessibilityLabel("Sort order")
}
Section("Audio (Opus)") {
Toggle("Stereo", isOn: $stereo)
TextField("Bitrate (bps)", text: $bitrate)
.keyboardType(.numberPad)
.accessibilityLabel("Bitrate in bits per second")
TextField("Sample rate (Hz)", text: $sampleRate)
.keyboardType(.numberPad)
.accessibilityLabel("Sample rate in Hz")
Picker("Frame", selection: $frameMs) {
ForEach([UInt32(10), 20, 40, 60], id: \.self) { ms in
Text("\(ms) ms").tag(ms)
}
}
.accessibilityLabel("Opus frame duration")
Picker("Application", selection: $application) {
Text("VoIP").tag(UInt32(0))
Text("Audio").tag(UInt32(1))
Text("Low delay").tag(UInt32(2))
}
.accessibilityLabel("Opus application profile")
TextField("Expected packet loss %", text: $packetLoss)
.keyboardType(.numberPad)
.accessibilityLabel("Expected packet loss percent, 0 to 100")
Stepper("Complexity: \(complexity)", value: $complexity, in: 0...10)
.accessibilityLabel("Opus complexity, 0 to 10")
Toggle("FEC (forward error correction)", isOn: $fec)
Toggle("DTX (discontinuous transmission)", isOn: $dtx)
Toggle("DRED (deep redundancy)", isOn: $dred)
}
}
.navigationTitle(channelId == nil ? "New Channel" : "Edit Channel")
.navigationTitle(isEditing ? "Edit Channel" : "New Channel")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -27,12 +97,67 @@ struct ChannelEditView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
session.createChannel(name: name, topic: topic)
save()
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear(perform: loadIfEditing)
}
}
/// Channels offered as a parent. Excludes the channel being edited so it can't parent itself.
private var parentOptions: [Channel] {
session.channels
.filter { $0.id != channelId }
.sorted { $0.name < $1.name }
}
private func loadIfEditing() {
guard let id = channelId,
let ch = session.channels.first(where: { $0.id == id }) else { return }
name = ch.name
topic = ch.topic
parentId = ch.parentId
passwordProtected = ch.passwordProtected
maxUsers = "\(ch.maxUsers)"
}
private func save() {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return }
let audio = AudioConfig(
stereo: stereo,
sampleRate: UInt32(sampleRate) ?? 48000,
bitrateBps: UInt32(bitrate) ?? 64000,
frameMs: frameMs,
application: application,
fec: fec,
expectedPacketLoss: min(UInt32(packetLoss) ?? 5, 100),
dtx: dtx,
complexity: UInt32(complexity),
dred: dred
)
let pw: String? = passwordProtected ? (password.isEmpty ? nil : password) : nil
let info = ChannelEdit(
id: channelId ?? 0,
parentId: parentId,
name: trimmedName,
topic: topic,
passwordProtected: passwordProtected,
password: pw,
maxUsers: UInt32(maxUsers) ?? 0,
sortOrder: UInt32(sortOrder) ?? 0,
audio: audio
)
if isEditing {
session.editChannel(info)
} else {
session.createChannel(info)
}
}
}

View File

@@ -12,6 +12,7 @@ struct ChannelNode: Identifiable {
struct ChannelTreeView: View {
@Bindable var session: SessionState
@State private var showCreateChannel = false
@State private var editChannel: Channel?
@State private var channelPassword = ""
@State private var passwordChannelId: UInt32?
@@ -34,6 +35,16 @@ struct ChannelTreeView: View {
}
}
}
.swipeActions(edge: .leading) {
if session.permissions.isAdmin {
Button {
editChannel = node.channel
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
}
.listStyle(.sidebar)
.toolbar {
@@ -59,6 +70,9 @@ struct ChannelTreeView: View {
.sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session)
}
.sheet(item: $editChannel) { ch in
ChannelEditView(channelId: ch.id, session: session)
}
.alert("Channel Password", isPresented: Binding(
get: { passwordChannelId != nil },
set: { if !$0 { passwordChannelId = nil; channelPassword = "" } }

View File

@@ -11,6 +11,9 @@ struct SavedServer: Codable, Identifiable {
var port: UInt16
var authMode: AuthMode
var savedUsername: String?
/// Free-form display name used when connecting as a guest. Distinct from the account
/// `savedUsername`. Empty/nil falls back to the system full name.
var nickname: String?
var keychainTag: String?
var displayString: String {

View File

@@ -13,6 +13,7 @@ final class AddServerSheet: NSViewController {
return f
}()
private let authPicker = NSPopUpButton()
private let nicknameField = NSTextField()
private let usernameField = NSTextField()
private let passwordField = NSSecureTextField()
private let savePwCheckbox = NSButton(checkboxWithTitle: "Save password in Keychain", target: nil, action: nil)
@@ -35,6 +36,7 @@ final class AddServerSheet: NSViewController {
hostField.stringValue = s.host
portField.stringValue = "\(s.port)"
authPicker.selectItem(withTitle: s.authMode == .guest ? "Guest" : "Account")
nicknameField.stringValue = s.nickname ?? ""
usernameField.stringValue = s.savedUsername ?? ""
updateAuthVisibility()
}
@@ -47,6 +49,7 @@ final class AddServerSheet: NSViewController {
let hostLabel = NSTextField(labelWithString: "Host:")
let portLabel = NSTextField(labelWithString: "Port:")
let authLabel = NSTextField(labelWithString: "Auth:")
let nickLabel = NSTextField(labelWithString: "Nickname:")
let userLabel = NSTextField(labelWithString: "Username:")
let pwLabel = NSTextField(labelWithString: "Password:")
@@ -60,6 +63,9 @@ final class AddServerSheet: NSViewController {
authPicker.target = self; authPicker.action = #selector(authChanged)
authPicker.setAccessibilityLabel("Authentication mode")
nicknameField.placeholderString = "leave blank to use your system name"
nicknameField.setAccessibilityLabel("Guest nickname (display name)")
usernameField.placeholderString = "username"
usernameField.setAccessibilityLabel("Username")
@@ -79,6 +85,7 @@ final class AddServerSheet: NSViewController {
[hostLabel, hostField],
[portLabel, portField],
[authLabel, authPicker],
[nickLabel, nicknameField],
[userLabel, usernameField],
[pwLabel, passwordField],
[NSView(), savePwCheckbox],
@@ -110,6 +117,7 @@ final class AddServerSheet: NSViewController {
private func updateAuthVisibility() {
let isAccount = authPicker.titleOfSelectedItem == "Account"
nicknameField.isEnabled = !isAccount
usernameField.isEnabled = isAccount
passwordField.isEnabled = isAccount
savePwCheckbox.isEnabled = isAccount
@@ -130,6 +138,8 @@ final class AddServerSheet: NSViewController {
server.host = host; server.port = port
server.authMode = isGuest ? .guest : .password
server.savedUsername = isGuest ? nil : usernameField.stringValue.trimmingCharacters(in: .whitespaces)
let nick = nicknameField.stringValue.trimmingCharacters(in: .whitespaces)
server.nickname = nick.isEmpty ? nil : nick
if !isGuest && savePwCheckbox.state == .on {
let pw = passwordField.stringValue

View File

@@ -25,7 +25,18 @@ final class ChannelEditSheet: NSViewController {
}()
private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil)
private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil)
private let dredCheckbox = NSButton(checkboxWithTitle: "DRED", target: nil, action: nil)
private let frameMsPicker = NSPopUpButton()
private let applicationPicker = NSPopUpButton()
private let sampleRateField: NSTextField = {
let f = NSTextField(); f.stringValue = "48000"; return f
}()
private let packetLossField: NSTextField = {
let f = NSTextField(); f.stringValue = "5"; return f
}()
private let complexityField: NSTextField = {
let f = NSTextField(); f.stringValue = "10"; return f
}()
init(channels: [Channel], editing: ChannelEdit?) {
self.channels = channels
@@ -72,14 +83,24 @@ final class ChannelEditSheet: NSViewController {
maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)")
sortOrderField.setAccessibilityLabel("Sort order")
for ms in ["20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
for ms in ["10", "20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
frameMsPicker.selectItem(withTitle: "20 ms")
frameMsPicker.setAccessibilityLabel("Opus frame duration")
applicationPicker.addItem(withTitle: "VoIP")
applicationPicker.addItem(withTitle: "Audio")
applicationPicker.addItem(withTitle: "Low delay")
applicationPicker.setAccessibilityLabel("Opus application profile")
fecCheckbox.state = .on
stereoCheckbox.setAccessibilityLabel("Stereo audio")
bitrateField.setAccessibilityLabel("Bitrate in bits per second")
sampleRateField.setAccessibilityLabel("Sample rate in Hz")
packetLossField.setAccessibilityLabel("Expected packet loss percent (0 to 100)")
complexityField.setAccessibilityLabel("Opus complexity (0 to 10)")
fecCheckbox.setAccessibilityLabel("Forward error correction")
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
dredCheckbox.setAccessibilityLabel("Deep redundancy (DRED)")
let generalGrid = NSGridView(views: [
[NSTextField(labelWithString: "Name:"), nameField],
@@ -94,9 +115,13 @@ final class ChannelEditSheet: NSViewController {
let audioGrid = NSGridView(views: [
[NSTextField(labelWithString: "Bitrate:"), bitrateField],
[NSTextField(labelWithString: "Sample rate:"), sampleRateField],
[NSTextField(labelWithString: "Frame:"), frameMsPicker],
[NSTextField(labelWithString: "Application:"), applicationPicker],
[NSTextField(labelWithString: "Packet loss %:"), packetLossField],
[NSTextField(labelWithString: "Complexity:"), complexityField],
[stereoCheckbox, fecCheckbox],
[dtxCheckbox, NSView()],
[dtxCheckbox, dredCheckbox],
])
audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8
audioGrid.column(at: 0).xPlacement = .trailing
@@ -149,8 +174,13 @@ final class ChannelEditSheet: NSViewController {
sortOrderField.stringValue = "\(e.sortOrder)"
stereoCheckbox.state = e.audio.stereo ? .on : .off
bitrateField.stringValue = "\(e.audio.bitrateBps)"
sampleRateField.stringValue = "\(e.audio.sampleRate)"
packetLossField.stringValue = "\(e.audio.expectedPacketLoss)"
complexityField.stringValue = "\(e.audio.complexity)"
fecCheckbox.state = e.audio.fec ? .on : .off
dtxCheckbox.state = e.audio.dtx ? .on : .off
dredCheckbox.state = e.audio.dred ? .on : .off
applicationPicker.selectItem(at: Int(min(e.audio.application, 2)))
let frameStr = "\(e.audio.frameMs) ms"
if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) }
}
@@ -167,15 +197,24 @@ final class ChannelEditSheet: NSViewController {
let maxUsers = UInt32(maxUsersField.stringValue) ?? 0
let sortOrder = UInt32(sortOrderField.stringValue) ?? 0
let bitrate = UInt32(bitrateField.stringValue) ?? 64000
let sampleRate = UInt32(sampleRateField.stringValue) ?? 48000
let packetLoss = min(UInt32(packetLossField.stringValue) ?? 5, 100)
let complexity = min(UInt32(complexityField.stringValue) ?? 10, 10)
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
let frameMs = UInt32(frameMsStr) ?? 20
let application = UInt32(max(applicationPicker.indexOfSelectedItem, 0))
let audio = AudioConfig(
stereo: stereoCheckbox.state == .on,
sampleRate: sampleRate,
bitrateBps: bitrate,
frameMs: frameMs,
application: application,
fec: fecCheckbox.state == .on,
dtx: dtxCheckbox.state == .on
expectedPacketLoss: packetLoss,
dtx: dtxCheckbox.state == .on,
complexity: complexity,
dred: dredCheckbox.state == .on
)
let pwProtected = pwCheckbox.state == .on
let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil

View File

@@ -216,7 +216,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
switch server.authMode {
case .guest:
let nick = server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName()
let nick = server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName()
newClient.authenticateGuest(nick)
case .password:
let username = server.savedUsername ?? ""
@@ -259,7 +259,7 @@ final class ConnectWindowController: NSWindowController, NSWindowDelegate {
case .authResult:
if event.result == .ok {
let nickname = server.authMode == .guest
? (server.savedUsername?.isEmpty == false ? server.savedUsername! : NSFullUserName())
? (server.nickname?.isEmpty == false ? server.nickname! : NSFullUserName())
: (server.savedUsername ?? "")
authSucceeded(client: client!, selfUserId: event.userId, nickname: nickname)
} else {