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 /// The server we are currently fully connected to. Set on auth success (when `session` is /// created) and cleared on teardown. Used to build the `lastSession` restore snapshot when a /// live-session disconnect fires through `SessionState.handleEvent` — `connectingServer` is /// already nil by then, and `handleConnectEvent`'s `server` parameter is out of scope because /// `SessionState` owns `client.onEvent` after auth success (see `SessionState.init`). private var connectedServer: SavedServer? // MARK: - Reconnect state // // The C core surfaces every unexpected connection drop as a `.disconnected` event; nothing // in the C core auto-reconnects (intentional — reconnect UX is the client's job). Two layers // drive iOS reconnect: // // 1. **Event-driven** (the C core's TCP read eventually fails after the keepalive/reaper // timeout, ~30-60 s on a hard Wi-Fi drop): `SessionState.handleEvent` `.disconnected` // plays the audible cue, then calls back into AppState via `onLiveSessionDisconnected`, // which snapshots the live session, tears it down, and arms `scheduleReconnect`. // (Live-session events never reach `AppState.handleConnectEvent` — `SessionState.init` // overwrites `client.onEvent`, so `AppState` cannot see them without the callback.) // // 2. **Path-driven** (proactive, much faster): `NWPathMonitor` runs the whole time we are // connected (started on auth success) and reacts to network changes — a Wi-Fi↔cellular // flip or the path becoming `.unsatisfied` calls `proactiveReconnect`, which tears the // live session down BEFORE the C core notices the dead TCP path. This is what makes the // 30-60 s wait collapse into ~1 s + the backoff tick. While mid-reconnect (no session) // the same monitor arms a fast-fresh retry whenever a path becomes `.satisfied`. // // Manual Disconnect cancels everything (task + path monitor) and clears `lastSession`. /// True at the top of `disconnect()`/`cancelConnect()` — suppresses auto-reconnect for the /// `.disconnected` event the core then emits in response to our `vc_disconnect()` call. private var userInitiatedDisconnect = false /// Snapshot of the live session state needed to restore after a reconnect. Cleared on /// successful restore and on user-initiated disconnect. private struct LastSession { let server: SavedServer let channelId: UInt32 let voiceSubscribed: Bool let micMuted: Bool let deafened: Bool } private var lastSession: LastSession? /// Reconnect attempt counter — drives exponential backoff. Reset to 0 on successful auth and /// on a path-driven fast-fresh retry. private var reconnectAttempt = 0 /// The in-flight reconnect `Task` (sleeps for the backoff, then calls `connectTo`). One /// at a time; cancelled on user disconnect / successful restore. private var reconnectTask: Task? /// Started on auth success and kept running while connected / mid-reconnect; stopped only on /// user-initiated disconnect. Its `pathUpdateHandler` (dispatched to @MainActor) handles two /// cases: a path change while connected → proactive reconnect; a satisfied path while /// mid-reconnect → fast-fresh retry. See the reconnect-state header comment. private var pathMonitor: NWPathMonitor? private let pathQueue = DispatchQueue(label: "cat.voice.network.path") /// Signature of the last path seen by the monitor (a stable string encoding status + active /// interface types). The very first path callback (when the monitor starts) sets this and is /// otherwise ignored — it's the baseline; only subsequent CHANGES are reconnect triggers. 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 /// Public connect entry. Always starts a fresh session (no restore). func connectTo(_ server: SavedServer) { connectTo(server, restoring: nil) } /// Internal connect that drives the full TLS/auth saga. `restoring` is non-nil for a /// reconnect attempt following an unexpected disconnect; the captured channel + voice/mic /// state is handed to the new `SessionState` after auth succeeds. private func connectTo(_ server: SavedServer, restoring: LastSession?) { guard !isConnecting else { return } isConnecting = true connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…" connectingServer = server identityHandled = false userInitiatedDisconnect = false // Discard any leftover connecting client. nil'ing the strong ref calls VoiceCatClient's // deinit, which synchronously joins the C core's io thread (vc_client_destroy) before // freeing the config-string storage — safe from @MainActor because the io thread never // blocks on main (it enqueues events via DispatchQueue.main.async and returns). 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) } } // 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. On a reconnect // the TOFU pin already matches (VC_TOFU_MATCHED), so the identity gate auto-confirms // inside the .serverIdentity case below and auth proceeds unattended. 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() { // Mark BEFORE we ask the core to disconnect, so the .disconnected event the core emits // in response is treated as user-initiated (no reconnect) rather than an unexpected drop. 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 /// Cancel any in-flight reconnect task and stop the path monitor. Safe to call when nothing /// is armed (no-op). Does NOT touch `userInitiatedDisconnect` or `lastSession` — callers set /// those as needed (disconnect/cancelConnect clear them; scheduleReconnect keeps them). /// /// `AppState` is the @Observable app root owned by the SwiftUI `App`; it lives for the /// whole app process and is torn down only on process exit, at which point OS cleanup /// suffices. This method is driven by `disconnect()`/`cancelConnect()` and on successful /// restore — those run on user-initiated teardown, which is the only path that matters. /// (The reconnect `Task` captures `[weak self]` and guards on `nil`/`userInitiatedDisconnect`, /// so a stray task left running when AppState is gone is a no-op; the monitor similarly guards.) private func cancelReconnect() { reconnectTask?.cancel() reconnectTask = nil stopPathMonitor() // Don't reset `reconnectAttempt` here: scheduleReconnect resets it on successful auth, // and the path monitor resets it to 0 for a fast fresh attempt on a path-satisfied event. // If a fresh user connect follows, connectTo() doesn't reset it either, but it doesn't // need to — `reconnectAttempt` only matters while we're mid-reconnect. } /// Arm the next reconnect attempt with exponential backoff (1s → 2s → 4s → 8s → 16s → 30s /// cap). Cancelled cleanly by `cancelReconnect()` on user disconnect or successful auth. /// Idempotent: a new call supersedes any in-flight one. The path monitor is armed here and /// disarmed on cancel; on a path-satisfied event it resets the attempt counter to 0 and /// re-arms via this same method, yielding a fast refresh after Wi-Fi ↔ cellular transitions. 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 } // Re-check under Task: a user disconnect between the sleep and this line must abort. 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 } /// Start (if not already running) the network path monitor. Runs the whole time we are /// connected (started on auth success) and stays armed across reconnects; stopped only on /// user-initiated disconnect. The handler dispatches to @MainActor before touching state and /// does two distinct things: /// - **While connected** (`session != nil`): a Wi-Fi↔cellular interface change OR the path /// becoming `.unsatisfied` triggers `proactiveReconnect()` — tearing the live session /// down before the C core notices the dead TCP read. Without this the disconnect would /// take 30-60 s (the TCP keepalive/reaper timeout); proactive teardown collapses that to /// ~1 s + the first backoff tick. Same-interface path refreshes (e.g. a Wi-Fi roam /// without an IP change) are ignored — likely the connection is still good. /// - **While mid-reconnect** (`session == nil`, `lastSession != nil`): a path becoming /// `.satisfied` arms a fast-fresh retry (backoff counter reset, `scheduleReconnect`). /// This is what makes a Wi-Fi→cellular flip reconnect on roughly the next tick instead /// of waiting out a long backoff. 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 // The first callback (when the monitor starts) is the baseline, not a change. if prevSig == nil { return } if self.session != nil { // See this method's doc comment for why these conditions trigger a // proactive reconnect. if path.status != .satisfied || sig != prevSig { self.proactiveReconnect() } } else if self.lastSession != nil { if path.status == .satisfied { self.reconnectAttempt = 0 self.scheduleReconnect() } } } } // Listen on a dedicated queue — the path monitor can't share the main queue (it would // re-enter main if any handler dispatched to main synchronously). monitor.start(queue: pathQueue) pathMonitor = monitor } private func stopPathMonitor() { pathMonitor?.cancel() pathMonitor = nil lastPathSignature = nil } /// A stable string signature of a network path: the path status plus the set of interface /// types it uses. Two paths with the same signature are treated as equivalent — no /// reconnect. A signature change is the trigger for `proactiveReconnect`. Used to ignore /// same-interface refreshes (signal-strength changes, BSSID roams) which usually don't break /// the TCP connection. 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) /// Called by `SessionState.handleEvent` `.disconnected` after the audible cue has already /// played. Once `SessionState` is created (auth success) it owns `client.onEvent`, so /// `AppState.handleConnectEvent` never sees live-session events — this callback is the only /// way AppState learns that a live session dropped. Snapshots the live session state, /// tears the session down, and arms `scheduleReconnect` so the backoff loop drives a fresh /// TLS/auth/restoration. Guarded against user-initiated disconnect (which nil's `session` /// synchronously, so SessionState is gone before the event could fire this callback) — but /// the guard is cheap insurance. func onLiveSessionDisconnected() { guard !userInitiatedDisconnect else { return } teardownLiveSessionAndReconnect(sound: false) } /// Called by `NWPathMonitor` when a path change is detected while a live session exists. /// Tears the session down immediately — nil'ing `session` releases `VoiceCatClient`, whose /// `deinit` calls `vc_client_destroy`; that closes the socket and joins the C core's io /// thread, so the io thread exits in milliseconds rather than blocking on a dead read for /// ~30-60 s. The proactive tear-down is what collapses the long TCP-reaper wait into a /// ~1 s reconnect. Plays the audible cue (no `.disconnected` event fires through to /// `SessionState` for this path, since `SessionState` is being torn down here — so the cue /// would otherwise be missing). private func proactiveReconnect() { guard !userInitiatedDisconnect else { return } guard session != nil else { return } teardownLiveSessionAndReconnect(sound: true) } /// Shared teardown for a live-session disconnect (event- or path-driven). Snapshots the live /// session into `lastSession`, stops audio, deactivates the AVAudioSession, releases the /// session (which releases `VoiceCatClient` → io-thread join), resets the backoff counter, /// and arms `scheduleReconnect`. `sound` is true for the proactive (path-driven) case — the /// `.disconnected` event that would have played it never fires because we're tearing down /// ahead of the C core noticing. The event-driven caller (`onLiveSessionDisconnected`) has /// ALREADY played the cue via `SessionState.handleEvent`, so it passes `sound: false`. private func teardownLiveSessionAndReconnect(sound: Bool) { // Snapshot BEFORE nil'ing `session` — we need the channel + voice/mic state to restore. 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() // Releasing `session` releases `VoiceCatClient`; its deinit joins the C core's io thread. // For a path-driven proactive teardown this is what avoids the 30-60 s reaper timeout. session = nil isConnecting = false connectingClient = nil connectedServer = nil if sound { EventFeedback.shared.play(.connectionLost) EventFeedback.shared.speak("Network changed — reconnecting") } // Reset the backoff counter so the first reconnect attempt after a drop uses the short // 1 s delay (the immediate path-driven attempt matters most; sustained-outage backoff is // driven by `scheduleReconnect`'s increment). 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 } } }