import Foundation import Network 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 /// Retained after authentication so an interrupted session can be restored. private var connectedServer: SavedServer? // MARK: - Reconnect state /// Distinguishes an explicit disconnect from a transport failure. private var userInitiatedDisconnect = false private struct LastSession { let server: SavedServer let channelId: UInt32 let voiceSubscribed: Bool let micMuted: Bool let deafened: Bool } private var lastSession: LastSession? private var reconnectAttempt = 0 private var reconnectTask: Task? /// Detects interface changes before TCP keepalive notices a dead path. private var pathMonitor: NWPathMonitor? private let pathQueue = DispatchQueue(label: "cat.voice.network.path") private var lastPathSignature: String? // 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) { connectTo(server, restoring: nil) } private func connectTo(_ server: SavedServer, restoring: LastSession?) { guard !isConnecting else { return } isConnecting = true connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…" connectingServer = server identityHandled = false userInitiatedDisconnect = false // Releasing the wrapper joins the core's I/O thread before freeing native strings. connectingClient = nil 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, restoring: restoring) } } // Authentication can start audio, so select the external path before connecting. client.setExternalPlayback(true) client.connect(host: server.host, port: server.port) 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() { // Set before disconnect so its event cannot arm reconnect. userInitiatedDisconnect = true cancelReconnect() lastSession = nil session?.leaveVoice() session?.client.disconnect() IOSAudioEngine.shared.stop() AudioSessionManager.shared.deactivateSession() session = nil connectingClient?.disconnect() connectingClient = nil connectingServer = nil connectedServer = 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() { // User explicitly cancelled — no reconnect for the resulting .disconnected event. userInitiatedDisconnect = true cancelReconnect() lastSession = nil connectingClient?.disconnect() connectingClient = nil connectingServer = nil connectedServer = nil isConnecting = false connectStatus = "" showPasswordPrompt = false pendingIdentity = nil } // MARK: - Reconnect orchestration private func cancelReconnect() { reconnectTask?.cancel() reconnectTask = nil stopPathMonitor() } /// Schedules the next reconnect with exponential backoff capped at 30 seconds. private func scheduleReconnect() { guard !userInitiatedDisconnect, let last = lastSession else { return } reconnectTask?.cancel() reconnectAttempt = max(1, reconnectAttempt + 1) let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0) connectStatus = "Reconnecting (attempt \(reconnectAttempt))…" startPathMonitor() let task = Task { [weak self, last] in guard let self else { return } try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000)) if Task.isCancelled { return } guard !self.userInitiatedDisconnect else { return } guard self.lastSession != nil else { return } guard self.session == nil else { return } self.connectTo(last.server, restoring: last) } reconnectTask = task } private func startPathMonitor() { guard pathMonitor == nil else { return } let monitor = NWPathMonitor() monitor.pathUpdateHandler = { [weak self] path in Task { @MainActor [weak self] in guard let self else { return } guard !self.userInitiatedDisconnect else { return } let sig = Self.pathSignature(path) let prevSig = self.lastPathSignature self.lastPathSignature = sig if prevSig == nil { return } if self.session != nil { if path.status != .satisfied || sig != prevSig { self.proactiveReconnect() } } else if self.lastSession != nil { if path.status == .satisfied { self.reconnectAttempt = 0 self.scheduleReconnect() } } } } monitor.start(queue: pathQueue) pathMonitor = monitor } private func stopPathMonitor() { pathMonitor?.cancel() pathMonitor = nil lastPathSignature = nil } private static func pathSignature(_ path: NWPath) -> String { guard path.status == .satisfied else { return "unsatisfied" } var parts: [String] = [] if path.usesInterfaceType(.wifi) { parts.append("wifi") } if path.usesInterfaceType(.cellular) { parts.append("cellular") } if path.usesInterfaceType(.wiredEthernet) { parts.append("wired") } if path.usesInterfaceType(.other) { parts.append("other") } return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+") } // MARK: - Live-session disconnect (called by SessionState) /// Receives disconnects after `SessionState` takes ownership of authenticated events. func onLiveSessionDisconnected() { guard !userInitiatedDisconnect else { return } teardownLiveSessionAndReconnect(sound: false) } private func proactiveReconnect() { guard !userInitiatedDisconnect else { return } guard session != nil else { return } teardownLiveSessionAndReconnect(sound: true) } private func teardownLiveSessionAndReconnect(sound: Bool) { if let s = session, let srv = connectedServer { lastSession = LastSession( server: srv, channelId: s.currentChannelId, voiceSubscribed: s.voiceState.voiceSubscribed, micMuted: s.voiceState.selfMuted, deafened: s.voiceState.selfDeafened) } IOSAudioEngine.shared.stop() AudioSessionManager.shared.deactivateSession() session = nil isConnecting = false connectingClient = nil connectedServer = nil if sound { EventFeedback.shared.play(.connectionLost) EventFeedback.shared.speak("Network changed — reconnecting") } reconnectAttempt = 0 scheduleReconnect() } // MARK: - Connect event handler private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer, restoring: LastSession?) { switch ev.type { case .connectionState: switch ev.connectionState { case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "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) newSession.appState = self connectingClient = nil isConnecting = false connectStatus = "" showPasswordPrompt = false connectedServer = server self.session = newSession EventFeedback.shared.play(.login) EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "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) // The path monitor runs the whole time we're connected so a network change fires // proactiveReconnect immediately instead of waiting for the C core's TCP keepalive // timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is // stopped only on user-initiated disconnect. startPathMonitor() // Reconnect restore: rejoin the prior channel and re-enable voice/mic if they // were on. The session is fresh (server auto-places us in Lobby), so the restore // is driven through SessionState.requestRestore, which issues a JoinChannel then // (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth // means the server is reachable, so the backoff counter resets and `lastSession` // clears; the path monitor keeps watching for the next change. if let restoring { newSession.requestRestore(channelId: restoring.channelId, voiceSubscribed: restoring.voiceSubscribed, micMuted: restoring.micMuted, deafened: restoring.deafened) reconnectAttempt = 0 lastSession = nil } } else { connectStatus = "Auth failed: \(ev.result.description)" showPasswordPrompt = true } case .disconnected: // This handler runs ONLY during the connecting phase — after auth success // `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect // reaches `SessionState.handleEvent` and comes back via // `onLiveSessionDisconnected`, not here. Two outcomes for this branch: // - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior // teardown) → re-arm `scheduleReconnect` so the backoff loop continues. // - A fresh connect failed before auth (`lastSession == nil`) → show the error, do // not auto-reconnect (the user should retry manually once the server is reachable). connectingClient = nil isConnecting = false IOSAudioEngine.shared.stop() AudioSessionManager.shared.deactivateSession() if userInitiatedDisconnect { connectStatus = "" showPasswordPrompt = false pendingIdentity = nil lastSession = nil connectedServer = nil cancelReconnect() } else if lastSession != nil { // Mid-reconnect drop — keep the backoff loop going. EventFeedback.shared.play(.connectionLost) EventFeedback.shared.speak("Connection lost — reconnecting") scheduleReconnect() } else { // Fresh connect failed before auth. Surface the reason; no auto-reconnect. connectStatus = ev.text ?? "Disconnected" showPasswordPrompt = false pendingIdentity = nil connectedServer = nil cancelReconnect() } case .error: connectStatus = ev.text ?? "Unknown error" // Errors don't disconnect us; the .disconnected event handles teardown/reconnect. default: break } } }