diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index fb650eb..d2b0614 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -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.", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index a275ced..7847c6a 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -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": "按下您希望用于对讲的按键或鼠标侧键。", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 07f8610..4abfc6d 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -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: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 838e1e2..06cbc3c 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -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'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 0ba19ea..03870cb 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -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 => '配置'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 6eaeef8..a38102f 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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( + 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, diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index c01a0d0..3fa2386 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -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, + /// 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>, /// 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!( diff --git a/core/chanora_core/src/ptt.rs b/core/chanora_core/src/ptt.rs new file mode 100644 index 0000000..4d0017f --- /dev/null +++ b/core/chanora_core/src/ptt.rs @@ -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`, a clone of the gate, the current +//! `PttBinding`, and the `watch::Sender` 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` — 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` — 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` — 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>>, + binding: Mutex, + gate: AudioTransmitGate, + capability_tx: watch::Sender, + descriptor_tx: watch::Sender, + forwarder: std::sync::Mutex>>, +} + +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 { + 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 { + 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 { + 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 { + 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` + // 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); + } +} diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index c7390b6..d6e85e2 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -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>` 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>>, - /// 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, } @@ -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 { - 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> { - 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 diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index 899efb6..9722615 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -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 { + PttSanitizer::wrap(self) + } } impl Layer 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 { + inner: L, +} + +impl PttSanitizer { + /// 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 Layer for PttSanitizer +where + S: Subscriber + for<'a> LookupSpan<'a>, + L: Layer, +{ + 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" + ); + } }