Files
voice-cat/clients/apple/iOS/VoiceCatiOS/AppState.swift

190 lines
6.8 KiB
Swift
Raw Normal View History

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) }
}
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() {
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
session?.stopMicStream()
session?.client.disconnect()
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
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
fix(ios): stereo mic + A2DP output, add vc_audio_restart ABI Diagnosed by comparing against TeamTalk5 (Client/iTeamTalk), which achieves stereo mic + A2DP output. Five fixes: 1. configureStereoCapture now calls setPreferredInput + setInputDataSource (mirroring TeamTalk5's SoundDevicesModel). Previously omitted based on incorrect diagnosis that setPreferredInput collapsed A2DP — the real culprit was setPreferredInputNumberOfChannels(2), which neither project uses. 2. New C ABI: vc_audio_restart (full stop + re-init, unlike suspend/resume which only stop/start). Swift wrapper added. The withAudioSuspend wrapper that used it was removed after on-device testing showed it killed all audio (including VoiceOver) when switching presets — the core's set_capture_channels handles engine restart internally. 3. Bluetooth options: Voice Chat preset now includes BOTH .allowBluetoothHFP AND .allowBluetoothA2DP (matching TeamTalk5's UtilSound.swift:228). Previously HFP-only blocked A2DP headphones. 4. Capture channels now reset when switching stereo→mono via selectCaptureChannels/applyPreset. AudioSessionManager tracks activeMicStreamId (set by SessionState on join/leave voice). 5. Docs synced: voice.md, tech-stack.md, architecture.md, PROGRESS.md. Removed stale setPreferredInputNumberOfChannels(2) references. Verified: ctest --preset dev 21/21 green, iOS client builds. Stereo mic + A2DP output still needs on-device debugging — the core recipe is correct but iOS 26 route behavior requires hands-on testing with a debugger.
2026-06-19 16:58:21 +02:00
// Activate the audio session now, while connected NOT lazily when the first
// remote stream arrives. The core opens its miniaudio playback device the moment
// a remote stream starts and only THEN emits .streamStarted; if we waited for
// that event to activate, the playback device would open against an inactive
// AVAudioSession and produce no sound (the "can't hear anyone" bug). Activating
// here guarantees the session is live before any device opens.
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
print("Audio session activate on connect failed: \(error)")
}
} else {
connectStatus = "Auth failed: \(ev.result.description)"
showPasswordPrompt = true
}
case .disconnected:
if session == nil { cancelConnect() }
fix(ios): output muted with A2DP, session lifecycle, mic input issues Three bugs causing no audio output and no mic input: 1. .voiceChat mode + A2DP = output muted. The .voiceChat mode uses hardware AEC/AGC/HPF but requires HFP-compatible routes. A2DP is NOT HFP — iOS mutes the output because it can't set up the voice processing pipeline on an A2DP route. Fix: use .default mode for Standard+A2DP (no hardware AEC, but audio routes correctly). .voiceChat kept for HFP and speaker modes. Added info warning in Settings UI for A2DP no-AEC. 2. Session lifecycle broken. stopMicStream() called deactivateAfterStreaming() which deactivated the AVAudioSession — but the AudioEngine keeps running for remote audio playback, so leaving voice killed all remote audio. And the session was never activated when a remote user started talking (only on Join Voice), so you couldn't hear anyone before joining voice. Fix: - ensureSessionActive() replaces activateForStreaming() — idempotent, called on Join Voice AND on .streamStarted (remote user starts talking). - stopMicStream() no longer deactivates the session. - deactivateSession() called only on disconnect from server. - isSessionActive flag tracks state, updated by interruption handler. 3. setPreferredInputNumberOfChannels(1) called for mono — unnecessary (1 is the default) and may put the session in a bad state on some devices. Fix: only call it when stereo is explicitly selected. Also handle empty input port ID (selecting 'Default' in the picker) correctly. Added comprehensive route logging — after activation, logs the current output and input route names so issues can be diagnosed from Console.app.
2026-06-19 13:46:20 +02:00
else {
AudioSessionManager.shared.deactivateSession()
session = nil; isConnecting = false
}
case .error:
connectStatus = ev.text ?? "Unknown error"
if session == nil { isConnecting = false }
default:
break
}
}
}