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:
@@ -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.",
|
||||
|
||||
@@ -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 => '配置';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user