Removes leftover debug scaffolding (stray Console.WriteLine/NSLog traces, dead nick_buf_ptr, a no-op --print-config flag now implemented for real), fixes stale/misleading comments (channel passwords are no longer a "future M5+" feature, a wrong cross-reference, a stale TlsContext::close() mention, an incomplete BanRecord::subject_type doc, and a smoke test pointing at a build/m1-dev preset that no longer exists), strips internal M1-M5 milestone jargon from comments now that the roadmap is done, trims comments that just restated the following line, and consolidates a few "why" explanations that were duplicated 2-3 times in the same file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
529 lines
27 KiB
Swift
529 lines
27 KiB
Swift
import Foundation
|
|
import Network
|
|
import VoiceCatCore
|
|
|
|
struct PendingIdentity: Identifiable {
|
|
let id = UUID()
|
|
let displayText: String
|
|
let tofuStatus: VoiceCatTofuStatus
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class AppState {
|
|
var servers: [SavedServer] = ServerListStore.shared.load()
|
|
var session: SessionState?
|
|
|
|
// Connect-flow state
|
|
var isConnecting = false
|
|
var connectStatus = ""
|
|
var showAddServer = false
|
|
var editingServer: SavedServer?
|
|
var showPasswordPrompt = false
|
|
var pendingIdentity: PendingIdentity?
|
|
|
|
private var connectingClient: VoiceCatClient?
|
|
private(set) var connectingServer: SavedServer?
|
|
private var identityHandled = false
|
|
|
|
/// The server we are currently fully connected to. Set on auth success (when `session` is
|
|
/// created) and cleared on teardown. Used to build the `lastSession` restore snapshot when a
|
|
/// live-session disconnect fires through `SessionState.handleEvent` — `connectingServer` is
|
|
/// already nil by then, and `handleConnectEvent`'s `server` parameter is out of scope because
|
|
/// `SessionState` owns `client.onEvent` after auth success (see `SessionState.init`).
|
|
private var connectedServer: SavedServer?
|
|
|
|
// MARK: - Reconnect state
|
|
//
|
|
// The C core surfaces every unexpected connection drop as a `.disconnected` event; nothing
|
|
// in the C core auto-reconnects (intentional — reconnect UX is the client's job). Two layers
|
|
// drive iOS reconnect:
|
|
//
|
|
// 1. **Event-driven** (the C core's TCP read eventually fails after the keepalive/reaper
|
|
// timeout, ~30-60 s on a hard Wi-Fi drop): `SessionState.handleEvent` `.disconnected`
|
|
// plays the audible cue, then calls back into AppState via `onLiveSessionDisconnected`,
|
|
// which snapshots the live session, tears it down, and arms `scheduleReconnect`.
|
|
// (Live-session events never reach `AppState.handleConnectEvent` — `SessionState.init`
|
|
// overwrites `client.onEvent`, so `AppState` cannot see them without the callback.)
|
|
//
|
|
// 2. **Path-driven** (proactive, much faster): `NWPathMonitor` runs the whole time we are
|
|
// connected (started on auth success) and reacts to network changes — a Wi-Fi↔cellular
|
|
// flip or the path becoming `.unsatisfied` calls `proactiveReconnect`, which tears the
|
|
// live session down BEFORE the C core notices the dead TCP path. This is what makes the
|
|
// 30-60 s wait collapse into ~1 s + the backoff tick. While mid-reconnect (no session)
|
|
// the same monitor arms a fast-fresh retry whenever a path becomes `.satisfied`.
|
|
//
|
|
// Manual Disconnect cancels everything (task + path monitor) and clears `lastSession`.
|
|
|
|
/// True at the top of `disconnect()`/`cancelConnect()` — suppresses auto-reconnect for the
|
|
/// `.disconnected` event the core then emits in response to our `vc_disconnect()` call.
|
|
private var userInitiatedDisconnect = false
|
|
|
|
/// Snapshot of the live session state needed to restore after a reconnect. Cleared on
|
|
/// successful restore and on user-initiated disconnect.
|
|
private struct LastSession {
|
|
let server: SavedServer
|
|
let channelId: UInt32
|
|
let voiceSubscribed: Bool
|
|
let micMuted: Bool
|
|
let deafened: Bool
|
|
}
|
|
private var lastSession: LastSession?
|
|
|
|
/// Reconnect attempt counter — drives exponential backoff. Reset to 0 on successful auth and
|
|
/// on a path-driven fast-fresh retry.
|
|
private var reconnectAttempt = 0
|
|
|
|
/// The in-flight reconnect `Task` (sleeps for the backoff, then calls `connectTo`). One
|
|
/// at a time; cancelled on user disconnect / successful restore.
|
|
private var reconnectTask: Task<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?
|
|
|
|
// MARK: - Server list management
|
|
|
|
func addServer(_ server: SavedServer, password: String?) {
|
|
if let pw = password, !pw.isEmpty {
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
}
|
|
servers.append(server)
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
func updateServer(_ server: SavedServer, password: String?) {
|
|
if let pw = password, !pw.isEmpty {
|
|
ServerListStore.shared.savePassword(pw, tag: server.keychainTag)
|
|
}
|
|
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
|
|
servers[idx] = server
|
|
}
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
func removeServer(_ server: SavedServer) {
|
|
ServerListStore.shared.deletePassword(tag: server.keychainTag)
|
|
servers.removeAll(where: { $0.id == server.id })
|
|
ServerListStore.shared.save(servers)
|
|
}
|
|
|
|
// MARK: - Connect flow
|
|
|
|
/// Public connect entry. Always starts a fresh session (no restore).
|
|
func connectTo(_ server: SavedServer) {
|
|
connectTo(server, restoring: nil)
|
|
}
|
|
|
|
/// Internal connect that drives the full TLS/auth saga. `restoring` is non-nil for a
|
|
/// reconnect attempt following an unexpected disconnect; the captured channel + voice/mic
|
|
/// state is handed to the new `SessionState` after auth succeeds.
|
|
private func connectTo(_ server: SavedServer, restoring: LastSession?) {
|
|
guard !isConnecting else { return }
|
|
isConnecting = true
|
|
connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
|
connectingServer = server
|
|
identityHandled = false
|
|
userInitiatedDisconnect = false
|
|
|
|
// Discard any leftover connecting client. nil'ing the strong ref calls VoiceCatClient's
|
|
// deinit, which synchronously joins the C core's io thread (vc_client_destroy) before
|
|
// freeing the config-string storage — safe from @MainActor because the io thread never
|
|
// blocks on main (it enqueues events via DispatchQueue.main.async and returns).
|
|
connectingClient = nil
|
|
|
|
let config = VoiceCatConfig(
|
|
clientName: "VoiceCat-iOS",
|
|
clientVersion: "0.0.1",
|
|
logLevel: .info,
|
|
tofuStorePath: ServerListStore.shared.tofuStorePath)
|
|
let client = VoiceCatClient(config: config)
|
|
connectingClient = client
|
|
|
|
client.onEvent = { [weak self] ev in
|
|
Task { @MainActor [weak self] in
|
|
self?.handleConnectEvent(ev, server: server, restoring: restoring)
|
|
}
|
|
}
|
|
// Put the core into external-playback mode BEFORE connect, so the flag is set on the
|
|
// io thread before any message is processed. The server sends AuthResult immediately
|
|
// followed by ServerStateSnapshot; handle_server_state runs ensure_audio_running() on
|
|
// the io thread, and if external_playback_ were still false at that point the core
|
|
// would open a hardware miniaudio playback+capture device (see the matching fix in
|
|
// vc_client::ensure_audio_running). Setting it here — before connect — guarantees the
|
|
// unified external path is in effect from the first frame. setExternalPlayback only
|
|
// flips an atomic + forwards to the engine's setter; both are safe pre-connect.
|
|
client.setExternalPlayback(true)
|
|
client.connect(host: server.host, port: server.port)
|
|
|
|
// Auth is queued immediately — the core serialises it behind TLS + TOFU. On a reconnect
|
|
// the TOFU pin already matches (VC_TOFU_MATCHED), so the identity gate auto-confirms
|
|
// inside the .serverIdentity case below and auth proceeds unattended.
|
|
switch server.authMode {
|
|
case .guest:
|
|
let nick = (server.nickname?.isEmpty == false) ? server.nickname! : "iOS User"
|
|
client.authenticateGuest(nick)
|
|
case .password:
|
|
let savedPw = ServerListStore.shared.loadPassword(tag: server.keychainTag)
|
|
if let pw = savedPw, !pw.isEmpty {
|
|
client.authenticateUser(server.savedUsername, password: pw)
|
|
} else {
|
|
showPasswordPrompt = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
// Mark BEFORE we ask the core to disconnect, so the .disconnected event the core emits
|
|
// in response is treated as user-initiated (no reconnect) rather than an unexpected drop.
|
|
userInitiatedDisconnect = true
|
|
cancelReconnect()
|
|
lastSession = nil
|
|
session?.leaveVoice()
|
|
session?.client.disconnect()
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
session = nil
|
|
connectingClient?.disconnect()
|
|
connectingClient = nil
|
|
connectingServer = nil
|
|
connectedServer = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
}
|
|
|
|
// MARK: - Auth actions (called from prompt sheets)
|
|
|
|
func authenticateUser(username: String, password: String) {
|
|
connectingClient?.authenticateUser(username, password: password)
|
|
showPasswordPrompt = false
|
|
}
|
|
|
|
func confirmServerIdentity(accept: Bool) {
|
|
connectingClient?.confirmServerIdentity(accept: accept)
|
|
pendingIdentity = nil
|
|
if !accept { cancelConnect() }
|
|
}
|
|
|
|
func cancelConnect() {
|
|
// User explicitly cancelled — no reconnect for the resulting .disconnected event.
|
|
userInitiatedDisconnect = true
|
|
cancelReconnect()
|
|
lastSession = nil
|
|
connectingClient?.disconnect()
|
|
connectingClient = nil
|
|
connectingServer = nil
|
|
connectedServer = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
}
|
|
|
|
// MARK: - Reconnect orchestration
|
|
|
|
/// Cancel any in-flight reconnect task and stop the path monitor. Safe to call when nothing
|
|
/// is armed (no-op). Does NOT touch `userInitiatedDisconnect` or `lastSession` — callers set
|
|
/// those as needed (disconnect/cancelConnect clear them; scheduleReconnect keeps them).
|
|
///
|
|
/// `AppState` is the @Observable app root owned by the SwiftUI `App`; it lives for the
|
|
/// whole app process and is torn down only on process exit, at which point OS cleanup
|
|
/// suffices. This method is driven by `disconnect()`/`cancelConnect()` and on successful
|
|
/// restore — those run on user-initiated teardown, which is the only path that matters.
|
|
/// (The reconnect `Task` captures `[weak self]` and guards on `nil`/`userInitiatedDisconnect`,
|
|
/// so a stray task left running when AppState is gone is a no-op; the monitor similarly guards.)
|
|
private func cancelReconnect() {
|
|
reconnectTask?.cancel()
|
|
reconnectTask = nil
|
|
stopPathMonitor()
|
|
// Don't reset `reconnectAttempt` here: scheduleReconnect resets it on successful auth,
|
|
// and the path monitor resets it to 0 for a fast fresh attempt on a path-satisfied event.
|
|
// If a fresh user connect follows, connectTo() doesn't reset it either, but it doesn't
|
|
// need to — `reconnectAttempt` only matters while we're mid-reconnect.
|
|
}
|
|
|
|
/// Arm the next reconnect attempt with exponential backoff (1s → 2s → 4s → 8s → 16s → 30s
|
|
/// cap). Cancelled cleanly by `cancelReconnect()` on user disconnect or successful auth.
|
|
/// Idempotent: a new call supersedes any in-flight one. The path monitor is armed here and
|
|
/// disarmed on cancel; on a path-satisfied event it resets the attempt counter to 0 and
|
|
/// re-arms via this same method, yielding a fast refresh after Wi-Fi ↔ cellular transitions.
|
|
private func scheduleReconnect() {
|
|
guard !userInitiatedDisconnect, let last = lastSession else { return }
|
|
reconnectTask?.cancel()
|
|
reconnectAttempt = max(1, reconnectAttempt + 1)
|
|
let delaySec = min(pow(2.0, Double(reconnectAttempt - 1)), 30.0)
|
|
connectStatus = "Reconnecting (attempt \(reconnectAttempt))…"
|
|
|
|
startPathMonitor()
|
|
|
|
let task = Task { [weak self, last] in
|
|
guard let self else { return }
|
|
try? await Task.sleep(nanoseconds: UInt64(delaySec * 1_000_000_000))
|
|
if Task.isCancelled { return }
|
|
// Re-check under Task: a user disconnect between the sleep and this line must abort.
|
|
guard !self.userInitiatedDisconnect else { return }
|
|
guard self.lastSession != nil else { return }
|
|
guard self.session == nil else { return }
|
|
self.connectTo(last.server, restoring: last)
|
|
}
|
|
reconnectTask = task
|
|
}
|
|
|
|
/// Start (if not already running) the network path monitor. Runs the whole time we are
|
|
/// connected (started on auth success) and stays armed across reconnects; stopped only on
|
|
/// user-initiated disconnect. The handler dispatches to @MainActor before touching state and
|
|
/// does two distinct things:
|
|
/// - **While connected** (`session != nil`): a Wi-Fi↔cellular interface change OR the path
|
|
/// becoming `.unsatisfied` triggers `proactiveReconnect()` — tearing the live session
|
|
/// down before the C core notices the dead TCP read. Without this the disconnect would
|
|
/// take 30-60 s (the TCP keepalive/reaper timeout); proactive teardown collapses that to
|
|
/// ~1 s + the first backoff tick. Same-interface path refreshes (e.g. a Wi-Fi roam
|
|
/// without an IP change) are ignored — likely the connection is still good.
|
|
/// - **While mid-reconnect** (`session == nil`, `lastSession != nil`): a path becoming
|
|
/// `.satisfied` arms a fast-fresh retry (backoff counter reset, `scheduleReconnect`).
|
|
/// This is what makes a Wi-Fi→cellular flip reconnect on roughly the next tick instead
|
|
/// of waiting out a long backoff.
|
|
private func startPathMonitor() {
|
|
guard pathMonitor == nil else { return }
|
|
let monitor = NWPathMonitor()
|
|
monitor.pathUpdateHandler = { [weak self] path in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
guard !self.userInitiatedDisconnect else { return }
|
|
let sig = Self.pathSignature(path)
|
|
let prevSig = self.lastPathSignature
|
|
self.lastPathSignature = sig
|
|
// The first callback (when the monitor starts) is the baseline, not a change.
|
|
if prevSig == nil { return }
|
|
|
|
if self.session != nil {
|
|
// See this method's doc comment for why these conditions trigger a
|
|
// proactive reconnect.
|
|
if path.status != .satisfied || sig != prevSig {
|
|
self.proactiveReconnect()
|
|
}
|
|
} else if self.lastSession != nil {
|
|
if path.status == .satisfied {
|
|
self.reconnectAttempt = 0
|
|
self.scheduleReconnect()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Listen on a dedicated queue — the path monitor can't share the main queue (it would
|
|
// re-enter main if any handler dispatched to main synchronously).
|
|
monitor.start(queue: pathQueue)
|
|
pathMonitor = monitor
|
|
}
|
|
|
|
private func stopPathMonitor() {
|
|
pathMonitor?.cancel()
|
|
pathMonitor = nil
|
|
lastPathSignature = nil
|
|
}
|
|
|
|
/// A stable string signature of a network path: the path status plus the set of interface
|
|
/// types it uses. Two paths with the same signature are treated as equivalent — no
|
|
/// reconnect. A signature change is the trigger for `proactiveReconnect`. Used to ignore
|
|
/// same-interface refreshes (signal-strength changes, BSSID roams) which usually don't break
|
|
/// the TCP connection.
|
|
private static func pathSignature(_ path: NWPath) -> String {
|
|
guard path.status == .satisfied else { return "unsatisfied" }
|
|
var parts: [String] = []
|
|
if path.usesInterfaceType(.wifi) { parts.append("wifi") }
|
|
if path.usesInterfaceType(.cellular) { parts.append("cellular") }
|
|
if path.usesInterfaceType(.wiredEthernet) { parts.append("wired") }
|
|
if path.usesInterfaceType(.other) { parts.append("other") }
|
|
return parts.isEmpty ? "none" : parts.sorted().joined(separator: "+")
|
|
}
|
|
|
|
// MARK: - Live-session disconnect (called by SessionState)
|
|
|
|
/// Called by `SessionState.handleEvent` `.disconnected` after the audible cue has already
|
|
/// played. Once `SessionState` is created (auth success) it owns `client.onEvent`, so
|
|
/// `AppState.handleConnectEvent` never sees live-session events — this callback is the only
|
|
/// way AppState learns that a live session dropped. Snapshots the live session state,
|
|
/// tears the session down, and arms `scheduleReconnect` so the backoff loop drives a fresh
|
|
/// TLS/auth/restoration. Guarded against user-initiated disconnect (which nil's `session`
|
|
/// synchronously, so SessionState is gone before the event could fire this callback) — but
|
|
/// the guard is cheap insurance.
|
|
func onLiveSessionDisconnected() {
|
|
guard !userInitiatedDisconnect else { return }
|
|
teardownLiveSessionAndReconnect(sound: false)
|
|
}
|
|
|
|
/// Called by `NWPathMonitor` when a path change is detected while a live session exists.
|
|
/// Tears the session down immediately — nil'ing `session` releases `VoiceCatClient`, whose
|
|
/// `deinit` calls `vc_client_destroy`; that closes the socket and joins the C core's io
|
|
/// thread, so the io thread exits in milliseconds rather than blocking on a dead read for
|
|
/// ~30-60 s. The proactive tear-down is what collapses the long TCP-reaper wait into a
|
|
/// ~1 s reconnect. Plays the audible cue (no `.disconnected` event fires through to
|
|
/// `SessionState` for this path, since `SessionState` is being torn down here — so the cue
|
|
/// would otherwise be missing).
|
|
private func proactiveReconnect() {
|
|
guard !userInitiatedDisconnect else { return }
|
|
guard session != nil else { return }
|
|
teardownLiveSessionAndReconnect(sound: true)
|
|
}
|
|
|
|
/// Shared teardown for a live-session disconnect (event- or path-driven). Snapshots the live
|
|
/// session into `lastSession`, stops audio, deactivates the AVAudioSession, releases the
|
|
/// session (which releases `VoiceCatClient` → io-thread join), resets the backoff counter,
|
|
/// and arms `scheduleReconnect`. `sound` is true for the proactive (path-driven) case — the
|
|
/// `.disconnected` event that would have played it never fires because we're tearing down
|
|
/// ahead of the C core noticing. The event-driven caller (`onLiveSessionDisconnected`) has
|
|
/// ALREADY played the cue via `SessionState.handleEvent`, so it passes `sound: false`.
|
|
private func teardownLiveSessionAndReconnect(sound: Bool) {
|
|
// Snapshot BEFORE nil'ing `session` — we need the channel + voice/mic state to restore.
|
|
if let s = session, let srv = connectedServer {
|
|
lastSession = LastSession(
|
|
server: srv,
|
|
channelId: s.currentChannelId,
|
|
voiceSubscribed: s.voiceState.voiceSubscribed,
|
|
micMuted: s.voiceState.selfMuted,
|
|
deafened: s.voiceState.selfDeafened)
|
|
}
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
// Releasing `session` releases `VoiceCatClient`; its deinit joins the C core's io thread.
|
|
// For a path-driven proactive teardown this is what avoids the 30-60 s reaper timeout.
|
|
session = nil
|
|
isConnecting = false
|
|
connectingClient = nil
|
|
connectedServer = nil
|
|
if sound {
|
|
EventFeedback.shared.play(.connectionLost)
|
|
EventFeedback.shared.speak("Network changed — reconnecting")
|
|
}
|
|
// Reset the backoff counter so the first reconnect attempt after a drop uses the short
|
|
// 1 s delay (the immediate path-driven attempt matters most; sustained-outage backoff is
|
|
// driven by `scheduleReconnect`'s increment).
|
|
reconnectAttempt = 0
|
|
scheduleReconnect()
|
|
}
|
|
|
|
// MARK: - Connect event handler
|
|
|
|
private func handleConnectEvent(_ ev: VoiceCatEvent, server: SavedServer,
|
|
restoring: LastSession?) {
|
|
switch ev.type {
|
|
case .connectionState:
|
|
switch ev.connectionState {
|
|
case .connecting: connectStatus = (restoring != nil) ? "Reconnecting…" : "Connecting…"
|
|
case .tlsHandshake: connectStatus = "TLS handshake…"
|
|
case .authenticating: connectStatus = "Authenticating…"
|
|
case .verifyingIdentity: connectStatus = "Verifying server identity…"
|
|
case .connected: connectStatus = "Connected"
|
|
default: break
|
|
}
|
|
case .serverIdentity:
|
|
guard !identityHandled else { break }
|
|
let tofuStatus = ev.tofuStatus ?? .firstConnect
|
|
if tofuStatus == .matched {
|
|
connectingClient?.confirmServerIdentity(accept: true)
|
|
} else {
|
|
identityHandled = true
|
|
let displayText = connectingClient?.getServerIdentityDisplay() ?? ""
|
|
pendingIdentity = PendingIdentity(displayText: displayText, tofuStatus: tofuStatus)
|
|
}
|
|
case .authResult:
|
|
if ev.result == .ok {
|
|
guard let client = connectingClient else { break }
|
|
let perms = client.getPermissions()
|
|
let newSession = SessionState(client: client, selfUserId: ev.userId, permissions: perms)
|
|
newSession.appState = self
|
|
connectingClient = nil
|
|
isConnecting = false
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
connectedServer = server
|
|
self.session = newSession
|
|
EventFeedback.shared.play(.login)
|
|
EventFeedback.shared.speak(restoring != nil ? "Reconnected" : "Connected")
|
|
// External-playback mode was enabled before connect() so the core never opens a
|
|
// miniaudio device on iOS (the single ordering rule of the unified audio path).
|
|
// Now activate the session and start the engine in listening mode so remote audio
|
|
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
|
|
do {
|
|
try AudioSessionManager.shared.ensureSessionActive()
|
|
} catch {
|
|
print("Audio session activate on connect failed: \(error)")
|
|
}
|
|
IOSAudioEngine.shared.startListening(client: client)
|
|
// The path monitor runs the whole time we're connected so a network change fires
|
|
// proactiveReconnect immediately instead of waiting for the C core's TCP keepalive
|
|
// timeout (~30-60 s on a hard Wi-Fi drop). It stays armed across reconnects and is
|
|
// stopped only on user-initiated disconnect.
|
|
startPathMonitor()
|
|
|
|
// Reconnect restore: rejoin the prior channel and re-enable voice/mic if they
|
|
// were on. The session is fresh (server auto-places us in Lobby), so the restore
|
|
// is driven through SessionState.requestRestore, which issues a JoinChannel then
|
|
// (on the resulting .joinResult) re-arms voice + mute/deafen. A successful auth
|
|
// means the server is reachable, so the backoff counter resets and `lastSession`
|
|
// clears; the path monitor keeps watching for the next change.
|
|
if let restoring {
|
|
newSession.requestRestore(channelId: restoring.channelId,
|
|
voiceSubscribed: restoring.voiceSubscribed,
|
|
micMuted: restoring.micMuted,
|
|
deafened: restoring.deafened)
|
|
reconnectAttempt = 0
|
|
lastSession = nil
|
|
}
|
|
} else {
|
|
connectStatus = "Auth failed: \(ev.result.description)"
|
|
showPasswordPrompt = true
|
|
}
|
|
case .disconnected:
|
|
// This handler runs ONLY during the connecting phase — after auth success
|
|
// `SessionState.init` overwrites `client.onEvent`, so a live-session disconnect
|
|
// reaches `SessionState.handleEvent` and comes back via
|
|
// `onLiveSessionDisconnected`, not here. Two outcomes for this branch:
|
|
// - A reconnect's connecting phase failed (`lastSession != nil`, set by a prior
|
|
// teardown) → re-arm `scheduleReconnect` so the backoff loop continues.
|
|
// - A fresh connect failed before auth (`lastSession == nil`) → show the error, do
|
|
// not auto-reconnect (the user should retry manually once the server is reachable).
|
|
connectingClient = nil
|
|
isConnecting = false
|
|
IOSAudioEngine.shared.stop()
|
|
AudioSessionManager.shared.deactivateSession()
|
|
|
|
if userInitiatedDisconnect {
|
|
connectStatus = ""
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
lastSession = nil
|
|
connectedServer = nil
|
|
cancelReconnect()
|
|
} else if lastSession != nil {
|
|
// Mid-reconnect drop — keep the backoff loop going.
|
|
EventFeedback.shared.play(.connectionLost)
|
|
EventFeedback.shared.speak("Connection lost — reconnecting")
|
|
scheduleReconnect()
|
|
} else {
|
|
// Fresh connect failed before auth. Surface the reason; no auto-reconnect.
|
|
connectStatus = ev.text ?? "Disconnected"
|
|
showPasswordPrompt = false
|
|
pendingIdentity = nil
|
|
connectedServer = nil
|
|
cancelReconnect()
|
|
}
|
|
case .error:
|
|
connectStatus = ev.text ?? "Unknown error"
|
|
// Errors don't disconnect us; the .disconnected event handles teardown/reconnect.
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|