feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)

The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:

* SDD-088 PttController — backend ownership + binding mutex +
  capability watch lived split between AudioEngine and
  ChanoraSession. Extracted into chanora_core::ptt::PttController.
  AudioEngine now owns only the cpal streams and the missed-key-up
  watchdog (SDD-092); the platform input backend, the active
  PttBinding, and the capability watch::Sender live in the
  controller. ChanoraSession::start_audio constructs the controller
  against the engine's gate; disconnect/reconnect/restart paths
  tear it down through stop().await before the engine.

* SDD-090 PttSanitizer — banned-field check was inlined as
  PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
  into a generic PttSanitizer<L> tracing_subscriber::Layer that
  decorates an inner Layer (canonical pairing:
  RedactingLogLayer::with_sanitizer). The inner layer keeps its
  own structural ban check as defence-in-depth for bare-install
  callers.

* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
  Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
  into a public PttCapabilityBadge widget and added the
  SDD-091-specified per-platform explanation sheet that opens on
  the info-icon tap when the resolved capability is L0Focused.
  New l10n strings (en + zh) cover the sheet copy.

Tests:
  * 2 new unit tests for PttController (arm + descriptor watch)
  * 1 new unit test for PttSanitizer (end-to-end through a real
    tracing subscriber proving banned drop + safe forward)
  cargo test --workspace: 55 passed / 0 failed / 3 ignored
  cargo deny check: advisories ok, bans ok, licenses ok, sources ok
  flutter analyze: no issues
  tools/validate_docs.py: zero undefined refs, zero direct-layer
    violations (pre-existing 35 old-package-name warning unchanged)

