//! 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 ---------- /// Default tracing filter. Suppresses the chatty /// `tsproto::resend` and `tsproto::packet_codec` paths that /// flood the diagnostic export during transient packet loss; /// users can still raise verbosity via `RUST_LOG=info`. const DEFAULT_LOG_FILTER: &str = "info,tsproto::resend=error,tsproto::packet_codec=error"; /// 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(DEFAULT_LOG_FILTER)); 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(DEFAULT_LOG_FILTER)); 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`]. /// /// `password` is optional — pass an empty string for servers that /// don't require one. pub async fn connect( host: String, nickname: String, password: String, ) -> Result { let cfg = chanora_core::ConnectConfig { address: host, nickname, password: if password.is_empty() { None } else { Some(password) }, 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_audio` was removed per SDD-094 — voice activation now // flows through `voice_join` / `voice_leave`, which transparently // drive `AudioEngine::ensure_running` / `shutdown_if_idle`. /// Set the push-to-talk state. /// /// Superseded in v1 by [`set_transmit_mode`] + the binding capture /// dialog. Retained so legacy callers and integration tests keep /// working; the new VoiceBar UI no longer invokes this. 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(()) } // ---------- v1 voice lifecycle (SDD-094/095/096) ---------- /// Voice transmit mode mirror (SDD-095). Schema-controlled enum; /// the wire encoding matches [`chanora_core::TransmitMode::as_u8`]. #[derive(Debug, Clone, Copy)] pub enum BridgeTransmitMode { /// Push-to-talk (default). Ptt, /// Continuous transmission while in channel and not muted. Continuous, /// Voice-activity detection — reserved per DEC-030; v1 behaves /// as `Continuous`. VoiceActivity, } impl From for chanora_core::TransmitMode { fn from(m: BridgeTransmitMode) -> Self { match m { BridgeTransmitMode::Ptt => Self::Ptt, BridgeTransmitMode::Continuous => Self::Continuous, BridgeTransmitMode::VoiceActivity => Self::VoiceActivity, } } } impl From for BridgeTransmitMode { fn from(m: chanora_core::TransmitMode) -> Self { match m { chanora_core::TransmitMode::Ptt => Self::Ptt, chanora_core::TransmitMode::Continuous => Self::Continuous, chanora_core::TransmitMode::VoiceActivity => Self::VoiceActivity, } } } fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode { chanora_core::TransmitMode::from_u8(v) .unwrap_or_default() .into() } /// Join a voice channel (SDD-094). Moves the user to `channel_id`, /// brings up the audio engine if needed, and emits /// `BridgeEvent::VoiceState`. `password` may be empty. pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> { let pw = if password.is_empty() { None } else { Some(password) }; runtime() .spawn(async move { session().voice_join(channel_id, pw).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Leave the current voice channel (SDD-094). Tears down the audio /// engine and emits `BridgeEvent::VoiceState`. pub async fn voice_leave() -> Result<(), BridgeError> { runtime() .spawn(async { session().voice_leave().await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Set the active transmit mode (SDD-095). pub async fn set_transmit_mode(mode: BridgeTransmitMode) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_transmit_mode(mode.into()).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Read the active transmit mode. pub async fn get_transmit_mode() -> BridgeTransmitMode { runtime() .spawn(async { session().transmit_mode() }) .await .unwrap_or(chanora_core::TransmitMode::Ptt) .into() } /// Update the release-tail in milliseconds (SDD-096). Values are /// clamped to `0..=500` on the Rust side; passing anything larger /// silently saturates. pub async fn set_release_tail_ms(ms: u32) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_release_tail_ms(ms).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Read the current release-tail in milliseconds. pub async fn get_release_tail_ms() -> u32 { runtime() .spawn(async { session().release_tail_ms() }) .await .unwrap_or(200) } /// Engage or release the hard-mute clamp (SDD-094). When `true` /// the audio engine transmits nothing regardless of mode. pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_hard_mute(muted).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings; /// the bridge never carries raw key codes. #[derive(Debug, Clone, Copy)] pub enum BridgePttInputClass { /// No binding is active. None, /// A keyboard key. Keyboard, /// A mouse side button (Mouse4 / Mouse5). MouseSideButton, } impl From for chanora_core::PttInputClass { fn from(c: BridgePttInputClass) -> Self { match c { BridgePttInputClass::None => chanora_core::PttInputClass::None, BridgePttInputClass::Keyboard => chanora_core::PttInputClass::Keyboard, BridgePttInputClass::MouseSideButton => chanora_core::PttInputClass::MouseSideButton, } } } /// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The /// platform_key string is opaque to the bridge — it identifies the /// bound key inside the platform backend and never appears in any /// log record or diagnostic export (DEC-027 enforced at the /// diagnostics-sanitizer layer). pub async fn set_ptt_binding( input_class: BridgePttInputClass, platform_key: String, ) -> Result<(), BridgeError> { let binding = chanora_core::PttBinding { input_class: input_class.into(), platform_key, }; runtime() .spawn(async move { session().set_ptt_binding(binding).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Read the current PTT capability descriptor. Returns a /// `(level, backend_id, bound_input_class)` triple matching the /// privacy-safe `BridgeEvent::PttCapability` event shape; useful /// for the initial UI render before the first event arrives. pub async fn ptt_descriptor() -> (String, String, String) { runtime() .spawn(async { session().ptt_descriptor().await }) .await .unwrap_or_else(|_| (String::new(), String::new(), String::new())) } /// Move our own client to `channel_id`. Optional channel password /// for password-protected channels — pass an empty string when not /// required. pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> { let pw = if password.is_empty() { None } else { Some(password) }; runtime() .spawn(async move { session().move_to_channel(channel_id, pw).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Toggle self input-mute (microphone) on the server. Independent /// of push-to-talk: a muted client never transmits regardless of /// PTT state. pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_self_muted(Some(muted), None).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Toggle self output-mute (speaker). Mutes locally *and* informs /// the server. The server uses this for the channel icon next to /// the client name; the local mute kicks in immediately even /// before the server acknowledges. pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_self_muted(None, Some(muted)).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Set master output gain. `1.0` is unity, `0.0` is silent. Values /// above `1.0` amplify and can clip downstream. Errors when audio /// is not started. pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_output_gain(gain).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(()) } /// Bookmark DTO mirroring [`chanora_core::Bookmark`]. #[derive(Debug, Clone)] pub struct BridgeBookmark { /// Row id assigned by SQLite. Use `0` when adding new rows; /// the returned id is then meaningful. pub id: i64, /// User-facing label. pub display_name: String, /// `hostname[:port]` or TSDNS name. pub host: String, /// Nickname to use for this bookmark. pub nickname: String, /// Optional remembered password. Empty string = none. pub password: String, } impl From for BridgeBookmark { fn from(b: chanora_core::Bookmark) -> Self { Self { id: b.id, display_name: b.display_name, host: b.host, nickname: b.nickname, password: b.password.unwrap_or_default(), } } } impl From for chanora_core::Bookmark { fn from(b: BridgeBookmark) -> Self { chanora_core::Bookmark { id: b.id, display_name: b.display_name, host: b.host, nickname: b.nickname, password: if b.password.is_empty() { None } else { Some(b.password) }, } } } /// List persisted bookmarks. pub async fn list_bookmarks() -> Result, BridgeError> { let v = runtime() .spawn(async { session().list_bookmarks().await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(v.into_iter().map(Into::into).collect()) } /// Insert a bookmark and return its assigned id. The `id` field on /// the input is ignored. pub async fn add_bookmark(b: BridgeBookmark) -> Result { let core_b: chanora_core::Bookmark = b.into(); let id = runtime() .spawn(async move { session().add_bookmark(core_b).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(id) } /// Update an existing bookmark. pub async fn update_bookmark(b: BridgeBookmark) -> Result<(), BridgeError> { let core_b: chanora_core::Bookmark = b.into(); runtime() .spawn(async move { session().update_bookmark(core_b).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Delete a bookmark by id. pub async fn delete_bookmark(id: i64) -> Result<(), BridgeError> { runtime() .spawn(async move { session().delete_bookmark(id).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, }, /// Detected desktop Push-to-Talk capability (gen2 v0.9.3, /// DEC-023..028). The fields carry only privacy-safe values per /// DEC-027: the capability level, a stable backend identifier, /// and the bound input class. No key codes, scan codes, or /// virtual-key values cross this boundary. PttCapability { /// Stable capability identifier (`"L0Focused"`, /// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`, /// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`). level: String, /// Stable backend identifier (e.g. `"focused"`). backend_id: String, /// Coarse bound input class (e.g. `"keyboard"`, /// `"mouse-side-button"`); empty when no binding is active. bound_input_class: String, }, /// Voice subsystem state snapshot (SDD-094). The Flutter /// VoiceBar listens to this stream. VoiceState { /// True when the session is currently joined to a voice /// channel and the audio engine is running. in_channel: bool, /// Active transmit mode. transmit_mode: BridgeTransmitMode, /// True when the hard-mute clamp is engaged. mute: bool, /// Current release-tail in milliseconds (0..=500). release_tail_ms: 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 } } chanora_core::SessionEvent::PttCapability { level, backend_id, bound_input_class, } => BridgeEvent::PttCapability { level, backend_id, bound_input_class, }, chanora_core::SessionEvent::VoiceState { in_channel, transmit_mode, mute, release_tail_ms, } => BridgeEvent::VoiceState { in_channel, transmit_mode: transmit_mode_from_u8(transmit_mode), mute, release_tail_ms, }, } } } /// 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, }) }