2026-06-19 02:10:25 +02:00
|
|
|
import Foundation
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
import Network
|
2026-06-19 02:10:25 +02:00
|
|
|
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
|
|
|
|
|
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
/// 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<Void, Never>?
|
|
|
|
|
|
|
|
|
|
/// 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?
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// MARK: - Teardown
|
|
|
|
|
|
|
|
|
|
/// `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. Reconnect state (the in-flight `Task` and the `NWPathMonitor`) is cancelled
|
|
|
|
|
/// via `cancelReconnect()` driven by `disconnect()`/`cancelConnect()` and on successful
|
|
|
|
|
/// restore — those run on user-initiated teardown, which is the only path that matters.
|
|
|
|
|
/// (The `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.)
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
// MARK: - Connect flow
|
|
|
|
|
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
/// Public connect entry. Always starts a fresh session (no restore).
|
2026-06-19 02:10:25 +02:00
|
|
|
func connectTo(_ server: SavedServer) {
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
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?) {
|
2026-06-19 02:10:25 +02:00
|
|
|
guard !isConnecting else { return }
|
|
|
|
|
isConnecting = true
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
2026-06-19 02:10:25 +02:00
|
|
|
connectingServer = server
|
|
|
|
|
identityHandled = false
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
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
|
2026-06-19 02:10:25 +02:00
|
|
|
|
|
|
|
|
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
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
Task { @MainActor [weak self] in
|
|
|
|
|
self?.handleConnectEvent(ev, server: server, restoring: restoring)
|
|
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
}
|
2026-06-23 17:47:49 +02:00
|
|
|
// 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)
|
2026-06-19 02:10:25 +02:00
|
|
|
client.connect(host: server.host, port: server.port)
|
|
|
|
|
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// 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.
|
2026-06-19 02:10:25 +02:00
|
|
|
switch server.authMode {
|
|
|
|
|
case .guest:
|
feat(clients): expose all channel codec params + guest nickname everywhere
Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.
- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
(backward-compatible Codable), shown in Guest mode, wired into the guest auth
path -- guests could not set a display name on either before (only Windows)
Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
2026-06-21 04:03:50 +02:00
|
|
|
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
2026-06-19 02:10:25 +02:00
|
|
|
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() {
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// 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
|
feat: fix voice join/leave, channel edit defaults, channel-update stream restart
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).
2026-06-24 14:29:39 +02:00
|
|
|
session?.leaveVoice()
|
2026-06-19 02:10:25 +02:00
|
|
|
session?.client.disconnect()
|
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine
The iOS audio path was a hybrid: Voice-Chat-class presets ran a native
VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran
the core's miniaudio devices. Nearly every "no input / no output / both"
bug lived in the seam between the two paths — the lingering miniaudio
capture unit fighting VPIO, the audioRestart ordering dance, the
route-change "glitching" loop, stereo<->mono stickiness, and
"can't hear anyone". Switching presets/routes mid-call routinely dropped
a direction.
Drive ALL iOS audio through one AVAudioEngine with the core fully
external at all times: setExternalPlayback(1) once at connect, every MIC
stream external_feed=1, mic via vc_stream_feed_pcm, playback via
vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so
remote audio plays before joining voice). VPIO + AGC toggle per preset.
Every preset/route/interruption change funnels through one deterministic
Swift-only reconfigure (stop -> apply session config -> rebuild -> start)
— no second path to hand off to, so a change can't drop a direction.
- IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node
playback, conditional mic tap, VPIO/AGC; one rebuild() backing
startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels.
- IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic /
Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call
IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath.
- AudioSessionManager slimmed; SessionState mic lifecycle collapsed;
AppState wires external playback + listening at connect, stop at
disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles.
No core/ABI/test changes — relies on the already-shipped external API
(test_external_pcm, test_external_playback). xcodebuild iOS device Debug
BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
|
|
|
IOSAudioEngine.shared.stop()
|
2026-06-19 13:46:20 +02:00
|
|
|
AudioSessionManager.shared.deactivateSession()
|
2026-06-19 02:10:25 +02:00
|
|
|
session = nil
|
|
|
|
|
connectingClient?.disconnect()
|
|
|
|
|
connectingClient = nil
|
|
|
|
|
connectingServer = nil
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
connectedServer = nil
|
2026-06-19 02:10:25 +02:00
|
|
|
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() {
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// User explicitly cancelled — no reconnect for the resulting .disconnected event.
|
|
|
|
|
userInitiatedDisconnect = true
|
|
|
|
|
cancelReconnect()
|
|
|
|
|
lastSession = nil
|
2026-06-19 02:10:25 +02:00
|
|
|
connectingClient?.disconnect()
|
|
|
|
|
connectingClient = nil
|
|
|
|
|
connectingServer = nil
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
connectedServer = nil
|
2026-06-19 02:10:25 +02:00
|
|
|
isConnecting = false
|
|
|
|
|
connectStatus = ""
|
|
|
|
|
showPasswordPrompt = false
|
|
|
|
|
pendingIdentity = nil
|
|
|
|
|
}
|
|
|
|
|
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// 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).
|
|
|
|
|
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 {
|
|
|
|
|
// Connected — tear down + reconnect on a meaningful path change.
|
|
|
|
|
// `.unsatisfied` (all radios off) OR a primary-interface change (Wi-Fi↔cellular)
|
|
|
|
|
// almost always breaks the live TCP connection; reconnecting proactively beats
|
|
|
|
|
// waiting for the C core's keepalive/reaper timeout.
|
|
|
|
|
if path.status != .satisfied || sig != prevSig {
|
|
|
|
|
self.proactiveReconnect()
|
|
|
|
|
}
|
|
|
|
|
} else if self.lastSession != nil {
|
|
|
|
|
// Mid-reconnect — a path is available again; fast-fresh the next attempt.
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 02:10:25 +02:00
|
|
|
// MARK: - Connect event handler
|
|
|
|
|
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
|
|
|
|
|
restoring: LastSession?) {
|
2026-06-19 02:10:25 +02:00
|
|
|
switch ev.type {
|
|
|
|
|
case .connectionState:
|
|
|
|
|
switch ev.connectionState {
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
2026-06-19 02:10:25 +02:00
|
|
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
|
|
|
|
case .authenticating: connectStatus = "Authenticating…"
|
|
|
|
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
case .connected: connectStatus = "Connected"
|
2026-06-19 02:10:25 +02:00
|
|
|
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)
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
newSession.appState = self
|
2026-06-19 02:10:25 +02:00
|
|
|
connectingClient = nil
|
|
|
|
|
isConnecting = false
|
|
|
|
|
connectStatus = ""
|
|
|
|
|
showPasswordPrompt = false
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
connectedServer = server
|
2026-06-19 02:10:25 +02:00
|
|
|
self.session = newSession
|
feat(clients): event sound effects + optional text-to-speech
Add audible cues and optional spoken announcements for session events
(join/leave, channel + PM sent/recv, login, logout/connection-lost,
mic on/off, voice-activity, PTT) across all three clients, driven off
the shared C ABI vc_event stream so the mapping stays consistent.
TTS is off by default; when enabled it announces events and reads
message/PM bodies aloud. Master toggles + a sound-volume slider; the
per-utterance voice-activity and PTT cues default off. WAVs ship from
assets/sounds/.
Windows (built + verified): new VoiceCat.App/Notifications/ layer
(FeedbackSettings -> %AppData%\VoiceCat\feedback.json, SoundPlayerPool
via System.Media.SoundPlayer, SpeechAnnouncer via Prismatoid 0.3.0,
EventFeedback dispatcher); MainForm hooks; NotificationSettingsForm
under Settings > Notifications; csproj adds the Prismatoid PackageRef
and copies the WAVs into sounds\.
macOS + iOS (written, not yet built -- needs a Mac): shared
VoiceCatCore/Feedback/ (SoundEvent, EventFeedback = AVAudioPlayer pool
+ native AVSpeechSynthesizer, FeedbackSettings over UserDefaults); WAVs
bundled via Package.swift resources (.process). Hooks in SessionState/
AppState (iOS) and MainWindowController (macOS); settings UI in
SettingsView (iOS) and SettingsWindowController (macOS).
No core/server code touched; ctest --preset dev unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:20:11 +02:00
|
|
|
EventFeedback.shared.play(.login)
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
|
2026-06-23 17:47:49 +02:00
|
|
|
// 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").
|
2026-06-19 16:58:21 +02:00
|
|
|
do {
|
|
|
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
|
|
|
} catch {
|
|
|
|
|
print("Audio session activate on connect failed: \(error)")
|
|
|
|
|
}
|
fix(ios-audio): unify iOS audio onto one always-external AVAudioEngine
The iOS audio path was a hybrid: Voice-Chat-class presets ran a native
VPIO AVAudioEngine (core external) while Stereo/Studio/A2DP presets ran
the core's miniaudio devices. Nearly every "no input / no output / both"
bug lived in the seam between the two paths — the lingering miniaudio
capture unit fighting VPIO, the audioRestart ordering dance, the
route-change "glitching" loop, stereo<->mono stickiness, and
"can't hear anyone". Switching presets/routes mid-call routinely dropped
a direction.
Drive ALL iOS audio through one AVAudioEngine with the core fully
external at all times: setExternalPlayback(1) once at connect, every MIC
stream external_feed=1, mic via vc_stream_feed_pcm, playback via
vc_set_mixed_output_sink (drained by an always-on AVAudioSourceNode so
remote audio plays before joining voice). VPIO + AGC toggle per preset.
Every preset/route/interruption change funnels through one deterministic
Swift-only reconfigure (stop -> apply session config -> rebuild -> start)
— no second path to hand off to, so a change can't drop a direction.
- IOSVoiceProcessingEngine.swift -> IOSAudioEngine: always-on source-node
playback, conditional mic tap, VPIO/AGC; one rebuild() backing
startListening/stop/startMic/stopMic/reconfigure/setCaptureChannels.
- IOSAudioRouter: 7 presets -> 4 (Voice Chat / Stereo Mic / Mono Mic /
Advanced); persisted voiceProcessingEnabled + agcEnabled; setters call
IOSAudioEngine.reconfigure() instead of audioRestart/reconcileVoicePath.
- AudioSessionManager slimmed; SessionState mic lifecycle collapsed;
AppState wires external playback + listening at connect, stop at
disconnect; SettingsView shows 4 presets + Advanced VPIO/AGC toggles.
No core/ABI/test changes — relies on the already-shipped external API
(test_external_pcm, test_external_playback). xcodebuild iOS device Debug
BUILD SUCCEEDED. Updates docs/voice.md §8 and PROGRESS.md.
2026-06-23 02:45:53 +02:00
|
|
|
IOSAudioEngine.shared.startListening(client: client)
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
} else {
|
|
|
|
|
connectStatus = "Auth failed: \(ev.result.description)"
|
|
|
|
|
showPasswordPrompt = true
|
|
|
|
|
}
|
|
|
|
|
case .disconnected:
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// 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()
|
2026-06-19 13:46:20 +02:00
|
|
|
}
|
2026-06-19 02:10:25 +02:00
|
|
|
case .error:
|
|
|
|
|
connectStatus = ev.text ?? "Unknown error"
|
feat(ios): auto-reconnect + audio-device-change recovery
Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.
Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
success, AppState.handleConnectEvent no longer sees live-session events.
Added a weak SessionState.appState; SessionState.handleEvent .disconnected
calls appState.onLiveSessionDisconnected after the cue -- the single path
AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
thread join via vc_client_destroy), resets the backoff, and arms
scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
<-> cellular interface change or path .unsatisfied it calls
proactiveReconnect: tearing the session down BEFORE the C core notices the
dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
and cancel all reconnect state (task + monitor + lastSession + connectedServer).
Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
.newDeviceAvailable route-change guard; fires on every externally-initiated
route change reason except the ones we cause ourselves (.categoryChange/
.routeConfigurationChange) to avoid a notification loop. Interruption-end
now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
engine.start() failure (iOS sometimes refuses until the session is
re-reactivated -- the silent-death case).
No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
2026-06-25 14:57:13 +02:00
|
|
|
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
|
2026-06-19 02:10:25 +02:00
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|