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.savedUsername.isEmpty ? "iOS User" : server.savedUsername 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?.stopMicStream() session?.client.disconnect() 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) ServerListStore.shared.writeBroadcastCredentials(server: server, nickname: nil) connectingClient = nil isConnecting = false connectStatus = "" showPasswordPrompt = false self.session = newSession // 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() } else { AudioSessionManager.shared.deactivateSession() session = nil; isConnecting = false } case .error: connectStatus = ev.text ?? "Unknown error" if session == nil { isConnecting = false } default: break } } }