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
+7
View File
@@ -40,6 +40,13 @@
"backend": { "type": "String" }
}
},
"pttCapabilityExplainTitle": "Push-to-Talk capability",
"pttCapabilityExplainFocusedHeading": "Focused PTT",
"pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.",
"pttCapabilityExplainGoGlobalWindows": "On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.",
"pttCapabilityExplainGoGlobalMacos": "On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.",
"pttCapabilityExplainGoGlobalLinux": "On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop's shortcut dialog when it appears.",
"pttCapabilityExplainGoGlobalGeneric": "Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.",
"pttConfigureAction": "Configure",
"pttConfigureTitle": "Configure Push-to-Talk binding",
"pttConfigurePrompt": "Press the key or mouse side button you want to use for Push-to-Talk.",
+7
View File
@@ -30,6 +30,13 @@
"pttTransmitting": "正在发送…",
"pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。",
"pttCapabilityBadge": "对讲能力:{level}{backend}",
"pttCapabilityExplainTitle": "对讲能力说明",
"pttCapabilityExplainFocusedHeading": "聚焦对讲",
"pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。",
"pttCapabilityExplainGoGlobalWindows": "在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。",
"pttCapabilityExplainGoGlobalMacos": "在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。",
"pttCapabilityExplainGoGlobalLinux": "在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。",
"pttCapabilityExplainGoGlobalGeneric": "当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。",
"pttConfigureAction": "配置",
"pttConfigureTitle": "配置对讲按键",
"pttConfigurePrompt": "按下您希望用于对讲的按键或鼠标侧键。",
@@ -253,6 +253,48 @@ abstract class AppL10n {
/// **'PTT: {level} ({backend})'**
String pttCapabilityBadge(String level, String backend);
/// No description provided for @pttCapabilityExplainTitle.
///
/// In en, this message translates to:
/// **'Push-to-Talk capability'**
String get pttCapabilityExplainTitle;
/// No description provided for @pttCapabilityExplainFocusedHeading.
///
/// In en, this message translates to:
/// **'Focused PTT'**
String get pttCapabilityExplainFocusedHeading;
/// No description provided for @pttCapabilityExplainFocusedBody.
///
/// In en, this message translates to:
/// **'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.'**
String get pttCapabilityExplainFocusedBody;
/// No description provided for @pttCapabilityExplainGoGlobalWindows.
///
/// In en, this message translates to:
/// **'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.'**
String get pttCapabilityExplainGoGlobalWindows;
/// No description provided for @pttCapabilityExplainGoGlobalMacos.
///
/// In en, this message translates to:
/// **'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.'**
String get pttCapabilityExplainGoGlobalMacos;
/// No description provided for @pttCapabilityExplainGoGlobalLinux.
///
/// In en, this message translates to:
/// **'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.'**
String get pttCapabilityExplainGoGlobalLinux;
/// No description provided for @pttCapabilityExplainGoGlobalGeneric.
///
/// In en, this message translates to:
/// **'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.'**
String get pttCapabilityExplainGoGlobalGeneric;
/// No description provided for @pttConfigureAction.
///
/// In en, this message translates to:
@@ -96,6 +96,32 @@ class AppL10nEn extends AppL10n {
return 'PTT: $level ($backend)';
}
@override
String get pttCapabilityExplainTitle => 'Push-to-Talk capability';
@override
String get pttCapabilityExplainFocusedHeading => 'Focused PTT';
@override
String get pttCapabilityExplainFocusedBody =>
'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.';
@override
String get pttCapabilityExplainGoGlobalWindows =>
'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.';
@override
String get pttCapabilityExplainGoGlobalMacos =>
'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.';
@override
String get pttCapabilityExplainGoGlobalLinux =>
'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.';
@override
String get pttCapabilityExplainGoGlobalGeneric =>
'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.';
@override
String get pttConfigureAction => 'Configure';
@@ -93,6 +93,32 @@ class AppL10nZh extends AppL10n {
return '对讲能力:$level$backend';
}
@override
String get pttCapabilityExplainTitle => '对讲能力说明';
@override
String get pttCapabilityExplainFocusedHeading => '聚焦对讲';
@override
String get pttCapabilityExplainFocusedBody =>
'Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。';
@override
String get pttCapabilityExplainGoGlobalWindows =>
'在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。';
@override
String get pttCapabilityExplainGoGlobalMacos =>
'在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。';
@override
String get pttCapabilityExplainGoGlobalLinux =>
'在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。';
@override
String get pttCapabilityExplainGoGlobalGeneric =>
'当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。';
@override
String get pttConfigureAction => '配置';
+160 -37
View File
@@ -11,6 +11,7 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
@@ -922,48 +923,19 @@ class _AudioControlsState extends State<_AudioControls> {
stats.pttActive ? 'on' : 'off',
);
final isGlobal = widget.pttLevel != 'L0Focused';
final badgeLabel = l10n.pttCapabilityBadge(
widget.pttLevel,
widget.pttBackendId,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Capability badge (gen2 v0.9.3 / SDD-091). Renders the
// active PTT level + backend so the user understands when
// Global PTT has fallen back to Focused PTT.
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Tooltip(
message: widget.pttBoundInputClass.isEmpty
? badgeLabel
: '$badgeLabel\n(${widget.pttBoundInputClass})',
child: Row(
children: [
Icon(
isGlobal ? Icons.public : Icons.crop_free,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
badgeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 14),
label: Text(l10n.pttConfigureAction),
onPressed: widget.onConfigurePtt,
),
],
),
),
// Global PTT has fallen back to Focused PTT, and surfaces
// a per-platform explanation sheet when the resolved
// capability is `L0Focused`.
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
onConfigure: widget.onConfigurePtt,
),
// Accessibility (SysRS-262 + SysRS-282 + SysRS-263):
// wrap the custom Listener-based PTT control in a
@@ -1079,6 +1051,157 @@ class _AudioControlsState extends State<_AudioControls> {
}
}
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
///
/// Renders the active PTT level + backend in the Voice Bar so the
/// user understands which input path is in effect. When the
/// resolved capability is `L0Focused` an info icon appears that
/// opens a per-platform explanation sheet describing why Global
/// PTT is not active and what the user can do to engage it.
///
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
/// updates the props on each transition.
class PttCapabilityBadge extends StatelessWidget {
/// Construct a badge.
const PttCapabilityBadge({
super.key,
required this.level,
required this.backendId,
required this.boundInputClass,
required this.onConfigure,
});
/// Resolved capability level as the bridge emits it
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
final String level;
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
final String backendId;
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
/// or empty when no binding is set).
final String boundInputClass;
/// Open the configure-binding dialog. Wired by the caller.
final VoidCallback onConfigure;
bool get _isFocused => level == 'L0Focused';
String _explainBodyForPlatform(AppL10n l10n) {
// Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
// because the latter is influenced by debug platform overrides
// that callers may toggle in dev mode. We want the badge's
// explanation to match the actual host OS.
switch (defaultTargetPlatform) {
case TargetPlatform.windows:
return l10n.pttCapabilityExplainGoGlobalWindows;
case TargetPlatform.macOS:
return l10n.pttCapabilityExplainGoGlobalMacos;
case TargetPlatform.linux:
return l10n.pttCapabilityExplainGoGlobalLinux;
default:
return l10n.pttCapabilityExplainGoGlobalGeneric;
}
}
void _openExplanationSheet(BuildContext context) {
final l10n = AppL10n.of(context);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
final tooltipMessage = boundInputClass.isEmpty
? badgeLabel
: '$badgeLabel\n($boundInputClass)';
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Tooltip(
message: tooltipMessage,
child: Row(
children: [
Icon(
_isFocused ? Icons.crop_free : Icons.public,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
badgeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (_isFocused)
IconButton(
icon: const Icon(Icons.info_outline, size: 16),
tooltip: l10n.pttCapabilityExplainTitle,
visualDensity: VisualDensity.compact,
onPressed: () => _openExplanationSheet(context),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 14),
label: Text(l10n.pttConfigureAction),
onPressed: onConfigure,
),
],
),
),
);
}
}
class _SnapshotView extends StatelessWidget {
const _SnapshotView({
required this.snapshot,
+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);
}
}
+21 -86
View File
@@ -108,13 +108,11 @@ pub struct AudioEngine {
/// denied microphone permission), PTT becomes a no-op and
/// `frames_sent` stays at 0.
capture_active: bool,
/// Active desktop PTT backend (SAD-071 / SDD-081). Stored
/// inside a `Mutex<Option<_>>` so `stop()` can move it out
/// and release OS-level resources before the engine is
/// dropped. The value is always `Some` between `start_audio`
/// and `stop`.
ptt_backend: Mutex<Option<Box<dyn crate::ptt_backends::DesktopPttBackend>>>,
/// Missed-key-up watchdog. Dropping aborts the task.
/// Missed-key-up watchdog (SDD-092). Dropping aborts the task.
/// The watchdog is independent of the PTT input backend — it
/// observes the gate directly. The platform input backend is
/// owned by `chanora_core::ptt::PttController` (SDD-088), not
/// by the engine.
ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog>,
}
@@ -307,33 +305,11 @@ impl AudioEngine {
}
});
// Select and arm the desktop PTT backend (SAD-071,
// SDD-081). This call is the only place that talks to the
// platform-input layer; the rest of the engine consumes
// the typed `AudioTransmitGate`. We always have a backend
// because the cross-platform factory falls back to
// `FocusedPttBackend` (SDD-087).
let mut ptt_backend = crate::ptt_backends::select();
let initial_binding = crate::ptt_backends::PttBinding::none();
match ptt_backend.start(transmit_gate.clone(), initial_binding) {
Ok(()) => {
let d = ptt_backend.descriptor();
info!(
target: "chanora_audio",
capability_level = %d.level,
backend_id = d.backend_id,
bound_input_class = ?d.bound_input_class,
"ptt backend armed"
);
}
Err(e) => {
warn!(
target: "chanora_audio",
error = %e,
"ptt backend start failed; engine continues with Focused fallback"
);
}
}
// Select and arm the desktop PTT backend is no longer the
// engine's job (SDD-088). The PTT controller lives in
// `chanora_core::ptt::PttController`; the engine is
// responsible only for the cpal streams and the
// missed-key-up watchdog (SDD-092).
// Spawn the missed-key-up watchdog. The task aborts on
// Drop of `MissedKeyUpWatchdog`, so the engine's `stop`
@@ -353,7 +329,6 @@ impl AudioEngine {
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_backend: Mutex::new(Some(ptt_backend)),
ptt_watchdog: Some(ptt_watchdog),
})
}
@@ -363,15 +338,10 @@ impl AudioEngine {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// Release the active PTT backend's OS resources before
// dropping the streams; the backend may hold a worker
// thread (Raw Input message loop, Event Tap run loop, etc.)
// that needs an explicit stop() to wind down cleanly.
if let Ok(mut guard) = self.ptt_backend.lock() {
if let Some(mut backend) = guard.take() {
backend.stop();
}
}
// The platform PTT backend is no longer owned by the
// engine (SDD-088); its lifecycle is managed by
// `chanora_core::ptt::PttController`. The engine only
// needs to abort its watchdog and drop the cpal streams.
// Aborting the watchdog cancels its tokio task.
self.ptt_watchdog.take();
// Drop the streams, which stops their callback threads.
@@ -404,52 +374,17 @@ impl AudioEngine {
&self.transmit_gate
}
/// Privacy-safe descriptor of the currently active PTT
/// backend (SDD-081 / SDD-091). Returns the universal Focused
/// fallback descriptor when the backend slot is empty
/// (typically only between `stop()` and Drop).
/// Privacy-safe descriptor of the engine's PTT view. The
/// platform backend lives in `chanora_core::ptt::PttController`
/// (SDD-088); the engine itself no longer owns it. This getter
/// always returns the universal Focused fallback descriptor
/// and is retained only for legacy callers that constructed
/// engines directly without a controller (tests, headless
/// diagnostics).
pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor {
if let Ok(guard) = self.ptt_backend.lock() {
if let Some(b) = guard.as_ref() {
return b.descriptor();
}
}
crate::ptt::PttBackendDescriptor::focused()
}
/// Replace the PTT binding on the active backend. Returns the
/// freshly-published descriptor so callers can re-emit the
/// capability event.
pub fn rebind_ptt(
&self,
binding: crate::ptt_backends::PttBinding,
) -> Result<crate::ptt::PttBackendDescriptor, AudioError> {
let mut guard = self
.ptt_backend
.lock()
.map_err(|_| AudioError::Backend("ptt_backend mutex poisoned".to_string()))?;
let backend = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ptt backend not armed".to_string()))?;
backend
.rebind(binding)
.map_err(|e| AudioError::Backend(format!("rebind: {e}")))?;
Ok(backend.descriptor())
}
/// Subscribe to PTT descriptor transitions on the active
/// backend. The Linux GNOME-Wayland portal backend uses this
/// channel to publish the post-`BindShortcuts` capability
/// transition. Returns `None` when the engine has no backend
/// armed (e.g. between `stop()` and Drop).
pub fn ptt_descriptor_watch(
&self,
) -> Option<tokio::sync::watch::Receiver<crate::ptt::PttBackendDescriptor>> {
let guard = self.ptt_backend.lock().ok()?;
let backend = guard.as_ref()?;
Some(backend.descriptor_watch())
}
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
/// the existing bridge `set_ptt` command and the existing
/// Flutter UI continue to compile during the v0.9.3 PTT
+104 -10
View File
@@ -408,11 +408,19 @@ const PTT_BANNED_FIELDS: &[&str] = &[
/// A `tracing` Layer that funnels records into an
/// [`InMemoryLogSink`]. Install during process init.
///
/// Per DEC-027 the layer also drops any record that carries one of
/// [`PTT_BANNED_FIELDS`] in its field set; the structural check
/// runs before format / redaction so a banned record never reaches
/// the in-memory sink and therefore never reaches the user-initiated
/// diagnostic export.
/// Per DEC-027 the records that carry raw key data must never reach
/// the in-memory sink. Per SDD-090 that responsibility lives in a
/// separate decorator Layer — [`PttSanitizer`] — so the
/// privacy boundary is its own named software unit. Constructors
/// that want the decorated stack should call
/// [`PttSanitizer::wrap`] (or use `RedactingLogLayer::with_sanitizer`
/// for the canonical pairing).
///
/// `RedactingLogLayer` retains the same structural ban check for
/// callers that install it bare, so existing wiring continues to
/// honour DEC-027 even without `PttSanitizer`. The two checks are
/// idempotent — a sanitiser-wrapped layer never sees a banned
/// record so the inner check is a no-op on that path.
#[derive(Debug, Clone)]
pub struct RedactingLogLayer {
sink: InMemoryLogSink,
@@ -423,6 +431,12 @@ impl RedactingLogLayer {
pub fn new(sink: InMemoryLogSink) -> Self {
Self { sink }
}
/// Convenience: wrap `self` in a [`PttSanitizer`] decorator
/// (SDD-090). Equivalent to `PttSanitizer::wrap(self)`.
pub fn with_sanitizer(self) -> PttSanitizer<Self> {
PttSanitizer::wrap(self)
}
}
impl<S> Layer<S> for RedactingLogLayer
@@ -430,11 +444,10 @@ where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
// PttSanitizer (SAD-077 / SDD-090): drop any record whose
// field set names a raw key code, scan code, virtual-key
// value, keysym, or timing sequence. The structural check
// is fast — at most one allocation-free pass over the
// field list.
// Defence-in-depth: a `RedactingLogLayer` installed without
// a surrounding `PttSanitizer` still drops banned records.
// When wrapped by `PttSanitizer` this check is unreachable
// (the sanitiser short-circuits first).
let mut ban_check = PttBanCheckVisitor::default();
event.record(&mut ban_check);
if ban_check.banned {
@@ -456,6 +469,56 @@ where
fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {}
}
/// PTT sanitiser Layer (SAD-077 / SDD-090).
///
/// Decorates an inner `tracing-subscriber::Layer` (typically
/// [`RedactingLogLayer`]). On each `on_event` the sanitiser performs
/// a single allocation-free pass over the event's field set and
/// drops the record if any field name matches the
/// [`PTT_BANNED_FIELDS`] list. Records without banned fields are
/// forwarded verbatim to the inner Layer's `on_event`.
///
/// The implementation is allocation-free on the success path (the
/// typical "no banned field" case): the visitor holds a single
/// `bool` on the stack and exits early once a banned name is seen.
#[derive(Debug, Clone)]
pub struct PttSanitizer<L> {
inner: L,
}
impl<L> PttSanitizer<L> {
/// Wrap an inner Layer with the PTT sanitiser. Use
/// [`RedactingLogLayer::with_sanitizer`] for the canonical
/// pairing.
pub fn wrap(inner: L) -> Self {
Self { inner }
}
/// Borrow the wrapped inner Layer (read-only).
pub fn inner(&self) -> &L {
&self.inner
}
}
impl<S, L> Layer<S> for PttSanitizer<L>
where
S: Subscriber + for<'a> LookupSpan<'a>,
L: Layer<S>,
{
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
let mut ban_check = PttBanCheckVisitor::default();
event.record(&mut ban_check);
if ban_check.banned {
return;
}
self.inner.on_event(event, ctx);
}
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
self.inner.on_new_span(attrs, id, ctx);
}
}
/// Lightweight `tracing::field::Visit` implementation that only
/// notes whether any visited field name matches the PTT banned
/// list. Allocation-free.
@@ -698,4 +761,35 @@ mod tests {
assert!(!PTT_BANNED_FIELDS.contains(&safe));
}
}
#[test]
fn ptt_sanitizer_drops_banned_records_before_inner_layer() {
// End-to-end test through a real tracing subscriber: a
// sanitiser-wrapped RedactingLogLayer must drop records
// that name banned fields and must forward records that
// do not. Uses `with_default` so the subscriber is scoped
// to the closure (no global state mutation across tests).
use tracing_subscriber::layer::SubscriberExt;
let secrets = KnownSecretRegistry::default();
let redactor = Redactor::with_secrets(secrets);
let sink = InMemoryLogSink::new(16, redactor);
let inner = RedactingLogLayer::new(sink.clone());
let sanitised = PttSanitizer::wrap(inner);
let subscriber = tracing_subscriber::registry().with(sanitised);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(key_code = 42, "banned record must drop");
tracing::info!(backend_id = "linux-portal", "safe record must pass");
});
let exported =
DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let text = exported.to_text();
assert!(
!text.contains("banned record must drop"),
"sanitiser must have dropped the banned record"
);
assert!(
text.contains("safe record must pass"),
"sanitiser must forward the safe record to the inner layer"
);
}
}