//! Public API exposed to Dart via `flutter_rust_bridge`. //! //! Naming follows the FRB v2 convention: free functions at the crate //! API root, with `#[frb(sync)]` for synchronous calls and async fn //! signatures for async ones. Every input and output is an owned //! type whose layout is schema-controlled (no `tsclientlib`, no //! `cpal`, no `Connection` handles). use std::sync::OnceLock; use std::time::Duration; use flutter_rust_bridge::frb; use tokio::runtime::Runtime; use tracing::{info, warn}; use crate::frb_generated::StreamSink; use crate::BridgeError; /// Process-wide tokio runtime used to drive the async core. Created /// lazily on first use and never torn down — the application's /// process lifetime is the runtime's lifetime. fn runtime() -> &'static Runtime { static RT: OnceLock = OnceLock::new(); RT.get_or_init(|| { tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .thread_name("chanora-rt") .build() .expect("tokio runtime") }) } /// Process-wide session handle. One instance per process is enough /// for Alpha (DEC-006 single-connection invariant); the Mutex inside /// `ChanoraSession` enforces the connection-count invariant. fn session() -> &'static chanora_core::ChanoraSession { static S: OnceLock = OnceLock::new(); S.get_or_init(chanora_core::ChanoraSession::new) } /// Process-wide redacted-log sink. Captures the last N tracing /// records (already redacted) so the user-initiated diagnostic /// export has something to ship. Lazily created on first access. fn log_sink() -> &'static chanora_core::InMemoryLogSink { static SINK: OnceLock = OnceLock::new(); SINK.get_or_init(|| { chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy()) }) } // ---------- Bridge lifecycle ---------- /// Initialise the bridge. Must be called once on Dart side before /// any other API call. Sets up panic logging. #[frb(init)] pub fn bridge_init() { flutter_rust_bridge::setup_default_user_utils(); // Always install the redacted in-memory log sink — diagnostic // export depends on it (DEC-016: user-initiated only, never // auto-upload). It runs alongside whatever platform sink // exists below; both consume the same `tracing` events. let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()); // On Android, also fan tracing output out to logcat so a user can // see protocol/audio diagnostics via `adb logcat -s chanora`. #[cfg(target_os = "android")] { use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; let android_layer = tracing_android::layer("chanora").ok(); let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); let _ = tracing_subscriber::registry() .with(filter) .with(android_layer) .with(redact_layer) .try_init(); } #[cfg(not(target_os = "android"))] { use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; let fmt_layer = tracing_subscriber::fmt::layer().with_target(true); let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); let _ = tracing_subscriber::registry() .with(filter) .with(fmt_layer) .with(redact_layer) .try_init(); } info!(target: "chanora_bridge", "bridge initialised"); } // ---------- DTOs ---------- /// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo` /// but with primitive `u64` ids so the Dart side gets `BigInt`s /// without any wrapper-type ceremony. #[derive(Debug, Clone)] pub struct BridgeChannel { /// Stable channel id. pub id: u64, /// Parent channel id; 0 means top-level. pub parent: u64, /// Display name. pub name: String, /// Server-side ordering hint. pub order: i64, } /// Client as seen by Dart. #[derive(Debug, Clone)] pub struct BridgeClient { /// Stable client id. pub id: u64, /// Channel id the client is currently in. pub channel: u64, /// Nickname. pub name: String, } /// Server snapshot as seen by Dart. #[derive(Debug, Clone)] pub struct BridgeSnapshot { /// Server name. pub server_name: String, /// Welcome banner text. pub welcome_message: String, /// Server platform (e.g. "Linux"). pub platform: String, /// Server version string. pub version: String, /// Channels currently known. pub channels: Vec, /// Clients currently known. pub clients: Vec, } impl From for BridgeSnapshot { fn from(s: chanora_protocol::ServerSnapshot) -> Self { Self { server_name: s.server_name, welcome_message: s.welcome_message, platform: s.platform, version: s.version, channels: s .channels .into_iter() .map(|c| BridgeChannel { id: c.id.0, parent: c.parent.0, name: c.name, order: c.order, }) .collect(), clients: s .clients .into_iter() .map(|c| BridgeClient { id: c.id.0, channel: c.channel.0, name: c.name, }) .collect(), } } } // ---------- Commands ---------- /// Connect to a TeamSpeak-compatible server and return the initial /// state snapshot. Honours the DEC-006 single-connection invariant /// via [`BridgeError::AlreadyConnected`]. pub async fn connect(host: String, nickname: String) -> Result { let cfg = chanora_core::ConnectConfig { address: host, nickname, password: None, identity: None, ready_timeout: Duration::from_secs(15), }; let snap = runtime() .spawn(async move { session().connect(cfg).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(snap.into()) } /// Re-fetch a fresh snapshot from the active connection. pub async fn snapshot() -> Result { let snap = runtime() .spawn(async { session().snapshot().await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(snap.into()) } /// Disconnect from the server. No-op if not connected. pub async fn disconnect() -> Result<(), BridgeError> { runtime() .spawn(async { session().disconnect().await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// True if a connection is currently active. pub async fn is_connected() -> bool { runtime() .spawn(async { session().is_connected().await }) .await .unwrap_or(false) } // ---------- Audio commands (Beta) ---------- /// Start the audio engine on the active connection. Requires a /// connection; idempotent (will replace any previous engine). pub async fn start_audio() -> Result<(), BridgeError> { runtime() .spawn(async { session() .start_audio(chanora_core::AudioEngineConfig::default()) .await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Set the push-to-talk state. pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_ptt(active).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Statistics from the audio engine. #[derive(Debug, Clone)] pub struct BridgeAudioStats { /// Number of Opus frames sent since audio started. pub frames_sent: u32, /// Number of inbound voice packets decoded. pub frames_received: u32, /// Current push-to-talk state. pub ptt_active: bool, } // ---------- Diagnostics (A.3) ---------- /// User-initiated diagnostic export. Returns a multi-line text /// blob, redacted per the production policy, that the user can /// share or copy. DEC-016 forbids automatic uploads — this is the /// only path that surfaces logs. #[frb(sync)] pub fn export_diagnostics() -> String { let metadata = vec![ ("crate_version".to_string(), env!("CARGO_PKG_VERSION").to_string()), ("target_os".to_string(), std::env::consts::OS.to_string()), ("target_arch".to_string(), std::env::consts::ARCH.to_string()), ]; match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { Ok(exp) => exp.to_text(), Err(e) => format!("(diagnostic export failed: {e})"), } } // ---------- Storage (A.2) ---------- /// Wire the identity persistence store to a platform-private /// directory. Should be called once on app start after Flutter has /// resolved `getApplicationSupportDirectory()` (or equivalent). /// /// Subsequent [`connect`] calls will reuse the persisted identity, /// or generate-and-persist a fresh one on first use. This keeps the /// server-visible UID stable across app restarts. /// /// Beta caveat: the identity is stored as a plain file (mode 0600 /// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents /// this gap; the v0.4 storage rework lands the proper Secret /// Service + Android Keystore + iOS Keychain backends. pub async fn init_storage(dir: String) -> Result<(), BridgeError> { runtime() .spawn(async move { session().init_storage(&dir).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } // ---------- Connectivity (A.6.1) ---------- /// Coarse OS-reported network state. Mirrors /// [`chanora_core::NetworkState`] across the bridge. #[derive(Debug, Clone, Copy)] pub enum BridgeNetworkState { /// No signal seen yet. Unknown, /// OS reports a usable network. Online, /// OS reports no network. Offline, } impl From for chanora_core::NetworkState { fn from(s: BridgeNetworkState) -> Self { match s { BridgeNetworkState::Unknown => chanora_core::NetworkState::Unknown, BridgeNetworkState::Online => chanora_core::NetworkState::Online, BridgeNetworkState::Offline => chanora_core::NetworkState::Offline, } } } /// Notify the core of the latest OS-reported connectivity state. /// Called by the Flutter side from `connectivity_plus` callbacks. /// The core's supervisor uses this to (a) pre-charge the watchdog /// on Offline and (b) short-circuit reconnect backoff on Online. #[frb(sync)] pub fn set_network_state(state: BridgeNetworkState) { session().set_network_state(state.into()); } // ---------- Events (A.6) ---------- /// Lifecycle event surfaced to Dart. Schema-controlled mirror of /// [`chanora_core::SessionEvent`] — no core types cross the bridge. #[derive(Debug, Clone)] pub enum BridgeEvent { /// Initial connect succeeded, or a reconnect attempt succeeded. Connected { /// Server name reported by the server snapshot. server_name: String, }, /// Connection lost; supervisor will retry. Lost { /// Reason classification from the protocol layer. reason: String, }, /// Supervisor is sleeping before its next reconnect attempt. Reconnecting { /// 1-based attempt counter for the current outage. attempt: u32, /// Seconds the supervisor will sleep before this attempt. delay_secs: u32, }, /// Session ended (user-requested disconnect or unrecoverable). Disconnected { /// Reason classification. reason: String, }, /// Audio engine started. AudioStarted, /// Audio engine stopped. AudioStopped, /// Snapshot probe observed a change in channel/client counts. /// UI uses this to drive an auto-refresh without active /// polling. SnapshotChanged { /// Latest channel count. channels: u32, /// Latest client count. clients: u32, }, } impl From for BridgeEvent { fn from(e: chanora_core::SessionEvent) -> Self { match e { chanora_core::SessionEvent::Connected { server_name } => { BridgeEvent::Connected { server_name } } chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason }, chanora_core::SessionEvent::Reconnecting { attempt, delay_secs, } => BridgeEvent::Reconnecting { attempt, delay_secs, }, chanora_core::SessionEvent::Disconnected { reason } => { BridgeEvent::Disconnected { reason } } chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted, chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped, chanora_core::SessionEvent::SnapshotChanged { channels, clients } => { BridgeEvent::SnapshotChanged { channels, clients } } } } } /// Subscribe to lifecycle events. Each call yields a fresh /// subscription; multiple subscribers are supported. On slow /// consumers, events are dropped rather than blocking the supervisor /// (consistent with `tokio::sync::broadcast::Receiver` semantics). pub fn events_stream(sink: StreamSink) -> Result<(), BridgeError> { let mut rx = session().subscribe_events(); runtime().spawn(async move { loop { match rx.recv().await { Ok(evt) => { if sink.add(BridgeEvent::from(evt)).is_err() { // Dart side closed the sink — stop the bridge task. info!(target: "chanora_bridge", "events_stream: dart sink closed"); return; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events"); continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => { info!(target: "chanora_bridge", "events_stream: source closed"); return; } } } }); Ok(()) } /// Read audio statistics. Errors if no connection or audio not started. pub async fn audio_stats() -> Result { let (s, r, p) = runtime() .spawn(async { session().audio_stats().await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(BridgeAudioStats { frames_sent: s, frames_received: r, ptt_active: p, }) }