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).
202 lines
7.7 KiB
Swift
202 lines
7.7 KiB
Swift
import Foundation
|
|
import VoiceCatCore
|
|
|
|
struct PendingIdentity: Identifiable {
|
|
let id = UUID()
|
|
let displayText: String
|
|
let tofuStatus: VoiceCatTofuStatus
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class AppState {
|
|
var servers: [SavedServer] = ServerListStore.shared.load()
|
|
var session: SessionState?
|
|
|
|
// Connect-flow state
|
|
var isConnecting = false
|
|
var connectStatus = ""
|
|
var showAddServer = false
|
|
var editingServer: SavedServer?
|
|
var showPasswordPrompt = false
|
|
var pendingIdentity: PendingIdentity?
|
|
|
|
private var connectingClient: VoiceCatClient?
|
|
private(set) var connectingServer: SavedServer?
|
|
private var identityHandled = false
|
|
|
|
// MARK: - Server list management
|
|
|
|
func addServer(_ server: SavedServer, password: String?) {
|
|
if let pw = password, !pw.isEmpty {
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
}
|
|
servers.append(server)
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
func updateServer(_ server: SavedServer, password: String?) {
|
|
if let pw = password, !pw.isEmpty {
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
}
|
|
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
|
servers[idx] = server
|
|
}
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
func removeServer(_ server: SavedServer) {
|
|
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
|
servers.removeAll(where: { $0.id == server.id })
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
// MARK: - Connect flow
|
|
|
|
func connectTo(_ server: SavedServer) {
|
|
guard !isConnecting else { return }
|
|
isConnecting = true
|
|
connectStatus = "Connecting…"
|
|
connectingServer = server
|
|
identityHandled = false
|
|
|
|
let config = VoiceCatConfig(
|
|
clientName: "VoiceCat-iOS",
|
|
clientVersion: "0.0.1",
|
|
logLevel: .info,
|
|
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
|
let client = VoiceCatClient(config: config)
|
|
connectingClient = client
|
|
|
|
client.onEvent = { [weak self] ev in
|
|
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
|
|
}
|
|
// Put the core into external-playback mode BEFORE connect, so the flag is set on the
|
|
// io thread before any message is processed. The server sends AuthResult immediately
|
|
// followed by ServerStateSnapshot; handle_server_state runs ensure_audio_running() on
|
|
// the io thread, and if external_playback_ were still false at that point the core
|
|
// would open a hardware miniaudio playback+capture device (see the matching fix in
|
|
// vc_client::ensure_audio_running). Setting it here — before connect — guarantees the
|
|
// unified external path is in effect from the first frame. setExternalPlayback only
|
|
// flips an atomic + forwards to the engine's setter; both are safe pre-connect.
|
|
client.setExternalPlayback(true)
|
|
client.connect(host: server.host, port: server.port)
|
|
|
|
// Auth is queued immediately — the core serialises it behind TLS + TOFU.
|
|
switch server.authMode {
|
|
case .guest:
|
|
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
|
client.authenticateGuest(nick)
|
|
case .password:
|
|
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
|
if let pw = savedPw, !pw.isEmpty {
|
|
client.authenticateUser(server.savedUsername, password: pw)
|
|
} else {
|
|
showPasswordPrompt = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
session?.leaveVoice()
|
|
session?.client.disconnect()
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
session = nil
|
|
connectingClient?.disconnect()
|
|
connectingClient = nil
|
|
connectingServer = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
}
|
|
|
|
// MARK: - Auth actions (called from prompt sheets)
|
|
|
|
func authenticateUser(username: String, password: String) {
|
|
connectingClient?.authenticateUser(username, password: password)
|
|
showPasswordPrompt = false
|
|
}
|
|
|
|
func confirmServerIdentity(accept: Bool) {
|
|
connectingClient?.confirmServerIdentity(accept: accept)
|
|
pendingIdentity = nil
|
|
if !accept { cancelConnect() }
|
|
}
|
|
|
|
func cancelConnect() {
|
|
connectingClient?.disconnect()
|
|
connectingClient = nil
|
|
connectingServer = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
}
|
|
|
|
// MARK: - Connect event handler
|
|
|
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer) {
|
|
switch ev.type {
|
|
case .connectionState:
|
|
switch ev.connectionState {
|
|
case .connecting: connectStatus = "Connecting…"
|
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
|
case .authenticating: connectStatus = "Authenticating…"
|
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
|
case .connected: connectStatus = "Connected"
|
|
default: break
|
|
}
|
|
case .serverIdentity:
|
|
guard !identityHandled else { break }
|
|
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
|
if tofuStatus == .matched {
|
|
connectingClient?.confirmServerIdentity(accept: true)
|
|
} else {
|
|
identityHandled = true
|
|
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
|
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
|
}
|
|
case .authResult:
|
|
if ev.result == .ok {
|
|
guard let client = connectingClient else { break }
|
|
let perms = client.getPermissions()
|
|
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
|
connectingClient = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
self.session = newSession
|
|
EventFeedback.shared.play(.login)
|
|
EventFeedback.shared.speak("Connected")
|
|
// External-playback mode was enabled before connect() so the core never opens a
|
|
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
|
// Now activate the session and start the engine in listening mode so remote audio
|
|
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
|
do {
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
} catch {
|
|
print("Audio session activate on connect failed: \(error)")
|
|
}
|
|
IOSAudioEngine.shared.startListening(client: client)
|
|
} else {
|
|
connectStatus = "Auth failed: \(ev.result.description)"
|
|
showPasswordPrompt = true
|
|
}
|
|
case .disconnected:
|
|
if session == nil { cancelConnect() }
|
|
else {
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
session = nil; isConnecting = false
|
|
}
|
|
case .error:
|
|
connectStatus = ev.text ?? "Unknown error"
|
|
if session == nil { isConnecting = false }
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|