Adds an end-to-end auto-reconnect path so a brief network outage no longer leaves the client wedged in a half-dead state. The flow has three layers, each motivated by a real failure mode observed on the Moto G live test: * `chanora_protocol::DisconnectReason` (`UserRequested` / `StreamEnded` / `Error(String)`) is reported on a `oneshot` when the per-connection task exits, so the supervisor can tell user intent apart from a real loss. * `chanora_core` spawns a supervisor task per `ChanoraSession`. It listens for the loss notifier AND runs a watchdog that issues `snapshot()` probes every 5s with a 4s timeout — three consecutive misses synthesise a `DisconnectReason::Error(...)` and trigger the reconnect path. The watchdog catches the "ghost connected" case where tsclientlib silently resets internal state but the event stream never errors. Backoff schedule: 1s, 2s, 5s, 15s, 30s, 60s (capped). On success the supervisor swaps the dead `ProtocolClient` for the new one in place and, if audio was running, restarts the audio engine bound to the new `voice_in`/`voice_out` channels. * `SessionEvent` (Connected / Lost / Reconnecting / Disconnected / AudioStarted / AudioStopped) is broadcast on a 64-slot channel. `chanora_bridge` re-exports it as `BridgeEvent` and exposes `events_stream(StreamSink)`; the Flutter side subscribes from `initState` and renders a reconnect banner with attempt count and delay. New `SnapshotProbe` exposes a clone-friendly snapshot path so the watchdog can probe without holding `&self` across awaits. Localization adds `statusReconnecting` and `statusConnectionLost` keys to `app_en.arb` and `app_zh.arb`. Verified on Moto G Stylus 5G (Android 14) against cn.teamspeak.app: killed Wi-Fi + cellular for ~70 s; watchdog declared loss at three misses, supervisor walked the backoff schedule, and the UI reconnected automatically once the radios came back. Snapshot tree re-rendered without user action.
103 lines
3.6 KiB
Rust
103 lines
3.6 KiB
Rust
//! # `chanora_protocol`
|
|
//!
|
|
//! TeamSpeak-compatible protocol adapter. Isolates `tsclientlib`
|
|
//! behind a typed boundary so the rest of Chanora is decoupled from
|
|
//! the upstream library's types (SAD-067, SysDes-011, SysDes-029).
|
|
//!
|
|
//! ## What this crate exposes
|
|
//!
|
|
//! * [`ConnectConfig`] — typed connection parameters.
|
|
//! * [`ProtocolClient`] — async handle owning the connection task.
|
|
//! * [`ServerSnapshot`], [`ChannelInfo`], [`ClientInfo`] — opaque
|
|
//! DTOs containing only `String`s and primitives.
|
|
//! * [`ProtocolError`] — typed error catalogue.
|
|
//!
|
|
//! ## What this crate does NOT expose
|
|
//!
|
|
//! * `tsclientlib::*` types.
|
|
//! * `tsproto::*` types.
|
|
//! * Any audio-related types — those live in `chanora_audio`.
|
|
//!
|
|
//! Promoted from `poc/tsclientlib-connect-spike` on 2026-05-14
|
|
//! as part of the Alpha build.
|
|
//!
|
|
//! ## Hostname resolution (A.1)
|
|
//!
|
|
//! Upstream `tsclientlib` uses `hickory-resolver` which reads
|
|
//! `/etc/resolv.conf`. That file does not exist on Android or iOS,
|
|
//! and the Beta UI surfaced the resulting cryptic "connection task
|
|
//! exited before signalling ready" errors. We side-step the issue by
|
|
//! resolving hostnames ourselves with `tokio::net::lookup_host`,
|
|
//! which uses platform `getaddrinfo` (works correctly on every
|
|
//! supported platform), and feeding the resulting `SocketAddr`
|
|
//! directly to `tsclientlib::Connection::build`. A small in-process
|
|
//! positive-result cache keeps reconnects fast.
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![warn(missing_docs)]
|
|
|
|
mod adapter;
|
|
mod dto;
|
|
mod resolver;
|
|
|
|
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
|
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
|
|
|
|
// Re-export the upstream voice types so chanora_audio can build outbound
|
|
// voice packets without taking a direct dependency on tsclientlib /
|
|
// tsproto_packets. Per SAD-067 this is the *one* deliberate
|
|
// re-export: the audio path is performance-sensitive and a parallel
|
|
// type hierarchy would force copies for every 20 ms frame.
|
|
pub use tsproto_packets::packets::{
|
|
AudioData, CodecType, Direction, InAudioBuf, OutAudio, OutPacket,
|
|
};
|
|
|
|
use thiserror::Error;
|
|
|
|
/// Errors surfaced by the protocol adapter. None of these expose
|
|
/// `tsclientlib`-specific types; raw upstream errors are mapped here
|
|
/// to typed arms.
|
|
#[derive(Debug, Error)]
|
|
pub enum ProtocolError {
|
|
/// Configuration is invalid before any I/O is attempted (bad
|
|
/// hostname, missing identity, etc.).
|
|
#[error("invalid protocol configuration: {0}")]
|
|
Invalid(String),
|
|
|
|
/// Hostname resolution failed. Distinct from [`Self::Connect`]
|
|
/// so the UI can show a meaningful "Server not found" message
|
|
/// instead of a generic connection error.
|
|
#[error("dns lookup failed for '{host}': {reason}")]
|
|
DnsFailed {
|
|
/// The hostname (or `host:port`) the caller submitted.
|
|
host: String,
|
|
/// Reason from the platform resolver.
|
|
reason: String,
|
|
},
|
|
|
|
/// Failed to dial / handshake with the server.
|
|
#[error("connect failed: {0}")]
|
|
Connect(String),
|
|
|
|
/// The connection ended before becoming ready.
|
|
#[error("disconnected before ready: {0}")]
|
|
DisconnectedEarly(String),
|
|
|
|
/// Connection lost after becoming ready.
|
|
#[error("connection lost: {0}")]
|
|
Lost(String),
|
|
|
|
/// Identity parsing failed.
|
|
#[error("identity error: {0}")]
|
|
Identity(String),
|
|
|
|
/// Operation timed out.
|
|
#[error("protocol timeout")]
|
|
Timeout,
|
|
|
|
/// A backend error escaped the mapping. Production callers
|
|
/// should never see this; if they do, it is a mapping bug here.
|
|
#[error("protocol backend: {0}")]
|
|
Backend(String),
|
|
}
|