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:
EdisonJwa
2026-05-14 21:37:06 +08:00
parent e0f34009d9
commit 4915ec0a1b
30 changed files with 7504 additions and 206 deletions
+1 -1
View File
@@ -15,6 +15,6 @@ chanora_state = { path = "../../crates/chanora_state" }
chanora_audio = { path = "../../crates/chanora_audio" }
chanora_storage = { path = "../../crates/chanora_storage" }
chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
chanora_bridge = { path = "../../crates/chanora_bridge" }
thiserror.workspace = true
tracing.workspace = true
tokio = { version = "1", features = ["sync", "rt"] }
+85 -33
View File
@@ -6,76 +6,114 @@
//!
//! `chanora_core` is the integration point. It owns no protocol,
//! audio, or storage logic directly; instead it composes the
//! subsystem crates ([`chanora_protocol`], [`chanora_state`],
//! [`chanora_audio`], [`chanora_storage`], [`chanora_diagnostics`])
//! behind a stable, typed API consumed by [`chanora_bridge`] (which
//! in turn exposes it to Flutter via `flutter_rust_bridge`, per
//! DEC-014).
//! subsystem crates (`chanora_protocol`, `chanora_state`,
//! `chanora_audio`, `chanora_storage`, `chanora_diagnostics`)
//! behind a stable, typed API consumed by `chanora_bridge`.
//!
//! ## Invariants
//!
//! * Single active server connection at runtime (DEC-006 / SAD-064).
//! * Protocol-specific types from `tsclientlib` do not cross out of
//! [`chanora_protocol`] (SAD-067).
//! * Secret material never lands in [`chanora_storage`]; secrets
//! live in [`chanora_diagnostics`]'s `KnownSecretRegistry` *only*
//! for redaction and in the platform secure-store (DEC-013.2).
//! `chanora_protocol` (SAD-067).
//! * Secret material never lands in `chanora_storage`'s non-secret
//! side (DEC-013.2 / SS-AUD-001/002).
//!
//! ## Status
//! ## Alpha scope
//!
//! This crate is a **scaffold**. No PoC code has been promoted in
//! yet. The public surface below is the integration contract; bodies
//! are placeholders.
//! `ChanoraSession::connect`, `snapshot`, and `disconnect` are wired
//! through `chanora_protocol`. Audio, storage, and diagnostics are
//! still scaffolds.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::Mutex;
pub use chanora_protocol::{
ChannelInfo, ClientInfo, ConnectConfig, ProtocolError, ServerSnapshot,
};
/// Errors that can arise during top-level orchestration.
///
/// Each arm wraps a typed error from the subsystem that produced it,
/// so callers can match on the originating layer without parsing
/// strings. The variants are intentionally narrow at this stage and
/// will expand as the subsystems land.
#[derive(Debug, Error)]
pub enum CoreError {
/// Protocol-layer error originating from [`chanora_protocol`].
/// Protocol-layer error.
#[error("protocol: {0}")]
Protocol(#[from] chanora_protocol::ProtocolError),
/// State-synchronisation error from [`chanora_state`].
/// State-synchronisation error.
#[error("state: {0}")]
State(#[from] chanora_state::StateError),
/// Audio-subsystem error from [`chanora_audio`].
/// Audio-subsystem error.
#[error("audio: {0}")]
Audio(#[from] chanora_audio::AudioError),
/// Storage error from [`chanora_storage`].
/// Storage error.
#[error("storage: {0}")]
Storage(#[from] chanora_storage::StorageError),
/// Diagnostics error from [`chanora_diagnostics`].
/// Diagnostics error.
#[error("diagnostics: {0}")]
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
/// Bridge / DTO error from [`chanora_bridge`].
#[error("bridge: {0}")]
Bridge(#[from] chanora_bridge::BridgeError),
/// A precondition was violated (typically caller bug or
/// concurrent misuse).
#[error("invariant violated: {0}")]
Invariant(&'static str),
/// No active connection.
#[error("not connected")]
NotConnected,
/// An attempt was made to start a second connection while one
/// was already active (forbidden by DEC-006).
#[error("already connected")]
AlreadyConnected,
}
/// The top-level Chanora session. Owns exactly one active server
/// connection (DEC-006). Construction does **not** dial the server;
/// see [`ChanoraSession::connect`] (scaffolded only).
/// The top-level Chanora session. Owns at most one active server
/// connection (DEC-006).
#[derive(Clone)]
pub struct ChanoraSession {
_seal: (),
inner: Arc<Mutex<Option<chanora_protocol::ProtocolClient>>>,
}
impl ChanoraSession {
/// Construct a new session with the default subsystem
/// configurations. Performs no I/O.
/// Construct an empty session. Performs no I/O.
pub fn new() -> Self {
Self { _seal: () }
Self {
inner: Arc::new(Mutex::new(None)),
}
}
/// Connect to a server. Fails with [`CoreError::AlreadyConnected`]
/// if a connection is already active (DEC-006).
pub async fn connect(&self, cfg: ConnectConfig) -> Result<ServerSnapshot, CoreError> {
let mut guard = self.inner.lock().await;
if guard.is_some() {
return Err(CoreError::AlreadyConnected);
}
let client = chanora_protocol::ProtocolClient::connect(cfg).await?;
let snap = client.snapshot().await?;
*guard = Some(client);
Ok(snap)
}
/// Return a fresh snapshot of the current server state.
pub async fn snapshot(&self) -> Result<ServerSnapshot, CoreError> {
let guard = self.inner.lock().await;
let client = guard.as_ref().ok_or(CoreError::NotConnected)?;
Ok(client.snapshot().await?)
}
/// True if a connection is currently active.
pub async fn is_connected(&self) -> bool {
self.inner.lock().await.is_some()
}
/// Disconnect from the server. No-op if not connected.
pub async fn disconnect(&self) -> Result<(), CoreError> {
let mut guard = self.inner.lock().await;
if let Some(client) = guard.take() {
client.disconnect().await;
}
Ok(())
}
}
@@ -93,4 +131,18 @@ mod tests {
fn session_can_be_constructed() {
let _ = ChanoraSession::new();
}
#[tokio::test]
async fn disconnect_when_not_connected_is_noop() {
let s = ChanoraSession::new();
assert!(!s.is_connected().await);
s.disconnect().await.unwrap();
}
#[tokio::test]
async fn empty_address_is_rejected() {
let s = ChanoraSession::new();
let r = s.connect(ConnectConfig::default()).await;
assert!(matches!(r, Err(CoreError::Protocol(ProtocolError::Invalid(_)))));
}
}
+34
View File
@@ -0,0 +1,34 @@
//! Live smoke test against cn.teamspeak.app. Tagged #[ignore] so
//! `cargo test` does not hit the network by default. Run explicitly
//! with: `cargo test -p chanora_core -- --ignored alpha_smoke`.
#![cfg(test)]
use std::time::Duration;
use chanora_core::{ChanoraSession, ConnectConfig};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "hits the network; run with --ignored"]
async fn alpha_smoke() {
let s = ChanoraSession::new();
let cfg = ConnectConfig {
address: "cn.teamspeak.app".to_string(),
nickname: "ChanoraAlphaSmoke".to_string(),
password: None,
identity: None,
ready_timeout: Duration::from_secs(15),
};
let snap = s.connect(cfg).await.expect("connect");
println!(
"snapshot: server='{}' channels={} clients={}",
snap.server_name,
snap.channels.len(),
snap.clients.len()
);
assert!(!snap.server_name.is_empty());
assert!(!snap.channels.is_empty());
s.disconnect().await.unwrap();
assert!(!s.is_connected().await);
}