//! # `chanora_bridge` //! //! Typed Flutter/Rust bridge — schema-controlled DTOs for commands, //! results, and events. Backed by `flutter_rust_bridge` 2.x per //! DEC-014. //! //! ## Alpha scope //! //! Exposes three commands that target the Alpha release goal: //! //! * `bridge_init()` — one-time process initialisation. Sets up //! logging. //! * `connect(host, nickname)` — connects to a TS3-compatible //! server and returns a typed [`ConnectResultDto`] containing the //! server snapshot. //! * `disconnect()` — clean disconnect. //! * `snapshot()` — re-fetch the current server snapshot. //! //! No audio commands cross the bridge in Alpha; audio is Beta scope. //! //! ## Boundary discipline //! //! Every type in this module is built from owned primitives or //! `String`s — no `tsclientlib`, `cpal`, or backend types may //! appear in the public surface (SAD-067, SDD-079). Cross-language //! serialisation is handled by `flutter_rust_bridge`'s generated //! glue, so most public DTOs and the `BridgeEvent` enum intentionally //! do *not* carry `serde::{Serialize, Deserialize}` derives — FRB //! emits its own SSE encoders/decoders. `BridgeError` carries serde //! derives historically; new bridge types should follow the //! FRB-only convention unless an explicit non-FRB consumer is added. //! //! Note: this crate cannot use `#![forbid(unsafe_code)]` because the //! FRB-generated glue (in `frb_generated`) legitimately uses unsafe //! for the FFI boundary. Hand-written code in this crate must //! nonetheless avoid `unsafe` and is held to that standard by review. #![warn(missing_docs)] pub mod api; mod frb_generated; #[cfg(target_os = "android")] mod android_init; #[cfg(target_os = "android")] mod permission_jni; use thiserror::Error; /// Errors raised at the bridge boundary. Production code must keep /// these user-safe — no secrets, no protocol details, no path /// information beyond what the redaction policy permits. #[derive(Debug, Error, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BridgeError { /// The caller submitted a malformed command DTO. #[error("invalid command: {0}")] InvalidCommand(String), // TODO(refactor): DnsFailed and ServerRejected mirror ProtocolError variants // in chanora_protocol. These cannot be unified without changing the public FFI // API (flutter_rust_bridge generates Dart types from these). Revisit only if // the bridge error types are being reworked. /// Hostname resolution failed. Distinct from `Connection` so the /// UI can show a meaningful "Server not found" message. #[error("dns: could not resolve '{host}': {reason}")] DnsFailed { /// The hostname (or `host:port`) the caller submitted. host: String, /// Reason from the platform resolver. reason: String, }, /// Connection layer failure (typed-mapped from CoreError). #[error("connection: {0}")] Connection(String), /// Operation requires an active connection. #[error("not connected")] NotConnected, /// Already connected; DEC-006 forbids a second concurrent /// connection. #[error("already connected")] AlreadyConnected, /// A server command was rejected by the TeamSpeak server. The /// `code` is the canonical TS3 error number (see /// https://github.com/ReSpeak/tsdeclarations Errors.csv); the /// `message` is the server-supplied text. The UI uses `code` /// to look up a localised explanation. #[error("server rejected (code {code}): {message}")] ServerRejected { /// Raw TS3 error code. code: u32, /// Server-supplied message text. message: String, }, /// An unmapped error escaped the subsystem boundary. Production /// callers should never see this; if they do, it is a mapping /// bug here. #[error("unmapped: {0}")] Unmapped(String), } impl BridgeError { fn unmapped_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self { BridgeError::Unmapped(format!("{ctx}: {e}")) } } impl From for BridgeError { fn from(e: chanora_core::CoreError) -> Self { match e { chanora_core::CoreError::NotConnected => BridgeError::NotConnected, chanora_core::CoreError::AlreadyConnected => BridgeError::AlreadyConnected, chanora_core::CoreError::AudioNotStarted => { BridgeError::InvalidCommand("audio not started".to_string()) } chanora_core::CoreError::Protocol(chanora_core::ProtocolError::DnsFailed { host, reason, }) => BridgeError::DnsFailed { host, reason }, chanora_core::CoreError::Protocol(chanora_core::ProtocolError::ServerRejected { code, message, }) => BridgeError::ServerRejected { code, message }, chanora_core::CoreError::Protocol(chanora_core::ProtocolError::FileTransfer(p)) => { BridgeError::Connection(format!("file transfer: {p}")) } chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")), chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")), chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")), chanora_core::CoreError::Cache(c) => BridgeError::Connection(format!("cache: {c}")), other => BridgeError::Unmapped(format!("{other}")), } } } #[cfg(test)] mod tests { use super::*; fn roundtrip_json< T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, >( value: &T, ) { let json = serde_json::to_string(value).expect("serialize"); let back: T = serde_json::from_str(&json).expect("deserialize"); assert_eq!(&back, value, "roundtrip failed"); } #[test] fn bridge_error_invalid_command() { let err = BridgeError::InvalidCommand("bad".to_string()); assert_eq!(err.to_string(), "invalid command: bad"); } #[test] fn bridge_error_dns_failed() { let err = BridgeError::DnsFailed { host: "example.com".to_string(), reason: "timeout".to_string(), }; let msg = err.to_string(); assert!(msg.contains("example.com")); assert!(msg.contains("timeout")); } #[test] fn bridge_error_connection() { let err = BridgeError::Connection("refused".to_string()); assert_eq!(err.to_string(), "connection: refused"); } #[test] fn bridge_error_not_connected() { let err = BridgeError::NotConnected; assert_eq!(err.to_string(), "not connected"); } #[test] fn bridge_error_already_connected() { let err = BridgeError::AlreadyConnected; assert_eq!(err.to_string(), "already connected"); } #[test] fn bridge_error_server_rejected() { let err = BridgeError::ServerRejected { code: 2568, message: "insufficient permissions".to_string(), }; let msg = err.to_string(); assert!(msg.contains("2568")); assert!(msg.contains("insufficient permissions")); } #[test] fn bridge_error_unmapped() { let err = BridgeError::Unmapped("mystery".to_string()); assert_eq!(err.to_string(), "unmapped: mystery"); } #[test] fn bridge_error_serde_roundtrip() { roundtrip_json(&BridgeError::InvalidCommand("test".to_string())); roundtrip_json(&BridgeError::NotConnected); roundtrip_json(&BridgeError::AlreadyConnected); roundtrip_json(&BridgeError::Connection("fail".to_string())); roundtrip_json(&BridgeError::Unmapped("x".to_string())); roundtrip_json(&BridgeError::DnsFailed { host: "h".to_string(), reason: "r".to_string(), }); roundtrip_json(&BridgeError::ServerRejected { code: 42, message: "nope".to_string(), }); } #[test] fn bridge_error_clone_preserves() { let err = BridgeError::InvalidCommand("orig".to_string()); let cloned = err.clone(); assert_eq!(cloned.to_string(), err.to_string()); } #[test] fn from_core_error_not_connected() { let core_err = chanora_core::CoreError::NotConnected; let bridge_err: BridgeError = core_err.into(); assert!(matches!(bridge_err, BridgeError::NotConnected)); } #[test] fn from_core_error_already_connected() { let core_err = chanora_core::CoreError::AlreadyConnected; let bridge_err: BridgeError = core_err.into(); assert!(matches!(bridge_err, BridgeError::AlreadyConnected)); } #[test] fn from_core_error_audio_not_started() { let core_err = chanora_core::CoreError::AudioNotStarted; let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::InvalidCommand(msg) => { assert!(msg.contains("audio not started")); } other => panic!("expected InvalidCommand, got {other:?}"), } } #[test] fn from_core_error_protocol_dns_failed() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::DnsFailed { host: "bad.host".to_string(), reason: "no address".to_string(), }, ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::DnsFailed { host, reason } => { assert_eq!(host, "bad.host"); assert_eq!(reason, "no address"); } other => panic!("expected DnsFailed, got {other:?}"), } } #[test] fn from_core_error_protocol_server_rejected() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::ServerRejected { code: 0x0501, message: "channel password wrong".to_string(), }, ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::ServerRejected { code, message } => { assert_eq!(code, 0x0501); assert_eq!(message, "channel password wrong"); } other => panic!("expected ServerRejected, got {other:?}"), } } #[test] fn from_core_error_protocol_file_transfer() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::FileTransfer("disk full".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("file transfer")); assert!(msg.contains("disk full")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_generic() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::Connect("refused".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("refused")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_lost() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::Lost("timeout".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("timeout")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_invalid() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::Invalid("bad config".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("bad config")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_disconnected_early() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::DisconnectedEarly("premature".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("premature")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_identity() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::Identity("parse error".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("parse error")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_timeout() { let core_err = chanora_core::CoreError::Protocol(chanora_core::ProtocolError::Timeout); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("timeout")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_protocol_backend() { let core_err = chanora_core::CoreError::Protocol( chanora_core::ProtocolError::Backend("raw".to_string()), ); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Connection(msg) => { assert!(msg.contains("raw")); } other => panic!("expected Connection, got {other:?}"), } } #[test] fn from_core_error_invariant() { let core_err = chanora_core::CoreError::Invariant("broken"); let bridge_err: BridgeError = core_err.into(); match bridge_err { BridgeError::Unmapped(msg) => { assert!(msg.contains("broken")); } other => panic!("expected Unmapped, got {other:?}"), } } #[test] fn error_surfaces_all_protocol_error_variants() { let protocol_errors: Vec = vec![ chanora_core::ProtocolError::Invalid("x".into()), chanora_core::ProtocolError::DnsFailed { host: "h".into(), reason: "r".into(), }, chanora_core::ProtocolError::Connect("c".into()), chanora_core::ProtocolError::DisconnectedEarly("d".into()), chanora_core::ProtocolError::Lost("l".into()), chanora_core::ProtocolError::Identity("i".into()), chanora_core::ProtocolError::Timeout, chanora_core::ProtocolError::ServerRejected { code: 1, message: "m".into(), }, chanora_core::ProtocolError::Backend("b".into()), chanora_core::ProtocolError::FileTransfer("f".into()), ]; for p_err in protocol_errors { let core_err = chanora_core::CoreError::Protocol(p_err); let bridge_err: BridgeError = core_err.into(); let msg = bridge_err.to_string(); assert!(!msg.is_empty(), "BridgeError message must not be empty"); } } }