No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
This commit is contained in:
EdisonJwa
2026-05-15 17:45:03 +08:00
parent 63f2901a6a
commit 9831624079
10 changed files with 747 additions and 170 deletions
+82 -37
View File
@@ -45,6 +45,8 @@ use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tokio::task::JoinHandle;
use tracing::{info, warn};
pub mod ptt;
pub use chanora_audio::{
AudioEngine, AudioEngineConfig, PttBackendDescriptor, PttCapabilityLevel,
};
@@ -89,6 +91,9 @@ pub enum CoreError {
/// Audio engine is not running.
#[error("audio not started")]
AudioNotStarted,
/// PTT controller error.
#[error("ptt: {0}")]
Ptt(#[from] ptt::PttControllerError),
}
/// High-level lifecycle event surfaced to subscribers.
@@ -185,6 +190,12 @@ struct SupervisorInner {
struct ConnectedState {
protocol: chanora_protocol::ProtocolClient,
audio: Option<chanora_audio::AudioEngine>,
/// Active PTT controller (SDD-088). Owns the platform input
/// backend, the active binding, and the capability watch
/// channel that drives `BridgeEvent::PttCapability`. Wired in
/// `start_audio` after the engine is up; torn down by
/// `stop_audio` / disconnect.
ptt_controller: Option<Arc<ptt::PttController>>,
/// Cancellation signal for the supervisor task. Dropped on
/// explicit disconnect to break the supervisor out of its
/// backoff sleep.
@@ -406,6 +417,7 @@ impl ChanoraSession {
*guard = Some(ConnectedState {
protocol: client,
audio: None,
ptt_controller: None,
cancel_tx: Some(cancel_tx),
supervisor: Some(supervisor),
cfg,
@@ -434,7 +446,12 @@ impl ChanoraSession {
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
// Tear down any prior engine.
// Tear down any prior engine + controller. The controller
// must be torn down before the engine because its forwarder
// task references the gate that lives in the engine.
if let Some(prev) = state.ptt_controller.take() {
prev.stop().await;
}
if let Some(mut prev) = state.audio.take() {
prev.stop();
}
@@ -445,8 +462,17 @@ impl ChanoraSession {
.take_voice_in()
.ok_or(CoreError::Invariant("voice_in already taken"))?;
let engine = chanora_audio::AudioEngine::start(cfg.clone(), voice_out, voice_in)?;
let gate = engine.transmit_gate().clone();
state.audio = Some(engine);
// Wire the PTT controller (SDD-088). It owns the platform
// backend, the active binding, and the capability watch
// channel. Constructed here because the controller needs
// the engine's gate clone, and we want the engine to
// exist before any backend tries to drive the gate.
let controller = ptt::PttController::new(gate);
state.ptt_controller = Some(controller.clone());
// Record desired state so the supervisor will re-start audio
// after a reconnect.
{
@@ -455,40 +481,31 @@ impl ChanoraSession {
sup.audio_running = true;
}
let _ = self.events_tx.send(SessionEvent::AudioStarted);
// Publish the current PTT capability so the UI badge can
// render an honest value (SRS-196 / SDD-091). Each
// platform backend reports its actual runtime capability
// through its descriptor; the universal Focused fallback
// reports `L0Focused`.
//
// The engine itself owns the active backend, so we ask it
// for the live value rather than synthesising a focused
// descriptor here. We also subscribe to descriptor
// transitions and spawn a forwarder task: backends that
// resolve their capability asynchronously (notably the
// Linux portal backend after the user accepts the
// BindShortcuts dialog) re-publish through this watcher.
let initial_desc = state.audio.as_ref().map(|a| a.ptt_descriptor()).unwrap_or_else(
PttBackendDescriptor::focused,
);
// render an honest value (SRS-196 / SDD-091). Backends
// that resolve asynchronously (notably the Linux portal
// backend after the user accepts the BindShortcuts
// dialog) re-publish through the controller's
// descriptor-watch.
let initial_desc = controller.descriptor().await;
let _ = self.events_tx.send(SessionEvent::PttCapability {
level: initial_desc.level.as_str().to_string(),
backend_id: initial_desc.backend_id.to_string(),
bound_input_class: initial_desc.bound_input_class.unwrap_or("").to_string(),
});
if let Some(mut watch_rx) = state.audio.as_ref().and_then(|a| a.ptt_descriptor_watch()) {
let events_tx = self.events_tx.clone();
tokio::spawn(async move {
while watch_rx.changed().await.is_ok() {
let d = watch_rx.borrow_and_update().clone();
let _ = events_tx.send(SessionEvent::PttCapability {
level: d.level.as_str().to_string(),
backend_id: d.backend_id.to_string(),
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
});
}
});
}
let mut watch_rx = controller.descriptor_watch();
let events_tx = self.events_tx.clone();
tokio::spawn(async move {
while watch_rx.changed().await.is_ok() {
let d = watch_rx.borrow_and_update().clone();
let _ = events_tx.send(SessionEvent::PttCapability {
level: d.level.as_str().to_string(),
backend_id: d.backend_id.to_string(),
bound_input_class: d.bound_input_class.unwrap_or("").to_string(),
});
}
});
Ok(())
}
@@ -502,15 +519,19 @@ impl ChanoraSession {
}
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
/// binding flows into the platform backend's `rebind` hook
/// and the freshly-published `PttBackendDescriptor` is
/// broadcast as `SessionEvent::PttCapability` so the UI badge
/// updates immediately. The audio engine must be running.
/// binding flows into the platform backend's `rebind` hook via
/// the [`ptt::PttController`] (SDD-088) and the freshly-published
/// `PttBackendDescriptor` is broadcast as
/// `SessionEvent::PttCapability` so the UI badge updates
/// immediately. The audio engine must be running.
pub async fn set_ptt_binding(&self, binding: PttBinding) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
let desc = audio.rebind_ptt(binding)?;
let controller = state
.ptt_controller
.as_ref()
.ok_or(CoreError::AudioNotStarted)?;
let desc = controller.set_binding(binding).await?;
let _ = self.events_tx.send(SessionEvent::PttCapability {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
@@ -526,8 +547,8 @@ impl ChanoraSession {
/// audio engine is running.
pub async fn ptt_descriptor(&self) -> (String, String, String) {
let guard = self.inner.lock().await;
let desc = match guard.as_ref().and_then(|s| s.audio.as_ref()) {
Some(audio) => audio.ptt_descriptor(),
let desc = match guard.as_ref().and_then(|s| s.ptt_controller.as_ref()) {
Some(controller) => controller.descriptor().await,
None => PttBackendDescriptor::focused(),
};
(
@@ -598,6 +619,9 @@ impl ChanoraSession {
if let Some(tx) = state.cancel_tx.take() {
let _ = tx.send(());
}
if let Some(controller) = state.ptt_controller.take() {
controller.stop().await;
}
if let Some(mut audio) = state.audio.take() {
audio.stop();
let _ = self.events_tx.send(SessionEvent::AudioStopped);
@@ -808,6 +832,9 @@ async fn supervisor_loop(
{
let mut guard = state_arc.lock().await;
if let Some(state) = guard.as_mut() {
if let Some(controller) = state.ptt_controller.take() {
controller.stop().await;
}
if let Some(mut audio) = state.audio.take() {
audio.stop();
let _ = events_tx.send(SessionEvent::AudioStopped);
@@ -936,9 +963,27 @@ async fn supervisor_loop(
audio_cfg, voice_out, voice_in,
) {
Ok(engine) => {
let gate = engine.transmit_gate().clone();
state.audio = Some(engine);
// Re-arm the PTT controller against
// the new engine's gate (SDD-088).
let controller = ptt::PttController::new(gate);
state.ptt_controller = Some(controller.clone());
let _ = events_tx
.send(SessionEvent::AudioStarted);
// Re-publish the post-reconnect
// capability (SRS-196 / SDD-091).
let d = controller.descriptor().await;
let _ = events_tx.send(
SessionEvent::PttCapability {
level: d.level.as_str().to_string(),
backend_id: d.backend_id.to_string(),
bound_input_class: d
.bound_input_class
.unwrap_or("")
.to_string(),
},
);
}
Err(e) => {
warn!(
+272
View File
@@ -0,0 +1,272 @@
//! Cross-platform Push-to-Talk controller (SDD-088).
//!
//! The controller is the orchestration seam between the platform
//! input backend (selected by `chanora_audio::ptt_backends::select`)
//! and the audio engine's `AudioTransmitGate`. It owns the active
//! `Box<dyn DesktopPttBackend>`, a clone of the gate, the current
//! `PttBinding`, and the `watch::Sender<PttCapabilityLevel>` the
//! bridge subscribes to for the UI capability badge (SDD-091).
//!
//! Source: SAD-071 (cross-platform PTT plumbing), SAD-076 (binding
//! lifecycle), SDD-088.
//!
//! The previous implementation kept the backend slot inside
//! `AudioEngine` and split the binding / capability publication
//! between the engine and `ChanoraSession`. SDD-088 names a
//! dedicated `PttController` software unit; this module is that
//! unit. The audio engine retains ownership only of the cpal
//! streams and the missed-key-up watchdog (SDD-092).
use std::sync::Arc;
use chanora_audio::ptt_backends::{
select as select_ptt_backend, DesktopPttBackend, PttBackendError, PttBinding,
};
use chanora_audio::{AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel};
use tokio::sync::{watch, Mutex};
use tracing::{info, warn};
/// Errors raised by [`PttController`]. Thin wrapper over
/// [`PttBackendError`] plus a "not armed" variant for callers that
/// touch the controller after `stop`.
#[derive(Debug, thiserror::Error)]
pub enum PttControllerError {
/// The wrapped platform backend reported an error.
#[error("ptt backend: {0}")]
Backend(#[from] PttBackendError),
/// `set_binding` or `descriptor` was called after `stop`.
#[error("ptt controller not armed")]
NotArmed,
}
/// Cross-platform PTT controller (SDD-088).
///
/// Constructed by `ChanoraSession::start_audio` after the audio
/// engine is up. Owns:
///
/// * `Box<dyn DesktopPttBackend>` — the selected platform backend.
/// The factory in [`chanora_audio::ptt_backends::select`] never
/// fails; on any platform that lacks a global-capture path the
/// universal `FocusedPttBackend` is returned, so this slot is
/// always populated between `new()` and `stop()`.
/// * `AudioTransmitGate` — a clone of the engine's gate, handed to
/// the backend on arm and held here so re-arms after `rebind` do
/// not need a reference back to the engine.
/// * `tokio::sync::Mutex<PttBinding>` — the active binding. The
/// binding capture dialog in Flutter calls into the bridge which
/// funnels here; the mutex guards the rebind path against
/// concurrent updates.
/// * `watch::Sender<PttCapabilityLevel>` — fans out the
/// live-resolved capability for SDD-091 (UI badge). The Linux
/// GNOME-Wayland portal backend resolves its capability
/// asynchronously after the user accepts the BindShortcuts
/// dialog; the controller's forwarder task observes the
/// backend's own descriptor-watch and republishes the
/// capability through this channel.
pub struct PttController {
backend: Mutex<Option<Box<dyn DesktopPttBackend>>>,
binding: Mutex<PttBinding>,
gate: AudioTransmitGate,
capability_tx: watch::Sender<PttCapabilityLevel>,
descriptor_tx: watch::Sender<PttBackendDescriptor>,
forwarder: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl PttController {
/// Construct the controller and arm the selected platform
/// backend. The factory in
/// [`chanora_audio::ptt_backends::select`] never fails, but the
/// arming `start` call may; on failure the controller still
/// returns successfully with a Focused-fallback backend already
/// armed (matching the previous engine behaviour) so the audio
/// path is never blocked by a PTT-arm failure.
pub fn new(gate: AudioTransmitGate) -> Arc<Self> {
let mut backend = select_ptt_backend();
let initial_binding = PttBinding::none();
match backend.start(gate.clone(), initial_binding.clone()) {
Ok(()) => {
let d = backend.descriptor();
info!(
target: "chanora_core",
capability_level = %d.level,
backend_id = d.backend_id,
bound_input_class = ?d.bound_input_class,
"ptt controller armed"
);
}
Err(e) => {
warn!(
target: "chanora_core",
error = %e,
"ptt backend start failed; controller continues with current backend instance"
);
}
}
let descriptor = backend.descriptor();
let capability = descriptor.level;
let (capability_tx, _capability_rx) = watch::channel(capability);
let (descriptor_tx, _descriptor_rx) = watch::channel(descriptor.clone());
// Subscribe to the backend's own descriptor-watch and
// re-publish to both the descriptor sender (consumed by
// `ChanoraSession` for `BridgeEvent::PttCapability`) and
// the capability sender (SDD-088 public surface).
let mut backend_rx = backend.descriptor_watch();
let cap_tx_for_task = capability_tx.clone();
let desc_tx_for_task = descriptor_tx.clone();
let forwarder = tokio::spawn(async move {
while backend_rx.changed().await.is_ok() {
let d = backend_rx.borrow_and_update().clone();
let _ = cap_tx_for_task.send(d.level);
let _ = desc_tx_for_task.send(d);
}
});
Arc::new(Self {
backend: Mutex::new(Some(backend)),
binding: Mutex::new(initial_binding),
gate,
capability_tx,
descriptor_tx,
forwarder: std::sync::Mutex::new(Some(forwarder)),
})
}
/// Replace the active binding (SDD-088 public surface).
///
/// Returns the freshly-published descriptor so callers can
/// emit the corresponding `BridgeEvent::PttCapability` event.
/// On success the new binding is recorded under the binding
/// mutex and the capability watch is republished.
pub async fn set_binding(
&self,
binding: PttBinding,
) -> Result<PttBackendDescriptor, PttControllerError> {
let mut backend_guard = self.backend.lock().await;
let backend = backend_guard
.as_mut()
.ok_or(PttControllerError::NotArmed)?;
backend.rebind(binding.clone())?;
let descriptor = backend.descriptor();
// Record the new binding under its own mutex so the
// SDD-088 surface (held binding) reflects the resolved
// value. The backend mutex is held first to preserve the
// documented lock order (backend before binding).
{
let mut binding_guard = self.binding.lock().await;
*binding_guard = binding;
}
// Republish the capability so subscribers that did not
// wire the backend's own descriptor-watch still observe
// the transition.
let _ = self.capability_tx.send(descriptor.level);
let _ = self.descriptor_tx.send(descriptor.clone());
Ok(descriptor)
}
/// Current resolved capability (SDD-088 public surface).
pub fn current_capability(&self) -> PttCapabilityLevel {
*self.capability_tx.borrow()
}
/// Subscribe to capability transitions (SDD-088 public surface).
/// Used by the bridge to drive the Flutter `PttCapabilityBadge`
/// stream (SDD-091).
pub fn subscribe_capability(&self) -> watch::Receiver<PttCapabilityLevel> {
self.capability_tx.subscribe()
}
/// Privacy-safe descriptor of the active backend. Returns the
/// universal Focused fallback when the controller has been
/// stopped (the slot is empty between `stop()` and Drop).
pub async fn descriptor(&self) -> PttBackendDescriptor {
let guard = self.backend.lock().await;
match guard.as_ref() {
Some(b) => b.descriptor(),
None => PttBackendDescriptor::focused(),
}
}
/// Subscribe to descriptor transitions. Backends that resolve
/// asynchronously (Linux portal) republish through here.
pub fn descriptor_watch(&self) -> watch::Receiver<PttBackendDescriptor> {
self.descriptor_tx.subscribe()
}
/// Reference to the underlying gate. Diagnostic / test use only.
pub fn gate(&self) -> &AudioTransmitGate {
&self.gate
}
/// Release OS-level resources held by the platform backend
/// (Raw Input message loop, Event Tap run loop, portal D-Bus
/// session, …). Idempotent.
pub async fn stop(&self) {
if let Some(h) = self.forwarder.lock().ok().and_then(|mut g| g.take()) {
h.abort();
}
let mut guard = self.backend.lock().await;
if let Some(mut b) = guard.take() {
b.stop();
}
}
}
impl Drop for PttController {
fn drop(&mut self) {
if let Some(h) = self.forwarder.get_mut().ok().and_then(|g| g.take()) {
h.abort();
}
// The canonical shutdown path is `PttController::stop().await`
// invoked by the session before drop. We cannot lock the
// tokio Mutex synchronously here; if the controller is
// dropped without an explicit stop, the backend's own
// `Drop` runs through the `Box<dyn DesktopPttBackend>`
// and is responsible for releasing OS resources.
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn controller_arms_and_reports_capability() {
// The focused fallback is always selectable; the factory
// never fails on a host without any global capture path.
let gate = AudioTransmitGate::new(false);
let controller = PttController::new(gate);
// Every backend returns a non-null backend_id and a
// capability level that round-trips through the watch.
let desc = controller.descriptor().await;
assert!(!desc.backend_id.is_empty());
let level = controller.current_capability();
// The descriptor's level must match the watch.
assert_eq!(level, desc.level);
// Subscribing must yield the same value without blocking.
let mut rx = controller.subscribe_capability();
assert_eq!(*rx.borrow_and_update(), level);
}
#[tokio::test]
async fn set_binding_updates_descriptor_watch() {
let gate = AudioTransmitGate::new(false);
let controller = PttController::new(gate);
let mut desc_rx = controller.descriptor_watch();
// Drain the initial value.
let _ = desc_rx.borrow_and_update();
// The focused fallback accepts every binding (it's just a
// diagnostic carrier; the actual press detection happens
// inside the Flutter Listener path).
let binding = PttBinding {
input_class: chanora_audio::PttInputClass::Keyboard,
platform_key: "space".to_string(),
};
let new_desc = controller.set_binding(binding.clone()).await.unwrap();
// The descriptor watch must have received the post-rebind
// value. We don't assert a specific level here because the
// focused backend's level does not change on rebind, but
// the descriptor send is observable.
assert_eq!(new_desc.backend_id, controller.descriptor().await.backend_id);
}
}