feat(alpha): wire connect→snapshot→disconnect end-to-end (v0.1.0-alpha.1)
First Internal Alpha build per DEC-001. Closes the milestone of
'Flutter UI calls Rust via the typed bridge, Rust connects to a
TeamSpeak-compatible server through tsclientlib, returns a typed
snapshot, and disconnects cleanly.' Audio remains Beta scope.
Promotions from PoC:
poc/tsclientlib-connect-spike → crates/chanora_protocol/
New product code:
crates/chanora_protocol/src/{dto.rs,adapter.rs} — typed boundary
over tsclientlib. Tokio task owns the Connection; public
handle communicates via mpsc/oneshot. No tsclientlib types
cross out of the crate (SAD-067 / SysDes-011 / SysDes-029).
core/chanora_core/src/lib.rs — ChanoraSession composes the
protocol crate, enforces the DEC-006 single-connection
invariant.
crates/chanora_bridge/src/{api.rs,frb_generated.rs} — FRB 2.12.0
bridge per DEC-014. cdylib + staticlib + rlib. Typed
BridgeSnapshot / BridgeChannel / BridgeClient / BridgeError
DTOs. Process-wide OnceLock<Runtime> + OnceLock<ChanoraSession>.
flutter_rust_bridge.yaml at repo root.
apps/chanora_flutter/lib/main.dart — Alpha UI: server form,
connect button, channel tree, disconnect.
apps/chanora_flutter/lib/l10n/app_{en,zh}.arb expanded with the
Alpha key set; ARB metadata reaffirms ADR-008 for
server-provided content.
Generated Dart bindings under apps/chanora_flutter/lib/src/rust/.
Empirical verification (2026-05-14):
Workspace: cargo check + cargo test clean
(workspace tests: all green).
Bridge cdylib: target/release/libchanora_bridge.so produced
(~15 MB).
Flutter: flutter analyze clean; flutter test runs 3/3 green
including the alpha_e2e_test that drives the full
Dart → FRB → chanora_bridge → chanora_core → chanora_protocol
→ tsclientlib → UDP → cn.teamspeak.app
path. The captured logcat/stdout shows the tsproto resender
transitioning Connected → Disconnecting → Disconnected on
clean teardown.
Architecture changes:
- Removed the chanora_core ↔ chanora_bridge cyclic dependency.
chanora_core no longer knows the bridge exists; the bridge
maps from CoreError.
- chanora_bridge crate's #![forbid(unsafe_code)] lint relaxed
because FRB-generated glue legitimately uses unsafe at the
FFI boundary. Hand-written code remains unsafe-free.
Open follow-ups (NOT in this Alpha):
- Audio capture/playback wiring into chanora_audio
(Beta scope per DEC-001).
- Identity persistence via chanora_storage
(currently regenerated on every connect).
- Per-message diagnostics + redaction
(chanora_diagnostics still scaffold).
- Reconnect / network-loss recovery.
- Mobile (Android) build of the bridge cdylib + UI verification.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
//! 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;
|
||||
|
||||
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<Runtime> = 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<chanora_core::ChanoraSession> = OnceLock::new();
|
||||
S.get_or_init(chanora_core::ChanoraSession::new)
|
||||
}
|
||||
|
||||
// ---------- 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();
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(true)
|
||||
.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<BridgeChannel>,
|
||||
/// Clients currently known.
|
||||
pub clients: Vec<BridgeClient>,
|
||||
}
|
||||
|
||||
impl From<chanora_protocol::ServerSnapshot> 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<BridgeSnapshot, BridgeError> {
|
||||
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<BridgeSnapshot, BridgeError> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user