Files
chanora/crates/chanora_bridge/src/api.rs
T
EdisonJwa 43a3c9ba76 feat(events): A.4 — emit SnapshotChanged from the watchdog probe
Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.

* `chanora_core::SessionEvent::SnapshotChanged { channels, clients }`
  carries the latest channel and client counts.
* The supervisor compares the probe result to `last_counts` and
  fires the event only when the count actually changes. `last_counts`
  is reset to `None` on a successful reconnect so the freshly
  dialled session re-emits its initial counts.
* `chanora_bridge::api::BridgeEvent::SnapshotChanged` is the
  cross-bridge mirror.
* Flutter routes the event through `_onEvent`, which calls
  `_onRefresh()` to repopulate the snapshot view.

The probe-driven detection has known limits — pure within-channel
client moves do not change the count and so are not surfaced. That
gap will close when the supervisor tracks a content hash in
addition to the count; the count-only signal is sufficient for the
common "someone joined / someone left" case observed on cn.teamspeak.app.
2026-05-15 01:27:57 +08:00

441 lines
15 KiB
Rust

//! 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<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)
}
/// 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<chanora_core::InMemoryLogSink> = OnceLock::new();
SINK.get_or_init(|| {
chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy())
})
}
// ---------- 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();
// 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("info"));
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("info"));
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<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)
}
// ---------- Audio commands (Beta) ----------
/// Start the audio engine on the active connection. Requires a
/// connection; idempotent (will replace any previous engine).
pub async fn start_audio() -> Result<(), BridgeError> {
runtime()
.spawn(async {
session()
.start_audio(chanora_core::AudioEngineConfig::default())
.await
})
.await
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
Ok(())
}
/// Set the push-to-talk state.
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(())
}
/// 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(())
}
// ---------- 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<BridgeNetworkState> 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,
},
}
impl From<chanora_core::SessionEvent> 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 }
}
}
}
}
/// 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<BridgeEvent>) -> 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<BridgeAudioStats, BridgeError> {
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,
})
}