Files
chanora/crates/chanora_bridge/src/lib.rs
T
Edison Jwa 40883c40c8 refactor: reduce map_err boilerplate with context helper methods (TODO-025)
Add per-crate variant_ctx() helper methods to StorageError, BlobCacheError,
ProtocolError, and BridgeError. Replace 78 .map_err(|e| format!(...))
closures with concise context calls. Identical error messages preserved.
2026-06-11 12:25:00 +09:00

439 lines
16 KiB
Rust

//! # `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<chanora_core::CoreError> 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<chanora_core::ProtocolError> = 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");
}
}
}