Implement the SDD-094 / SDD-095 / SDD-096 / SDD-097 detailed designs
committed in dfa84ee.
Rust side
- chanora_audio::TransmitMode enum (Ptt/Continuous/VoiceActivity) with
serde-friendly u8 repr (SDD-095).
- chanora_audio::TransmitModeSelector: lock-free Atomic-backed selector
that is the sole writer of transmit_active (per SAD-083), applying
hard_mute as a final clamp. VoiceActivity falls through to Continuous
for v1 (DEC-030 placeholder).
- chanora_audio::ReleaseTailTimer: tokio-task-owning struct driving the
selector's ptt_held input; default 200 ms tail, configurable 0–500 ms
with AtomicU32 hot read; pending JoinHandle held in a std::sync::Mutex
touched only on PTT edge transitions (SDD-096).
- chanora_storage: AudioMeta persisted as audio_meta.json next to
identity.dek; get/set_transmit_mode + get/set_release_tail_ms with
0..=500 clamp on write.
- chanora_core::ChanoraSession: voice_join(channel, password) and
voice_leave() are the new lifecycle entry points; ensure_audio_running
and shutdown_audio_if_idle are private helpers around the existing
Option<AudioEngine> field. SessionEvent::VoiceState carries the
in_channel / transmit_mode / mute / release_tail_ms tuple. Selector
state survives reconnect; supervisor rewires it to each fresh engine
gate.
- chanora_bridge: drop start_audio; add voice_join, voice_leave,
set/get_transmit_mode, set/get_release_tail_ms, set_hard_mute.
BridgeEvent::VoiceState mirrors the core event. AudioStarted/Stopped
kept for backwards compat but Flutter ignores them in the new UI.
Flutter side
- New apps/chanora_flutter/lib/widgets/voice_bar.dart replaces the
legacy _AudioControls widget. Renders channel pill, mode badge,
mute toggle, level meter, PttCapabilityBadge, leave button. No
manual Start affordance anywhere.
- New apps/chanora_flutter/lib/widgets/voice_settings.dart dialog with
TransmitMode radio group (VoiceActivity disabled with 'Coming soon'
trailing label per DEC-030), bind-key button, release-tail slider
0–500 ms step 25.
- main.dart: state fields _inChannel, _transmitMode, _hardMute,
_releaseTailMs driven by BridgeEvent_VoiceState. Channel-tap now
calls voiceJoin instead of moveToChannel. Removed _onStartAudio,
_audioStarted-gated branch, and the FilledButton.
- l10n: 11 new strings in app_en.arb + app_zh.arb.
Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored
(chanora_audio: +12 new tests for TransmitMode/Selector/ReleaseTail;
chanora_storage: +2 new tests for audio_meta round-trip).
- flutter analyze: 0 errors, 0 warnings; 6 infos are the Flutter 3.32
Radio.groupValue deprecation (pre-existing API usage).
- FRB Dart/Rust bindings regenerated via flutter_rust_bridge_codegen.
Follow-up (intentionally deferred)
- PttController and per-platform PTT backends still drive AudioTransmitGate
directly via the legacy set_ptt path; routing those key edges through
ChanoraSession::release_tail_timer().{key_down,key_up} so the tail
applies to native PTT input is a contained wiring change in a follow-up.
- Real audio-level RMS in BridgeAudioStats (current meter is binary).
- VoiceActivity backend (DEC-030).
789 lines
27 KiB
Rust
789 lines
27 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 ----------
|
|
|
|
/// Default tracing filter. Suppresses the chatty
|
|
/// `tsproto::resend` and `tsproto::packet_codec` paths that
|
|
/// flood the diagnostic export during transient packet loss;
|
|
/// users can still raise verbosity via `RUST_LOG=info`.
|
|
const DEFAULT_LOG_FILTER: &str = "info,tsproto::resend=error,tsproto::packet_codec=error";
|
|
|
|
/// 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(DEFAULT_LOG_FILTER));
|
|
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(DEFAULT_LOG_FILTER));
|
|
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`].
|
|
///
|
|
/// `password` is optional — pass an empty string for servers that
|
|
/// don't require one.
|
|
pub async fn connect(
|
|
host: String,
|
|
nickname: String,
|
|
password: String,
|
|
) -> Result<BridgeSnapshot, BridgeError> {
|
|
let cfg = chanora_core::ConnectConfig {
|
|
address: host,
|
|
nickname,
|
|
password: if password.is_empty() { None } else { Some(password) },
|
|
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_audio` was removed per SDD-094 — voice activation now
|
|
// flows through `voice_join` / `voice_leave`, which transparently
|
|
// drive `AudioEngine::ensure_running` / `shutdown_if_idle`.
|
|
|
|
/// Set the push-to-talk state.
|
|
///
|
|
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
|
|
/// dialog. Retained so legacy callers and integration tests keep
|
|
/// working; the new VoiceBar UI no longer invokes this.
|
|
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(())
|
|
}
|
|
|
|
// ---------- v1 voice lifecycle (SDD-094/095/096) ----------
|
|
|
|
/// Voice transmit mode mirror (SDD-095). Schema-controlled enum;
|
|
/// the wire encoding matches [`chanora_core::TransmitMode::as_u8`].
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BridgeTransmitMode {
|
|
/// Push-to-talk (default).
|
|
Ptt,
|
|
/// Continuous transmission while in channel and not muted.
|
|
Continuous,
|
|
/// Voice-activity detection — reserved per DEC-030; v1 behaves
|
|
/// as `Continuous`.
|
|
VoiceActivity,
|
|
}
|
|
|
|
impl From<BridgeTransmitMode> for chanora_core::TransmitMode {
|
|
fn from(m: BridgeTransmitMode) -> Self {
|
|
match m {
|
|
BridgeTransmitMode::Ptt => Self::Ptt,
|
|
BridgeTransmitMode::Continuous => Self::Continuous,
|
|
BridgeTransmitMode::VoiceActivity => Self::VoiceActivity,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<chanora_core::TransmitMode> for BridgeTransmitMode {
|
|
fn from(m: chanora_core::TransmitMode) -> Self {
|
|
match m {
|
|
chanora_core::TransmitMode::Ptt => Self::Ptt,
|
|
chanora_core::TransmitMode::Continuous => Self::Continuous,
|
|
chanora_core::TransmitMode::VoiceActivity => Self::VoiceActivity,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode {
|
|
chanora_core::TransmitMode::from_u8(v)
|
|
.unwrap_or_default()
|
|
.into()
|
|
}
|
|
|
|
/// Join a voice channel (SDD-094). Moves the user to `channel_id`,
|
|
/// brings up the audio engine if needed, and emits
|
|
/// `BridgeEvent::VoiceState`. `password` may be empty.
|
|
pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> {
|
|
let pw = if password.is_empty() { None } else { Some(password) };
|
|
runtime()
|
|
.spawn(async move { session().voice_join(channel_id, pw).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Leave the current voice channel (SDD-094). Tears down the audio
|
|
/// engine and emits `BridgeEvent::VoiceState`.
|
|
pub async fn voice_leave() -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async { session().voice_leave().await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the active transmit mode (SDD-095).
|
|
pub async fn set_transmit_mode(mode: BridgeTransmitMode) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_transmit_mode(mode.into()).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the active transmit mode.
|
|
pub async fn get_transmit_mode() -> BridgeTransmitMode {
|
|
runtime()
|
|
.spawn(async { session().transmit_mode() })
|
|
.await
|
|
.unwrap_or(chanora_core::TransmitMode::Ptt)
|
|
.into()
|
|
}
|
|
|
|
/// Update the release-tail in milliseconds (SDD-096). Values are
|
|
/// clamped to `0..=500` on the Rust side; passing anything larger
|
|
/// silently saturates.
|
|
pub async fn set_release_tail_ms(ms: u32) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_release_tail_ms(ms).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the current release-tail in milliseconds.
|
|
pub async fn get_release_tail_ms() -> u32 {
|
|
runtime()
|
|
.spawn(async { session().release_tail_ms() })
|
|
.await
|
|
.unwrap_or(200)
|
|
}
|
|
|
|
/// Engage or release the hard-mute clamp (SDD-094). When `true`
|
|
/// the audio engine transmits nothing regardless of mode.
|
|
pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_hard_mute(muted).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
|
|
/// the bridge never carries raw key codes.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BridgePttInputClass {
|
|
/// No binding is active.
|
|
None,
|
|
/// A keyboard key.
|
|
Keyboard,
|
|
/// A mouse side button (Mouse4 / Mouse5).
|
|
MouseSideButton,
|
|
}
|
|
|
|
impl From<BridgePttInputClass> for chanora_core::PttInputClass {
|
|
fn from(c: BridgePttInputClass) -> Self {
|
|
match c {
|
|
BridgePttInputClass::None => chanora_core::PttInputClass::None,
|
|
BridgePttInputClass::Keyboard => chanora_core::PttInputClass::Keyboard,
|
|
BridgePttInputClass::MouseSideButton => chanora_core::PttInputClass::MouseSideButton,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
|
|
/// platform_key string is opaque to the bridge — it identifies the
|
|
/// bound key inside the platform backend and never appears in any
|
|
/// log record or diagnostic export (DEC-027 enforced at the
|
|
/// diagnostics-sanitizer layer).
|
|
pub async fn set_ptt_binding(
|
|
input_class: BridgePttInputClass,
|
|
platform_key: String,
|
|
) -> Result<(), BridgeError> {
|
|
let binding = chanora_core::PttBinding {
|
|
input_class: input_class.into(),
|
|
platform_key,
|
|
};
|
|
runtime()
|
|
.spawn(async move { session().set_ptt_binding(binding).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the current PTT capability descriptor. Returns a
|
|
/// `(level, backend_id, bound_input_class)` triple matching the
|
|
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
|
|
/// for the initial UI render before the first event arrives.
|
|
pub async fn ptt_descriptor() -> (String, String, String) {
|
|
runtime()
|
|
.spawn(async { session().ptt_descriptor().await })
|
|
.await
|
|
.unwrap_or_else(|_| (String::new(), String::new(), String::new()))
|
|
}
|
|
|
|
/// Move our own client to `channel_id`. Optional channel password
|
|
/// for password-protected channels — pass an empty string when not
|
|
/// required.
|
|
pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> {
|
|
let pw = if password.is_empty() { None } else { Some(password) };
|
|
runtime()
|
|
.spawn(async move { session().move_to_channel(channel_id, pw).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle self input-mute (microphone) on the server. Independent
|
|
/// of push-to-talk: a muted client never transmits regardless of
|
|
/// PTT state.
|
|
pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_self_muted(Some(muted), None).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle self output-mute (speaker). Mutes locally *and* informs
|
|
/// the server. The server uses this for the channel icon next to
|
|
/// the client name; the local mute kicks in immediately even
|
|
/// before the server acknowledges.
|
|
pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_self_muted(None, Some(muted)).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set master output gain. `1.0` is unity, `0.0` is silent. Values
|
|
/// above `1.0` amplify and can clip downstream. Errors when audio
|
|
/// is not started.
|
|
pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().set_output_gain(gain).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(())
|
|
}
|
|
|
|
/// Bookmark DTO mirroring [`chanora_core::Bookmark`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct BridgeBookmark {
|
|
/// Row id assigned by SQLite. Use `0` when adding new rows;
|
|
/// the returned id is then meaningful.
|
|
pub id: i64,
|
|
/// User-facing label.
|
|
pub display_name: String,
|
|
/// `hostname[:port]` or TSDNS name.
|
|
pub host: String,
|
|
/// Nickname to use for this bookmark.
|
|
pub nickname: String,
|
|
/// Optional remembered password. Empty string = none.
|
|
pub password: String,
|
|
}
|
|
|
|
impl From<chanora_core::Bookmark> for BridgeBookmark {
|
|
fn from(b: chanora_core::Bookmark) -> Self {
|
|
Self {
|
|
id: b.id,
|
|
display_name: b.display_name,
|
|
host: b.host,
|
|
nickname: b.nickname,
|
|
password: b.password.unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<BridgeBookmark> for chanora_core::Bookmark {
|
|
fn from(b: BridgeBookmark) -> Self {
|
|
chanora_core::Bookmark {
|
|
id: b.id,
|
|
display_name: b.display_name,
|
|
host: b.host,
|
|
nickname: b.nickname,
|
|
password: if b.password.is_empty() {
|
|
None
|
|
} else {
|
|
Some(b.password)
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List persisted bookmarks.
|
|
pub async fn list_bookmarks() -> Result<Vec<BridgeBookmark>, BridgeError> {
|
|
let v = runtime()
|
|
.spawn(async { session().list_bookmarks().await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(v.into_iter().map(Into::into).collect())
|
|
}
|
|
|
|
/// Insert a bookmark and return its assigned id. The `id` field on
|
|
/// the input is ignored.
|
|
pub async fn add_bookmark(b: BridgeBookmark) -> Result<i64, BridgeError> {
|
|
let core_b: chanora_core::Bookmark = b.into();
|
|
let id = runtime()
|
|
.spawn(async move { session().add_bookmark(core_b).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Update an existing bookmark.
|
|
pub async fn update_bookmark(b: BridgeBookmark) -> Result<(), BridgeError> {
|
|
let core_b: chanora_core::Bookmark = b.into();
|
|
runtime()
|
|
.spawn(async move { session().update_bookmark(core_b).await })
|
|
.await
|
|
.map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a bookmark by id.
|
|
pub async fn delete_bookmark(id: i64) -> Result<(), BridgeError> {
|
|
runtime()
|
|
.spawn(async move { session().delete_bookmark(id).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,
|
|
},
|
|
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
|
|
/// DEC-023..028). The fields carry only privacy-safe values per
|
|
/// DEC-027: the capability level, a stable backend identifier,
|
|
/// and the bound input class. No key codes, scan codes, or
|
|
/// virtual-key values cross this boundary.
|
|
PttCapability {
|
|
/// Stable capability identifier (`"L0Focused"`,
|
|
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
|
|
/// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
|
|
level: String,
|
|
/// Stable backend identifier (e.g. `"focused"`).
|
|
backend_id: String,
|
|
/// Coarse bound input class (e.g. `"keyboard"`,
|
|
/// `"mouse-side-button"`); empty when no binding is active.
|
|
bound_input_class: String,
|
|
},
|
|
/// Voice subsystem state snapshot (SDD-094). The Flutter
|
|
/// VoiceBar listens to this stream.
|
|
VoiceState {
|
|
/// True when the session is currently joined to a voice
|
|
/// channel and the audio engine is running.
|
|
in_channel: bool,
|
|
/// Active transmit mode.
|
|
transmit_mode: BridgeTransmitMode,
|
|
/// True when the hard-mute clamp is engaged.
|
|
mute: bool,
|
|
/// Current release-tail in milliseconds (0..=500).
|
|
release_tail_ms: 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 }
|
|
}
|
|
chanora_core::SessionEvent::PttCapability {
|
|
level,
|
|
backend_id,
|
|
bound_input_class,
|
|
} => BridgeEvent::PttCapability {
|
|
level,
|
|
backend_id,
|
|
bound_input_class,
|
|
},
|
|
chanora_core::SessionEvent::VoiceState {
|
|
in_channel,
|
|
transmit_mode,
|
|
mute,
|
|
release_tail_ms,
|
|
} => BridgeEvent::VoiceState {
|
|
in_channel,
|
|
transmit_mode: transmit_mode_from_u8(transmit_mode),
|
|
mute,
|
|
release_tail_ms,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
})
|
|
}
|